fix(sync): an import a barrel passes on under another name relinks when its module gains it (#2665)

A sync that gives a module a definition retries the failed references
waiting for the names the module declares, and rebinds the edges into the
names that changed (CG-33). An importer that reaches the module through a
barrel passing it on under another name waits under neither: matx's
`export { default as MatxLoading } from './MatxLoading'` hands
MatxLoading.jsx to `import { MatxLoading } from 'app/components'`, a name
the file never declares. That import and the calls through it stayed
failed, and where a name match had taken the name elsewhere while the
module lacked it (vben's `alert`, which its alert barrel passes on from
`vbenAlert`, onto the `alert.vue` component), the edge stayed there, until
the importer changed or the project was indexed again.

reopenReferencesThroughBarrels now records each importer below a barrel
with the names the barrel passes the changed files on as, and re-opens that
importer's failed references through those names and, for the names the
files never declare, its edges bound before the sync resolved anything, as
the added-file path does. A declared name stays the retry's and the
rebind's, which cap a common one. The per-importer lookup replaces the
repo-wide retry by the passed-on names, whose per-name ceiling skips a name
as common as `alert`; the barrels' module keys are still retried for lazy
routes. passedOn, which the added-file path shares, now also reads an
import a barrel exports again, with the names it exports it as (`import
Card from './Card'; export { Card as AppCard }`), which only the lazy-route
walk followed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-19 16:05:15 +00:00
committed by GitHub
parent 90d6501b09
commit a6cfb52371
4 changed files with 297 additions and 66 deletions
+12 -8
View File
@@ -1129,13 +1129,17 @@ export class CodeGraph {
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-added-module-importers: ${Date.now() - tAdded}ms (${reopened} refs re-opened)`);
}
// A failed reference that waits for a module through a barrel —
// React Router's `import:@/pages#HomePage`, parked under
// `module:pages` — waits for a file below the barrel too
// (`pages/Home/index.tsx`), whose own keys name its folder and
// stem. Those parked under the keys of the barrels that pass on a
// file this sync adds, or one whose definitions it changed, go
// back to pending for the sweep; a body-only edit satisfies none.
// A reference that reaches a module through a barrel waits for a
// file below it: React Router's `import:@/pages#HomePage`, parked
// under `module:pages`, for `pages/Home/index.tsx`, whose own keys
// name its folder and stem; `import { MatxLoading } from
// './components'` for the `MatxLoading.jsx` the barrel passes on
// as `default as MatxLoading`, a name that file never declares.
// Those that reach a file this sync adds, or one whose definitions
// it changed, go back to pending for the sweep: the failed ones,
// and the edges bound before this sync resolved anything, which a
// name match may have taken elsewhere while the file lacked the
// name. A body-only edit satisfies none.
const tBarrels = Date.now();
const delta = new Set(result.definitionDelta ?? []);
const seeds = new Map<string, Language>(added.map((file) => [file.path, file.language]));
@@ -1146,7 +1150,7 @@ export class CodeGraph {
}
}
const throughBarrels = reopenReferencesThroughBarrels(
this.queries, importReader, [...seeds].map(([path, language]) => ({ path, language }))
this.queries, importReader, [...seeds].map(([path, language]) => ({ path, language })), edgesBeforeResolution
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-barrel-modules: ${Date.now() - tBarrels}ms (${throughBarrels} refs re-opened)`);
} else {
+102 -58
View File
@@ -81,15 +81,16 @@ export interface ImportReader {
/** The modules a Python file star-imports, which have no import mapping. */
starImports(file: string, language: Language): string[];
/**
* The bindings an ES module's own exports hand over as they are: each local
* an export clause names (`export { Page }`), and its default export when
* that is a binding (`export default Page;`).
* The bindings an ES module's own exports hand over as they are, each with
* the names it is exported as: each local an export clause names (`export {
* Page }`, `export { Card as AppCard }`), and its default export when that
* is a binding (`export default Page;`, exported as `default`).
*/
exportedBindings(file: string, language: Language): ReadonlySet<string>;
exportedAs(file: string, language: Language): ReadonlyMap<string, readonly string[]>;
}
export function createImportReader(rootDir: string): ImportReader {
const memo = new Map<string, { content: string; mappings: ImportMapping[]; reExports: ReExport[]; starImports: string[]; exported?: ReadonlySet<string> }>();
const memo = new Map<string, { content: string; mappings: ImportMapping[]; reExports: ReExport[]; starImports: string[]; exported?: Map<string, string[]> }>();
const read = (file: string, language: Language) => {
let entry = memo.get(file);
if (!entry) {
@@ -109,25 +110,31 @@ export function createImportReader(rootDir: string): ImportReader {
}
return entry;
};
const exportsOf = (file: string, language: Language): Map<string, string[]> => {
const entry = read(file, language);
if (!entry.exported) {
// Most importers export nothing they import; a commented-out clause
// read as one only costs a key lookup.
const exported = new Map<string, string[]>();
const add = (local: string, as: string): void => {
const names = exported.get(local);
if (names) names.push(as);
else exported.set(local, [as]);
};
if (EXPORTS_A_BINDING.test(entry.content)) {
for (const alias of extractLocalExportAliases(entry.content)) add(alias.localName, alias.exportedName);
const binding = defaultExportBinding(entry.content);
if (binding) add(binding, 'default');
}
entry.exported = exported;
}
return entry.exported;
};
return {
mappings: (file, language) => read(file, language).mappings,
reExports: (file, language) => read(file, language).reExports,
starImports: (file, language) => read(file, language).starImports,
exportedBindings: (file, language) => {
const entry = read(file, language);
if (!entry.exported) {
// Most importers export nothing they import; a commented-out clause
// read as one only costs a key lookup.
const exported = new Set<string>();
if (EXPORTS_A_BINDING.test(entry.content)) {
for (const alias of extractLocalExportAliases(entry.content)) exported.add(alias.localName);
const binding = defaultExportBinding(entry.content);
if (binding) exported.add(binding);
}
entry.exported = exported;
}
return entry.exported;
},
exportedAs: (file, language) => exportsOf(file, language),
};
}
@@ -183,60 +190,85 @@ export function reopenReferencesThroughAddedFiles(
const BARREL_LANGUAGES = new Set<Language>(['typescript', 'tsx', 'javascript', 'jsx']);
/**
* Re-open, for the sync's sweep, the failed references that wait for a module
* through a barrel when a file below the barrel appears or gains a
* definition. React Router's `import:@/pages#HomePage` waits under the key of
* the module it names, `module:pages`. When `pages/index.ts` passes on
* Re-open, for the sync's sweep, the references that reach a module through a
* barrel when a file below the barrel appears or gains a definition. React
* Router's `import:@/pages#HomePage` waits under the key of the module it
* names, `module:pages`. When `pages/index.ts` passes on
* `pages/Home/index.tsx`, that file is what the reference waits for, but its
* own keys name its folder and stem (`module:Home`), never the barrel's.
*
* The barrels are the modules that pass one of `files` on, through `export …
* from` or an import they export again (`import { HomePage } from './Home';
* export { HomePage }`), followed up as deep as the resolver chases a name.
* Only keys a failed reference waits under are looked up, so a sync with none
* reads nothing. Call after the failed-ref retry: an added file's importers
* reach it through the import edges that retry made. Returns the number
* re-opened.
* export { HomePage }`), followed up as deep as the resolver chases a name
* ({@link passedOn}). What waits under their keys goes back, and so does what
* their importers reach through a name they pass the file on as: matx's
* `export { default as MatxLoading } from './MatxLoading'` hands
* `MatxLoading.jsx` to `import { MatxLoading } from 'app/components'`, a name
* the file never declares, so neither the retry by the file's names nor the
* rebind of the edges into them reaches it. Such an importer's failed
* references through those names go back. So do its edges through the names
* the files never declare, bound before this sync resolved anything (up to
* `resolvedUpToEdgeId`): while the module lacked the binding, a name match
* could take the name elsewhere (vben's `alert`, which the barrel passes on
* from `vbenAlert`, to the `alert.vue` component). A name a file declares is
* the rebind's, which caps a common one, and a chain of `export *` hands over
* only those. Call after the failed-ref retry: an added file's importers
* reach it through the import edges it made. Returns the number re-opened.
*/
export function reopenReferencesThroughBarrels(
queries: QueryBuilder,
reader: ImportReader,
files: ReadonlyArray<{ path: string; language: Language }>,
resolvedUpToEdgeId: number,
): number {
let level = files.filter((file) => BARREL_LANGUAGES.has(file.language));
let level: Module[] = files.filter((file) => BARREL_LANGUAGES.has(file.language)).map((file) => ({ ...file, passes: null }));
if (level.length === 0) return 0;
const waiting = new Set(queries.getFailedModuleKeys());
if (waiting.size === 0) return 0;
const keys = new Set<string>();
const seen = new Set(level.map((file) => file.path));
for (let depth = 1; level.length > 0 && depth <= REEXPORT_MAX_DEPTH; depth++) {
const next: Array<{ path: string; language: Language }> = [];
const declared = new Set(queries.getNodesByFiles(level.map((module) => module.path)).map((node) => node.name));
// The importers below a barrel, with the names it passes the files on as,
// and with those of them the files never declare.
const importers: Importers = new Map();
const renamed: Importers = new Map();
const record = (into: Importers, importer: string, language: Language, source: string, names: string[]): void => {
if (names.length === 0) return;
const entry = into.get(importer) ?? { language, sources: new Map<string, Set<string> | null>() };
entry.sources.set(source, new Set([...(entry.sources.get(source) ?? []), ...names]));
into.set(importer, entry);
};
const seen = new Set(level.map((module) => module.path));
for (let depth = 1; level.length > 0; depth++) {
// Per importer of this level, its statements naming each module of it.
const naming = new Map<string, { language: Language; statements: Array<{ source: string; module: Module }> }>();
for (const module of level) {
for (const i of queries.getImportsInto(`file:${module.path}`, module.path)) {
if (i.refName === null || seen.has(i.filePath) || !BARREL_LANGUAGES.has(i.language)) continue;
if (!passesOn(i.filePath, i.language, i.refName, reader)) continue;
seen.add(i.filePath);
for (const key of moduleReferenceKeys(i.filePath)) if (waiting.has(key)) keys.add(key);
next.push({ path: i.filePath, language: i.language });
if (i.refName === null || seen.has(i.filePath)) continue;
if (module.passes !== null && i.language !== 'python' && BINDING_LANGUAGES.has(i.language)) {
record(importers, i.filePath, i.language, i.refName, [...module.passes]);
record(renamed, i.filePath, i.language, i.refName, [...module.passes].filter((name) => !declared.has(name)));
}
if (depth > REEXPORT_MAX_DEPTH || !BARREL_LANGUAGES.has(i.language)) continue;
const found = naming.get(i.filePath) ?? { language: i.language, statements: [] };
found.statements.push({ source: i.refName, module });
naming.set(i.filePath, found);
}
}
const next: Module[] = [];
for (const [importer, { language, statements }] of naming) {
const passes = passedOn(importer, language, statements, reader);
if (passes === undefined) continue;
seen.add(importer);
for (const key of moduleReferenceKeys(importer)) if (waiting.has(key)) keys.add(key);
next.push({ path: importer, language, passes });
}
level = next;
}
if (keys.size === 0) return 0;
const parked = queries.getRetryableFailedReferences([...keys]).flatMap((ref) => (ref.rowId === undefined ? [] : [ref.rowId]));
return queries.reopenFailedReferences(parked);
}
/**
* Whether `importer` passes on what it imports from `source`: an `export …
* from` of it, or a binding it imports from it and exports again — the forms
* a lazy route's lookup follows through a barrel (`exportedComponent`).
*/
function passesOn(importer: string, language: Language, source: string, reader: ImportReader): boolean {
if (reader.reExports(importer, language).some((rex) => rex.source === source)) return true;
const exported = reader.exportedBindings(importer, language);
return exported.size > 0 &&
reader.mappings(importer, language).some((m) => m.source === source && !m.isNamespace && exported.has(m.localName));
const failed = new Set([...bindingsOf(importers, reader)].flatMap(([file, { names }]) => queries.getFailedReferencesThrough(file, [], names)));
if (keys.size > 0) {
for (const ref of queries.getRetryableFailedReferences([...keys])) if (ref.rowId !== undefined) failed.add(ref.rowId);
}
return reopenEdges(queries, bindingsOf(renamed, reader), (e) => e.edgeId <= resolvedUpToEdgeId) +
queries.reopenFailedReferences([...failed]);
}
/** An import's module edge into a file that an added file now outranks. */
@@ -357,10 +389,12 @@ function importersOf(
}
/**
* What `importer` passes on of what its `statements` import, or undefined
* when the resolver reads no name through it: an ES module what it `export …
* from`s; a Python module what it star-imports, and a package's `__init__.py`
* the names it imports too.
* What `importer` passes on of what its `statements` import, under the names
* its own importers use, or undefined when the resolver reads no name through
* it: an ES module what it `export … from`s and the imports it exports again
* (`import Card from './Card'; export { Card as AppCard }`); a Python module
* what it star-imports, and a package's `__init__.py` the names it imports
* too.
*/
function passedOn(
importer: string,
@@ -382,6 +416,16 @@ function passedOn(
else if (passing(module, rex.originalName)) pass(new Set([rex.exportedName]));
}
}
const exportedAs = reader.exportedAs(importer, language);
if (exportedAs.size > 0) {
for (const m of reader.mappings(importer, language)) {
const names = m.isNamespace ? undefined : exportedAs.get(m.localName);
if (!names) continue;
for (const { source, module } of statements) {
if (source === m.source && passing(module, m.isDefault ? 'default' : m.exportedName)) pass(new Set(names));
}
}
}
return passes;
}
const stars = new Set(reader.starImports(importer, language));