From 978f87cb67630882b54bae1f04ee15caa6f1dea2 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 10 Aug 2026 18:06:50 +0100 Subject: [PATCH] fix(watch): rebuild on a doc-only deletion batch so deleted docs are evicted (#2580) graphify watch only rebuilt on a code-file event, so deleting only doc files while watching flagged needs_update but evicted nothing until the next code change. A batch containing any vanished path now triggers a full reconcile rebuild (which evicts the deleted source); a surviving modified doc still writes the needs-LLM flag. The general deleted-file leak was already fixed in 0.9.10; this closes the live-watcher residual. Thanks @angmeng. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/watch.py | 30 ++++++++++++++--- tests/test_watch.py | 78 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/graphify/watch.py b/graphify/watch.py index 4984bfd76..4f2b143f2 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1784,6 +1784,30 @@ def _has_non_code(changed_paths: list[Path]) -> bool: return any(p.suffix.lower() not in _CODE_EXTENSIONS for p in changed_paths) +def _batch_triggers_rebuild(batch: list[Path]) -> bool: + """True when a debounced watch batch needs an immediate rebuild. + + Code changes always rebuild (AST extraction needs no LLM). Deletions of + ANY watched file also rebuild: eviction needs no LLM either — the full + corpus reconcile drops nodes whose source is gone from disk. Without + this, a doc-only deletion batch would sit behind the needs_update flag + until the next code event or a manual `graphify update` (#2580). + """ + has_code = any(p.suffix.lower() in _CODE_EXTENSIONS for p in batch) + has_deletion = any(not p.exists() for p in batch) + return has_code or has_deletion + + +def _batch_needs_llm_flag(batch: list[Path]) -> bool: + """True when the batch contains a non-code file that still exists on disk. + + Only surviving non-code files need the needs_update flag (LLM-backed + re-extraction); deleted ones are already handled by the rebuild's + reconcile sweep, so a pure-deletion batch must not leave a stale flag. + """ + return _has_non_code([p for p in batch if p.exists()]) + + def watch(watch_path: Path, debounce: float = 3.0) -> None: """ Watch watch_path for new or modified files and auto-update the graph. @@ -1864,11 +1888,9 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None: batch = list(changed) changed.clear() print(f"\n[graphify watch] {len(batch)} file(s) changed") - has_non_code = _has_non_code(batch) - has_code = any(p.suffix.lower() in _CODE_EXTENSIONS for p in batch) - if has_code: + if _batch_triggers_rebuild(batch): _rebuild_code(watch_path) - if has_non_code: + if _batch_needs_llm_flag(batch): _notify_only(watch_path) except KeyboardInterrupt: print("\n[graphify watch] Stopped.") diff --git a/tests/test_watch.py b/tests/test_watch.py index d06140c74..401ddde95 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -7,7 +7,14 @@ import time from pathlib import Path import pytest -from graphify.watch import _notify_only, _WATCHED_EXTENSIONS, _rebuild_lock, _check_shrink +from graphify.watch import ( + _notify_only, + _WATCHED_EXTENSIONS, + _rebuild_lock, + _check_shrink, + _batch_triggers_rebuild, + _batch_needs_llm_flag, +) # --- _notify_only --- @@ -56,6 +63,75 @@ def test_watched_extensions_excludes_noise(): assert ".log" not in _WATCHED_EXTENSIONS +# --- _batch_triggers_rebuild / _batch_needs_llm_flag: watch dispatch gating --- + +def test_batch_doc_only_deletion_triggers_rebuild(tmp_path): + """#2580: deleting ONLY doc files while `graphify watch` runs must trigger + an immediate full rebuild (eviction needs no LLM), not just the + needs_update flag deferred to the next code event.""" + gone = tmp_path / "docs" / "x.md" + assert not gone.exists() + assert _batch_triggers_rebuild([gone]) is True + +def test_batch_doc_only_deletion_skips_llm_flag(tmp_path): + """A pure-deletion batch has nothing left needing LLM re-extraction — + the reconcile sweep inside the rebuild evicts it, so no needs_update flag.""" + gone = tmp_path / "docs" / "x.md" + assert not gone.exists() + assert _batch_needs_llm_flag([gone]) is False + +def test_batch_modified_doc_only_does_not_rebuild(tmp_path): + """A doc that still exists needs LLM re-extraction, not an AST rebuild: + stays on the _notify_only path.""" + doc = tmp_path / "docs" / "x.md" + doc.parent.mkdir() + doc.write_text("# heading\n", encoding="utf-8") + assert _batch_triggers_rebuild([doc]) is False + assert _batch_needs_llm_flag([doc]) is True + +def test_batch_code_file_still_triggers_rebuild(tmp_path): + """Regression: existing code-file batches keep rebuilding as before.""" + code = tmp_path / "app.py" + code.write_text("x = 1\n", encoding="utf-8") + assert _batch_triggers_rebuild([code]) is True + assert _batch_needs_llm_flag([code]) is False + +def test_batch_mixed_deletion_and_modified_doc(tmp_path): + """Deleted doc + still-existing modified doc in one debounce window: + rebuild fires for the eviction AND the flag is kept for the survivor.""" + survivor = tmp_path / "docs" / "kept.md" + survivor.parent.mkdir() + survivor.write_text("# kept\n", encoding="utf-8") + gone = tmp_path / "docs" / "gone.md" + batch = [survivor, gone] + assert _batch_triggers_rebuild(batch) is True + assert _batch_needs_llm_flag(batch) is True + +def test_doc_only_deletion_full_rebuild_evicts_md_nodes(tmp_path): + """End-to-end pin for #2580: the full rebuild the watcher now triggers on + a doc-only deletion actually evicts the deleted .md's nodes.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "app.py").write_text("def run(): pass\n", encoding="utf-8") + doc = corpus / "notes.md" + doc.write_text("# Notes\n", encoding="utf-8") + + assert _rebuild_code(corpus, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + labels = {n["label"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]} + assert "notes.md" in labels + + doc.unlink() + batch = [doc] + assert _batch_triggers_rebuild(batch) is True + assert _rebuild_code(corpus, acquire_lock=False) is True + labels = {n["label"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]} + assert "notes.md" not in labels, "deleted doc's nodes must be evicted by the watch-triggered rebuild" + assert "run()" in labels + + # --- watch() import error without watchdog --- def test_check_update_no_flag_returns_true(tmp_path):