fix: preserve edge direction in merge-graphs

This commit is contained in:
himanshupatro-334
2026-07-29 17:02:00 +01:00
committed by safishamsi
parent a428378ad3
commit 6d6b674b20
4 changed files with 115 additions and 3 deletions
+8 -3
View File
@@ -1450,15 +1450,20 @@ def prefix_graph_for_global(G: nx.Graph, repo_tag: str) -> nx.Graph:
"""Return a copy of G with all node IDs prefixed with repo_tag::.
Labels are preserved unchanged (for display). A 'local_id' attribute
is added to each node so the original ID can be recovered. Edges are
rewritten to match the new prefixed IDs. The 'repo' attribute is set
on every node.
is added to each node so the original ID can be recovered. Edges and
their directional attributes (_src/_tgt) are rewritten to match the new
prefixed IDs. The 'repo' attribute is set on every node.
"""
relabel = {n: f"{repo_tag}::{n}" for n in G.nodes}
H = nx.relabel_nodes(G, relabel, copy=True)
for node, data in H.nodes(data=True):
data["repo"] = repo_tag
data.setdefault("local_id", node.split("::", 1)[1])
for u, v, data in H.edges(data=True):
if "_src" in data and data["_src"] in relabel:
data["_src"] = relabel[data["_src"]]
if "_tgt" in data and data["_tgt"] in relabel:
data["_tgt"] = relabel[data["_tgt"]]
return H
+16
View File
@@ -2093,6 +2093,15 @@ def dispatch_command(cmd: str) -> None:
# via node_link_data but older runs may have used "edges" (#738).
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
# Preserve stored edge direction across undirected node_link_graph (#2261).
# Mirrors cli.py's query pattern and export.py's _src/_tgt restoration.
data = dict(
data,
links=[
{**link, "_src": link.get("source"), "_tgt": link.get("target")}
for link in data.get("links", [])
],
)
try:
G = _jg.node_link_graph(data, edges="links")
except TypeError:
@@ -2129,6 +2138,13 @@ def dispatch_command(cmd: str) -> None:
out_data = _jg.node_link_data(merged, edges="links")
except TypeError:
out_data = _jg.node_link_data(merged)
# Restore original edge direction from _src/_tgt markers (same pattern as export.py #563/#2261)
for link in out_data.get("links", []):
tsrc = link.pop("_src", None)
ttgt = link.pop("_tgt", None)
if tsrc is not None and ttgt is not None:
link["source"] = tsrc
link["target"] = ttgt
out_path.parent.mkdir(parents=True, exist_ok=True)
from graphify.paths import write_json_atomic as _wja
_wja(out_path, out_data, indent=2)
+16
View File
@@ -66,6 +66,22 @@ def test_prefix_graph_rewrites_edges():
assert not H.has_edge("a", "b")
def test_prefix_graph_rewrites_edge_directional_attributes():
"""prefix_graph_for_global must update directional edge attributes (_src/_tgt)
so they stay aligned with the prefixed node IDs (#2261)."""
from graphify.build import prefix_graph_for_global
G = _make_graph(
[{"id": "rota", "label": "rota.js"}, {"id": "collections", "label": "collections.js"}],
[{"source": "rota", "target": "collections", "relation": "imports_from", "_src": "rota", "_tgt": "collections"}],
)
H = prefix_graph_for_global(G, "repoA")
assert H.has_edge("repoA::rota", "repoA::collections")
data = H.get_edge_data("repoA::rota", "repoA::collections")
assert data["_src"] == "repoA::rota"
assert data["_tgt"] == "repoA::collections"
def test_prune_repo_removes_correct_nodes():
from graphify.build import prune_repo_from_graph
G = nx.Graph()
+75
View File
@@ -92,3 +92,78 @@ def test_distinct_repo_tags_unit(tmp_path):
Path("c/src/graphify-out/graph.json"),
])
assert len(set(tags3)) == 3, tags3
def test_merge_graphs_preserves_import_edge_direction(tmp_path):
# #2261: merge-graphs loaded graph.json into an undirected nx.Graph without
# stashing _src/_tgt directional markers, causing node_link_data to re-serialize
# edges based on arbitrary node ordering and reversing import edge direction whenever
# target node index < source node index (turning imports into self-imports/reversed links).
a = tmp_path / "repo1" / "graphify-out" / "graph.json"
b = tmp_path / "repo2" / "graphify-out" / "graph.json"
a.parent.mkdir(parents=True, exist_ok=True)
b.parent.mkdir(parents=True, exist_ok=True)
# Note: collections comes BEFORE rota in nodes list, so collections has a smaller index
a.write_text(json.dumps({
"directed": False,
"multigraph": False,
"nodes": [
{"id": "collections", "label": "collections.js"},
{"id": "empresa", "label": "empresa.js"},
{"id": "logger", "label": "logger.js"},
{"id": "rota", "label": "rota.js"},
],
"links": [
{"source": "rota", "target": "collections", "relation": "imports_from", "context": "import"},
{"source": "rota", "target": "empresa", "relation": "imports_from", "context": "import"},
{"source": "rota", "target": "logger", "relation": "imports_from", "context": "import"},
],
}))
b.write_text(json.dumps({
"directed": False,
"multigraph": False,
"nodes": [
{"id": "main", "label": "main.js"},
{"id": "utils", "label": "utils.js"},
],
"links": [
{"source": "main", "target": "utils", "relation": "imports_from", "context": "import"},
],
}))
out = tmp_path / "merged.json"
r = _run(["merge-graphs", str(a), str(b), "--out", str(out)], tmp_path)
assert r.returncode == 0, f"merge failed: {r.stderr}"
data = json.loads(out.read_text(encoding="utf-8"))
# 1. Node count & edge count correct
assert len(data["nodes"]) == 6
assert len(data["links"]) == 4
# 2. Repo prefixes applied
node_ids = {n["id"] for n in data["nodes"]}
expected_ids = {
"repo1::collections", "repo1::empresa", "repo1::logger", "repo1::rota",
"repo2::main", "repo2::utils"
}
assert node_ids == expected_ids
# 3. Import relationships preserve original direction (source=rota, target=collections/empresa/logger)
# and no import edge becomes a self-import or reversed.
repo1_links = [l for l in data["links"] if l["source"].startswith("repo1::") or l["target"].startswith("repo1::")]
assert len(repo1_links) == 3
for link in repo1_links:
src = link["source"]
tgt = link["target"]
assert src != tgt, f"import edge became a self-import: {link}"
assert src == "repo1::rota", f"expected source repo1::rota, got {src} in link {link}"
assert tgt in {"repo1::collections", "repo1::empresa", "repo1::logger"}, f"unexpected target {tgt} in link {link}"
repo2_link = [l for l in data["links"] if l["source"].startswith("repo2::")][0]
assert repo2_link["source"] == "repo2::main"
assert repo2_link["target"] == "repo2::utils"