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
+1
View File
@@ -180,6 +180,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript, a field or variable set from a dependency-injection call now has the class that call is given, so calls through it link to that class's methods: Angular's `private readonly dataService = inject(DataService)` or `inject<DialogParams>(DIALOG_DATA)`, and vscode's `this.instantiationService.createInstance(Widget, …)`, also inside a wrapper like `this._register(…)`. Before, such a value had no type, so calls through it, among the most common in Angular apps, linked nowhere or to a same-named method of another class. Only Angular's `inject` counts, and only a `createInstance` the project declares to return an instance of the class it is given. A call on a value whose type the file imports under another name, like `import { OrganizationApiServiceAbstraction as OrganizationApiService }`, now also reaches the imported class rather than a class with the local name. Re-index TypeScript projects after upgrading.
- In JavaScript and TypeScript, a call to a method whose name contains a `$` now links to that method through its receiver's type, like any other method call: RxJS-style `this.organizationService.organizations$(userId)`, or vscode's `this._proxy.$acceptModelChanged(…)` on the interface its RPC proxy is declared with. Before, such a call linked nothing, however well its receiver was typed. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading.
- `codegraph sync` and the file watcher now relink an import that reaches a module through a barrel file passing it on under another name, like `import { MatxLoading } from './components'` when `components/index.js` says `export { default as MatxLoading } from './MatxLoading'`, once an edit gives that module what is imported. Before, the import and every call through it stayed unlinked, or stayed on something else in the project with the same name, until the importing file itself changed or the project was indexed again.
## [1.6.2] - 2026-10-03
@@ -0,0 +1,182 @@
/**
* A sync relinks an import that reaches a module through a barrel which passes
* it on under another name, once an edit gives the module what is imported.
*
* matx's `app/components/index.js` says `export { default as MatxLoading }
* from './MatxLoading'`, and `MatxSuspense.jsx` imports `{ MatxLoading }` from
* the barrel. While `MatxLoading.jsx` has no default export, the import and
* the calls through it fail, parked under the name they use: `MatxLoading`.
* When an edit adds `export default function Loading`, a sync retries what
* waits for the names the file now declares (`Loading`) and what waits under
* the barrel's module key, and neither is `MatxLoading`. Only a fresh index
* linked them. The same holds for `export { Spinner as AppSpinner } from`.
*
* When something else in the project is named `MatxLoading`, the import does
* not fail while the module lacks it: a name match takes it there. The edit's
* rebind re-opens the edges into what the file now declares (`Loading`), not
* into another `MatxLoading`, so that edge stayed too (vben's `alert`, which
* its alert barrel passes on from `vbenAlert`, stayed on the `alert.vue`
* component).
*
* The barrel also passes on `Card` under the name `Card.js` declares, which
* the retry by the changed names and the rebind already relink: the fixture
* keeps it so they go on doing so.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
type Files = Record<string, string>;
const IMPORTERS: Files = {
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18' } }),
'src/components/index.js':
"export { default as MatxLoading } from './MatxLoading';\n" +
"export { Spinner as AppSpinner } from './Spinner';\n" +
"export { Card } from './Card';\n",
'src/components/MatxSuspense.js':
"import { MatxLoading, AppSpinner, Card } from '.';\n" +
'export function suspense() {\n MatxLoading();\n Card();\n return AppSpinner();\n}\n',
};
const MODULES: Files = {
'src/components/MatxLoading.js': 'export default function Loading() {\n return null;\n}\n',
'src/components/Spinner.js': 'export function Spinner() {\n return null;\n}\n',
'src/components/Card.js': 'export function Card() {\n return null;\n}\n',
};
/** What a fresh index links from `suspense` through the barrel. */
const CALLS = [
'suspense -> src/components/Card.js::Card',
'suspense -> src/components/MatxLoading.js::Loading',
'suspense -> src/components/Spinner.js::Spinner',
];
let roots: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true, maxRetries: 5 });
roots = [];
});
function tempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-barrel-rename-'));
roots.push(root);
return root;
}
function write(root: string, files: Files): void {
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
}
async function index(root: string): Promise<CodeGraph> {
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}
/** Every edge, by its ends' natural keys, with where it is written and how it resolved. */
function edgesOf(graph: CodeGraph): string[] {
const keys = new Map(
graph.getFiles()
.flatMap((file) => graph.getNodesInFile(file.path))
.map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const)
);
return graph.getOutgoingEdgesFrom([...keys.keys()])
.map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`)
.sort();
}
/** The calls `suspense` makes, as `suspense -> <file>::<name>`. */
function callsOf(graph: CodeGraph): string[] {
const suspense = graph.getNodesInFile('src/components/MatxSuspense.js').find((n) => n.name === 'suspense');
if (!suspense) return [];
return graph.getCallees(suspense.id).map((c) => `suspense -> ${c.node.filePath}::${c.node.name}`).sort();
}
async function fullIndexOf(files: Files): Promise<string[]> {
const root = tempRoot();
write(root, files);
return edgesOf(await index(root));
}
describe('sync relinks an import a barrel passes on under another name', () => {
it('when an edit gives the module what is imported', async () => {
const root = tempRoot();
write(root, IMPORTERS);
write(root, Object.fromEntries(Object.keys(MODULES).map((file) => [file, 'export const placeholder = 1;\n'])));
const graph = await index(root);
expect(callsOf(graph)).toEqual([]);
write(root, MODULES);
const result = await graph.sync();
expect(result.filesModified).toBe(Object.keys(MODULES).length);
expect(result.filesAdded).toBe(0);
expect(callsOf(graph)).toEqual(CALLS);
expect(edgesOf(graph)).toEqual(await fullIndexOf({ ...IMPORTERS, ...MODULES }));
}, 120_000);
it('when the module is added after its importers', async () => {
const root = tempRoot();
write(root, IMPORTERS);
const graph = await index(root);
expect(callsOf(graph)).toEqual([]);
write(root, MODULES);
const result = await graph.sync();
expect(result.filesAdded).toBe(Object.keys(MODULES).length);
expect(callsOf(graph)).toEqual(CALLS);
expect(edgesOf(graph)).toEqual(await fullIndexOf({ ...IMPORTERS, ...MODULES }));
}, 120_000);
});
/** Declarations elsewhere named as the barrel passes the modules on. */
const NAMESAKES: Files = {
'src/legacy/MatxLoading.js': 'export function MatxLoading() {\n return 1;\n}\n',
'src/legacy/AppSpinner.js': 'export function AppSpinner() {\n return 1;\n}\n',
'src/legacy/Card.js': 'export function Card() {\n return 1;\n}\n',
};
/** Where a name match takes the imports while the modules lack them. */
const NAMESAKE_CALLS = [
'suspense -> src/legacy/AppSpinner.js::AppSpinner',
'suspense -> src/legacy/Card.js::Card',
'suspense -> src/legacy/MatxLoading.js::MatxLoading',
];
describe('sync relinks an import a barrel passes on under another name that a namesake took', () => {
it('when an edit gives the module what is imported', async () => {
const root = tempRoot();
write(root, { ...IMPORTERS, ...NAMESAKES });
write(root, Object.fromEntries(Object.keys(MODULES).map((file) => [file, 'export const placeholder = 1;\n'])));
const graph = await index(root);
expect(callsOf(graph)).toEqual(NAMESAKE_CALLS);
write(root, MODULES);
const result = await graph.sync();
expect(result.filesModified).toBe(Object.keys(MODULES).length);
expect(callsOf(graph)).toEqual(CALLS);
expect(edgesOf(graph)).toEqual(await fullIndexOf({ ...IMPORTERS, ...NAMESAKES, ...MODULES }));
}, 120_000);
it('when the module is added after its importers', async () => {
const root = tempRoot();
write(root, { ...IMPORTERS, ...NAMESAKES });
const graph = await index(root);
expect(callsOf(graph)).toEqual(NAMESAKE_CALLS);
write(root, MODULES);
const result = await graph.sync();
expect(result.filesAdded).toBe(Object.keys(MODULES).length);
expect(callsOf(graph)).toEqual(CALLS);
expect(edgesOf(graph)).toEqual(await fullIndexOf({ ...IMPORTERS, ...NAMESAKES, ...MODULES }));
}, 120_000);
});
+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));