Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -1502,6 +1502,31 @@ 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")))
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

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionmerge_raw_extraction()

fans out to 10 callees (efferent coupling); 8 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added callee is _sweep_raw_orphans, in the same module. Both raw-merge and exclusion-only pruning need the same last-reference rule, so the shared helper keeps their behavior consistent. The regression tests cover shared importers, removal of the last importer, standalone nodes, and both hyperedge layouts. I checked the diff and am keeping the shared helper; this coupling-count increase does not identify a correctness regression.

new: dict,
graph_path: str | Path,
Expand Down Expand Up @@ -1644,6 +1669,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
Expand Down
4 changes: 4 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
92 changes: 92 additions & 0 deletions tests/test_external_import_incremental.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""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"])


@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 = {
"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"]}],
}
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",
}