Repository navigation
Incremental hook rebuild silently zeroes all nodes for 168 detected, unchanged, non-ignored files; shrink guard does not refuse #3580
Description
Environment
- graphifyy 0.9.42
- Windows 11, Python 3.13, git 2.53.0.windows.1
- Corpus: 4496 files, ~37.5k nodes, rebuilt by the installed
post-commithook (_rebuild_code(_root, changed_paths=changed))
Summary
A single incremental post-commit rebuild with 3 changed paths took the graph from 37482 to 36591 nodes. In that one run, 168 source files went from N nodes to exactly zero (1588 nodes), and nothing in the log mentioned any of them. Subsequent incremental rebuilds stayed pinned at 36591 forever; a full rebuild restored all 168 files and exactly 1588 nodes.
The affected set has a clean fingerprint: 165 of the 168 files live inside git submodules or at untracked paths in the parent repo (two submodules: 148 files; one untracked directory plus a handful of untracked root-level .md files: 18; tracked: 2). File types: 78 .md, 38 .tsx, 35 .ts, 6 .sql, 5 .sh, 3 .json, 3 js/mjs/cjs.
What was ruled out, by measurement
-
Not extraction.
extract_markdownon one of the zeroed files returns 32 nodes right now. -
Not detection.
detect(Path(root))returns all 4496 files, including all 169 of the affected paths that exist on disk (the other 5 "sources" in the diff are import stubs likereact,node:fs). -
Not corpus eviction.
manifest.jsonstill carries every one of them, withseenstamped at that rebuild and an unchangedast_hash(byte-identical to the pre-loss manifest row apart fromseen). So graphify saw them and considered them unchanged. -
Not an ignore rule (update: newly-ignored files are never removed from an existing graph — .graphifyignore only takes effect on a clean rebuild #2495 / fix(watch): require deletion evidence before evicting nodes for a missing source #1795).
ignored_predicate(root, gitignore=False)andignored_predicate(root, gitignore=True)both returnFalsefor all 169. -
No warning was emitted. The rebuild log for that run contains no
produced zero nodesline, noexcluded but alivemessage, and no shrink warning. It reads as a clean success:[graphify hook] 3 file(s) changed - rebuilding graph... Graph has 36591 nodes (above 5000 limit). Building aggregated community view... [graphify watch] Rebuilt: 36591 nodes, 64913 edges, 2127 communitiesThe immediately preceding run logged
Rebuilt: 37482 nodes, 65657 edges. -
Not recoverable incrementally. Two further hook rebuilds produced exactly 36591 again. Only
_rebuild_code(root, changed_paths=None)healed it: 37950 nodes, and all 168 files back with exactly 1588 nodes restored.
Second bug in the same run: the shrink guard did not refuse
This run had changed_paths set, so rebuilt_sources was 3 files (watch.py:1413). 1588 lost nodes belonged to files that were not in that set, so _check_shrink at watch.py:1662 should have hit the _accounted check, failed it, and refused the write.
It did not. The only two ways past _check_shrink are:
if force or not existing_data:
return Trueforce is False (the hook only sets it from GRAPHIFY_FORCE, which was unset), which leaves existing_data arriving empty or falsey from _reconcile_existing_graph. If reconcile can return {} on a path where the graph was in fact loaded and preserved from, then the guard is silently disarmed for that whole class of rebuild.
Why this is bad
Everything about the run looks healthy: the files are detected, manifest-stamped, unchanged by hash, not ignored, and extractable. Their nodes simply stop existing, incremental rebuilds can never bring them back because those files are never re-extracted again, and the guard that exists to catch exactly this said nothing. The only signal was a 2.4% drop in the node count printed in the log.
Happy to run any instrumented build against the same tree; the failure is reproducible in the sense that the graph re-entered the bad state twice and the full rebuild consistently fixes it.
Activity
turzail-png commentedon Sep 15, 2026
Related: #3579, the full-rebuild branch of the same guard, which cannot refuse at all.
L4XB commentedon Sep 15, 2026
Tried to reproduce the fingerprint on
v8(0.9.61) and could not, with a minimal corpus carrying exactly the shape you describe. Posting the harness rather than a theory, since a negative result narrows this too.A parent repo with one tracked
.py, one untracked.md, and a real git submodule holding one.mdand one.py. Full rebuild, then a topology-changing edit to the tracked file and_rebuild_code(main, changed_paths=[tracked.py]), which is the hook's call:The submodule and untracked sources keep their nodes exactly; only the re-extracted file moves. One thing the first attempt taught me, in case it matters for your instrumented run: an edit that does not change topology short-circuits with
No code-graph topology changes detected; outputs left untouched, so the write and reconcile paths are never reached. The run has to actually write for this to be a test of anything.Two notes on the analysis, offered as narrowing rather than contradiction:
The
existing_dataroute looks closed on your version already. The fail-closed load landed in2099873(#2251), released in 0.9.28, so by 0.9.42_reconcile_existing_graphpropagates a read failure instead of returning{}. Its only remaining early return needs_load_existing_graphto answerNonefor a file that.exists().There is a third way past
_check_shrinkthat the issue does not list:A wholesale bypass, not the per-source
_accountedcheck. Onv8the hook path always computesrebuilt_sources(both branches ofif changed_paths is None), so it cannot fire there now — but it is worth confirming against 0.9.42, because it would explain a guard that says nothing whilerebuilt_sourceslooks populated from the outside.What I could not model is scale: yours is 4496 files past the 5000-node aggregated-community threshold, mine is four files. If you can run the script above against your own tree, or tell me which of scale, the aggregated path, or a second consecutive hook run is load-bearing, I will build the corpus that way and look again. Happy to send it as a file if that is easier than retyping.
azmeenafandi commentedon Sep 27, 2026
Additional occurrence data (Linux, small corpus) + a negative replay on 0.9.69
@L4XB asked which of scale, the aggregated path, or a second consecutive hook run is load-bearing. This repo narrows the first two out — same class, but with none of those traits — so I'm posting the artifacts plus a negative result, since a negative result narrows it too.
Environment
.tsfiles undersrc// 118 tracked files; graph 990 nodes / 2572 edges / 61 communities. No submodules, no untracked source directories,graphify-out/is gitignored, maintained by the stockpost-commithook (graphify hook install).Three incidents in this repo
src/paths.ts)src/paths.tsnodes gonepackage.json,package-lock.json,README.md,CHANGELOG.md— no source files)src/prelude.tsfile node + its symbols + ~10 other exported symbols (990 → 943 nodes)Each time the hook logged a clean success, no guard output of any kind (
grep -E "WARNING|Refusing"over the entire~/.cache/graphify-rebuild.log→ 0 matches), and only a full rebuild (graphify . --update+cluster-only+label) restored every node.The 2026-09-27 hook run, verbatim:
A full rebuild afterwards restored 990 nodes / 2572 edges / 61 communities with
src/prelude.tspresent (9 prelude nodes) — that is the state we've been running since. The reduced graph itself was overwritten by the recovery, and the dated2026-09-27/backup dir was refreshed by the recovery's own backup afterwards (its files are stamped 99 s after the hook run's last log line), so I can't hand over the 943 file — the log line above and the recovery result are the primary evidence. Happy to attach the recovered graph or the dated backup dir if useful.Replay on 0.9.69 → clean
Against the same tree and graph state, replaying exactly the hook's call:
→
Rebuilt: 990 nodes, 2571 edges, 60 communities, prelude intact, write proceeded (guard had nothing to refuse).Caveats on the negative result (so it isn't over-read)
force=False(GRAPHIFY_FORCE unset);had_explicit_deletions and rebuilt_sources is Noneshouldn't apply (the 0.9.69 hook path passesrebuilt_sourcesatwatch.py:1966/2180);new_n >= existing_ncan't be it (943 < 990). That leaves the_accounted()path — the lost nodes'source_files were not inrebuilt_sources, so it should have refused — unless those nodes carried a falseysource_file, orexisting_datawas falsey at guard time (the route this issue already suspected).The full rebuild log is attached; happy to run instrumented builds against this tree.
graphify-rebuild.log