Repository navigation
fix(sync): a reference through an import binding links its module when the module appears later - #2450
Merged
Conversation
…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>
…sky-f7b8af # Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.import tagsController from './tag/tag.controller'(ts-express),import authHandler from '../handlers/auth'(takenote),import Error404 from "@/views/error/404.vue"(vue3-element-admin)import { Component as GroupsPageWrapper } from '@/authdb/pages/groups_page'(luci-go milo)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) theirmodule: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):X, notX.member) whose name is a default, namespace or aliased ({ A as X },{ default as X }, CommonJSrequire) binding of a project module is parked undermodule:<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'smoduleReferenceKeysgive 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.getRetryableFailedReferencesruns a second lookup by wholereference_nameover 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 linksFormSchemaby name to the type in another package; parked only undermodule: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.idx_unresolved_failed_module_name ON unresolved_refs(status, reference_name) WHERE status = 'failed' AND name_tail GLOB 'module:*'. The leadingstatusequality 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 throughidx_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 withCODEGRAPH_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):refsdiffer only in thename_tailof refs that already failed (nowmodule:*): 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.export {};), restored, syncedlate.sh)Error404)GroupsPageWrapper,LookupPageWrapper)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
FormSchemarefs 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):
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.CREATE INDEXover 830K rows).module:sessionState(546 refs ofimport { URI as ProtocolURI }in 33 files). They fail in a fresh index too and were never retried before either (ProtocolURIhad the same 546).Residues (pre-existing, unchanged)
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.utils(@follow/utils/utils,jotai/utils), so with held-out files theutilskey passes fix(sync): an import links the file it names when that file appears later #2392's 500 ceiling and the project's ownmodules/settings/utils.tsisn't relinked.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.from .models import User as UserModel: the same gap; not covered here (dotted module paths need their own key).import { Button as Btn } from './lib'wherelib/index.tsre-exports./ui/Buttonand onlyui/Button.tsxis added: none of the added file's keys ismodule:lib.import { useInvocation } from '@/test_investigation/context'waits foruseInvocation, 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
module:keys and reuses itsmoduleReferenceKeyslookup;moduleTailis factored out of its route branch.import { default as X }is the module's default import #2412 (merged):{ default as X }is a default import there; either way it is parked under its module here.isExternalImport, which the parking decision calls with its default behavior; no shared hunks. Its catch-all"*"alias case makes every package look local, which here only moves those packages' failed bindings tomodule:keys; the by-name lookup still finds them.🤖 Generated with Claude Code