From 9f25a3aaa1050913c2d8a1b9f0b0f0ed18296abd Mon Sep 17 00:00:00 2001 From: safishamsi Date: Thu, 6 Aug 2026 17:29:18 +0100 Subject: [PATCH] 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) --- CHANGELOG.md | 11 ++- graphify/callflow_html.py | 42 ++++++++-- graphify/serve.py | 40 ++++++++- pyproject.toml | 2 +- tests/test_callflow_html.py | 160 ++++++++++++++++++++++++++++++++++++ tests/test_serve.py | 80 ++++++++++++++++++ 6 files changed, 324 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d264a..077647f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/graphify/callflow_html.py b/graphify/callflow_html.py index dabb299..3c8a34b 100644 --- a/graphify/callflow_html.py +++ b/graphify/callflow_html.py @@ -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 "
".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( {callee_header} {desc_header} -{generate_call_table_rows(sec_nodes, sec_edges, lang)} +{generate_call_table_rows(sec_nodes, sec_edges, lang, edges, nodes)} {generate_section_cards(sec, sec_nodes, sec_edges, lang)} diff --git a/graphify/serve.py b/graphify/serve.py index 1f5e993..3b205d8 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 7b8e715..6288b9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_callflow_html.py b/tests/test_callflow_html.py index 9605c9b..e42d1b4 100644 --- a/tests/test_callflow_html.py +++ b/tests/test_callflow_html.py @@ -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") diff --git a/tests/test_serve.py b/tests/test_serve.py index eb4a989..881247c 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -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