mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 18:37:12 +00:00
fix calls edge direction flipping on bidirectional pairs in undirected build (#1061)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user