Skip to content

fix(sync): a barrel whose re-export points elsewhere re-links the files importing through it - #2985

Closed
danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/2978-barrel-reexport-retarget
Closed

danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/2978-barrel-reexport-retarget

Conversation

@danusha2345

Copy link
Copy Markdown
Contributor

Fixes #2978.

What happened

c.ts imports foo through index.ts, which re-exports it from a.ts. When the barrel's clause is changed to b.ts and the tree is synced, bar keeps calling a.ts:foo; a fresh index gives b.ts:foo. The barrel declares nothing of foo, so the definition delta (CG-33) has nothing to rebind; no file was added for the walks below a barrel (#2665) or the added-files pass; the binding never failed, so the retries (#1240, #2744) never see it; and the name-matched-import pass (#2744's other half) reads only bindings a name match took. export * moves the same way, and a scoped sync of the barrel alone too.

The change

  • The orchestrator reports modules whose re-exports changed. An import node is named by the module it names, so sync() samples a changed file's file\0module import pairs before the store replaces them, as it samples the definition pairs, and returns reExportsChangedFilePaths: the ES modules whose set of imported modules changed and that pass bindings on now (export … from) or lost an import. A body-only edit, or an import edit in a file that re-exports nothing, reports nothing.
  • Their importers' bindings go back for the sweep (reopenReferencesThroughRetargetedModules in module-importers.ts): the edges each importer makes through the names it binds from the module, up through the modules that pass them on in turn (a second barrel over the first), with the failures parked under them, and the edges into the members of what the bindings pointed at, which a value typed by a binding reached by their own names (w.run() with w: Widget). It is the importer walk the removed-files pass uses, without the exclusion of edges into the file itself, and it runs after the name-matched-import pass (fix(sync): a name imported through a barrel links when an edit gives the barrel its re-export #2744), which reads the bindings a name match took before they go back. The sweep then binds them against the barrel as it is now.

Tests

__tests__/sync-barrel-retarget.test.ts (7 tests), each comparing the synced graph with a full index of the final tree:

  • the issue's named re-export retargeted; an export * retargeted; a scoped sync of the barrel alone;
  • a re-export taken away (the importer lands where a full index lands it);
  • the change reaching the importer through a second barrel that passes the first one on;
  • a value typed by the binding (new Widget(); w.run()) reaches the members of the new target;
  • a body-only edit of the barrel reports nothing and re-resolves nothing.

6 of 7 fail on main (the control passes); all 7 pass here.

Full suite on main 01e0ac3 with the native kernel (CODEGRAPH_KERNEL_EXPECT=1): 9,278 passed, 55 skipped, 0 failed; tsc --noEmit clean.

🤖 Generated with Claude Code

…es importing through it

`c.ts` imports `foo` through `index.ts`, which re-exports it from `a.ts`. When
the barrel's clause is changed to `b.ts`, the barrel declares nothing of `foo`
and `c.ts` did not change, so no definition delta, added file or failed
reference named `c.ts`'s binding: it stayed on `a.ts` until a full re-index,
and an `export *` moved the same way, in a full or a scoped sync (colbymchenry#2978).

An import node is named by the module it names, so the orchestrator samples the
`file\0module` pairs of a changed file's import nodes before the store replaces
them, as it samples the definition pairs, and reports the ES modules whose set
of imported modules changed when they pass bindings on now or lost an import:
`reExportsChangedFilePaths`. For those, the sync re-opens what their importers
bind through them, up through the modules that pass them on in turn, with the
failures parked under them, and the edges into the members of what the
bindings pointed at, which a value typed by a binding reached by their own
names (reopenReferencesThroughRetargetedModules, built on the importer walk the
removed-files pass uses). It runs after the name-matched-import pass, which
reads the bindings a name match took before they go back. The sweep binds them
against the barrel as it is now. A module given re-exports is read the same
way; one whose body alone changed reports nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
colbymchenry pushed a commit that referenced this pull request Oct 30, 2026
…orters

`c.ts` imports `foo` through `index.ts`, which re-exports it from `a.ts`.
Change the clause to `./b` and sync, and `bar` kept calling `a.ts:foo`
where a fresh index gives `b.ts:foo`. The barrel declares nothing of
`foo`, so the definition delta has nothing to rebind; no file was added
for the walks below a barrel or the added-files pass; the binding never
failed, so the retries never see it; and the name-matched-import pass
reads only bindings a name match took. `export *` moved the same way, and
a scoped sync of the barrel alone too.

The orchestrator now reports the modified ES modules whose `export … from`
statements changed, read off their import nodes (named by the module, with
the statement as signature) before and after the store. Their importers'
bindings through them go back for the sweep, up through the modules that
pass them on in turn, with the failures parked under them and the edges
into the members of what the bindings pointed at (`w.run()` with
`w: Widget`). It is the importer walk the removed-files pass uses, run
after the name-matched-import pass.

Review changes: the trigger compares the `export … from` statements
themselves. The original compared the modules a file imports and counted a
module that lost any import, so dropping a plain import from excalidraw's
`types.ts` (218 importers, no re-exports) re-opened 2,186 refs for the same
graph; it now re-opens none. Comparing statements also catches a name
dropped from a clause whose module stays (`export { foo, qux } from './a'`
to `export { foo } from './a'`), which the module set missed. Plus tests
for both, and the doc comments the new code sat between moved back.

Validation: 9 tests; on main's source 7 fail (the 2 controls pass), and
on the original trigger the 2 new ones fail. 15 fixture edits, each
compared with a fresh index, in a later process and in the same process:
main diverged in 8 of each, this in none. excalidraw with
`export * from "./utils"` in its math barrel (116 importers) retargeted to
`./constants`: main was 224 edges off, identical here over nodes, edges,
refs and files. Fresh indexes are identical to main's.

Original PR #2985 by @danusha2345.
Fixes #2978

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry

Copy link
Copy Markdown
Owner

Thank you @danusha2345! Retargeting a barrel's re-export really did leave the files importing through it on the old module, and your importer walk fixed it. It landed in #3059 as your commit, with one change: the trigger now compares the barrel's export … from statements instead of the modules a file imports, so dropping a plain import from a widely imported module no longer re-opens every importer's bindings, and a name dropped from a clause is caught too. Thanks again!

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.

sync: retargeting a barrel's re-export (export { foo } from './a' -> './b') leaves importers on the old module

2 participants