fix(resolution): a barrel's export { A } from './x' links what ./x exports, not a namesake (#2685)

A barrel's own export { A, B as C } from './x' emits one imports
reference per name it passes on, named as ./x calls it. No import binds
a re-exported name, so the resolver looked each one up by its name alone
and could reach a namesake elsewhere in the project, or a project
function named like a package's export.

Each such name now resolves through the module the statement names, as
an import does, following barrels of barrels and renames. A name passed
on from an outside package counts as bound to that package, so it never
name-matches a project symbol. A name two statements re-export from
different modules keeps the old lookup.

Sync re-opens a barrel's re-exported names when the module they go
through is deleted or added, as it already did for the names an importer
binds, so a synced graph keeps matching a fresh index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-19 22:18:47 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 32b839acae
commit 951d575e62
5 changed files with 168 additions and 3 deletions
+1
View File
@@ -200,6 +200,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `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.
- In JavaScript and TypeScript projects, a folder named `build` inside `src/` is now indexed for its TypeScript, JSX, Vue, Svelte and Astro files, so a feature kept there, like the `src/build/` pages of luci-go's milo, shows up in `codegraph_explore` and the routes that lazily load its pages link to them. Before, every folder named `build` was skipped as build output. The JavaScript, declaration and other files such a folder holds are still left out, since a build may have written them, and so is every `build` folder outside `src/`.
- On Windows, a Swift call written after `try!`, like `try! Message2(data: data)` or `try! Settings.Defaults.load()`, now links to what it calls, as it already did on macOS and Linux. Before, the `!` was read as a logical not, so the call linked to nothing, and a call made through a type lost the type it was made on. Re-index Swift projects on Windows after upgrading.
- In JavaScript and TypeScript, a barrel's `export { A, B as C } from './x'` now links each name it passes on to what `./x` exports, following barrels that pass it on in turn. Before, each name was looked up by itself and could link to a same-named symbol elsewhere in the project, such as another folder's `Provider`, and a name passed on from a package, like `export { debounce } from 'lodash'`, could link to a project function of that name. Re-index JavaScript and TypeScript projects after upgrading.
## [1.6.2] - 2026-10-03
+117
View File
@@ -0,0 +1,117 @@
/**
* A barrel's own `export { A, B as C } from './x'` links each name it passes
* on to what that module exports under it. No import statement binds a
* re-exported name, so the resolver used to look it up by name alone: in
* luci-go's milo/ui, `export { DefaultExpandedProvider, … } from './context'`
* in the commit table's `context/index.ts` linked to a namesake the build
* table's `context.ts` declares, as soon as both were indexed. A name a barrel
* passes on from a package (`export { debounce } from 'lodash'`) is the
* package's, never a project namesake.
*/
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';
type Files = Record<string, string>;
let dirs: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
graphs = [];
dirs = [];
});
const write = (dir: string, files: Files): void => {
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
}
};
/** A project of `files`, indexed from scratch in a folder of its own. */
const indexed = async (files: Files): Promise<{ dir: string; graph: CodeGraph }> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-named-reexport-'));
dirs.push(dir);
write(dir, files);
const graph = await CodeGraph.init(dir, { index: true });
graphs.push(graph);
return { dir, graph };
};
/** What `file`'s `imports` edges to symbols reach, as `name@file`, by the name imported. */
const reExportLinks = (graph: CodeGraph, file: string): Record<string, string[]> => {
const fileNode = graph.getNodesInFile(file).find((n) => n.kind === 'file')!;
const out: Record<string, string[]> = {};
for (const edge of graph.getOutgoingEdges(fileNode.id)) {
if (edge.kind !== 'imports') continue;
const target = graph.getNode(edge.target)!;
if (target.kind === 'file') continue;
const refName = String((edge.metadata as Record<string, unknown> | undefined)?.refName);
(out[refName] ??= []).push(`${target.name}@${target.filePath}`);
}
for (const list of Object.values(out)) list.sort();
return out;
};
const PACKAGE = JSON.stringify({ name: 'app', private: true, dependencies: { react: '^18', lodash: '^4' } });
describe('a re-export links what its module exports', () => {
it('passes on the module\'s own export, not a namesake elsewhere', async () => {
const { graph } = await indexed({
'package.json': PACKAGE,
'src/table/context/index.ts': "export { Provider, Lonely } from './context';\nexport { Provider as TableProvider } from './context';\n",
'src/table/context/context.tsx': 'export const Provider = 1;\nexport const Lonely = 2;\n',
'src/grid/context.ts': 'export const Provider = 3;\nexport const Lonely = 4;\n',
});
expect(reExportLinks(graph, 'src/table/context/index.ts')).toEqual({
Provider: ['Provider@src/table/context/context.tsx', 'Provider@src/table/context/context.tsx'],
Lonely: ['Lonely@src/table/context/context.tsx'],
});
}, 60_000);
it('follows a module that passes the name on itself', async () => {
const { graph } = await indexed({
'package.json': PACKAGE,
'src/kit/index.ts': "export { Button } from './inner';\n",
'src/kit/inner.ts': "export { Button } from './button/button';\n",
'src/kit/button/button.tsx': 'export function Button() {\n return null;\n}\n',
'src/legacy/Button.tsx': 'export function Button() {\n return null;\n}\n',
});
expect(reExportLinks(graph, 'src/kit/index.ts')).toEqual({ Button: ['Button@src/kit/button/button.tsx'] });
expect(reExportLinks(graph, 'src/kit/inner.ts')).toEqual({ Button: ['Button@src/kit/button/button.tsx'] });
}, 60_000);
it('leaves a name a package passes on to the package', async () => {
const { graph } = await indexed({
'package.json': PACKAGE,
'src/hooks/index.ts': "export { debounce } from 'lodash';\nexport { useCounter } from './counter';\n",
'src/hooks/counter.ts': 'export function useCounter() {\n return 0;\n}\n',
'src/legacy/timing.ts': 'export function debounce() {\n return 0;\n}\n',
});
expect(reExportLinks(graph, 'src/hooks/index.ts')).toEqual({ useCounter: ['useCounter@src/hooks/counter.ts'] });
}, 60_000);
});
describe('sync, for a re-export', () => {
const BARREL: Files = {
'package.json': PACKAGE,
'src/table/context/index.ts': "export { Provider } from './context';\n",
'src/grid/context.ts': 'export const Provider = 3;\n',
};
const MODULE: Files = {
'src/table/context/context.tsx': 'export const Provider = 1;\n',
};
it('moves to the module once it appears, as a fresh index has it', async () => {
const { dir, graph } = await indexed(BARREL);
write(dir, MODULE);
expect((await graph.sync()).filesAdded).toBe(1);
const expected = { Provider: ['Provider@src/table/context/context.tsx'] };
expect(reExportLinks(graph, 'src/table/context/index.ts')).toEqual(expected);
expect(reExportLinks((await indexed({ ...BARREL, ...MODULE })).graph, 'src/table/context/index.ts')).toEqual(expected);
}, 60_000);
});
+19
View File
@@ -25,6 +25,7 @@ import {
inferLocalReceiverType,
arrayElementType,
tsImportedTypeFile,
reExportSource,
} from './name-matcher';
import { JS_ARRAY_METHODS } from './js-builtins';
@@ -2248,6 +2249,24 @@ export function resolveViaImport(
}
}
// A barrel's own `export { A, B as C } from './x'` names `A` and `B` the way
// the module calls them, and binds neither, so no import above holds them.
// Each resolves through the module the statement names, as an import does:
// looked up by its name alone, it reached a namesake elsewhere in the
// project (luci-go milo's commit table barrel, a build table's context).
if (ref.referenceKind === 'imports' && ESM_IMPORT_LANGUAGES.has(ref.language)) {
const source = reExportSource(ref.referenceName, ref, context);
const resolvedPath = source === undefined ? null : resolveImportPath(source, ref.filePath, ref.language, context);
const targetNode = resolvedPath === null ? undefined : findExportedSymbol(
resolvedPath,
{ isDefault: false, isNamespace: false, exportedName: ref.referenceName, memberName: null },
ref.language,
context,
new Set()
);
if (targetNode) return { original: ref, targetNodeId: targetNode.id, confidence: 0.9, resolvedBy: 'import' };
}
return null;
}
+8 -1
View File
@@ -509,7 +509,9 @@ function addPythonImporters(
/**
* Per importer, what its statements naming the modules import: the modules
* named whole (not the package around one), and the local names bound. An ES
* namespace import (`import * as z`) reads whatever its module passes on.
* namespace import (`import * as z`) reads whatever its module passes on, and
* an ES `export { A } from './x'` names `A`, which it resolves through `./x`
* as an import binds it.
*/
function bindingsOf(importers: Importers, reader: ImportReader): Map<string, { modules: string[]; names: string[] }> {
const bindings = new Map<string, { modules: string[]; names: string[] }>();
@@ -524,6 +526,11 @@ function bindingsOf(importers: Importers, reader: ImportReader): Map<string, { m
})
.map((m) => m.localName)
);
for (const rex of reader.reExports(file, language)) {
if (rex.kind !== 'named') continue;
const only = sources.get(rex.source);
if (only === null || only?.has(rex.originalName)) names.add(rex.originalName);
}
const modules = [...sources].filter(([, only]) => only === null).map(([source]) => source);
if (modules.length > 0 || names.size > 0) bindings.set(file, { modules, names: [...names] });
}
+23 -2
View File
@@ -15122,10 +15122,31 @@ function isImportBinding(receiver: string, ref: UnresolvedRef, context: Resoluti
return context.getImportMappings?.(ref.filePath, ref.language)?.some((m) => m.localName === root) === true;
}
/** Does the file bind `name` by importing it from outside the repository? */
/**
* The module the file passes `name` on from with `export { name } from './x'`
* (or `export { name as alias } from './x'`), `name` being what that module
* calls it, when exactly one statement of the file does. A re-export binds no
* local name, so the file's import bindings never hold it.
*/
export function reExportSource(name: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined {
let source: string | undefined;
for (const rex of context.getReExports?.(ref.filePath, ref.language) ?? []) {
if (rex.kind !== 'named' || rex.originalName !== name) continue;
if (source !== undefined && source !== rex.source) return undefined;
source = rex.source;
}
return source;
}
/**
* Does the file bind `name` by importing it from outside the repository? So
* does a barrel's `export { name } from 'pkg'`: its `imports` reference names
* the package's export.
*/
function isOutOfRepoBinding(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
const binding = context.getImportMappings?.(ref.filePath, ref.language)?.find((m) => m.localName === name);
return !!binding && context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true;
const source = binding?.source ?? (ref.referenceKind === 'imports' ? reExportSource(name, ref, context) : undefined);
return source !== undefined && context.isOutOfRepoImport?.(source, ref.filePath, ref.language) === true;
}
/** A module a bundler loads as data, not code: a stylesheet, an image, a GraphQL document, JSON, a font. */