From cde0c28eebbeddec68719469aaba3f3e22ac6340 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 | 12 +++++++-- tests/test_query_cli.py | 55 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index b6da90202..8283cbd8c 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 20e964723..fd82682e2 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -860,6 +860,14 @@ def _adj(n): 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) 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 +879,10 @@ def _adj(n): 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 cf8eb6e56..0db4e6fa8 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