From 203e7ec33f8e5a8069090266377c68fc23d8a8f5 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 19 Oct 2026 17:16:17 -0500 Subject: [PATCH] fix(resolution): a barrel's export { A } from './x' links what ./x exports, not a namesake 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 --- CHANGELOG.md | 1 + __tests__/named-reexport-module.test.ts | 117 ++++++++++++++++++++++++ src/resolution/import-resolver.ts | 19 ++++ src/resolution/module-importers.ts | 9 +- src/resolution/name-matcher.ts | 25 ++++- 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 __tests__/named-reexport-module.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2c27d8a..ad4e7cc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/named-reexport-module.test.ts b/__tests__/named-reexport-module.test.ts new file mode 100644 index 000000000..7bd4e68ce --- /dev/null +++ b/__tests__/named-reexport-module.test.ts @@ -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; + +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 => { + const fileNode = graph.getNodesInFile(file).find((n) => n.kind === 'file')!; + const out: Record = {}; + 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 | 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); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index f7b9c57a3..8ab161c84 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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; } diff --git a/src/resolution/module-importers.ts b/src/resolution/module-importers.ts index 9ac7e86de..52c4adcbb 100644 --- a/src/resolution/module-importers.ts +++ b/src/resolution/module-importers.ts @@ -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 { const bindings = new Map(); @@ -524,6 +526,11 @@ function bindingsOf(importers: Importers, reader: ImportReader): Map 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] }); } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 64e83ebbe..b214d2de4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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. */