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:
co-authored by
Claude Opus 4.8
parent
92274745ad
commit
978f87cb67
+26
-4
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user