fix(callflow,query): direction-aware callflow; drop relational-verb seed pollution; bump to 0.9.35
#2508 (thanks @Tomaskobel): callflow loads the graph directed and recovers direction from _src/_tgt markers (consistent with the path fix), so caller/callee columns are correct; indirect calls are now counted. #2507 (thanks @filipechagas): relational-intent verbs (calls/uses/extends/ ...) are excluded from the per-term seed guarantee, so a decoy matching only the verb no longer becomes a traversal root, while a verb that is a real symbol name can still be seeded on merit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2320f2adc1
commit
9f25a3aaa1
+10
-1
@@ -2,7 +2,16 @@
|
||||
|
||||
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
|
||||
|
||||
## 0.9.34 (unreleased)
|
||||
## 0.9.35 (unreleased)
|
||||
|
||||
- Fix: the `build_merge` #479 shrink guard is no longer effectively dead (#2497, thanks @sortakool). It read the post-replace node count, so a broken partial re-extract could silently destroy nodes without tripping the guard, and the guard was skipped entirely under `prune_sources`. The guard now diffs the on-disk baseline by node identity and refuses any loss from a source that was neither re-extracted nor pruned this run (active even under `prune_sources`, skipped only under `dedup`), and reports how many nodes a re-extract replaced.
|
||||
- Fix: `build_merge`/`merge_raw_extraction` `prune_sources` now prunes correctly when given absolute paths under a non-standard layout, deriving the scan root by suffix-matching stored source paths, and warns (instead of reporting "already clean") when a prune matches nothing (#2446, thanks @AI-invest).
|
||||
- Fix: `graphify update` now removes newly-ignored files from an existing graph (#2495, thanks @alisson-acioli). A file that was added to `.graphifyignore`/`--exclude` (or a skip rule) is evicted even though it still exists on disk; `.gitignore`-driven eviction applies on an explicit full `update`. Files that merely changed are still preserved, and a file that leaves the corpus without matching any live ignore rule stays (fail-closed, #1795).
|
||||
- Fix: a Java local class and a same-named external annotation (e.g. a local `class Component` and Spring's `@Component`) no longer collapse into one node (#2504, thanks @te7ina-honey). The Java type resolver now runs before the unique-label stub rewire and parks an imported-but-external type on its fully-qualified name, and cross-file import resolution checks the package. In-corpus annotation resolution is unchanged.
|
||||
- Fix: `graphify callflow` now respects edge direction, so the caller/callee columns are correct (#2508, thanks @Tomaskobel). The call-flow HTML loads the graph directed and recovers direction from the stored `_src`/`_tgt` markers (consistent with the `path` fix), and indirect calls are counted.
|
||||
- Fix: relational-intent verbs in a `query` ("calls", "uses", "extends", ...) no longer seat spurious seeds (#2507, thanks @filipechagas). Such a verb is excluded from the per-term seed guarantee, so a decoy matching only the verb no longer becomes a traversal root, while a verb that is a genuine symbol name can still be seeded on merit.
|
||||
|
||||
## 0.9.34 (2026-08-05)
|
||||
|
||||
- Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped.
|
||||
- Fix: `graphify path` (and the MCP `shortest_path` tool) now respect edge direction by default instead of running on an undirected view, so a returned path no longer traverses edges backwards (#2487, thanks @luliaz0601). Direction is recovered from the stored `_src`/`_tgt` markers. Pass `--undirected` (CLI) or `undirected=true` (MCP) to search ignoring direction; when no directed path exists the command says so instead of silently returning a reversed one.
|
||||
|
||||
@@ -233,7 +233,16 @@ def _node_link_payload(data: dict) -> tuple[list, list] | None:
|
||||
# though the shape check above accepts "edges" (#2212).
|
||||
from graphify.paths import load_node_link_graph
|
||||
|
||||
graph = load_node_link_graph(data)
|
||||
# Force directed/multigraph so the stored caller->callee direction and
|
||||
# parallel edges survive the round-trip; mirrors affected.py,
|
||||
# serve.py and cli.py (#1174) and the directed-view fix for
|
||||
# path/shortest_path (#2487, #2309). graph.json is written with
|
||||
# "directed": false for backward compatibility, so without this
|
||||
# networkx returns an undirected Graph and edge orientation becomes
|
||||
# arbitrary -- which silently swaps the Caller and Callee columns of
|
||||
# the call table and drops parallel edges. The _src/_tgt override
|
||||
# below still wins on legacy marker files.
|
||||
graph = load_node_link_graph({**data, "directed": True, "multigraph": True})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -1207,19 +1216,38 @@ def format_node_refs(node_ids: set, node_by_id: dict, lang: str, empty_text: str
|
||||
return "<br>".join(parts)
|
||||
|
||||
|
||||
def generate_call_table_rows(nodes: list, section_edges: list, lang: str) -> str:
|
||||
"""Generate call table row scaffolding for a section's nodes."""
|
||||
def generate_call_table_rows(
|
||||
nodes: list,
|
||||
section_edges: list,
|
||||
lang: str,
|
||||
all_edges: list | None = None,
|
||||
all_nodes: list | None = None,
|
||||
) -> str:
|
||||
"""Generate call table row scaffolding for a section's nodes.
|
||||
|
||||
The Caller/Callee columns make a claim about the whole graph ("External
|
||||
entry / no inbound edge"), so they must be computed from the whole graph.
|
||||
Built from ``section_edges`` alone they only see edges whose *both*
|
||||
endpoints sit in this section, so a node called from anywhere else is
|
||||
mislabelled an entry point. Section coverage makes that the common case
|
||||
rather than a corner case: only ``max_sections`` communities are rendered,
|
||||
so most callers are not in any rendered section at all.
|
||||
|
||||
``all_edges``/``all_nodes`` are the full graph; ``all_nodes`` also lets
|
||||
out-of-section callers render as labels instead of raw node ids. Both
|
||||
default to None, preserving the previous behaviour for other callers.
|
||||
"""
|
||||
if not nodes:
|
||||
return ""
|
||||
|
||||
# Build source/target lookup from edges
|
||||
node_by_id = {n.get("id"): n for n in nodes}
|
||||
node_by_id = {n.get("id"): n for n in (all_nodes or nodes)}
|
||||
callers = defaultdict(set)
|
||||
callees = defaultdict(set)
|
||||
for e in section_edges:
|
||||
for e in (all_edges if all_edges is not None else section_edges):
|
||||
src = e.get("source", "")
|
||||
tgt = e.get("target", "")
|
||||
if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method"):
|
||||
if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method", "indirect_call"):
|
||||
callers[tgt].add(src)
|
||||
callees[src].add(tgt)
|
||||
|
||||
@@ -1743,7 +1771,7 @@ def write_callflow_html(
|
||||
<th style="width:20%">{callee_header}</th>
|
||||
<th style="width:20%">{desc_header}</th>
|
||||
</tr>
|
||||
{generate_call_table_rows(sec_nodes, sec_edges, lang)}
|
||||
{generate_call_table_rows(sec_nodes, sec_edges, lang, edges, nodes)}
|
||||
</table>
|
||||
|
||||
{generate_section_cards(sec, sec_nodes, sec_edges, lang)}
|
||||
|
||||
+38
-2
@@ -722,8 +722,31 @@ def _pick_seeds(
|
||||
return seeds
|
||||
|
||||
|
||||
# Verb-shaped tokens that express the RELATION a query asks about ("who calls
|
||||
# X", "what uses Y") rather than a symbol to look up. `_query_terms` keeps them
|
||||
# on purpose (a corpus can legitimately define an identifier named `calls`, see
|
||||
# #1597), but they must not be handed a guaranteed seed slot in `_pick_seeds`:
|
||||
# an incidental prefix match (e.g. "calls" prefixing `.callStoreWithAmount()`)
|
||||
# would otherwise seat an unrelated decoy as a BFS root (#2507). Demotion
|
||||
# happens at the `_query_graph_text` call site, so `_score_query`'s ranking —
|
||||
# where such a verb can still win a seat on merit via the gap window — is
|
||||
# untouched. Deliberately verbs only; relation NOUNS (module, field, return)
|
||||
# stay eligible for the guarantee.
|
||||
_RELATIONAL_INTENT_TERMS: frozenset[str] = frozenset({
|
||||
"call", "calls", "called", "caller", "callers",
|
||||
"invoke", "invokes", "invoked",
|
||||
"use", "uses", "used", "using",
|
||||
"import", "imports", "imported",
|
||||
"export", "exports", "exported",
|
||||
"extend", "extends", "extended",
|
||||
"implement", "implements", "implemented",
|
||||
"depend", "depends",
|
||||
"reference", "references", "referenced",
|
||||
})
|
||||
|
||||
|
||||
_CONTEXT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("call", ("call", "calls", "called", "invoke", "invokes", "invoked")),
|
||||
("call", ("call", "calls", "called", "caller", "callers", "invoke", "invokes", "invoked")),
|
||||
("import", ("import", "imports", "imported", "module", "modules")),
|
||||
("field", ("field", "fields", "member", "members", "property", "properties")),
|
||||
("parameter_type", ("parameter", "parameters", "param", "params", "argument", "arguments")),
|
||||
@@ -1105,7 +1128,20 @@ def _query_graph_text(
|
||||
# time; on a 100k-node, three-term benchmark ~71% of scoring time was
|
||||
# spent in those redundant per-term passes.
|
||||
qs = _score_query(G, terms, collect_per_term_seeds=True)
|
||||
start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term)
|
||||
# Relational-intent verbs ("calls", "uses", ...) describe the relation the
|
||||
# question asks about, not a symbol to seed from; drop them from the
|
||||
# per-term seed GUARANTEE so an incidental verb match cannot seat a decoy
|
||||
# BFS root (#2507). They keep their place in `qs.ranked`, so a genuine
|
||||
# identifier named after a verb can still win a seat on merit via the gap
|
||||
# window — and when the query consists ONLY of intent words (bare "calls"),
|
||||
# the guarantee is left intact so such an identifier stays reachable.
|
||||
best_seed_by_term = qs.best_seed_by_term
|
||||
intent = {t for t in best_seed_by_term if t in _RELATIONAL_INTENT_TERMS}
|
||||
if intent and any(t not in _RELATIONAL_INTENT_TERMS for t in terms):
|
||||
best_seed_by_term = {
|
||||
t: nid for t, nid in best_seed_by_term.items() if t not in intent
|
||||
}
|
||||
start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=best_seed_by_term)
|
||||
if not start_nodes:
|
||||
return "No matching nodes found."
|
||||
resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.9.34"
|
||||
version = "0.9.35"
|
||||
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -185,3 +185,163 @@ def test_load_graph_rejects_oversized_file(monkeypatch, tmp_path):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
load_graph(graph_path)
|
||||
assert "exceeds" in str(excinfo.value)
|
||||
|
||||
|
||||
def _write_graph(tmp_path: Path, nodes: list, links: list) -> Path:
|
||||
graph_path = tmp_path / "graph.json"
|
||||
graph_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"directed": False,
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes,
|
||||
"links": links,
|
||||
"hyperedges": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return graph_path
|
||||
|
||||
|
||||
def test_load_graph_preserves_edge_direction(tmp_path):
|
||||
"""#1174/#2487: graph.json is written with "directed": false, so the
|
||||
node-link parser must be told otherwise or networkx returns an undirected
|
||||
Graph and caller->callee orientation becomes arbitrary."""
|
||||
from graphify.callflow_html import generate_call_table_rows, load_graph
|
||||
|
||||
# Callee node inserted first: an undirected round-trip deterministically
|
||||
# yields the flipped (api, run) arc, so this fails without the forced
|
||||
# directed load.
|
||||
graph_path = _write_graph(
|
||||
tmp_path,
|
||||
nodes=[
|
||||
{"id": "api", "label": "ApiClient", "source_file": "src/api.py", "file_type": "code", "community": 0},
|
||||
{"id": "run", "label": "run()", "source_file": "src/main.py", "file_type": "code", "community": 0},
|
||||
],
|
||||
links=[
|
||||
{"source": "run", "target": "api", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
|
||||
],
|
||||
)
|
||||
nodes, edges, _hyper, _meta = load_graph(graph_path)
|
||||
|
||||
directed = {(e["source"], e["target"]) for e in edges}
|
||||
assert ("run", "api") in directed
|
||||
assert ("api", "run") not in directed, "edge direction was lost (undirected load)"
|
||||
|
||||
api_node = [n for n in nodes if n["id"] == "api"]
|
||||
rows = generate_call_table_rows(api_node, edges, "en", edges, nodes)
|
||||
assert "run()" in rows, "api's Caller column should list run()"
|
||||
assert "External entry" not in rows
|
||||
assert "No direct outbound edge" in rows, "api has no callees"
|
||||
|
||||
|
||||
def test_load_graph_legacy_markers_override_arc_order(tmp_path):
|
||||
"""Legacy graph.json files carry _src/_tgt markers on each link; they must
|
||||
override the stored arc even under the forced-directed load."""
|
||||
from graphify.callflow_html import generate_call_table_rows, load_graph
|
||||
|
||||
graph_path = _write_graph(
|
||||
tmp_path,
|
||||
nodes=[
|
||||
{"id": "A", "label": "alpha()", "source_file": "src/a.py", "file_type": "code", "community": 0},
|
||||
{"id": "B", "label": "beta()", "source_file": "src/b.py", "file_type": "code", "community": 0},
|
||||
],
|
||||
links=[
|
||||
{"source": "B", "target": "A", "_src": "A", "_tgt": "B", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
|
||||
],
|
||||
)
|
||||
nodes, edges, _hyper, _meta = load_graph(graph_path)
|
||||
|
||||
assert len(edges) == 1
|
||||
assert (edges[0]["source"], edges[0]["target"]) == ("A", "B")
|
||||
|
||||
b_node = [n for n in nodes if n["id"] == "B"]
|
||||
rows = generate_call_table_rows(b_node, edges, "en", edges, nodes)
|
||||
assert "alpha()" in rows, "B's Caller column should list alpha()"
|
||||
assert "External entry" not in rows
|
||||
|
||||
|
||||
def test_call_table_counts_indirect_call_relation():
|
||||
"""indirect_call edges are real callers (affected.py's relation set); a
|
||||
node reached only via indirect_call must not be an "External entry"."""
|
||||
from graphify.callflow_html import generate_call_table_rows
|
||||
|
||||
nodes = [
|
||||
{"id": "A", "label": "alpha()", "source_file": "src/a.py", "file_type": "code"},
|
||||
{"id": "B", "label": "beta()", "source_file": "src/b.py", "file_type": "code"},
|
||||
]
|
||||
edges = [{"source": "A", "target": "B", "relation": "indirect_call"}]
|
||||
|
||||
rows = generate_call_table_rows([nodes[1]], edges, "en", edges, nodes)
|
||||
assert "External entry" not in rows
|
||||
assert "alpha()" in rows, "indirect caller should appear in the Caller column"
|
||||
|
||||
|
||||
def test_load_graph_preserves_parallel_edges(tmp_path):
|
||||
"""Forcing multigraph keeps parallel edges between the same endpoints, and
|
||||
the caller set still dedupes so the table does not double-count."""
|
||||
from graphify.callflow_html import generate_call_table_rows, load_graph
|
||||
|
||||
graph_path = _write_graph(
|
||||
tmp_path,
|
||||
nodes=[
|
||||
{"id": "A", "label": "alpha()", "source_file": "src/a.py", "file_type": "code", "community": 0},
|
||||
{"id": "B", "label": "beta()", "source_file": "src/b.py", "file_type": "code", "community": 0},
|
||||
],
|
||||
links=[
|
||||
{"source": "A", "target": "B", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
|
||||
{"source": "A", "target": "B", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0},
|
||||
],
|
||||
)
|
||||
nodes, edges, _hyper, _meta = load_graph(graph_path)
|
||||
|
||||
parallel = [e for e in edges if (e["source"], e["target"]) == ("A", "B")]
|
||||
assert len(parallel) == 2, "parallel edges were collapsed (multigraph forcing lost)"
|
||||
assert {e["relation"] for e in parallel} == {"calls", "references"}
|
||||
|
||||
b_node = [n for n in nodes if n["id"] == "B"]
|
||||
rows = generate_call_table_rows(b_node, edges, "en", edges, nodes)
|
||||
assert rows.count("alpha()") == 1, "caller set should dedupe parallel edges"
|
||||
|
||||
|
||||
def test_call_table_caller_column_sees_other_sections(tmp_path):
|
||||
"""A node called from a different section is not an "External entry".
|
||||
|
||||
``export`` is used by ``api``, which lives in another community. Computing
|
||||
the Caller column from section-local edges alone mislabels it an entry
|
||||
point -- a whole-graph claim made from partial data.
|
||||
"""
|
||||
from graphify.callflow_html import generate_call_table_rows, load_graph
|
||||
|
||||
out = _make_graphify_out(tmp_path)
|
||||
nodes, edges, _hyper, _meta = load_graph(out / "graph.json")
|
||||
export_node = [n for n in nodes if n["id"] == "export"]
|
||||
|
||||
rows = generate_call_table_rows(export_node, [], "en", edges, nodes)
|
||||
assert "External entry" not in rows
|
||||
assert "ApiClient" in rows, "cross-section caller should render as a label"
|
||||
|
||||
|
||||
def test_call_table_rows_without_whole_graph_params_unchanged(tmp_path):
|
||||
"""The new all_edges/all_nodes params default to None, so existing
|
||||
three-argument callers keep the previous section-local behaviour."""
|
||||
from graphify.callflow_html import generate_call_table_rows, load_graph
|
||||
|
||||
out = _make_graphify_out(tmp_path)
|
||||
nodes, edges, _hyper, _meta = load_graph(out / "graph.json")
|
||||
section_nodes = [n for n in nodes if n["community"] == 0]
|
||||
section_ids = {n["id"] for n in section_nodes}
|
||||
section_edges = [e for e in edges if e["source"] in section_ids and e["target"] in section_ids]
|
||||
|
||||
legacy = generate_call_table_rows(section_nodes, section_edges, "en")
|
||||
# Section-local fixture: the whole graph *is* the section, so the new
|
||||
# path must be byte-identical to the legacy three-argument call.
|
||||
explicit = generate_call_table_rows(section_nodes, section_edges, "en", section_edges, section_nodes)
|
||||
assert legacy == explicit
|
||||
|
||||
# And without the params, out-of-section callers stay invisible (the old
|
||||
# semantics other call sites may rely on).
|
||||
export_node = [n for n in nodes if n["id"] == "export"]
|
||||
assert "External entry" in generate_call_table_rows(export_node, section_edges, "en")
|
||||
|
||||
@@ -921,6 +921,86 @@ def test_query_seeds_from_identifier_not_noise():
|
||||
assert "ServiceClient" in text
|
||||
|
||||
|
||||
# --- relational-intent verbs must not seat decoy seeds (#2507) ---
|
||||
|
||||
def _make_callers_graph() -> nx.Graph:
|
||||
"""A service, three callers wired via context='call' edges, and a decoy
|
||||
whose tokenized label ('callstorewithamount') prefix-matches the intent
|
||||
verb 'calls' — the #2507 pollution vector."""
|
||||
G = nx.Graph()
|
||||
G.add_node("svc", label="ChargeCustomerService", source_file="billing/charge.py")
|
||||
G.add_node("c1", label="BillingJob", source_file="billing/job.py")
|
||||
G.add_node("c2", label="CheckoutFlow", source_file="checkout/flow.py")
|
||||
G.add_node("c3", label="RetryWorker", source_file="workers/retry.py")
|
||||
for caller in ("c1", "c2", "c3"):
|
||||
G.add_edge(caller, "svc", relation="calls", context="call")
|
||||
G.add_node("decoy", label=".callStoreWithAmount()", source_file="store/amount.py")
|
||||
return G
|
||||
|
||||
|
||||
def test_relational_verb_does_not_seat_decoy_seed():
|
||||
"""'Who calls X?' must seed on X, not on a decoy that merely prefix-matches
|
||||
the intent verb 'calls' via its tokenized label (#2507). The gap window
|
||||
already excludes the decoy; the per-term guarantee must not re-seat it."""
|
||||
G = _make_callers_graph()
|
||||
# Sanity-check the pollution premise: the decoy IS the singleton winner for
|
||||
# 'calls', so pre-fix the guarantee loop would have seated it as a BFS root.
|
||||
qs = _score_query(G, _query_terms("Who calls ChargeCustomerService?"), collect_per_term_seeds=True)
|
||||
assert qs.best_seed_by_term.get("calls") == "decoy"
|
||||
|
||||
text = _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2)
|
||||
header = text.splitlines()[0]
|
||||
assert "ChargeCustomerService" in header.split("Start:")[1]
|
||||
assert ".callStoreWithAmount()" not in header
|
||||
for caller in ("BillingJob", "CheckoutFlow", "RetryWorker"):
|
||||
assert caller in text
|
||||
|
||||
|
||||
def test_relational_verb_as_bare_query_still_seeds_symbol():
|
||||
"""All-intent fallback: a query that is ONLY intent words keeps the seed
|
||||
guarantee, so a corpus-legit identifier literally named 'calls' stays
|
||||
reachable via the bare query 'calls' (#2507, preserving #1597's intent)."""
|
||||
G = nx.Graph()
|
||||
G.add_node("calls_fn", label="calls", source_file="src/calls.py")
|
||||
G.add_node("other", label="unrelated_helper", source_file="src/other.py")
|
||||
text = _query_graph_text(G, "calls", mode="bfs", depth=1)
|
||||
assert "No matching nodes found." not in text
|
||||
assert "calls" in text.splitlines()[0].split("Start:")[1]
|
||||
|
||||
|
||||
def test_relational_verb_symbol_still_wins_seat_on_merit():
|
||||
"""Demotion only strips the GUARANTEE: a node literally named 'calls' whose
|
||||
score sits within the gap window is still seeded alongside the other term's
|
||||
node on a multi-term query (#2507)."""
|
||||
G = nx.Graph()
|
||||
G.add_node("calls_fn", label="calls", source_file="src/calls.py")
|
||||
G.add_node("ext", label="extract", source_file="src/extract.py")
|
||||
text = _query_graph_text(G, "calls extract", mode="bfs", depth=1)
|
||||
start = text.splitlines()[0].split("Start:")[1]
|
||||
assert "calls" in start
|
||||
assert "extract" in start
|
||||
|
||||
|
||||
def test_uses_phrasing_does_not_seat_decoy_seed():
|
||||
"""'what uses X' must not seat a decoy that prefix-matches the intent verb
|
||||
'uses' (#2507). 'uses' is deliberately NOT a _CONTEXT_HINTS alias (its
|
||||
relation is ambiguous); the demotion set alone handles it."""
|
||||
G = nx.Graph()
|
||||
G.add_node("svc", label="ChargeCustomerService", source_file="billing/charge.py")
|
||||
G.add_node("c1", label="BillingJob", source_file="billing/job.py")
|
||||
G.add_edge("c1", "svc", relation="uses", context="call")
|
||||
G.add_node("decoy", label="usesDiscountCode()", source_file="promo/discount.py")
|
||||
text = _query_graph_text(G, "what uses ChargeCustomerService?", mode="bfs", depth=2)
|
||||
header = text.splitlines()[0]
|
||||
assert "ChargeCustomerService" in header.split("Start:")[1]
|
||||
assert "usesDiscountCode()" not in header
|
||||
|
||||
|
||||
def test_infer_context_filters_for_callers_question():
|
||||
"""'callers of X' phrasing infers the call context (#2507 companion)."""
|
||||
assert _infer_context_filters("callers of ChargeCustomerService") == ["call"]
|
||||
|
||||
|
||||
def test_query_graph_text_parameter_type_context_filter_changes_traversal():
|
||||
import networkx as nx
|
||||
from graphify.serve import _query_graph_text
|
||||
|
||||
Reference in New Issue
Block a user