diff --git a/graphify/build.py b/graphify/build.py index a4d33c5e..07fbb034 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -210,6 +210,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat # causing display functions to show edges backwards. attrs["_src"] = src attrs["_tgt"] = tgt + # When the graph is undirected and the same node pair appears twice with + # the same relation but opposite directions (e.g. a `calls` b and b `calls` a), + # nx.Graph collapses them into one edge. The deterministic sort above means + # the lexicographically-later direction would systematically overwrite the + # earlier one's _src/_tgt, silently flipping the surviving edge's caller + # and callee. First-seen direction wins instead — drop the redundant + # reverse-direction duplicate so the original direction is preserved (#1061). + if not G.is_directed() and G.has_edge(src, tgt): + existing = edge_data(G, src, tgt) + if existing.get("relation") == attrs.get("relation") and ( + existing.get("_src") == tgt and existing.get("_tgt") == src + ): + continue G.add_edge(src, tgt, **attrs) hyperedges = extraction.get("hyperedges", []) if hyperedges: diff --git a/tests/test_build.py b/tests/test_build.py index 065d7f41..9be6c128 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -216,6 +216,65 @@ def test_build_merge_preserves_call_edge_direction(tmp_path): ) +def test_build_from_json_preserves_first_direction_on_bidirectional_pair(tmp_path): + """Regression for #1061. + + When an extraction emits two `calls` edges between the same pair in + opposite directions (mutual recursion, callbacks, event handlers, etc.), + nx.Graph collapses them into a single undirected edge. The deterministic + edge sort introduced in #1010 ordered edges by (source, target, relation), + so the lexicographically-later direction always wrote second and clobbered + the first edge's _src/_tgt — the surviving edge then exported with caller + and callee systematically swapped on every collision. + + build_from_json must keep the first-seen direction for the surviving edge + instead of letting the second add_edge overwrite _src/_tgt. + """ + from graphify.export import to_json + + # Lexicographic order of (src, tgt, rel) puts `a` < `z` first, so the sort + # processes `a -> z` BEFORE `z -> a`. Without the fix, the second write + # overwrites _src/_tgt and the exported edge becomes z -> a. With the fix, + # the first-seen `a -> z` direction is preserved. + extraction = { + "nodes": [ + {"id": "a_handler", "label": "a", "file_type": "code", "source_file": "a.ts"}, + {"id": "z_emitter", "label": "z", "file_type": "code", "source_file": "z.ts"}, + ], + "edges": [ + {"source": "a_handler", "target": "z_emitter", "relation": "calls", + "confidence": "EXTRACTED", "source_file": "a.ts"}, + {"source": "z_emitter", "target": "a_handler", "relation": "calls", + "confidence": "EXTRACTED", "source_file": "z.ts"}, + ], + "input_tokens": 0, + "output_tokens": 0, + } + G = build_from_json(extraction) + # Only one undirected edge between the pair survives, but its stored + # direction must be the first-seen one (a_handler -> z_emitter), not the + # lexicographically-later one (z_emitter -> a_handler). + assert G.number_of_edges() == 1 + data = edge_data(G, "a_handler", "z_emitter") + assert data["_src"] == "a_handler" + assert data["_tgt"] == "z_emitter" + + graph_path = tmp_path / "graph.json" + assert to_json(G, {}, str(graph_path), force=True) + saved = json.loads(graph_path.read_text()) + saved_calls = [e for e in saved.get("links", saved.get("edges", [])) + if e.get("relation") == "calls"] + assert len(saved_calls) == 1 + assert saved_calls[0]["source"] == "a_handler", ( + f"calls edge source flipped on bidirectional collision: " + f"expected a_handler, got {saved_calls[0]['source']}" + ) + assert saved_calls[0]["target"] == "z_emitter", ( + f"calls edge target flipped on bidirectional collision: " + f"expected z_emitter, got {saved_calls[0]['target']}" + ) + + # Regression tests for #796 — edge_data / edge_datas helpers must tolerate # MultiGraph and MultiDiGraph, which networkx's node_link_graph() produces # whenever the loaded JSON has multigraph: true. Plain G.edges[u, v] crashes diff --git a/uv.lock b/uv.lock index 1f7038a6..a6bea3d5 100644 --- a/uv.lock +++ b/uv.lock @@ -1110,7 +1110,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.8.22" +version = "0.8.23" source = { editable = "." } dependencies = [ { name = "datasketch" },