diff --git a/CHANGELOG.md b/CHANGELOG.md index f750348e..d477a85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.46 (unreleased) +- Fix: pruning a source file now also sweeps the external-import placeholder nodes it strands at degree 0, instead of leaving them to accumulate in the node count, `GRAPH_REPORT.md`, and exports; genuinely-isolated real nodes (which carry a `source_file`) are never touched (#2807, thanks @abhay-codes07). - Fix: when two edges connect the same node pair with different relations, the graph builder now keeps the more specific relation (`calls`, `imports`, `inherits`, ...) instead of letting a generic `references`/`uses`/`mentions` overwrite it; previously a real `calls` could be downgraded to `references` and then dropped from the call graph (#2803, thanks @abhay-codes07). ## 0.9.45 (2026-08-16) diff --git a/graphify/build.py b/graphify/build.py index d2309151..6cae7539 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -1839,6 +1839,12 @@ def build_merge( # match on source_file. A node with neither a source_file nor an edge # names nothing and connects nothing, so dropping it loses no # information (#2807). + # + # A single pass suffices: external-import stubs are only ever edge + # TARGETS (extractors mint them as the target of an imports_from/ + # references/inherits edge, never as a source), so removing one can + # never drop another to degree 0. A future extractor emitting a + # stub->stub edge would require iterating this to a fixpoint. orphaned = [ n for n, d in G.nodes(data=True) if not d.get("source_file") diff --git a/tests/test_prune_sweeps_orphans.py b/tests/test_prune_sweeps_orphans.py index 46c6265f..925f0ffe 100644 --- a/tests/test_prune_sweeps_orphans.py +++ b/tests/test_prune_sweeps_orphans.py @@ -153,3 +153,33 @@ def test_a_prune_that_matches_nothing_sweeps_nothing(tmp_path): def test_pruning_every_file_leaves_an_empty_graph(tmp_path): G = _prune(_corpus_graph(), tmp_path, ["a.py", "b.py"]) assert G.number_of_nodes() == 0, sorted(G.nodes) + + +def test_sweeping_an_orphan_does_not_trip_the_shrink_guard(tmp_path): + """The #479 shrink guard raises on unexplained node loss. Swept orphans are + source-less, so they must be treated as explained: build_merge completes + (does not raise) and returns the pruned graph with the orphan gone.""" + # build_merge itself runs the guard; this pruning both a file AND sweeping + # its stranded stub must not raise. + G = _prune(_corpus_graph(), tmp_path, ["a.py"]) + assert "mod_a_path" not in G.nodes # swept orphan + assert "mod_b_keep" in G.nodes # unrelated file survives + + +def test_a_stub_referenced_by_a_surviving_file_is_not_swept(tmp_path): + """A stub shared by two files must survive when only one referrer is pruned + (single-pass sweep only removes it once it is genuinely degree 0).""" + nodes = [ + {"id": "mod_a_run", "label": "run()", "file_type": "code", "source_file": "a.py"}, + {"id": "mod_b_run", "label": "run()", "file_type": "code", "source_file": "b.py"}, + {"id": "ext_path", "label": "Path", "file_type": "code"}, # shared external stub + ] + edges = [ + {"source": "mod_a_run", "target": "ext_path", "relation": "references", + "confidence": "EXTRACTED", "source_file": "a.py"}, + {"source": "mod_b_run", "target": "ext_path", "relation": "references", + "confidence": "EXTRACTED", "source_file": "b.py"}, + ] + G = _prune(build_from_json(_extraction(nodes, edges)), tmp_path, ["a.py"]) + assert "ext_path" in G.nodes, "stub still referenced by b.py must not be swept" + assert "mod_b_run" in G.nodes