Skip to content

fix(sync): an import a name match took follows its module once it resolves another way - #2752

Merged
colbymchenry merged 1 commit into
mainfrom
claude/sync-name-matched-imports
Oct 21, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/sync-name-matched-imports

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Problem

A named import that a name match linked to a declaration elsewhere, while its module did not export the name, kept that link in codegraph sync and the file watcher after an edit gave the module the name. A fresh index of the same tree links it through the module:

// src/lib/index.ts   before: export const version = 1;
//                    after:  + export { default as Widget } from './impl';
// src/app/y.ts (unchanged)
import { Widget } from '../lib';
new Widget();            // sync: other/widget.ts::Widget (name match) · fresh index: lib/impl.ts::Impl
(w: Widget) => w.run();  // sync: Widget::run (other) · fresh index: Impl::run

This is the residue PR #2744 handed over: the "retargets" in its gut runs (folo EmptyState / CollapseCss through layouts/shared/index.ts and ui/collapse/index.ts, vben Breadcrumb, milo InvocationProvider / ArtifactsProvider), plus about 670 lines where the target was the same and only the exact-match / import metadata differed. PR #2749 found the mirror image: import { Widget } from '../x', where x.ts exports Widget only as its default, is matched by name to that class; once the edit drops the default, a fresh index finds nothing.

Re-verified on main fc293f0 (CLI probes, sync vs a fresh index of the final tree):

