Skip to content

fix(sync): a reference through an import binding links its module when the module appears later - #2450

Merged
colbymchenry merged 10 commits into
mainfrom
claude/elastic-heyrovsky-f7b8af
Oct 7, 2026
Merged

colbymchenry merged 10 commits into
mainfrom
claude/elastic-heyrovsky-f7b8af

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Problem

codegraph sync (and the watcher) never relinked a reference through an import binding whose local name isn't a name the module declares, when the module was added after the importing file was indexed, came back after a delete, or gained the export in a later edit. A fresh index links it.

  • a default import: import tagsController from './tag/tag.controller' (ts-express), import authHandler from '../handlers/auth' (takenote), import Error404 from "@/views/error/404.vue" (vue3-element-admin)
  • an aliased import: import { Component as GroupsPageWrapper } from '@/authdb/pages/groups_page' (luci-go milo)
  • a namespace import: import * as NS from './ns'

Every reference through such a name stayed failed, not only the import: a call (runIt()), new W(), extends Base, a function value, a JSX / Vue / Svelte / Astro template tag, and an Express mount (app.use('/api/upload', uploadRoutes), proshop).

Cause

Sync's #1240 retry finds a parked failed ref by its name_tail, looked up by the changed files' node names and (since #2422) their module: keys. A ref through such a binding was parked under its own name — the alias — which neither the module's file nor any of its symbols carries (router, Component, 404, default…). The import retry (#2392) only finds module-path refs. The row doesn't carry its import's source; the importing file's import mappings do.

Fix

When resolution gives up on a ref, the resolver decides where it waits (importBindingTail, called where the unresolved list is built — in pool workers too, where the file's mappings are still cached):

  • a whole-name ref (X, not X.member) whose name is a default, namespace or aliased ({ A as X }, { default as X }, CommonJS require) binding of a project module is parked under module:<stem of the module path> — module:tag, module:groups_page, module:404 — the keys fix(sync): a route links the module it lazily loads when the module appears later #2422's moduleReferenceKeys give every file a sync adds or changes, so an edit that gives the module its export relinks too. A package's binding (import express from 'express'), an unaliased named import and a member ref keep their tails.
  • it is also still found by its own name: getRetryableFailedReferences runs a second lookup by whole reference_name over module-parked failed rows. Without it the vben A/B regressed: import type { VbenFormSchema as FormSchema } from '@vben/common-ui' never resolves through the workspace package, and a fresh index links FormSchema by name to the type in another package; parked only under module:common-ui, restoring that type's file no longer relinked it (12 refs). The by-name half restores main's behavior exactly, so the triggers are main's plus the module's.
  • schema v15 adds idx_unresolved_failed_module_name ON unresolved_refs(status, reference_name) WHERE status = 'failed' AND name_tail GLOB 'module:*'. The leading status equality is what keeps the planner on it (fix(sync): an import links the file it names when that file appears later #2392's trap): on vscode's index the load query otherwise goes through idx_unresolved_status (14.5 s), and an index on (reference_name) alone is still bypassed without ANALYZE. A test pins the plan.

No extraction change, so no kernel change. Rows parked by an older version keep their name tail (still found by name); a re-index parks them under their modules — hence the CHANGELOG's re-index note.

Directions considered: (2) "retry the binding refs of files whose path import the import retry just relinked" covers added/restored modules but not an edit that adds the export (the gutted-module A/B below: takenote 76 → 0 needs it), and the module-key-only variant of (1) regressed vben as above.

Validation

Tests — __tests__/sync-import-binding-retry.test.ts (8): added / restored / edited module scenarios over TS, TSX, JS (Express), Vue, Svelte and Astro, each compared edge-for-edge with a full index of the final tree in a second folder; the vben by-name case; parking keys; v15 migration replay, lookup and query plan. On main (ed199e6) 7 of the 8 fail: the 3 module scenarios (1 of 17 links after add/restore, 11 of 17 after the edit), the parking test and the 3 v15 tests. The by-name case passes on main by design and fails on the module-key-only variant, which is the regression it guards. All 8 pass with the kernel and with CODEGRAPH_KERNEL=0. The related suites (sync*, migrations/schema, import resolution, dead code, React/Vue routers, Express; 674 tests) pass.

A/B, 19 repos (%TEMP%\cgbind; main = ed199e6, fix = this branch; both arms with the same kernel):

  • Fresh index: nodes, edges and files byte-identical in all 19. refs differ only in the name_tail of refs that already failed (now module:*): berry 28, bulletproof 6, folo 427, ghostfolio 10, infrahub 10, matx 4, milo-ui 104, proshop 6, redux-realworld 28, takenote 53, ts-express 9, vben 146, vue-element-admin 154, vue-realworld 7, vue3-element-admin 28. Controls fully byte-identical: angular-realworld, django-realworld (Python), gin (Go), ngx-admin.
  • Late scenarios, each arm's sync compared with its own fresh index (differing dump lines, main → fix):
scenario repo main fix
every 7th source file held out, restored, synced ts-express 6 0
takenote 6 0
proshop 6 0
vben 218 116
vue-element-admin 40 20
folo 224 204
vue-realworld 12 6
vue3-element-admin 190 182
milo-ui 2141 2127
ghostfolio / infrahub 82 / 12 80 / 10
bulletproof 82 82
every 7th TS/JS/Vue file emptied (export {};), restored, synced takenote 76 0
proshop 6 0
milo-ui 1030 924
folo / ghostfolio / infrahub / vue3-element-admin 220 / 82 / 148 / 184 216 / 80 / 146 / 182
vben / vue-element-admin / vue-realworld 62 / 18 / 10 unchanged
route modules held out (late.sh) vue3-element-admin (Error404) 6 0
milo-ui (GroupsPageWrapper, LookupPageWrapper) 8 0

Every other run is 0 in both arms: angular-realworld, berry, django-realworld (Python), gin (Go), matx, ngx-admin and redux-realworld, and the other 7 route runs. Every residue bucket that changed went down (80 buckets) and none went up. The module-key-only variant had one that went up (vben, the 12 FormSchema refs above); with the by-name lookup it doesn't.

What's left in the fix arm across all 45 runs is pre-existing and present on main too: 1,732 edges that differ only in metadata, 194 member calls on an imported constant, 171 other failed refs (mostly an unaliased named import through a barrel that was held out, like milo's import { useInvocation } from '@/test_investigation/context'), 72 retargeted, 26 module-path imports, 2 navigation calls, 1 route module. See Residues.

The decision is made where a ref fails, so it also has to cross the resolver pool: takenote and vue3-element-admin indexed with the pool forced on (CODEGRAPH_PARALLEL_RESOLVE_MIN=0) are byte-identical to the sequential index, parking keys included.

Cost (vscode, 15,358 files, 1.9 GB index; box shared with other sessions):

  • Parking decision: 816K failed JS-family refs, 13K bindings considered, 1,236 parked under a module; ~0.4–0.6 s per full index once mappings are warm, as they are in resolution (~0.1–0.15% of a 6 m 17 s index).
  • Sync lookup: the module keys of a changed file cost 0.0–0.6 ms (idx_unresolved_failed_tail, covering); the by-name lookup through the new index 0.8 ms count / 2.3 ms load for 500 names, versus 410 ms / 14.5 s without it.
  • v15 migration: 1.2 s warm, 8.2 s cold (one-time CREATE INDEX over 830K rows).
  • One key is over the 500 ceiling on vscode: module:sessionState (546 refs of import { URI as ProtocolURI } in 33 files). They fail in a fresh index too and were never retried before either (ProtocolURI had the same 546).

Residues (pre-existing, unchanged)

  • Same edge, other metadata: a ref resolved by name (exact-match, 0.5) while its module or barrel was missing keeps that metadata when the module returns; a fresh index resolves it through the import (0.9). Same target. milo-ui 964 lines, vue3-element-admin 87, bulletproof 41, ghostfolio 36, folo 26.
  • Import retry ceiling: folo already has 484 failed package imports with tail utils (@follow/utils/utils, jotai/utils), so with held-out files the utils key passes fix(sync): an import links the file it names when that file appears later #2392's 500 ceiling and the project's own modules/settings/utils.ts isn't relinked.
  • Member call on an imported constant (NODE_PATH_EXCLUDELIST.includes, useEntryStore.setState): parked under the member's name, which is over the ceiling; a fresh index links it to the constant through its import.
  • Python from .models import User as UserModel: the same gap; not covered here (dotted module paths need their own key).
  • Barrel elsewhere: import { Button as Btn } from './lib' where lib/index.ts re-exports ./ui/Button and only ui/Button.tsx is added: none of the added file's keys is module:lib.
  • Unaliased named import through a barrel: import { useInvocation } from '@/test_investigation/context' waits for useInvocation, which a barrel added later doesn't declare. Its local name is the module's name for it, so it's outside this gap; with the by-name lookup in place it could now be parked under its module as well without losing its name trigger.

Overlap

🤖 Generated with Claude Code

…n the module appears later

A default, namespace or aliased import binds a name its module never
declares: `import tagsController from './tag/tag.controller'`,
`import { Component as GroupsPageWrapper } from './groups_page'`. Every
reference through that name (the import, a call, `new`, a base class, a
JSX or template tag, an Express mount) was parked under the name, which
no file a sync adds or changes carries. So a module added after its
importers were indexed, restored after a delete, or given its export in a
later edit never relinked them, while a fresh index did.

Such a reference is now parked under its project module's key
(`module:tag`), which sync already looks up for every file it adds or
changes (#2422), and it is still found by its own name: a binding its
module never resolves is linked by that name alone, as vben's
`{ VbenFormSchema as FormSchema }` from a workspace package is. Schema
v15 adds the partial index that by-name lookup needs; its leading
`status` keeps the planner off idx_unresolved_status, which read 830K
rows on vscode's index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit e47cb25 into main Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant