Files
graphify/tests/test_query_cli.py
T
Yyunozor 4ace95182b 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.
2026-07-21 21:45:53 +01:00

126 lines
4.8 KiB
Python

"""Tests for graphify query CLI context filtering."""
from __future__ import annotations
import json
import networkx as nx
from networkx.readwrite import json_graph
import graphify.__main__ as mainmod
def _write_graph(tmp_path):
G = nx.Graph()
G.add_node("n1", label="extract", source_file="extract.py", source_location="L10", community=0)
G.add_node("n2", label="cluster", source_file="cluster.py", source_location="L5", community=0)
G.add_node("n3", label="build", source_file="build.py", source_location="L1", community=1)
G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", context="call")
G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED", context="import")
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_explicit_context_filter(monkeypatch, tmp_path, capsys):
graph_path = _write_graph(tmp_path)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "query", "extract", "--context", "call", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out
assert "Context: call (explicit)" in out
assert "cluster" in out
assert "build" not in out
def test_query_cli_heuristic_context_filter(monkeypatch, tmp_path, capsys):
graph_path = _write_graph(tmp_path)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "query", "who calls extract", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out
assert "Context: call (heuristic)" in out
assert "cluster" in out
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
graph_path = _write_graph(tmp_path)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr("graphify.security._MAX_GRAPH_FILE_BYTES", 16)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "query", "extract", "--graph", str(graph_path)],
)
with pytest.raises(SystemExit):
mainmod.main()
err = capsys.readouterr().err
assert "exceeds" in err
assert "byte cap" in err