fix(watch): evict stale nodes in full re-extraction path when changed_paths is None (#1007)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-27 11:21:55 +01:00
parent b07f0eb6a7
commit d1d5751fa4
2 changed files with 54 additions and 9 deletions
+23 -9
View File
@@ -340,7 +340,7 @@ def _rebuild_code(
try:
from graphify.extract import extract, _get_extractor
from graphify.detect import detect
from graphify.build import build_from_json
from graphify.build import build_from_json, _norm_source_file as _nsf
from graphify.cluster import cluster, remap_communities_to_previous, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
@@ -375,10 +375,7 @@ def _rebuild_code(
# File was deleted, renamed away, or filtered out by detect
# (e.g. .gitignore, vendored). Either way, evict any
# preserved nodes that still claim this source path.
try:
deleted_paths.add(cand.relative_to(project_root).as_posix())
except ValueError:
deleted_paths.add(cand.as_posix())
deleted_paths.add(_nsf(str(cand), str(project_root)) or str(cand))
if not wanted and not deleted_paths:
print("[graphify watch] No tracked code files in change set - skipping rebuild.")
return True
@@ -412,10 +409,27 @@ def _rebuild_code(
evict_sources: set[str] = set(deleted_paths)
if changed_paths is not None:
for p in extract_targets:
try:
evict_sources.add(p.relative_to(project_root).as_posix())
except ValueError:
evict_sources.add(p.as_posix())
evict_sources.add(_nsf(str(p), str(project_root)) or str(p))
else:
# Full re-extraction: reconcile against current code files to
# evict nodes from files deleted since the last run (#1007).
_root_str = str(project_root)
current_sources = {
_nsf(str(p.relative_to(project_root)), _root_str)
for p in code_files
if p.is_relative_to(project_root)
}
for n in existing.get("nodes", []):
sf = n.get("source_file")
if not sf:
continue
if Path(sf).suffix.lower() not in _CODE_EXTENSIONS:
continue
norm = _nsf(sf, _root_str)
if norm not in current_sources:
evict_sources.add(sf)
evict_sources.add(norm)
deleted_paths.add(norm)
preserved_nodes = [
n for n in existing.get("nodes", [])
if n["id"] not in new_ast_ids
+31
View File
@@ -141,6 +141,37 @@ def test_rebuild_lock_does_not_accumulate_pids_across_runs(tmp_path):
assert not lock_path.exists()
def test_rebuild_code_evicts_nodes_from_deleted_files(tmp_path):
"""#1007: graphify update (_rebuild_code with no changed_paths) must remove
nodes and edges from files deleted since the last run."""
import json
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "auth.py").write_text(
"def login(): pass\ndef logout(): pass\n", encoding="utf-8"
)
(corpus / "utils.py").write_text(
"def format_date(): pass\n", encoding="utf-8"
)
assert _rebuild_code(corpus, acquire_lock=False) is True
graph_path = corpus / "graphify-out" / "graph.json"
data = json.loads(graph_path.read_text(encoding="utf-8"))
node_labels_before = {n["label"] for n in data.get("nodes", [])}
assert "format_date()" in node_labels_before
(corpus / "utils.py").unlink()
assert _rebuild_code(corpus, acquire_lock=False) is True
data = json.loads(graph_path.read_text(encoding="utf-8"))
node_labels_after = {n["label"] for n in data.get("nodes", [])}
assert "format_date()" not in node_labels_after, "stale function node from deleted file must be evicted"
assert "login()" in node_labels_after, "nodes from surviving file must be kept"
@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)")
def test_rebuild_lock_non_blocking_does_not_clobber_holder(tmp_path):
"""GH-858: a non-blocking caller that fails to acquire the lock must not