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