fix: preserve calls edge direction in graphify query output

`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.
This commit is contained in:
Yyunozor
2026-07-21 21:45:53 +01:00
committed by safishamsi
parent deb2620be9
commit 4ace95182b
3 changed files with 86 additions and 2 deletions
+15
View File
@@ -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:
+16 -2
View File
@@ -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)
+55
View File
@@ -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