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 @@ -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

Expand Down
182 changes: 182 additions & 0 deletions __tests__/sync-barrel-renamed-reexport.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
20 changes: 12 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand All @@ -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 {
Expand Down
Loading