diff --git a/graphify/dedup.py b/graphify/dedup.py index 7c74371ff..8eb1fe6c1 100644 --- a/graphify/dedup.py +++ b/graphify/dedup.py @@ -1030,7 +1030,10 @@ def deduplicate_entities( # Remove legacy keys so they don't leak into edge attrs in graph.json. e.pop("from", None) e.pop("to", None) - if e["source"] != e["target"]: + # Drop only self-loops created by the merge (an edge whose distinct + # endpoints collapsed into one node); preserve pre-existing self-loops + # (e.g. recursive calls, self-referencing foreign keys) (#3809). + if e["source"] != e["target"] or src == tgt: deduped_edges.append(e) return deduped_nodes, deduped_edges diff --git a/tests/test_dedup.py b/tests/test_dedup.py index 07e4614b7..b855fbce5 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -94,6 +94,48 @@ def test_self_loops_dropped_after_merge(): assert result_edges == [] +def test_preexisting_self_loops_preserved_after_unrelated_merge(): + # #3809: Pre-existing self-loops (e.g. recursive calls, self-referencing FKs) + # must survive entity deduplication even when an unrelated merge occurs. + # Only self-loops created by the merge itself should be dropped. + nodes = [ + {"id": "walk", "label": "walk()", "source_file": "src/tree.py", "file_type": "code"}, + {"id": "categories", "label": "categories", "source_file": "schema.sql", "file_type": "code"}, + {"id": "cache", "label": "Cache", "source_file": "docs/guide.md", "file_type": "concept"}, + {"id": "cache_2", "label": "cache", "source_file": "docs/guide.md", "file_type": "concept"}, + ] + edges = [ + {"source": "walk", "target": "walk", "relation": "calls"}, + {"source": "categories", "target": "categories", "relation": "references"}, + # An edge between the duplicate concepts that collapses upon merge + {"source": "cache_2", "target": "cache", "relation": "references"}, + ] + _, result_edges = deduplicate_entities(nodes, edges, communities={}) + + loops = [(e["source"], e["target"], e.get("relation")) for e in result_edges if e["source"] == e["target"]] + assert ("walk", "walk", "calls") in loops + assert ("categories", "categories", "references") in loops + # The collapsed edge cache_2 -> cache must NOT produce a self-loop + assert len(loops) == 2 + + +def test_preexisting_self_loop_on_merged_node_rewires_to_winner(): + # If a merged node itself had a pre-existing self-loop, the self-loop + # is rewired to the winner, not dropped. + nodes = [ + {"id": "cache", "label": "Cache", "source_file": "docs/guide.md", "file_type": "concept"}, + {"id": "cache_2", "label": "cache", "source_file": "docs/guide.md", "file_type": "concept"}, + ] + edges = [ + {"source": "cache_2", "target": "cache_2", "relation": "references"}, + ] + _, result_edges = deduplicate_entities(nodes, edges, communities={}) + assert len(result_edges) == 1 + assert result_edges[0]["source"] == "cache" + assert result_edges[0]["target"] == "cache" + + + def test_community_boost_aids_merge(): # Two nodes in same community with score in 0.75-0.85 zone get boosted nodes = _make_nodes("AuthManager", "Auth Manager")