Files
Colby Mchenry d348d301c1 fix(sync): a navigation call links its route when the route appears later (#2425)
A router's navigation call (`history.push('/login')`, `navigate('/login')`,
`router.push('/x')`, `goto('/x')`) names its route by path and its reference
by the router's method, so sync never noticed a route that changed in another
file. A synced index then kept answers a fresh index of the same files does
not have (CG-33):

- A call parked as failed while its route was missing never matched the #1240
  retry, which keys on the names the synced files define.
- A call bound to a catch-all, a parameter route or the other arm of a
  conditional kept that binding.
- A route renamed in place by runPostExtract kept the calls bound to it.
- Template links from a routes file that matches no synthesis trigger were
  never redrawn.

When a navigation router is detected, sync now reads the route nodes at its
first file change and again after runPostExtract. For each route on one side
only, it puts back the calls the routers' new `navigation` hook (method tails
and app scope) says that route can answer: the failed ones, and the
`navigates` edges a resolver made. The orchestrator's orphan sweep resolves
them, under the same 500-per-name ceiling, and the sync refreshes synthesis.
This covers re-extracted and removed files, postExtract renames and
cross-file table routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:16:49 +00:00

13 KiB
Raw Permalink Blame History

Index drift: incremental sync vs. full rebuild (CG-33)

Measured 2026-08-06. A live, auto-sync-maintained index does not converge to a clean full rebuild of the identical working tree. On codegraph's own repo, 4.3% of distinct edges were wrong, in both directions, overwhelmingly calls edges.

This matters because it is silent: nothing warns, nothing surfaces it, and the README tells users the index is never stale and there is nothing to re-run. Retrieval quality decays invisibly, and the user-visible symptom — an agent falling back to Read — reads as "codegraph isn't very good" rather than "this index needs rebuilding."

Result

Subject: codegraph's own .codegraph/codegraph.db, long-lived and incrementally synced, against a full rebuild of the same tree with the same build. Edges compared as distinct (source, target, kind) triples.

count
distinct edge triples (rebuild) 28,809
in rebuild but missing from live 751
in live but absent from rebuild (stale) 476
total divergent 1,227 — 4.3%

Missing edges by kind: calls=635, contains=38, references=34, instantiates=21, imports=13, extends=10.

Raw counts hide it

Raw edge rows were 39,845 live vs 40,122 rebuilt — a benign-looking +0.7%. The divergence is bidirectional, so a net-count check nets it out and reports almost nothing wrong. Any drift detector must compare edge sets, not totals.

The indexer is deterministic

Control, rebuild vs rebuild on the same tree and build: 0 differing edges (28,809 both runs). So the live-vs-rebuild delta is not run-to-run noise.

It is resolution, not residue

Node sets are identical — files 501 = 501, nodes 10,110 = 10,110, heuristic edges 36 = 36 — and every integrity check is 0 on both indexes: no duplicate nodes, no orphan edges, no nodes referencing a missing file row.

Nothing accumulates. Cross-file resolution goes stale.

Mechanism — two causes, both confirmed

ReferenceResolver binds a reference to one of the same-named definitions project-wide. Two things follow, and the drift needed both to be fixed.

1. Scope. Incremental sync re-resolves only the references in the changed files. Adding or removing a definition of pct changes the correct answer for every pct(...) reference in the repo, including references in files the sync never touches — and those references resolved successfully once, which deletes their unresolved_refs row, so nothing existed to revisit them with. (The #1240 retry only revisits refs parked as status='failed'.) The index kept an answer that was correct against an older graph.

2. Tie-break. When nothing disambiguated the candidates, findBestMatch kept the first one, and getNodesByName had no ORDER BY — so the winner was decided by rowid, i.e. by the order files happened to be written. A full index writes in scan order; a sync appends each file as it changes. The same tree therefore resolved to different edges depending on how the index was built, and no amount of re-resolution could converge, because re-resolving against the identical graph still picked a different candidate.

The fix

  • getNodesByName orders by (file_path, start_line) — a property of the code, not of the write order (src/db/queries.ts).
  • sync returns a definitionDelta: the names whose set of definitions the sync changed, computed as the symmetric difference of file\0name pairs sampled before and after the store phase (ExtractionOrchestrator.sync).
  • For each delta name, resurrectStaleResolutionEdges deletes the resolution edges targeting a symbol of that name whose source is in an unchanged file, and re-inserts each as the reference that created it (the metadata.refName stamp). The existing orphan sweep then resolves them against the post-sync graph — the same input a rebuild resolves from. Kill switch: CODEGRAPH_NO_REBIND=1.

The delta is compared per file, not as one name set over the whole batch: a commit that adds collect to a new file while an unrelated changed file already defines collect cancels out of a batch-wide name set, and that miss was the largest residual class in the first measurement of this fix.

Conservative by construction, because a wrong deletion is a permanent edge loss while a missed rebind is only residual drift: an edge with no refName stamp (synthesized, or built by an older engine) is never touched, edges whose source file the sync already re-extracted are skipped, and a per-name ceiling of 500 edges declines the generic names.

Result

Replaying real commits of this repo through sync one at a time, then diffing against a clean rebuild of the final tree:

replay baseline (main) + ORDER BY only + rebind pass (shipped)
16 commits 48 (24 missing / 24 stale) 20 0 — converged
80 commits 1,634 (963 / 671) 890 361 (359 / 2)

The direction that actively misleads — stale edges the index keeps asserting — drops from 671 to 2 over 80 commits, a 99.7% reduction.

Index and sync wall-clock are unchanged (392-file repo: index 1.88–2.02s in both arms, single-file sync 0.183s in both). The ORDER BY costs 18% per uncached name lookup in a tight loop (237ms → 280ms over 10,127 lookups), which does not reach wall-clock because ReferenceResolver memoizes the lookup per name. A composite (name, file_path, start_line) index would make the sort free, but it would widen every node index entry with a full path string on the write-heavy indexing path — not worth 43ms.

The residual, and why it is not chased

At 80 commits, 357 of the 361 remaining edges are a single pre-existing class: references to very generic names (push 260, join 97) that failed at index time and stay parked because getRetryableFailedReferences declines any name with more than 500 failed refs (1,412 for push, 2,346 for join). That ceiling is #1240/#999 policy, it is present on main, and what it declines to create is cross-language garbage: a TypeScript test file "calling" an R method named push, or a Rust method named join. The full rebuild is the wrong one here — converging would mean teaching sync to manufacture thousands of wrong edges. Left as is, deliberately.

Navigation waits on a route, not a name (2026-10)

A router's navigation call — history.push('/login'), navigate('/login'), router.push('/x'), goto('/x') — names its route by path and its reference by the router's method, so a route that changes in another file is invisible to every pass above:

  • A call parked while its route was missing. The #1240 retry looks failed refs up by the names the synced files define, and a call's name tail is push, never a path. On proshop_mern, deleting and restoring <Route path='/login'> left six history.push('/login') calls failed and their six navigates edges missing.
  • A call that bound elsewhere meanwhile. It resolved, so nothing revisits it: to a catch-all screen (Obytes' Expo starter: router.replace('/login') stayed on [...messing].tsx after login.tsx came back), to a parameter route (/users/new on /users/:id), or to the other arm of a conditional (sveltekit-realworld's redirect lost its /login arm for good).
  • A route renamed in place. runPostExtract renames a route whose path is built from a constant another file holds, an Angular route under its mount, or a React Router table route, and keeps its node, so the calls bound to it keep pointing at a path it no longer has. definitionDelta is read before that pass.
  • A link in markup. <Link to>, routerLink and the like are a synthesizer's edges, and a routes file that matches none of the synthesis triggers (an Angular Routes array, Ghostfolio's home-page.routes.ts) does not send a sync to the synthesizers.

Sync now reads every route node at its first file change, only when a router that resolves navigation calls is detected, and again after runPostExtract. When a route is on one side only (changedRoutes), the calls that routers' navigation hooks say it can answer go back to the pending set for the orphan sweep: the failed ones, found by their method tails through idx_unresolved_failed_tail, and the resolved ones, whose navigates edges are turned back into the references that made them, as the rebind does. A call counts when the router's claimsReference accepts its whole name and its file is in an app whose table holds the route, and a name more such calls share than the same 500 ceiling is skipped. The sync also refreshes synthesis. A sync that changes no route reads the routes twice and stops there.

