test(build): pin shrink-guard safety and shared-stub survival for orphan sweep (#2807)

Documents the target-only invariant that makes a single sweep pass sufficient,
and adds two tests: the sweep does not trip the #479 shrink guard (swept
source-less orphans count as explained loss), and a stub still referenced by a
surviving file is not swept. Also adds the CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-17 15:33:34 +01:00
co-authored by Claude Opus 4.8
parent ab73657432
commit 795102cc28
3 changed files with 37 additions and 0 deletions
+1
View File
@@ -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)
+6
View File
@@ -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")
+30
View File
@@ -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