From 3cb00b4da4ad42aed69f5c441482b52f53d9cfaa Mon Sep 17 00:00:00 2001 From: Vasu Bansal Date: Sat, 5 Sep 2026 03:22:42 +0530 Subject: [PATCH 1/2] fix: preserve external imports across incremental extraction --- graphify/build.py | 26 +++++++++ graphify/cli.py | 4 ++ graphify/extract.py | 5 +- tests/test_external_import_incremental.py | 71 +++++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/test_external_import_incremental.py diff --git a/graphify/build.py b/graphify/build.py index bb03fe1f5..db2558047 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -1502,6 +1502,29 @@ def _load_existing_graph(graph_path: Path) -> "tuple[list, list, list, bool] | N ) +def _sweep_raw_orphans(previous: dict, current: dict) -> None: + """Remove unowned nodes whose last reference this raw update removed. + + Preserve already-isolated nodes and references through hyperedges, as + neither is evidence that an update orphaned an external import stub. + """ + def referenced(data: dict) -> set: + ids = set() + for edge in data.get("links", data.get("edges", [])): + if isinstance(edge, dict): + ids.update((edge.get("source"), edge.get("target"))) + for hyperedge in data.get("hyperedges", []): + if isinstance(hyperedge, dict): + ids.update(hyperedge.get("nodes", [])) + return ids + + lost_references = referenced(previous) - referenced(current) + current["nodes"] = [ + n for n in current.get("nodes", []) + if n.get("source_file") or n.get("id") not in lost_references + ] + + def merge_raw_extraction( new: dict, graph_path: str | Path, @@ -1644,6 +1667,9 @@ def _dropped(item: dict) -> bool: carried_hyper = [he for he in existing_hyperedges if not _dropped(he)] if carried_hyper or new.get("hyperedges"): new["hyperedges"] = carried_hyper + list(new.get("hyperedges", [])) + _sweep_raw_orphans( + {"edges": existing_edges, "hyperedges": existing_hyperedges}, new, + ) if unverified_semantic_shrink: new["_unverified_semantic_shrink"] = unverified_semantic_shrink return new diff --git a/graphify/cli.py b/graphify/cli.py index 6642f57d5..d1573bc45 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -620,10 +620,14 @@ def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int len(kept_hyper) == len(data.get("hyperedges", [])) ): return 0 + previous = dict(data) data["nodes"] = kept_nodes data[links_key] = kept_edges if "hyperedges" in data: data["hyperedges"] = kept_hyper + from graphify.build import _sweep_raw_orphans + _sweep_raw_orphans(previous, data) + n_removed = len(nodes) - len(data["nodes"]) from graphify.export import backup_if_protected as _backup _backup(graph_path.parent) from graphify.paths import write_json_atomic diff --git a/graphify/extract.py b/graphify/extract.py index e015c9d71..99d7da93f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1622,7 +1622,10 @@ def _resolve_rescued_specifier( module_name = raw.split("/")[-1] if not module_name: return None - return _make_id(module_name), raw, None + # A package specifier is not a local source path. Match other external + # symbols' unowned source metadata, or incremental extraction mistakes + # the package name for a deleted file and prunes its live import edge. + return _make_id(module_name), "", None def _emit_rescued_import( diff --git a/tests/test_external_import_incremental.py b/tests/test_external_import_incremental.py new file mode 100644 index 000000000..d1a7061b8 --- /dev/null +++ b/tests/test_external_import_incremental.py @@ -0,0 +1,71 @@ +"""External package references must survive a no-change extraction.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from graphify.build import _sweep_raw_orphans + + +@pytest.mark.parametrize("specifier", ["external-package", "@scope/external-package"]) +@pytest.mark.parametrize("no_cluster", [True, False]) +def test_external_import_survives_incremental_extract(tmp_path, specifier, no_cluster): + corpus = tmp_path / "corpus" + corpus.mkdir() + loader = corpus / "loader.ts" + loader.write_text(f"export async function load() {{ return import('{specifier}'); }}\n") + other = corpus / "other.ts" + other.write_text("export function other() { return 1; }\n") + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1])) + + def run(): + subprocess.run( + [sys.executable, "-m", "graphify", "extract", "corpus", "--code-only", + "--max-workers", "1", "--out", "result"] + + (["--no-cluster"] if no_cluster else []), + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30, check=True, + ) + return json.loads((tmp_path / "result/graphify-out/graph.json").read_text()) + + def imports(graph): + ids = {n["id"] for n in graph["nodes"] if n.get("label") == specifier} + return [e for e in graph.get("links", graph.get("edges", [])) + if e["target"] in ids and e.get("relation") == "dynamic_import"] + + fresh = run() + assert imports(fresh), "fresh extraction must establish the dependency" + unchanged = run() + assert imports(unchanged) == imports(fresh) + other.write_text("export function other() { return 2; }\n") + assert imports(run()) == imports(fresh) + second = corpus / "second.ts" + second.write_text(f"export async function second() {{ return import('{specifier}'); }}\n") + assert len(imports(run())) == 2 + loader.unlink() + deleted = run() + assert not any(n.get("source_file") == "loader.ts" for n in deleted["nodes"]) + assert len(imports(deleted)) == 1, "the surviving importer still needs this package" + # An exclusion-only update also runs raw graph pruning before early exit. + (corpus / ".graphifyignore").write_text("second.ts\n") + excluded = run() + assert not imports(excluded) + assert not any(n.get("label") == specifier for n in excluded["nodes"]) + + +def test_raw_orphan_sweep_preserves_standalone_and_hyperedge_nodes(): + previous = {"edges": [{"source": "file", "target": name} + for name in ("removed", "group-member", "still-used")]} + current = { + "nodes": [{"id": name, "source_file": ""} + for name in ("removed", "group-member", "still-used", "standalone")] + + [{"id": "owned", "source_file": "keep.ts"}], + "edges": [{"source": "other", "target": "still-used"}], + "hyperedges": [{"nodes": ["group-member", "other", "owned"]}], + } + _sweep_raw_orphans(previous, current) + assert {n["id"] for n in current["nodes"]} == { + "group-member", "still-used", "standalone", "owned", + } From 7818da95b5db095bdf87d55b6418015c6d6e2dcc Mon Sep 17 00:00:00 2001 From: Vasu Bansal Date: Sat, 5 Sep 2026 03:24:04 +0530 Subject: [PATCH 2/2] fix: preserve nested hyperedge references during orphan cleanup --- graphify/build.py | 4 +++- tests/test_external_import_incremental.py | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index db2558047..dd8661291 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -1513,7 +1513,9 @@ def referenced(data: dict) -> set: for edge in data.get("links", data.get("edges", [])): if isinstance(edge, dict): ids.update((edge.get("source"), edge.get("target"))) - for hyperedge in data.get("hyperedges", []): + metadata = data.get("graph", {}) + nested_hyperedges = metadata.get("hyperedges", []) if isinstance(metadata, dict) else [] + for hyperedge in list(data.get("hyperedges", [])) + list(nested_hyperedges): if isinstance(hyperedge, dict): ids.update(hyperedge.get("nodes", [])) return ids diff --git a/tests/test_external_import_incremental.py b/tests/test_external_import_incremental.py index d1a7061b8..9ca11c556 100644 --- a/tests/test_external_import_incremental.py +++ b/tests/test_external_import_incremental.py @@ -55,7 +55,8 @@ def imports(graph): assert not any(n.get("label") == specifier for n in excluded["nodes"]) -def test_raw_orphan_sweep_preserves_standalone_and_hyperedge_nodes(): +@pytest.mark.parametrize("nested", [True, False]) +def test_raw_orphan_sweep_preserves_standalone_and_hyperedge_nodes(nested): previous = {"edges": [{"source": "file", "target": name} for name in ("removed", "group-member", "still-used")]} current = { @@ -65,7 +66,27 @@ def test_raw_orphan_sweep_preserves_standalone_and_hyperedge_nodes(): "edges": [{"source": "other", "target": "still-used"}], "hyperedges": [{"nodes": ["group-member", "other", "owned"]}], } + if nested: + current["graph"] = {"hyperedges": current.pop("hyperedges")} _sweep_raw_orphans(previous, current) assert {n["id"] for n in current["nodes"]} == { "group-member", "still-used", "standalone", "owned", } + + +def test_exclusion_pruning_preserves_nested_hyperedge_members(tmp_path): + from graphify.cli import _prune_graph_json_sources + + graph_path = tmp_path / "graph.json" + graph_path.write_text(json.dumps({ + "nodes": [{"id": "old", "source_file": "old.ts"}, + {"id": "package", "source_file": ""}, + {"id": "a", "source_file": "keep.ts"}, + {"id": "b", "source_file": "keep.ts"}], + "links": [{"source": "old", "target": "package", "source_file": "old.ts"}], + "graph": {"hyperedges": [{"nodes": ["package", "a", "b"]}]}, + })) + assert _prune_graph_json_sources(graph_path, ["old.ts"]) == 1 + assert {n["id"] for n in json.loads(graph_path.read_text())["nodes"]} == { + "package", "a", "b", + }