From 4ace95182bdecd65961df876643bf81e57a24cc2 Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Tue, 21 Jul 2026 04:35:38 +0200 Subject: [PATCH] fix: preserve calls edge direction in graphify query output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `graphify query` builds an undirected nx.Graph (so BFS/DFS can explore both callers and callees of the seed node), but its text renderer assumed the BFS/DFS visit order (u, v) was always the edge's (source, target). On an undirected graph that assumption only holds when the seed happens to be the caller: seeding on the callee makes BFS/DFS visit the callee first, so a `caller --calls--> callee` edge was rendered backwards as `callee --calls--> caller`. graph.json's own source/target fields stay correct on disk; only the query rendering was wrong. `graphify path` and `graphify explain` don't have this problem because they force directed=True on load (#849, #853), and the MCP query_graph tool's _load_graph() does the same. Doing that for CLI `query` too was tried and reverted: forcing a DiGraph makes G.neighbors() return successors only, so a query seeded on a leaf/sink node (no outgoing edges) found zero neighbors instead of its callers — a recall regression, not just a display fix, and it would make the CLI and MCP query tools diverge in what they discover even though they'd render direction identically. Fix instead mirrors the _src/_tgt pattern graphify/build.py already uses for the same underlying problem (undirected storage loses direction): the CLI now stashes each link's true source/target on its edge data as _src/_tgt before constructing the (still undirected) graph, and _subgraph_to_text renders EDGE lines from _src/_tgt when present, falling back to (u, v) otherwise. Traversal itself is unchanged, so recall is unaffected — verified against the unpatched CLI, the node counts returned for the same seeds are identical before and after this fix, only the printed edge direction changes. Adds two regression tests in tests/test_query_cli.py seeding the same `calls` edge from both endpoints; the callee-seeded case fails on the prior code with the exact backwards-edge symptom above. --- graphify/cli.py | 15 +++++++++++ graphify/serve.py | 18 ++++++++++++-- tests/test_query_cli.py | 55 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index 06942728..816eb794 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -818,6 +818,21 @@ def dispatch_command(cmd: str) -> None: _raw = _json.loads(gp.read_text(encoding="utf-8")) if "links" not in _raw and "edges" in _raw: _raw = dict(_raw, links=_raw["edges"]) + # `query` deliberately keeps the graph undirected (unlike `path` / + # `explain`, which force directed=True): BFS/DFS here must explore + # both callers and callees of the seed node to build useful + # context, and forcing a DiGraph would make G.neighbors() return + # successors only, silently dropping every caller-side result for + # a seed with no outgoing edges. Direction is instead preserved + # per-edge below (mirrors graphify/build.py's _src/_tgt pattern) + # so the *rendering* stays correct without narrowing traversal. + _raw = dict( + _raw, + links=[ + {**link, "_src": link.get("source"), "_tgt": link.get("target")} + for link in _raw.get("links", []) + ], + ) try: G = json_graph.node_link_graph(_raw, edges="links") except TypeError: diff --git a/graphify/serve.py b/graphify/serve.py index c5cd1698..f32a9167 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -860,6 +860,20 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu if u in nodes and v in nodes: raw = G[u][v] d = next(iter(raw.values()), {}) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) else raw + # (u, v) is BFS/DFS visit order, not necessarily the true edge + # direction: on an undirected graph G.neighbors() walks callers + # and callees alike, so a caller->callee edge renders backwards + # whenever the callee is visited first. _src/_tgt (stashed on the + # edge data by the `query` CLI loader) carry the real direction; + # fall back to (u, v) for graphs/edges that don't set them. + src = d.get("_src", u) + tgt = d.get("_tgt", v) + # Guard against a stray/dangling _src/_tgt (hand-edited or adversarial + # graph.json): only trust them when they name exactly this edge's + # endpoints, else fall back to (u, v). Without this, G.nodes[src] + # would KeyError on an unknown id (#2080 review). + if {src, tgt} != {u, v}: + src, tgt = u, v context = d.get("context") context_suffix = f" context={sanitize_label(str(context))}" if context else "" # The relation SITE (call/import/reference line in the source's @@ -871,10 +885,10 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu if _loc else "" ) line = ( - f"EDGE {sanitize_label(G.nodes[u].get('label', u))} " + f"EDGE {sanitize_label(G.nodes[src].get('label', src))} " f"--{sanitize_label(str(d.get('relation', '')))} " f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> " - f"{sanitize_label(G.nodes[v].get('label', v))}{at_suffix}" + f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}" ) lines.append(line) output = "\n".join(lines) diff --git a/tests/test_query_cli.py b/tests/test_query_cli.py index cf8eb6e5..0db4e6fa 100644 --- a/tests/test_query_cli.py +++ b/tests/test_query_cli.py @@ -51,6 +51,61 @@ def test_query_cli_heuristic_context_filter(monkeypatch, tmp_path, capsys): assert "build" not in out +def _write_calls_graph(tmp_path): + """A single directed `calls` edge on an (on-disk) undirected graph.json, + + the standard `graphify extract`/`update` output shape (`"directed": + false`, direction implied only by each link's source/target). + """ + G = nx.Graph() + G.add_node("caller", label="caller_fn", source_file="a.py", source_location="L1", community=0) + G.add_node("callee", label="callee_fn", source_file="b.py", source_location="L1", community=1) + G.add_edge("caller", "callee", relation="calls", confidence="EXTRACTED", context="call") + graph_path = tmp_path / "graph.json" + graph_path.write_text(json.dumps(json_graph.node_link_data(G, edges="links"))) + return graph_path + + +def test_query_cli_preserves_calls_direction_when_seeded_on_callee(monkeypatch, tmp_path, capsys): + """`graphify query` must render `calls` edges caller->callee regardless of + which endpoint the query term matches first. + + The graph `query` loads is undirected (so BFS/DFS can explore both + callers and callees of the seed), so `G.neighbors()` returns `caller_fn` + as a neighbor of `callee_fn` with no direction of its own. Before the + fix, the renderer assumed the BFS/DFS visit order (u, v) was the edge's + (source, target), so seeding on the callee printed the edge backwards: + "callee_fn --calls--> caller_fn". graph.json's `source`/`target` for this + edge stay correct on disk either way; only the query rendering was wrong. + """ + graph_path = _write_calls_graph(tmp_path) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "query", "callee_fn", "--graph", str(graph_path)], + ) + mainmod.main() + out = capsys.readouterr().out + assert "caller_fn --calls" in out + assert "callee_fn --calls" not in out + + +def test_query_cli_preserves_calls_direction_when_seeded_on_caller(monkeypatch, tmp_path, capsys): + """Same edge, seeded from the caller side — must stay correct too.""" + graph_path = _write_calls_graph(tmp_path) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "query", "caller_fn", "--graph", str(graph_path)], + ) + mainmod.main() + out = capsys.readouterr().out + assert "caller_fn --calls" in out + assert "callee_fn --calls" not in out + + def test_query_cli_rejects_oversized_graph(monkeypatch, tmp_path, capsys): """#F4: query CLI must refuse to parse a graph.json that exceeds the cap.""" import pytest