Skip to content

codegraph sync should invalidate on an extraction-version mismatch (upstream report pending) #85

Closed

Description

@inth3shadows

Status: blocked on upstream. Close this when colbymchenry/codegraph#1842 merges. That PR (open, not ours) makes sync consult isIndexStale() and refuse with a codegraph index pointer instead of printing "Already up to date", which is exactly the defect below. A closing keyword can't link a PR we didn't author, so close this by hand on merge.

Context:

testgraph is already guarded in the meantime: #86 pins codegraph_extraction_version and integrity.check warns on an older index, naming codegraph index.

The original report follows, unchanged.

Summary

codegraph sync re-extracts changed files only. When the extractor itself changes — a bug fix, a version bump — the edges it previously produced for unchanged files are never re-derived. The index ends up newer than the fix and containing none of its output, indefinitely, with no warning.

This is not hypothetical. It produced a wrong conclusion in this repo today (PR #84), and it survived a deliberate deployment of a fix specifically for the bug it was hiding.

Observed

The fork carrying #66's extractor fix became the default codegraph on PATH at 18:12. main's index was written by sync at 20:40 — after the fix — and still carried all 7 of #66's fabricated ledger.append edges, because none of the files carrying them had changed in between.

Same commit (6c00e76), two indexes:

                          incrementally synced        codegraph init (fresh)
ledger.append inbound     7 fabricated + 1 real       all real
                                                      (log_invocation, add_outcome,
                                                       write_outcomes)
reconcile phantom edges   6 confirmed                 0

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
# compare `edges` between /tmp/fresh/.codegraph/codegraph.db and the synced one

The part that makes this a clean fix

The index already records the field that would catch it, and nothing compares it.

synced (main)   indexed_with_extraction_version = 25   indexed_with_version = 1.6.0
fresh           indexed_with_extraction_version = 26   indexed_with_version = 1.6.0

Both are project_metadata rows written at index time. Note that indexed_with_version is identical across the two — the release version is useless as a staleness signal, because a fork or a patch release can change extraction without changing it. indexed_with_extraction_version is the real discriminator, it already differs, and it is already stored.

Why the existing guards do not catch it

testgraph.integrity checks file freshness — content hash and mtime against the index — and correctly warned 42 source file(s) newer than the index on the run that produced the wrong answer. That warning is about files. Every file whose edges were wrong was, by construction, unchanged, so no file-level check can see it. There is no stale-edge concept anywhere in the stack.

Same for codegraph status: an index can be 100% file-current and 100% edge-stale at once.

Fix shape

Two independent halves. The second is ours and is actionable now.

Upstream (codegraph) — sync should compare the running extractor's version against indexed_with_extraction_version and force a full re-extract on mismatch, rather than trusting per-file content hashes alone. The per-file hash answers "did the input change"; it was never meant to answer "did the function that reads the input change". Cheap version: refuse to sync and tell the user to run index.

Here (testgraph.integrity) — add a stale-edge check beside the stale-file one: read project_metadata.indexed_with_extraction_version and compare it against the extraction version of the codegraph on PATH. On mismatch, warn that the edges may predate the current extractor and name codegraph index as the fix. This fits the existing contract exactly — integrity already degrades toward test more, never toward silence, and this is a trust warning of precisely the kind it exists to raise.

Worth checking whether the running extractor's version is obtainable without a full index run; if it is not, comparing against the last value testgraph saw for that repo still catches the transition, one run late.

Bonus, already working

testgraph.reconcile (PR #84) detects this as a side effect: a phantom-edge count that drops to zero after a full codegraph index is a stale-edge signal. That is a diagnosis, not a guard — it needs a trace and a registry, and it tells you after the fact. The integrity check above is the guard.

Why this rates soundness

Every downstream answer inherits it silently. A selector reading a stale-edge index reports NONE with status: OK for a change it should have flagged, and there is currently no signal anywhere that distinguishes that from a genuine clean bill — which is the failure mode this project exists to prevent.

Activity

  1. changed the title [-]codegraph sync keeps edges from a superseded extractor; the index records the version that would catch it and nobody reads it[/-] [+]codegraph sync should invalidate on an extraction-version mismatch (upstream report pending)[/+] on Sep 10, 2026
  2. inth3shadows commented on Sep 10, 2026

    @inth3shadows
    OwnerAuthor

    Reopened, scoped to the upstream half only

    PR #86 (4c11270) shipped the testgraph half and closed this by keyword. That was the wrong keyword — the upstream ask is still open and this issue is its only tracker, so: reopened, retitled, and the remaining work is just the first bullet of the original "Fix shape".

    Done, not part of the upstream ask:

    Registries pin codegraph_extraction_version; integrity.check compares it against project_metadata.indexed_with_extraction_version and warns, naming codegraph index and explicitly not sync. It found two stale-edge indexes on its first run — honeyslate and signedintake, both at extraction 25 against a current 26. select.none_is_unknown takes warnings wholesale, so an empty journey list off a stale-edge index now reads UNKNOWN rather than as a clean bill. 398 tests.

    Still to report upstream to colbymchenry/codegraph — and it is a smaller ask than this issue originally described:

    codegraph status --json already computes the whole comparison:

    "index": {
      "builtWithVersion": "1.6.0",
      "builtWithExtractionVersion": 26,
      "currentExtractionVersion": 26,
      "reindexRecommended": false
    }

    So codegraph already knows when an index's edges predate the running extractor, and already has a name for it. sync simply does not consult reindexRecommended — it trusts per-file content hashes alone. A content hash answers "did the input change"; it was never meant to answer "did the function reading the input change".

    The ask is therefore not "add a version field" (it exists) or "add the comparison" (it exists) but: sync should act on the flag it already computes — force a full re-extract on mismatch, or refuse and tell the user to run index.

    Worth including in the report: the release version is useless as 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, because a fork or a patch release can change extraction without moving the release string. builtWithExtractionVersion is the only field that discriminates.

    Repro, unchanged:

    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 `edges` against the synced index of the same commit

    Measured instance: the fixed extractor became the default on PATH at 18:12, the index was synced at 20:40, and it still carried all seven of #66's fabricated ledger.append edges. The fresh index had none.

    Filed here rather than upstream because inth3shadows/codegraph has issues disabled — same arrangement as inth3shadows/codegraph#4. Close this once the upstream issue exists and is linked.

  3. inth3shadows commented on Sep 10, 2026

    @inth3shadows
    OwnerAuthor

    Second finding for the upstream report: status reports currency it does not have, and committing is what hides the change

    Found the same way as the first — by tripping over it. After merging #88, codegraph status --json on testgraph/main reported pendingChanges: {added: 0, modified: 0, removed: 0}, while testgraph/verify.py and harness/unit_vs_journey.py existed on disk with zero nodes and no files row. An immediate codegraph sync found Added: 3, Modified: 11.

    Controlled 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
                                                   fileCount   pendingChanges
    fresh index                                        2       {added:0, modified:0}
    add pkg/two.py + edit pkg/one.py (uncommitted)     2       {added:1, modified:1}   correct
    git commit those same changes                      2       {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 lies, and it lies only after the change is committed.

    Mechanism (inferred from the behaviour above, not read from source)

    pendingChanges looks to be computed from the git working tree — the uncommitted diff — rather than from an index-vs-disk comparison. That makes the signal invert on the single most normal action in a git workflow: while your work is uncommitted status correctly says the index is behind, and the moment you commit it, status says everything is current. sync itself is unaffected and finds the work correctly, so the two disagree.

    That is exactly why the testgraph case looked clean: a merge commits everything, so status had nothing in the working tree to report.

    Why it belongs with the sync issue rather than on its own

    Different mechanism, same class, and between them they are why the fabricated-edge conclusion in #84 was wrong for a day:

    signal says "current" when
    this issue's first half sync the extractor changed under unchanged files
    this half status.pendingChanges the change is committed but unindexed

    Neither is detectable from the other. Together they mean an index can be reported current, freshly synced, and still wrong — with reindexRecommended: false sitting alongside.

    Suggested fix shape

    pendingChanges should compare the index against disk (files.content_hash / mtime versus the working copy), which is what sync already does when it decides its own work — the two should not be able to disagree about whether there is work. If the working-tree diff is genuinely wanted, it belongs in a differently named field, because "pending changes" reads as "changes this index has not absorbed".

    For what it is worth, testgraph caught this and codegraph did not

    testgraph.integrity.stale_by_mtime warned 7 source file(s) newer than the index on the very run where status reported zero pending changes. It compares files.indexed_at against disk mtime and never consults git — which is the comparison this field should be making.

  4. inth3shadows commented on Sep 10, 2026

    @inth3shadows
    OwnerAuthor

    Correction: B's mechanism, now read from source rather than inferred

    My comment above said pendingChanges "looks to be computed from the git working tree — the uncommitted diff — rather than from an index-vs-disk comparison," and labelled it inferred. It has now been read against codegraph source (v1.6.0-em.1 / c18d6fa) and that description is wrong. Correcting it here so this tracker does not keep a wrong explanation for a right observation.

    The hash comparison is sound. The candidate set is not.

    getChangedFiles() (src/extraction/index.ts:2935) does compare content hashes against the DB:

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

    The defect is one level up: getGitChangedFiles (:1107) builds that candidate 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, whatever the DB holds. Committing removes a file from the candidate set — the check is correct, it is simply never reached.

    Two things that make the fix smaller than the original write-up implied:

    • The non-git fallback in the same function (:2979) walks scanDirectory and compares against getAllFiles(), and 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 #206)". Same bug class — git state standing in for index state — patched for one case without generalizing.

    Half A was confirmed as written, and is stronger than filed. EXTRACTION_VERSION has three consumers (index.ts:694 writes the stamp, index.ts:1233 is isIndexStale(), bin/codegraph.ts:1020 reports it); isIndexStale() has two callers, status and the web UI — neither is sync. And src/index.ts:686 already documents the invariant sync violates:

    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).

    So the codebase already knows a sync leaves the bulk stale and deliberately withholds the stamp for that reason. The gap is enforcement, not knowledge.

    Everything observed downstream — both repros, the 25-vs-26 measurement, the Added: 3, Modified: 11 after a "clean" status — is unchanged. Only the explanation of B moved.

    Filed upstream-bound: inth3shadows/codegraph#1, with both mechanisms verified and the fix shape for each. Issues had to be enabled on that fork first (GitHub disables them on forks by default), which is why this issue existed here in the first place. The code fix happens there, not here; this stays as the tracker until an issue exists on colbymchenry/codegraph.

  5. inth3shadows commented on Sep 26, 2026

    @inth3shadows
    OwnerAuthor

    Closing: the fork now carries colbymchenry/codegraph#1842 (merged into fork main as c66dcd2, first in tag v1.6.0-em.10).

    Verified by building v1.6.0-em.10 from source and running the repro on a fresh index with its extraction stamp set back to 25:

    v1.6.0-em.8  (deployed at close time)  codegraph sync  ->  "Done", exit 0      (the defect)
    v1.6.0-em.10                           codegraph sync  ->  "Index extraction is stale.
                                                                Run "codegraph index" for a full
                                                                rebuild before syncing.", exit 1
    

    The fix is active on a machine only once its codegraph is rebuilt at em.10 or later; ~/.local/bin/codegraph still pointed at em.8 when this closed. testgraph's own guard from #86 covers the gap either way.

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

    blockedNeeds a decision or dependency firstmeasurementAccuracy harness / ground truthpriority-3LatersoundnessGuards against silent under-selection

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions