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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-10 18:06:50 +01:00
co-authored by Claude Opus 4.8
parent 92274745ad
commit 978f87cb67
2 changed files with 103 additions and 5 deletions
+26 -4
View File
@@ -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.")
+77 -1
View File
@@ -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):