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
22 changes: 19 additions & 3 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,11 @@ def main() -> 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"])
# #849: the serialized link.source/link.target fields encode the original
# caller→callee (or import→importee) direction regardless of the top-level
# "directed" flag the build wrote. Force directed interpretation on read so
# the renderer can recover and display the correct arrow direction below.
_raw = {**_raw, "directed": True}
try:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
Expand Down Expand Up @@ -1547,7 +1552,9 @@ def main() -> None:
file=sys.stderr,
)
try:
path_nodes = _nx.shortest_path(G, src_nid, tgt_nid)
# Traverse on the undirected view so a path can be found regardless of
# which direction the user supplied src/tgt in.
path_nodes = _nx.shortest_path(G.to_undirected(as_view=True), src_nid, tgt_nid)
except (_nx.NetworkXNoPath, _nx.NodeNotFound):
print(f"No path found between '{source_label}' and '{target_label}'.")
sys.exit(0)
Expand All @@ -1556,13 +1563,22 @@ def main() -> None:
from graphify.build import edge_data
for i in range(len(path_nodes) - 1):
u, v = path_nodes[i], path_nodes[i + 1]
edata = edge_data(G, u, v)
# #849: render the arrow in the original stored edge direction.
if G.has_edge(u, v):
edata = edge_data(G, u, v)
forward = True
else:
edata = edge_data(G, v, u)
forward = False
rel = edata.get("relation", "")
conf = edata.get("confidence", "")
conf_str = f" [{conf}]" if conf else ""
if i == 0:
segments.append(G.nodes[u].get("label", u))
segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
if forward:
segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
else:
segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}")
print(f"Shortest path ({hops} hops):\n " + " ".join(segments))

elif cmd == "explain":
Expand Down
92 changes: 92 additions & 0 deletions tests/test_path_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Tests for `graphify path` arrow-direction rendering (regression for #849)."""
from __future__ import annotations

import json

import networkx as nx
from networkx.readwrite import json_graph

import graphify.__main__ as mainmod


def _write_undirected_graph(tmp_path):
"""Write a graph.json with `directed: false` (the build's default) but link
source/target encoding caller→callee. This mirrors the on-disk shape that
ships from `graphify update`."""
G = nx.Graph()
G.add_node(
"create_patch_handler",
label="createPatchHandler()",
source_file="server/create-patch-handler.ts",
source_location="L14",
community=0,
)
G.add_node(
"validate_sanity_session",
label="validateSanitySession()",
source_file="server/sanity-validate-session.ts",
source_location="L9",
community=0,
)
# The link is serialized as source=createPatchHandler, target=validateSanitySession
# because nx.Graph.add_edge(u, v, ...) places u in `source` and v in `target`
# when round-tripped through node_link_data.
G.add_edge(
"create_patch_handler",
"validate_sanity_session",
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")))
# Sanity-check the on-disk shape matches the production build (#849 reproducer).
on_disk = json.loads(graph_path.read_text())
assert on_disk["directed"] is False
assert on_disk["links"][0]["source"] == "create_patch_handler"
assert on_disk["links"][0]["target"] == "validate_sanity_session"
return graph_path


def _run_path(monkeypatch, graph_path, source_label, target_label, capsys):
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "path", source_label, target_label, "--graph", str(graph_path)],
)
mainmod.main()
return capsys.readouterr().out


def test_path_renders_forward_arrow_when_traversal_matches_edge_direction(
monkeypatch, tmp_path, capsys
):
"""createPatchHandler --calls--> validateSanitySession is the stored direction;
querying in that order should print the arrow pointing right."""
graph_path = _write_undirected_graph(tmp_path)
out = _run_path(
monkeypatch, graph_path, "createPatchHandler", "validateSanitySession", capsys
)
assert "Shortest path (1 hops):" in out
assert "createPatchHandler() --calls [EXTRACTED]--> validateSanitySession()" in out


def test_path_renders_reverse_arrow_when_traversal_opposes_edge_direction(
monkeypatch, tmp_path, capsys
):
"""When the user queries `path B A` but the stored edge is A→B, the arrow must
point back at A — not forward at A as if B called A. This is the #849 regression."""
graph_path = _write_undirected_graph(tmp_path)
out = _run_path(
monkeypatch, graph_path, "validateSanitySession", "createPatchHandler", capsys
)
assert "Shortest path (1 hops):" in out
# Reverse arrow: traversal goes validate→create but edge points create→validate.
assert (
"validateSanitySession() <--calls [EXTRACTED]-- createPatchHandler()" in out
)
# Critical guard: must NOT render forward in this direction (the #849 bug output).
assert (
"validateSanitySession() --calls [EXTRACTED]--> createPatchHandler()" not in out
)