mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-29 01:36:33 +00:00
`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.
126 lines
4.8 KiB
Python
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
|