shape main this PR
a barrel gains a re-export under another name (export { default as Widget } from './impl') differs converges
a barrel gains the re-export of the declaration the name match had also found (same target, exact-match → import) differs converges
a class exported only as the default, imported by name; the default dropped differs converges
the module edited to declare the name itself (control: the rebind's case) converges converges
a barrel drops a re-export an import resolved through differs differs (not in this PR, below)

Cause

A binding a name match linked never failed, so no retry looks for it: neither #1240's retry nor PR #2744's module keys, which park only failed bindings. The edit that makes it stale declares nothing: a re-export or an export clause gives no definition delta for the CG-33 rebind, and adds no file for the PR #2512 walk. The PR #2560 / #2665 walk below a barrel starts only from files added or whose definitions changed.

Fix

reopenNameMatchedImports (module-importers.ts), run after the default-export step for each JS/TS module the sync modified:

  • Importers. They come from the imports edges into the module's file node: one indexed lookup.
  • Candidate bindings. getImportBindingsResolvedElsewhere selects those importers' binding edges that resolved some other way than their import: an indexed range per importer, and few rows. For each, the importer's own import statement, from the sync's import reader, must name this module.
  • The check. ReferenceResolver.resolveNow resolves the binding again against the graph as it is, writing nothing. A binding that resolves exactly as before stays, for example a name match a fresh index makes too (bulletproof's nextjs-pages app has 24 such bindings).
  • The re-open. Otherwise the importer's edges through the name go back for the sweep, together with its edges into the members of what the name match took (w.run() with w: Widget). Only edges bound before this sync resolved anything are taken.

Validation

Tests. __tests__/sync-name-matched-imports.test.ts has 4 cases. Each indexes a tree, edits it, syncs, and compares edges and unresolved refs with a full index of the final tree in a folder of its own:

  • a barrel gaining a renamed re-export, with a typed receiver;
  • a barrel gaining a re-export of the declaration the name match also found;
  • a dropped default that a named import was matched to;
  • a body edit that must re-open nothing while the module still lacks the name.

On main fc293f0 the 3 change cases fail and the no-op passes by design. All 4 pass with the native kernel (CODEGRAPH_KERNEL_EXPECT=1) and with CODEGRAPH_KERNEL=0. The 12 neighbouring sync suites pass (93 tests).

Mutations, each restored from the committed tree:

mutation fails
main's src the 3 change cases
the step disabled the 3 change cases
no members of what the name match took the typed receiver
re-open every candidate, no resolve-now check the no-op case
compare targets only, not how they were found the same-target case

Fresh indexes. codegraph init -y with the native kernel, compared with scripts/dump-graph.mjs, is byte-identical between main and this branch on 8 repos: vben, folo, milo-ui, excalidraw, bulletproof-react, outline, react-native, bitwarden. The change only runs in a sync.

Gut runs, PR #2744's scenario. From each arm's own fresh index, every 7th JS/TS file is emptied (export {};), synced, restored and synced again, then compared with that arm's own fresh index of the untouched tree. Differing dump lines:

repo main this PR re-opened on restore
vben 2 0 55 refs, 42 ms
milo-ui 6 0 205 refs, 125 ms
outline 55 53 91 refs, 87 ms
excalidraw 0 0 7 refs, 27 ms
bulletproof-react 0 0 179 refs, 28 ms
folo 1,794 1,794 281 refs, 132 ms

What's left is the same on main:

  • outline: the 2 lines this PR removes are a DocumentScene binding. The rest are second-order: calls on typed receivers (context.t) and statics through model classes whose base class was gutted (User.createWithCtx).
  • folo: all 1,794 lines are cn, 895 references past the by-name retry's 500 ceiling while its file was gutted.
  • bulletproof and excalidraw: the restore re-opens bindings that do resolve differently now, edges the later CG-33 rebind would otherwise re-open.

Cost, the sync-name-matched-imports phase from CODEGRAPH_SYNTH_TIMINGS. Body-only edits of the most-imported modules (PR #2749's picks: 12 in outline, 12 in react-native, 11 in bitwarden) re-open nothing and cost 64, 7 and 4 ms per sync. Sync wall times are within run-to-run noise of main's.

Full suite: on 1335c9b (this branch on main bbf987e), with the native kernel built and CODEGRAPH_KERNEL_EXPECT=1, --maxWorkers=4: 689 test files passed, 6 skipped; 8,226 tests passed, 82 skipped, 0 failed.

Not in this PR

  • An import that resolved through a barrel, when an edit removes or repoints the re-export while the declaration stays (the last probe row). Telling which importers read the dropped name means reading every importer's import statements on every edit of a barrel. Unlike name-matched bindings, nothing narrows those down. The probes show only metadata moves (import → exact-match, same target), and it does not occur in PR fix(sync): a name imported through a barrel links when an edit gives the barrel its re-export #2744's gut-and-restore runs.
  • A default import whose usages a name match took while its module had no default. Its binding edge links the module's file, so only the consumers' usages show it, and finding them would scan every default consumer on each edit of a default-exported module.
  • Second-order drift through a re-opened binding's type: inherited statics and typed receivers of a gutted base class (outline above).

🤖 Generated with Claude Code

…olves another way

A named import that a name match linked to a declaration elsewhere while
its module did not export the name kept that link after an edit gave the
module the name. A barrel's `export { EmptyState } from './EmptyState'`
declares nothing, so no definition delta, failed-ref retry or barrel walk
saw it (the "retargets" PR #2744 left). A name match into the module
itself, a class exported only as its default and imported by name, kept
its link after the default was dropped, where a fresh index finds nothing.

reopenNameMatchedImports (module-importers.ts): for each module the sync
modified, its importers' binding edges that resolved some other way than
their import (getImportBindingsResolvedElsewhere, an indexed range per
importer), whose import statement names this module, are resolved again
against the graph as it is (ReferenceResolver.resolveNow, writing
nothing). When the answer differs, the importer's edges through the name
and into the members of what the name match took go back for the sweep.

Regression test sync-name-matched-imports.test.ts: 4 cases comparing the
synced graph with a fresh index of the final tree. Fresh indexes are
byte-identical on 8 repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 553d5d1 into main Oct 21, 2026
@colbymchenry
colbymchenry deleted the claude/sync-name-matched-imports branch October 21, 2026 15:23
@colbymchenry
colbymchenry restored the claude/sync-name-matched-imports branch October 21, 2026 15:24
@colbymchenry
colbymchenry deleted the claude/sync-name-matched-imports branch October 21, 2026 15:24
@colbymchenry
colbymchenry restored the claude/sync-name-matched-imports branch October 21, 2026 15:24
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