Skip to content

Two index-currency signals report "current" when the index is not: sync ignores isIndexStale(), and status.pendingChanges only hash-checks what git nominates #1

Closed

Description

@inth3shadows

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 commit

Verified 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: 1

fileCount 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") walks scanDirectory and compares against getAllFiles(). 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 :2952 records 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

  1. inth3shadows commented on Sep 10, 2026

    @inth3shadows
    OwnerAuthor

    Both mechanisms verified against source — one confirmed as written, one needs a correction

    Read against v1.6.0-em.1 (c18d6fa), whose src/ 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_VERSION has exactly three functional consumers in the whole 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() in turn has two callers — src/bin/codegraph.ts:985 (the status command) and src/ui-server/api/stats.ts:113. Neither is sync, and its own docstring scopes it deliberately:

    This is the signal behind codegraph status's re-index hint and codegraph upgrade's reminder.

    The stronger finding is at src/index.ts:686, where the stamp is written:

    // 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 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 surfaces reindexRecommended. 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.ts also names this precise case as a reason to bump:

    BUMP THIS when a release changes extraction output enough that existing indexes should be rebuilt to benefit — e.g. a new language/framework extractor, a new dynamic-dispatch synthesizer, a new node/edge kind, or a resolver fix that materially changes which edges exist.

    Which is what happened: the resolver fix bumped 25 → 26, the index sat at 25, reindexRecommended was true, and sync re-extracted only edited files.

    B — correction: pendingChanges is not the git diff, but its candidate set is

    My 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:

    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);
      }

    and 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 hash check is sound; it is simply never reached.

    Two details that matter for the fix:

    • The non-git fallback below (:2979, "full scan") walks scanDirectory and compares against getAllFiles(). That path is correct. So this is specific to the git fast path, and a correct implementation already exists in the same function.
    • There is a comment at :2952 recording 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

    • A: have sync consult isIndexStale() and either force a full re-extract or refuse with a pointer to index. The signal, the threshold and the intent are all already in the tree.
    • B: 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 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.

  2. changed the title [-]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[/+] on Sep 10, 2026
  3. inth3shadows commented on Sep 10, 2026

    @inth3shadows
    OwnerAuthor

    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 @ 3ed73bc before filing: getChangedFiles, isIndexStale and collectGitStatus are byte-identical to the v1.6.0-em.1 versions 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 of 3ed73bc — including a sharper demonstration of A: with the stamp at 25 and the engine at 26, codegraph sync prints Already up to date about an index the same tool reports as reindexRecommended: true.

    Correction: unioning with getAllFiles() is not sufficient for B

    The 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 in getAllFiles() 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> HEAD to 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 every status.

    For A, the enforcement point is where you said it was: codegraph sync now re-indexes in full when the extractor moved and says why, --no-upgrade keeps incremental with a warning, and -q (git hooks) stays incremental so a commit is never held up by a rebuild. SyncResult.staleEngine reports 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 pristine upstream/main @ 3ed73bc in a throwaway worktree against this machine's own index.

  4. inth3shadows commented on Sep 26, 2026

    @inth3shadows
    OwnerAuthor

    Both halves now ship on this fork's main (22a3f5e):

    Reopen if either reproduces on the fork's main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions