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 @@ -274,6 +274,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, an import written from the app's root with a leading slash, like bigbluebutton's `import Icon from '/imports/ui/components/common/icon/component'`, now links to the file it names when the app's `tsconfig.json` or `jsconfig.json` maps it, as `"paths": { "/*": ["*"] }` does. Before, such an import was taken for a package, so the module looked unused and every name it imports linked by name instead, often to the wrong one of several components of the same name, or to nothing. Re-index projects that import this way after upgrading.
- In JavaScript and TypeScript, a call whose callee is not a name, like an immediately invoked function `(() => { … })()`, a curried call such as `connect(mapState)(View)`, `handlers[type]()` or `(0, fn)()`, no longer stores the callee's whole source text as the name of a reference that nothing in the project can answer. A single immediately invoked function could leave its entire body in the index that way. The calls inside such a callee, and the inner call of a curried one, are still recorded. A call through parentheses, a cast or a non-null assertion, like `(fn)()`, `(fn as F)()` or `fn!()`, now links to `fn` exactly as `fn()` does, so a parameter named `fn` no longer links to a same-named property elsewhere in the project. In TypeScript, a call with type arguments behind an operator, like `!settings.get<boolean>('X')` or `void apiRequest<Status>()`, now links to what it calls, as `await load<T>()` already did.
- In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once.
- `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again.

## [1.6.2] - 2026-10-03

Expand Down
207 changes: 207 additions & 0 deletions __tests__/sync-default-export-change.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* A sync re-links what a default import reads when the module's default changes.
*
* `import Y from './x'; new Y()` resolves to whatever `x` exports as its
* default. When an edit makes that another declaration (`export default A`
* becomes `export default B`) or stops exporting one, while the old default
* stays declared, a sync moved the edges other files made into `x` onto the
* node that replaces each old target. That is still `A`, so `new Y()` kept
* linking `A` where a full index links `B` (or nothing). An `export default`
* statement is no node, so neither the definition delta (CG-33) nor the
* failed-reference retry (#1240, #2450) saw it change.
*
* Each case indexes the first tree, edits it, syncs, and compares the graph
* with a full index of the final tree in a folder of its own (#2411).
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { DatabaseConnection, getDatabasePath } from '../src/db';
import { QueryBuilder } from '../src/db/queries';
import { ReferenceResolver } from '../src/resolution';

type Files = Record<string, string>;

const run = '{ static create() { return 1; } run(): number { return 1; } }';

/** Every way another file reaches `src/x.ts`'s default export, and a named import that must stay put. */
const READERS: Files = {
'src/y.ts': "import Y from './x';\nexport function make(): unknown { return new Y(); }\nexport function build(): unknown { return Y.create(); }\nexport let held: Y | undefined;\n",
'src/w.ts': "import { default as W } from './x';\nexport function wrap(): unknown { return new W(); }\n",
'src/r.ts': "export function load(): unknown { const R = require('./x').default; return new R(); }\n",
'src/n.ts': "import { A } from './x';\nexport function named(): unknown { return new A(); }\n",
// A value typed by the default, and a subclass of it, reach its members by name only.
'src/t.ts': "import T from './x';\nexport function typed(v: T): number { return v.run(); }\n",
'src/s.ts': "import S from './x';\nexport class Sub extends S { go(): number { return this.run(); } }\n",
};

let roots: string[] = [];
let graphs: CodeGraph[] = [];

afterEach(() => {
vi.restoreAllMocks();
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});

function tempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-default-'));
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, and every reference left unresolved. */
function stateOf(root: string, 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)
);
const edges = 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 ?? {})}`);
const db = DatabaseConnection.open(getDatabasePath(root));
try {
const refs = (db.getDb()
.prepare('SELECT reference_name, reference_kind, file_path, line, col, status, name_tail FROM unresolved_refs')
.all() as Array<Record<string, unknown>>)
.map((r) => `ref ${r.file_path}:${r.line}:${r.col} ${r.reference_kind} ${r.reference_name} ${r.status} ${r.name_tail}`);
return [...edges, ...refs].sort();
} finally {
db.close();
}
}

/** Where each reference to `refName` landed: `<source> -> <target kind> <file>::<qualified name>`. */
function targetsOf(graph: CodeGraph, refName: string): string[] {
const ids = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path)).map((n) => n.id);
return graph.getOutgoingEdgesFrom(ids)
.filter((e) => e.metadata?.refName === refName && e.kind !== 'imports')
.map((e) => {
const target = graph.getNode(e.target)!;
return `${graph.getNode(e.source)!.name} -> ${target.kind} ${target.filePath}::${target.qualifiedName}`;
})
.sort();
}

/** Index `before`, write `after` over it, sync; return the graph and what a full index of `after` holds. */
async function syncEdit(before: Files, after: Files): Promise<{ root: string; graph: CodeGraph; full: string[]; modified: number }> {
const root = tempRoot();
write(root, before);
const graph = await index(root);
write(root, after);
const result = await graph.sync();
const fullRoot = tempRoot();
write(fullRoot, { ...before, ...after });
const full = stateOf(fullRoot, await index(fullRoot));
return { root, graph, full, modified: result.filesModified };
}

describe('sync re-links default-import readers when the module default changes', () => {
it('when `export default` names another declaration', async () => {
const reopened = vi.spyOn(QueryBuilder.prototype, 'replaceResolutionEdgesWithUnresolvedRefs');
const before = { ...READERS, 'src/x.ts': `export class A ${run}\nexport class B ${run}\nexport default A;\n` };
const after = { 'src/x.ts': `export class A ${run}\nexport class B ${run}\nexport default B;\n` };
const { root, graph, full, modified } = await syncEdit(before, after);
expect(modified).toBe(1);
// What reads the default went back, and what reaches the old default's
// members; not the named import, which reads A as before.
expect(reopened.mock.calls.flatMap(([, refs]) => refs.map((ref) => ref.referenceName)).sort())
.toEqual(['R', 'S', 'T', 'W', 'Y', 'Y', 'Y.create', 'run', 'v.run']);
expect(targetsOf(graph, 'Y')).toEqual(['held -> class src/x.ts::B', 'make -> class src/x.ts::B']);
expect(targetsOf(graph, 'Y.create')).toEqual(['build -> method src/x.ts::B::create']);
expect(targetsOf(graph, 'W')).toEqual(['wrap -> class src/x.ts::B']);
expect(targetsOf(graph, 'R')).toEqual(['load -> class src/x.ts::B']);
expect(targetsOf(graph, 'v.run')).toEqual(['typed -> method src/x.ts::B::run']);
expect(targetsOf(graph, 'S')).toEqual(['Sub -> class src/x.ts::B']);
// The named import still reads A.
expect(targetsOf(graph, 'A')).toEqual(['named -> class src/x.ts::A']);
expect(stateOf(root, graph)).toEqual(full);
expect(graph.getPendingReferenceCount()).toBe(0);
}, 120_000);

it('when a route renders the default as its layout', async () => {
const root = (dflt: string): Files => ({
'src/root.tsx': `export function AppRoot() { return null; }\nexport function Shell() { return null; }\nexport default ${dflt};\n`,
});
const before = {
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }),
'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom';\nimport { default as AppRoot } from './root';\nimport { One } from './one';\n" +
"export const router = createBrowserRouter([\n {\n path: '/app',\n element: <AppRoot />,\n children: [{ path: 'one', element: <One /> }],\n },\n]);\n",
'src/one.tsx': 'export function One() { return null; }\n',
...root('AppRoot'),
};
const { root: dir, graph, full } = await syncEdit(before, root('Shell'));
expect(targetsOf(graph, 'layout:AppRoot').every((t) => t.endsWith('::Shell'))).toBe(true);
expect(stateOf(dir, graph)).toEqual(full);
}, 120_000);

it('when the default declaration moves to another class', async () => {
const before = { ...READERS, 'src/x.ts': `export default class A ${run}\nexport class B ${run}\n` };
const after = { 'src/x.ts': `export default class B ${run}\nexport class A ${run}\n` };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Y')).toEqual(['held -> class src/x.ts::B', 'make -> class src/x.ts::B']);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);

it('when the module stops exporting a default', async () => {
// Without `src/n.ts`: `{ A }` names a class exported only as the default,
// which no import of it reaches, so only a name match ever linked it.
const defaultReaders = Object.fromEntries(Object.entries(READERS).filter(([file]) => file !== 'src/n.ts'));
const before = { ...defaultReaders, 'src/x.ts': `export const VERSION = 1;\nclass A ${run}\nexport default A;\n` };
const after = { 'src/x.ts': `export const VERSION = 1;\nclass A ${run}\n` };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Y')).toEqual([]);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);

it('when a module forwards another module\'s default', async () => {
const lib = (from: string): Files => ({ 'src/lib/index.ts': `export { default } from './${from}';\n` });
const before = {
'src/lib/a.ts': `export default class A ${run}\n`,
'src/lib/b.ts': `export default class B ${run}\n`,
'src/y.ts': "import Y from './lib';\nexport function make(): unknown { return new Y(); }\n",
...lib('a'),
};
const { root, graph, full } = await syncEdit(before, lib('b'));
expect(targetsOf(graph, 'Y')).toEqual(['make -> class src/lib/b.ts::B']);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);

it('and puts nothing back when an edit leaves the default alone', async () => {
const moved = vi.spyOn(ReferenceResolver.prototype, 'changedDefaultExports');
const before = {
...READERS,
'src/x.ts': `export class A ${run}\nexport class B ${run}\nexport default A;\n`,
// A default that is an alias: a call through it links the function it names.
'src/alias.ts': 'function impl(): number { return 1; }\nconst go = impl;\nexport default go;\n',
'src/use-alias.ts': "import start from './alias';\nexport function call(): number { return start(); }\n",
};
const after = {
'src/x.ts': `export class A { static create() { return 2; } run(): number { return 2; } }\nexport class B ${run}\nexport default A;\n`,
'src/alias.ts': 'function impl(): number { return 2; }\nconst go = impl;\nexport default go;\n',
};
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'start')).toEqual(['call -> function src/alias.ts::impl']);
expect(moved).toHaveBeenCalledTimes(1);
expect(moved.mock.results[0]!.value).toEqual([]);
expect(targetsOf(graph, 'Y')).toEqual(['held -> class src/x.ts::A', 'make -> class src/x.ts::A']);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);
});
40 changes: 39 additions & 1 deletion src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3106,7 +3106,7 @@ export class QueryBuilder {
*/
getCrossFileIncomingEdgesWithTarget(
filePath: string
): Array<Edge & { targetName: string; targetKind: NodeKind; sourceFilePath: string; sourceLanguage: Language }> {
): Array<Edge & { edgeId: number; targetName: string; targetKind: NodeKind; sourceFilePath: string; sourceLanguage: Language }> {
const sql = `SELECT e.*, tgt.name AS target_name, tgt.kind AS target_kind,
src.file_path AS source_file_path, src.language AS source_language
FROM edges e
Expand All @@ -3120,6 +3120,7 @@ export class QueryBuilder {
>;
return rows.map(row => ({
...rowToEdge(row),
edgeId: row.id,
targetName: row.target_name,
targetKind: row.target_kind,
sourceFilePath: row.source_file_path,
Expand Down Expand Up @@ -3162,6 +3163,43 @@ export class QueryBuilder {
}));
}

/**
* The resolution edges into `targetIds` — every edge into one of them but
* `contains` and synthesized (`provenance='heuristic'`) ones — with the
* source file and language a resurrection needs and the file of each target.
*/
getResolutionEdgesInto(
targetIds: readonly string[]
): Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language; targetFilePath: string }> {
const out: Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language; targetFilePath: string }> = [];
const unique = [...new Set(targetIds)];
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT e.*, src.file_path AS source_file_path, src.language AS source_language, tgt.file_path AS target_file_path
FROM edges e
JOIN nodes src ON src.id = e.source
JOIN nodes tgt ON tgt.id = e.target
WHERE e.target IN (${placeholders})
AND e.kind != 'contains'
AND (e.provenance IS NULL OR e.provenance != 'heuristic')`
)
.all(...chunk) as Array<EdgeRow & { source_file_path: string; source_language: Language; target_file_path: string }>;
for (const row of rows) {
out.push({
...rowToEdge(row),
edgeId: row.id,
sourceFilePath: row.source_file_path,
sourceLanguage: row.source_language,
targetFilePath: row.target_file_path,
});
}
}
return out;
}

/**
* The row ids of the failed references of `filePath` that import one of
* `modules` or are written through one of `names` (see {@link THROUGH_NAMES}).
Expand Down
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
ResolutionResult,
} from './resolution';
import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer';
import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers';
import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughChangedDefaults, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers';
import { GraphTraverser, GraphQueryManager } from './graph';
import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks';
import { ContextBuilder, createContextBuilder } from './context';
Expand Down Expand Up @@ -1186,6 +1186,18 @@ export class CodeGraph {
this.queries, importReader, this.projectRoot, result.changedFilePaths, edgesBeforeResolution
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS && declared > 0) console.error(`[phase-timing] sync-declared-packages: ${declared} refs re-opened`);

// An edited module whose default export moved to another
// declaration, or went away, while the old one stayed declared:
// `new Y()` after `import Y from './x'` kept the old one, the node
// that replaced its target, and no name this sync's files carry
// changed. Read off the edges into the edited modules; an edit
// that leaves the default alone re-opens nothing.
const tDefaults = Date.now();
const movedDefaults = this.resolver.changedDefaultExports(modified, result.changedFilePaths, edgesBeforeResolution);
const throughDefaults = movedDefaults.length === 0 ? 0
: reopenReferencesThroughChangedDefaults(this.queries, importReader, movedDefaults, edgesBeforeResolution);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-default-exports: ${Date.now() - tDefaults}ms (${movedDefaults.length} defaults moved, ${throughDefaults} refs re-opened)`);
} else {
// No git info — use batched resolution to avoid OOM
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
Expand Down
Loading