From cb8032e802cc07d9bbbbef1c62adf99c9be84dfd Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:35:30 +0530 Subject: [PATCH 1/6] Stop pruning a dead manifest row before it can be reported Fixes issue 3426. save_manifest pruned a row for a file no longer on disk on its own, unconditionally, on the theory that a genuinely deleted file's row is dead weight. But detect_incremental is what reports a deletion to callers through deleted_files, and it runs before this function is asked to save again. Pruning the row here unconditionally could erase it before a caller that saves without also pruning the graph, a scan only run, an interrupted pipeline, ever got to act on that report, making a genuine deletion permanently unreportable from the very next run onward. When the caller supplies the full scan corpus, the existing excluded but alive check already prunes an in root row the scan no longer covers, which a deleted file always satisfies on path alone, regardless of whether it still exists. A full scan caller still cleans the row up, just sequenced after detect_incremental has had the chance to report it on that same scan, matching the ordinary update path exactly. Only a partial or subset caller with no scan corpus now leaves a dead row in place; the next full scan reconciles it. Co-Authored-By: Claude Sonnet 5 --- graphify/detect.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index eec8aaf1d2..c05a2c4bc1 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -2323,23 +2323,30 @@ def _normalise_entry(entry): # Seed from the existing manifest so incremental callers passing a subset # of files don't silently erase entries for untouched files (#917). - # Prune entries whose file no longer exists on disk — those are genuine - # deletions that detect_incremental() should treat as gone. When the - # caller supplied the full scan corpus, additionally prune in-root rows - # the scan no longer covers: those files were excluded, not deleted, and - # keeping the row makes them look deleted on every future run (#1908). + # + # A row for a file no longer on disk is NOT pruned here (#3426): that was + # this function's own doing until this fix, on the theory that a + # genuinely deleted file's row is dead weight. But detect_incremental() + # is what REPORTS a deletion to callers via deleted_files, and it runs + # before this function is asked to save again — pruning the row here, + # unconditionally, could erase it before a caller that saves without + # also pruning the graph (a scan-only run, an interrupted pipeline) ever + # gets to act on the report, making that genuine deletion permanently + # unreportable from the very next run onward. When the caller supplied + # the full scan corpus, the check below already prunes an in-root row + # the scan no longer covers — which a deleted file always satisfies, on + # path alone, regardless of whether it still exists — so a full-scan + # caller still cleans the row up, just sequenced after + # detect_incremental() has had the chance to report it on that same + # scan. Only a partial/subset caller (no scan_corpus) now leaves a dead + # row in place; the next full scan reconciles it. manifest: dict[str, dict] = {} for f, entry in existing.items(): normalised = _normalise_entry(entry) if normalised is None: continue - try: - if not Path(f).exists(): - continue - except OSError: - continue if scan_set is not None and not _in_scan(f) and _in_root(f): - continue # excluded-but-alive: drop the stale row (#1908) + continue # excluded-or-deleted, not in this scan: drop the stale row (#1908) if clear_ast_set is not None and _in_clear_ast(f): # AST failure this run (missing extra / zero nodes, #2543): blank # both hashes so either detect_incremental kind re-queues. From fa9c5fd112080651c3840a38c0e0910fa6d4d154 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:35:45 +0530 Subject: [PATCH 2/6] Add a regression test for the deleted row survival fix A save with no scan corpus must leave a deleted file's row in place so the deletion stays reportable on the next incremental pass, instead of silently disappearing after only one report. Co-Authored-By: Claude Sonnet 5 --- tests/test_detect.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_detect.py b/tests/test_detect.py index 22099029a5..1a13aa2eb3 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -3182,6 +3182,41 @@ def test_save_manifest_subset_save_preserves_untouched_rows(tmp_path): ) +def test_save_manifest_subset_save_keeps_a_deleted_files_row(tmp_path): + """#3426: without scan_corpus, a row for a file no longer on disk must + survive a save too -- not just an untouched one. detect_incremental() + is what REPORTS a deletion via deleted_files, and a save that runs + without also pruning the graph (a scan-only run, an interrupted + pipeline) must not erase the row before any caller gets a chance to + act on that report, or the deletion becomes unreportable from the very + next run onward.""" + import json + from graphify.detect import detect_incremental + + a = tmp_path / "a.py" + gone = tmp_path / "gone.py" + a.write_text("x = 1\n") + gone.write_text("y = 2\n") + manifest_path = str(tmp_path / "graphify-out" / "manifest.json") + save_manifest({"code": [str(a), str(gone)]}, manifest_path, root=tmp_path) + + gone.unlink() + detection = detect_incremental(tmp_path, manifest_path=manifest_path) + assert str(gone) in detection["deleted_files"], "must be reported deleted on this pass" + + # A save that is NOT given the full scan corpus (no scan_corpus) -- + # the row must not disappear before the report above was acted on. + save_manifest({"code": [str(a)]}, manifest_path, root=tmp_path) + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + assert "gone.py" in raw, ( + f"a deleted file's row must survive a subset save so it stays reportable, got {set(raw)}" + ) + + detection2 = detect_incremental(tmp_path, manifest_path=manifest_path) + assert str(gone) in detection2["deleted_files"], \ + "the deletion must still be reportable on the next pass" + + def test_save_manifest_full_scan_keeps_out_of_root_rows(tmp_path): """Out-of-root entries (--include sources, symlinked corpora) are never walked by detect, so their absence from the corpus is not exclusion From 2f2b7529aa98c2ece2233a72b8b4c0a9095a86ef Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:36:22 +0530 Subject: [PATCH 3/6] Add changelog entry for issue 3426 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515f..015dfb43b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) +- Fix: `save_manifest` no longer erases a deleted file's manifest row before `detect_incremental` gets to report it. The row was pruned unconditionally whenever the file no longer existed on disk, which could erase it before a caller that saves without also pruning the graph (a scan-only run, an interrupted pipeline) ever acted on the report — making a genuine deletion permanently unreportable from the very next run onward. A full-scan caller (passing the complete scan corpus) still cleans the row up, now correctly sequenced after the deletion had a chance to be reported on that same scan; only a partial save now leaves a dead row in place, reconciled by the next full scan (#3426, thanks @John-kibe). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From 08dc406ce1d6777e2601b1e1a091c1c610cbe73f Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:58:51 +0530 Subject: [PATCH 4/6] Prune a genuinely deleted out of root manifest row on a full scan A reviewer found that removing the old unconditional exists() check for issue 3426 preserved reconciliation only for in root rows via the scan exclusion check, leaving a genuinely deleted out of root row unprunable forever, even across full scans. Out of root rows must still never be pruned merely for being outside the current scan (they were never walked by detect, so absence from the corpus alone is not exclusion evidence), but that is a different condition from the file actually being gone from disk. A full scan already gives detect_incremental its chance to report the deletion first, the same safe reconciliation point the in root case relies on, so a full scan now also prunes an out of root row whose file no longer exists, leaving a partial save untouched exactly as before. Co-Authored-By: Claude Sonnet 5 --- graphify/detect.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index c05a2c4bc1..30798c5900 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -2225,9 +2225,12 @@ def save_manifest( forever and masquerading as deletions in detect_incremental. It must be the RAW detect output, not a stamp-filtered subset — pruning to a filtered set would erase rows the filter merely omitted (failed chunks, - --code-only doc rows). Out-of-root entries are never pruned. Callers - saving a SUBSET of files (changed_paths hooks, skill runbooks, #917) - must leave this None so their untouched rows are preserved. + --code-only doc rows). Out-of-root entries are never pruned merely for + being outside the current scan (they were never walked by detect, so + their absence from the corpus is not exclusion evidence) — only when + the file no longer exists on disk at all (#3426). Callers saving a + SUBSET of files (changed_paths hooks, skill runbooks, #917) must leave + this None so their untouched rows are preserved. ``clear_semantic`` (#1948): files that were dispatched this run but produced no stamped output (e.g. the LLM omitted their chunk on a @@ -2340,13 +2343,31 @@ def _normalise_entry(entry): # detect_incremental() has had the chance to report it on that same # scan. Only a partial/subset caller (no scan_corpus) now leaves a dead # row in place; the next full scan reconciles it. + # + # A review finding pointed out that _in_root(f) alone leaves an + # out-of-root row (a merged/foreign corpus entry, or a root that could + # not be resolved) permanently unpruned even on a full scan, since + # _in_root() fails open for exactly those paths. A full scan is still + # the same safe reconciliation point detect_incremental() already had + # its chance to report against this run, so an out-of-root row whose + # file no longer exists on disk is pruned here too — never on a + # partial/subset save, matching the in-root case above. manifest: dict[str, dict] = {} for f, entry in existing.items(): normalised = _normalise_entry(entry) if normalised is None: continue - if scan_set is not None and not _in_scan(f) and _in_root(f): - continue # excluded-or-deleted, not in this scan: drop the stale row (#1908) + if scan_set is not None and not _in_scan(f): + if _in_root(f): + continue # excluded-or-deleted, not in this scan: drop the stale row (#1908) + try: + out_of_root_gone = not Path(f).exists() + except OSError: + # Cannot tell: fail open and keep the row, matching _in_root's + # own "cannot tell in-root from out-of-root" fail-open rule. + out_of_root_gone = False + if out_of_root_gone: + continue # out-of-root deletion: safe to prune on a full scan if clear_ast_set is not None and _in_clear_ast(f): # AST failure this run (missing extra / zero nodes, #2543): blank # both hashes so either detect_incremental kind re-queues. From 047bdf2acb6c2668650714df0b1cada6ef253108 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:58:54 +0530 Subject: [PATCH 5/6] Add regression tests for the out of root deletion pruning fix Covers the fixed full scan case (genuinely deleted out of root row is now pruned) and the boundary that must stay unchanged (a partial save without scan_corpus still preserves a deleted out of root row, same as an in root one). Co-Authored-By: Claude Sonnet 5 --- tests/test_detect.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_detect.py b/tests/test_detect.py index 1a13aa2eb3..579b89697d 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -3244,6 +3244,65 @@ def test_save_manifest_full_scan_keeps_out_of_root_rows(tmp_path): outside.unlink(missing_ok=True) +def test_save_manifest_full_scan_prunes_genuinely_deleted_out_of_root_row(tmp_path): + """Review finding on #3426: an out-of-root row must never be pruned + merely for being outside the current scan (the test above), but that is + different from the file actually being gone from disk. #3426 removed the + old unconditional exists() check that used to prune ANY missing file's + row regardless of root, and preserved reconciliation only for in-root + rows via the scan-exclusion check -- silently leaving a genuinely + deleted out-of-root row unprunable forever, even across full scans. A + full scan is still detect_incremental()'s chance to report the + deletion first, so it is the same safe reconciliation point as the + in-root case.""" + import json + a = tmp_path / "a.py" + a.write_text("x = 1\n") + outside = tmp_path.parent / f"{tmp_path.name}-extern-gone.py" + outside.write_text("z = 3\n") + try: + manifest_path = str(tmp_path / "graphify-out" / "manifest.json") + save_manifest( + {"code": [str(a), str(outside)]}, manifest_path, root=tmp_path + ) + outside.unlink() + save_manifest( + {"code": [str(a)]}, manifest_path, root=tmp_path, + scan_corpus={str(a)}, + ) + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + assert "a.py" in raw + assert str(outside.resolve()) not in raw, ( + f"a genuinely deleted out-of-root row must be pruned on a full scan, got {set(raw)}" + ) + finally: + outside.unlink(missing_ok=True) + + +def test_save_manifest_subset_save_keeps_out_of_root_deleted_row(tmp_path): + """The fix above must stay scoped to full-scan saves only -- a partial + save (no scan_corpus) must still preserve a deleted out-of-root row, the + same as it already does for an in-root one (#3426).""" + import json + a = tmp_path / "a.py" + a.write_text("x = 1\n") + outside = tmp_path.parent / f"{tmp_path.name}-extern-gone2.py" + outside.write_text("z = 3\n") + try: + manifest_path = str(tmp_path / "graphify-out" / "manifest.json") + save_manifest( + {"code": [str(a), str(outside)]}, manifest_path, root=tmp_path + ) + outside.unlink() + save_manifest({"code": [str(a)]}, manifest_path, root=tmp_path) + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + assert str(outside.resolve()) in raw, ( + f"a subset save must not prune a deleted out-of-root row, got {set(raw)}" + ) + finally: + outside.unlink(missing_ok=True) + + def test_detect_incremental_reports_excluded_not_deleted(tmp_path): """A previously-indexed file that becomes excluded (still on disk) must land in excluded_files, not deleted_files (#1908).""" From 2bcae14b3cc8692050d551faf77fbc929a81acac Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:59:03 +0530 Subject: [PATCH 6/6] Update changelog entry for issue 3426 review finding Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 015dfb43b2..97d2039cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `save_manifest` no longer erases a deleted file's manifest row before `detect_incremental` gets to report it. The row was pruned unconditionally whenever the file no longer existed on disk, which could erase it before a caller that saves without also pruning the graph (a scan-only run, an interrupted pipeline) ever acted on the report — making a genuine deletion permanently unreportable from the very next run onward. A full-scan caller (passing the complete scan corpus) still cleans the row up, now correctly sequenced after the deletion had a chance to be reported on that same scan; only a partial save now leaves a dead row in place, reconciled by the next full scan (#3426, thanks @John-kibe). +- Fix: `save_manifest` no longer erases a deleted file's manifest row before `detect_incremental` gets to report it. The row was pruned unconditionally whenever the file no longer existed on disk, which could erase it before a caller that saves without also pruning the graph (a scan-only run, an interrupted pipeline) ever acted on the report — making a genuine deletion permanently unreportable from the very next run onward. A full-scan caller (passing the complete scan corpus) still cleans the row up, now correctly sequenced after the deletion had a chance to be reported on that same scan; only a partial save now leaves a dead row in place, reconciled by the next full scan. That reconciliation on a full scan now also covers an out-of-root row whose file is genuinely gone, which the in-root-only check left permanently unprunable (#3426, thanks @John-kibe). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov).