diff --git a/graphify/watch.py b/graphify/watch.py index 4a007644..ecc17218 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 diff --git a/tests/test_watch.py b/tests/test_watch.py index 49957cb7..db1e5ce4 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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