Measured by deleting a route (or renaming it), syncing, restoring it and syncing again, with both states diffed against a fresh index of the same tree. On main each of ten scenarios drifted in one of the two states; with the fix all twenty match: proshop_mern (<Route path='/login'>), next-saas-starter (the /sign-in and /pricing pages), create-t3-turbo (/), vue-realworld (/login in the router config), sveltekit-realworld (login/+page.svelte), angular-realworld (login in app.routes.ts), Obytes' Expo starter (app/login.tsx), Ghostfolio (home-page.routes.ts), and bulletproof-react with its /auth/login path constant renamed and back — the one that drifts while the route is renamed rather than after it returns. CleanArchitecture's React client, whose routes are a table App.jsx maps into <Route>, drifted when App.jsx stopped mapping it and started again: its two navigate('/login') calls stayed failed, though AppRoutes.jsx itself never changed. With the fix every step of that replay matches. A sync that changes no route spends 0–3 ms on the two reads (proshop's 49 routes, Ghostfolio's 203); a route change on Ghostfolio spends 12–17 ms putting 40–50 calls back.

Known limits, deliberate: a call bound by name to a method while its route was missing (a redirect that matched a project method called redirect) is not revisited, since only navigates edges are looked at; a project's own wrapper (Expo's safePush) is not looked up, since its tail is its own name and the failed-tail index cannot find a suffix; and a Vue route whose name: alone changes changes no route node.

codegraph status — decided: no drift metric

The issue asked whether status should surface divergence. Decision: no.

A drift number cannot be computed without the full rebuild it would be recommending, so anything cheap enough to run on status would be an estimate — and an honest estimate is not available. Shipping a proxy would violate the product rule that a screen must not overclaim, and post-fix it would fire on the generic-name residual above, training users to ignore it. (status already refuses to warn on parked failed refs for the same reason: every repo with external-library imports has them, so the warning would be permanent noise.)

The check that is exact stays available and is documented below.

Why it degrades retrieval

Graph mass (RWR) is relative and normalized, so call edges missing elsewhere inflate an unaffected file's share of the mass. Explore ranks files by that mass (allocateExploreBudget weights on it), so drift silently promotes files that should rank low.

Observed on a private application repo under heavy development: a generated ambient-types file carried graph mass 0.24750 on the drifted index vs 0.13119 on a clean rebuild (~1.9×), and score 49.0 vs 27.0. On the drifted index it took 60.7% of an explore envelope and starved the file the agent had actually named by symbol, which rendered 251 chars of a 10,970 reservation. After a full re-index — no code change — the same query answers correctly. That incident is what prompted this measurement; see CG-24.

Severity scales with churn and index age. codegraph's own repo shows 4.3%; a repo under heavier active development plausibly drifts further.

Reproducing

scripts/agent-eval/diff-index-drift.mjs is read-only and diffs two indexes. Snapshot the live index before rebuilding — the original artifact for this investigation was destroyed by re-indexing over it:

cp .codegraph/codegraph.db /tmp/live.db          # snapshot FIRST
node dist/bin/codegraph.js index .               # full rebuild
node scripts/agent-eval/diff-index-drift.mjs /tmp/live.db .codegraph/codegraph.db

Exit code is 0 when converged, 1 when drifted. To re-confirm determinism, diff two consecutive rebuilds — that must report 0.

To reproduce the regression rather than measure a live index, replay real commits through sync: clone the repo, check out HEAD~N, index, then git checkout <sha> && codegraph sync for each commit in order, snapshot the database, and diff it against a rebuild of the final tree. That is what produced the table above, and the unit-scale version of it is __tests__/sync-rebuild-convergence.test.ts.

Note on probing an index

The index file is .codegraph/codegraph.db. There is no graph.db. sqlite3 against a mistyped path creates an empty database rather than failing, and every subsequent query then answers from an empty schema — which reads exactly like a stale pre-migration index. That produced a wrong root cause during this investigation. diff-index-drift.mjs checks existsSync before opening for exactly this reason.