Repository navigation
Two index-currency signals report "current" when the index is not: sync ignores isIndexStale(), and status.pendingChanges only hash-checks what git nominates #1
Description
Staged on this fork before going upstream to colbymchenry/codegraph. Both halves are reproduced from scratch below and both mechanisms have been verified against source (v1.6.0-em.1 / c18d6fa, whose src/ is upstream 1.6.0 plus one Python resolver patch that touches neither of these paths). Nothing here depends on this fork's own changes.
Summary
Two independent signals tell you an index is current when it is not. Neither is detectable from the other, and together they let an index be reported current, freshly synced, and still wrong — with reindexRecommended: false sitting alongside.
| signal | reports "current" when | |
|---|---|---|
| A | codegraph sync |
the extractor changed under files that did not |
| B | codegraph status --json → pendingChanges |
the change is committed but unindexed |
Not theoretical: together they produced a wrong published conclusion in a downstream project for a day. A fabricated-edge bug was reported as "still present after the fix" when the fix had landed and the index simply never re-derived those edges.
Environment
codegraph 1.6.0 (EXTRACTION_VERSION 26)
build inth3shadows/codegraph @ v1.6.0-em.1 (c18d6fa = upstream 1.6.0 + one Python resolver fix)
kernel disabled (CODEGRAPH_KERNEL=0), WASM path only
node v26.7.0
platform Linux x86_64 (WSL2)
A. sync never consults the staleness signal the codebase already computes
sync decides its work from per-file content hashes. A content hash answers "did the input change"; it cannot answer "did the function reading the input change". So when the extractor is upgraded, every file that has not been edited keeps the edges the old extractor produced, indefinitely.
Observed: a fixed extractor became the default on PATH at 18:12. The index was synced at 20:40 — after the fix — and still carried all seven of the fabricated call edges that fix removes, because none of the files carrying them had been edited in between. The index was newer than the fix and contained none of its output.
Repro
git -C <repo> archive HEAD | (mkdir /tmp/fresh && tar -x -C /tmp/fresh)
cd /tmp/fresh && git init -q && git add -A && git commit -qm x
codegraph init /tmp/fresh
# diff the `edges` table against the synced index of the SAME commitVerified mechanism
EXTRACTION_VERSION has exactly three functional consumers in the tree:
src/index.ts:694 write it to metadata at index time
src/index.ts:1233 isIndexStale() compares it
src/bin/codegraph.ts:1020 report it in status --json
isIndexStale() has two callers — src/bin/codegraph.ts:985 (the status command) and src/ui-server/api/stats.ts:113. Neither is sync. Its docstring scopes it deliberately: "the signal behind codegraph status's re-index hint and codegraph upgrade's reminder."
The invariant sync violates is already written down, at src/index.ts:686:
// Stamp the index with the engine that built it, so `codegraph status`
// and `codegraph upgrade` can recommend a re-index when the running
// engine produces richer extraction than the one on disk. Only on a
// real full index — a sync touches a subset, so it must NOT advance the
// extraction stamp (the bulk would still be stale). See extraction-version.ts.So the codebase already knows a sync leaves the bulk stale, deliberately withholds the stamp for that reason, computes isIndexStale() from it, and surfaces reindexRecommended. The gap is enforcement, not knowledge.
extraction-version.ts names this precise case as a bump reason: "a resolver fix that materially changes which edges exist." Which is what happened — the fix bumped 25 → 26, the index sat at 25, reindexRecommended was true, and sync re-extracted only edited files.
The trap worth stating explicitly
The release version cannot be the signal. A synced index and a fresh one of the same commit both reported indexed_with_version: 1.6.0 while their extraction versions were 25 and 26. A fork or a patch release can change extraction without moving the release string. builtWithExtractionVersion is the only field that discriminates.
Fix shape
Have sync consult isIndexStale() and either force a full re-extract or refuse with a pointer to index. The signal, the threshold and the stated intent are all already in the tree.
B. status.pendingChanges hash-checks correctly, but only over a candidate set git chooses
pendingChanges reports correctly while edits are uncommitted, and reports zero the moment they are committed — while the index still lacks them. The signal inverts on the most ordinary action in a git workflow.
Repro
mkdir -p /tmp/r/pkg && cd /tmp/r
printf 'def a():\n return 1\n' > pkg/one.py && touch pkg/__init__.py
git init -q && git add -A && git commit -qm init
codegraph init /tmp/r
codegraph status --json # fileCount 2, pendingChanges {added:0, modified:0}
printf 'def b():\n return 2\n' > pkg/two.py
printf 'def a():\n return 99\n' > pkg/one.py
codegraph status --json # fileCount 2, pendingChanges {added:1, modified:1} correct
git add -A && git commit -qm add
codegraph status --json # fileCount 2, pendingChanges {added:0, modified:0} WRONG
codegraph sync # Added: 1, Modified: 1fileCount stays honest at 2 throughout — the index really does have two files and never three. pendingChanges is the field that misreports, and only after the change is committed.
Observed in the wild the same way: after a merge, status reported pendingChanges: {added: 0, modified: 0, removed: 0} on a repo where two new source files existed on disk with zero nodes and no files row. An immediate sync found Added: 3, Modified: 11.
Verified mechanism
The hash comparison in getChangedFiles() (src/extraction/index.ts:2935) is sound. The defect is one level up — where the candidates come from:
const gitChanges = getGitChangedFiles(this.rootDir);
if (gitChanges) {
// === Git fast path ===
...
for (const filePath of [...gitChanges.modified, ...gitChanges.added]) {
const contentHash = hashContent(content);
const tracked = this.queries.getFileByPath(filePath);
if (!tracked) added.push(filePath);
else if (tracked.contentHash !== contentHash) modified.push(filePath);
}getGitChangedFiles (:1107) builds that list from:
git status --porcelain --no-renames -uall
A committed file has no porcelain entry, so it never enters the loop and is never hash-compared — regardless of what the DB holds. Committing is what removes a file from the candidate set. The check is correct; it is simply never reached.
Two details that shape the fix:
- The non-git fallback in the same function (
:2979, "full scan") walksscanDirectoryand compares againstgetAllFiles(). That path is correct — a working implementation already sits a few lines below the broken one. This is specific to the git fast path. - A comment at
:2952records that untracked (??) files had to be hash-compared rather than always counted as added, "otherwise status reports them as pending forever (see issue codegraph status still reports untracked files as pending after sync colbymchenry/codegraph#206)." Same bug class — git state standing in for index state — patched for one case without generalizing.
Fix shape
In the git fast path, union the git candidate set with the DB's own file list (getAllFiles()), so a file the index tracks is hash-checked whether or not git currently reports it, and a file the index does not track is caught by a scan rather than by git's opinion. The fast path needs to stop treating "git says nothing changed" as "nothing changed".
If a working-tree diff is genuinely wanted somewhere, it belongs in a differently named field: "pending changes" reads as "changes this index has not absorbed", and that is how it gets used.
Why they are filed together
Different mechanisms, same class, and they compound: (A) makes edges stale under files that never changed; (B) hides files that changed but were committed. Fixing either alone still leaves an index that can report itself current while being wrong, so a reader of status has no single field to trust.
For what it is worth, a downstream guard comparing files.indexed_at against disk mtime — never consulting git — correctly warned 7 source file(s) newer than the index on the very run where status reported zero pending changes. The comparison is cheap and it works.
Downstream tracking: inth3shadows/testgraph#85, with the measurements that surfaced both.
Activity
inth3shadows commentedon Sep 10, 2026
Both mechanisms verified against source — one confirmed as written, one needs a correction
Read against
v1.6.0-em.1(c18d6fa), whosesrc/is upstream 1.6.0 plus one Python resolver patch that touches neither of these paths.A — confirmed, and the codebase already documents the invariant it violates
EXTRACTION_VERSIONhas exactly three functional consumers in the whole tree:isIndexStale()in turn has two callers —src/bin/codegraph.ts:985(thestatuscommand) andsrc/ui-server/api/stats.ts:113. Neither is sync, and its own docstring scopes it deliberately:The stronger finding is at
src/index.ts:686, where the stamp is written:So the invariant is already understood and written down: a sync leaves the bulk stale. The code deliberately withholds the stamp for exactly that reason, computes
isIndexStale()from it, and surfacesreindexRecommended. Sync then does nothing with any of it. The gap is enforcement, not knowledge — which makes the fix smaller than the original report implied.extraction-version.tsalso names this precise case as a reason to bump:Which is what happened: the resolver fix bumped 25 → 26, the index sat at 25,
reindexRecommendedwastrue, andsyncre-extracted only edited files.B — correction:
pendingChangesis not the git diff, but its candidate set isMy original description said the field is computed from the working-tree diff. That is wrong in a way worth fixing before this goes upstream.
getChangedFiles()(src/extraction/index.ts:2935) does hash-compare against the DB and the comparison is correct. The defect is one level up — where the candidates come from:and
getGitChangedFiles(:1107) builds that list fromA committed file has no porcelain entry, so it never enters the loop and is never hash-compared — regardless of what the DB holds. Committing is what removes a file from the candidate set. The hash check is sound; it is simply never reached.
Two details that matter for the fix:
:2979, "full scan") walksscanDirectoryand compares againstgetAllFiles(). That path is correct. So this is specific to the git fast path, and a correct implementation already exists in the same function.:2952recording that untracked (??) files had to be hash-compared rather than always counted as added, "otherwise status reports them as pending forever (see issue codegraph status still reports untracked files as pending after sync colbymchenry/codegraph#206)". That is the same class of bug — git state standing in for index state — patched for one case without generalizing.Revised fix shape
syncconsultisIndexStale()and either force a full re-extract or refuse with a pointer toindex. The signal, the threshold and the intent are all already in the tree.getAllFiles()), so a file the index tracks is hash-checked whether or not git currently reports it — and a file the index does NOT track is caught by a scan rather than by git's opinion. The fallback path already demonstrates the comparison; the fast path just needs to stop treating "git says nothing changed" as "nothing changed".Both repros in the issue body still stand unchanged; only the explanation of B's cause has been corrected.
[-]Two index-currency signals report "current" when the index is not: sync ignores extraction-version drift, and status.pendingChanges reads the git working tree[/-][+]Two index-currency signals report "current" when the index is not: sync ignores isIndexStale(), and status.pendingChanges only hash-checks what git nominates[/+]inth3shadows commentedon Sep 10, 2026
Filed upstream and fixed — with one correction to the revised fix shape above
Upstream: half A as a reproduced case on colbymchenry#1798 (that issue already proposed background rebuilds off the same foundation, so this went as a comment rather than a near-duplicate); half B as colbymchenry#1829.
Re-verified against current
upstream/main@3ed73bcbefore filing:getChangedFiles,isIndexStaleandcollectGitStatusare byte-identical to thev1.6.0-em.1versions read above (hashed at both revs), so none of the analysis had drifted. Both halves were also reproduced live twice — on the published 1.6.0 bundle and on a source build of3ed73bc— including a sharper demonstration of A: with the stamp at 25 and the engine at 26,codegraph syncprintsAlready up to dateabout an index the same tool reports asreindexRecommended: true.Correction: unioning with
getAllFiles()is not sufficient for BThe revised fix shape above says to union the git candidate set with the DB's file list. That catches a committed modification to a file the index already tracks — but not a committed new file, which is the first case in this issue's own repro. The DB has no row for
pkg/two.py, so it is not ingetAllFiles()either, and it stays invisible.What was implemented instead: the index stamps the commit it was built at, and the fast path adds
git diff --name-status <stamp> HEADto its candidates. That covers added, modified and deleted in one cheap call, and it is the only cheap way to catch a committed modification — the alternative is hashing every tracked file on everystatus.--pathssync absorbs only part of the diff, so it must not stamp), captured before change detection runs so a commit landing mid-sync is never claimed as absorbed.For A, the enforcement point is where you said it was:
codegraph syncnow re-indexes in full when the extractor moved and says why,--no-upgradekeeps incremental with a warning, and-q(git hooks) stays incremental so a commit is never held up by a rebuild.SyncResult.staleEnginereports it to library callers rather than forcing a surprise rebuild on the watcher.Branch:
claude-stop-reporting-index-current-1829(d14fda7) — 9 new tests, 5 of which fail against pre-fix source. Full suite clean: the 13 remaining failures reproduce on pristineupstream/main@3ed73bcin a throwaway worktree against this machine's own index.inth3shadows commentedon Sep 26, 2026
Both halves now ship on this fork's
main(22a3f5e):mainnow stamps the commit an index was built at (INDEXED_AT_COMMIT_KEYinsrc/extraction/index.ts), the stamp-and-diff approach described above. The fork inherits it from upstreamba3c21e.syncignores extraction staleness): carried from upstream PR fix(cli): reject sync for stale extraction indexes colbymchenry/codegraph#1842 (@bompus, merged here asc66dcd2).syncnow refuses a stale index and points atcodegraph indexinstead of printing "Already up to date". One fork-only change: that PR's quiet-mode test now launches the CLI with the runtime flags production uses (22a3f5e), because with fix(directory): a schema-less codegraph.db does not make a project initialized (#1895) colbymchenry/codegraph#1913 also carried it picked up node:sqlite's ExperimentalWarning.Reopen if either reproduces on the fork's
main.