Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
117 changes: 117 additions & 0 deletions __tests__/named-reexport-module.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
inferLocalReceiverType,
arrayElementType,
tsImportedTypeFile,
reExportSource,
} from './name-matcher';
import { JS_ARRAY_METHODS } from './js-builtins';

Expand Down Expand Up @@ -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;
}

Expand Down
9 changes: 8 additions & 1 deletion src/resolution/module-importers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>();
Expand All @@ -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] });
}
Expand Down
25 changes: 23 additions & 2 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down