Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions tests/test_query_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down