diff --git a/CHANGELOG.md b/CHANGELOG.md index deb774534..0e26e3970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1112,6 +1112,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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. - `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again. +- `codegraph sync` now follows an edit to the `paths` or `baseUrl` of a `tsconfig.json` or `jsconfig.json`, in the file itself or a config it `extends`, and to the module path in a `go.mod`: the imports they map are linked again by the new mapping, and a long-running session, like the background server behind your agent, picks the edit up at its next sync. Before, those imports kept the mapping read when the project was indexed until it was re-indexed. Thanks @inth3shadows for the report and @danusha2345. (#2977) +- `codegraph sync` and the file watcher now also update the files that import through a barrel module when an edit points one of its re-exports at another module, like `export { foo } from './a'` changed to `'./b'` or an `export *` moved the same way, takes one away, or drops a name from it, in JavaScript and TypeScript. Before, those files kept the names linked to the old module until they changed or the project was re-indexed. Thanks @inth3shadows for the report and @danusha2345. (#2978) +- `codegraph sync` and the file watcher now link a call that had two candidates once a sync deletes the file holding one of them or renames it away, like `x.go()` with `go` defined in two classes, as a full index does. Before, the call stayed unlinked until its own file changed or the project was re-indexed. Thanks @inth3shadows for the report and the pull request. (#3006) +- `codegraph sync` and the file watcher now link the files importing a file that was skipped, because it was over the size limit or failed to parse, once an edit lets it be indexed, as a full index does: the import of the file itself, and the names a barrel passes on from it. Before, those imports stayed unlinked until the importing files changed or the project was re-indexed. Thanks @inth3shadows for the report and the pull request. (#3016) #### MCP / indexing diff --git a/__tests__/sync-barrel-retarget.test.ts b/__tests__/sync-barrel-retarget.test.ts new file mode 100644 index 000000000..1af1dadd8 --- /dev/null +++ b/__tests__/sync-barrel-retarget.test.ts @@ -0,0 +1,206 @@ +/** + * A sync follows a barrel whose re-export points elsewhere (#2978). + * + * `c.ts` imports `foo` through `index.ts`, which re-exports it from `a.ts`. + * When the barrel's clause is changed to `b.ts`, the barrel declares nothing + * of `foo` and `c.ts` did not change, so no definition delta, added file or + * failed reference named `c.ts`'s binding, and it stayed on `a.ts` until a + * full re-index. The importers of a module whose re-exports changed now have + * their bindings through it re-resolved. + * + * Each case indexes the first tree, edits it, syncs, and compares edges and + * unresolved references with a full index of the final tree in a folder of + * its own. + */ +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'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; + +type Files = Record; + +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 }); + roots = []; +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-barrel-retarget-')); + 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 { + 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>) + .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 `bar`'s call to `foo` lands: `::`, or nothing. */ +function fooCalledBy(graph: CodeGraph): string[] { + const bar = graph.getNodesByQualifiedName('bar')[0]!; + return graph.getOutgoingEdges(bar.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${graph.getNode(e.target)!.filePath}::${graph.getNode(e.target)!.qualifiedName}`) + .sort(); +} + +/** Index `before`, write `after` over it, sync (scoped to `paths` when given); return the graph and a full index of the final tree. */ +async function syncEdit(before: Files, after: Files, paths?: string[]): Promise<{ root: string; graph: CodeGraph; full: string[] }> { + const root = tempRoot(); + write(root, before); + const graph = await index(root); + expect(fooCalledBy(graph)).toEqual(['src/a.ts::foo']); + write(root, after); + await graph.sync(paths ? { paths } : {}); + const fullRoot = tempRoot(); + write(fullRoot, { ...before, ...after }); + return { root, graph, full: stateOf(fullRoot, await index(fullRoot)) }; +} + +const BEFORE: Files = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/a.ts': 'export function foo() { return 1; }\n', + 'src/b.ts': 'export function foo() { return 2; }\n', + 'src/index.ts': "export { foo } from './a';\n", + 'src/c.ts': "import { foo } from './index';\nexport function bar() { return foo(); }\n", +}; + +describe('a sync follows a barrel whose re-export points elsewhere (#2978)', () => { + it('a named re-export retargeted to another module', async () => { + const { root, graph, full } = await syncEdit(BEFORE, { 'src/index.ts': "export { foo } from './b';\n" }); + expect(fooCalledBy(graph)).toEqual(['src/b.ts::foo']); + expect(stateOf(root, graph)).toEqual(full); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 120_000); + + it('an `export *` retargeted to another module', async () => { + const before = { ...BEFORE, 'src/index.ts': "export * from './a';\n" }; + const { root, graph, full } = await syncEdit(before, { 'src/index.ts': "export * from './b';\n" }); + expect(fooCalledBy(graph)).toEqual(['src/b.ts::foo']); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('a scoped sync of the barrel alone', async () => { + const { root, graph, full } = await syncEdit(BEFORE, { 'src/index.ts': "export { foo } from './b';\n" }, ['src/index.ts']); + expect(fooCalledBy(graph)).toEqual(['src/b.ts::foo']); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('a re-export taken away leaves the importer where a full index leaves it', async () => { + // The import fails through the barrel now; whatever a full index then + // links the call to by name, the sync links it to the same way. + const { root, graph, full } = await syncEdit(BEFORE, { 'src/index.ts': 'export const version = 1;\n' }); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('through a second barrel that passes the first one on', async () => { + const before = { + ...BEFORE, + 'src/lib.ts': "export * from './index';\n", + 'src/c.ts': "import { foo } from './lib';\nexport function bar() { return foo(); }\n", + }; + const { root, graph, full } = await syncEdit(before, { 'src/index.ts': "export { foo } from './b';\n" }); + expect(fooCalledBy(graph)).toEqual(['src/b.ts::foo']); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('a value typed by the binding reaches the members of the new target', async () => { + const before: Files = { + 'package.json': JSON.stringify({ name: 'app' }), + 'src/a.ts': 'export class Widget { run(): number { return 1; } }\n', + 'src/b.ts': 'export class Widget { run(): number { return 2; } }\n', + 'src/index.ts': "export { Widget } from './a';\n", + 'src/c.ts': "import { Widget } from './index';\nexport function make(): number { const w = new Widget(); return w.run(); }\n", + }; + const root = tempRoot(); + write(root, before); + const graph = await index(root); + const runTargets = (g: CodeGraph) => g.getOutgoingEdges(g.getNodesByQualifiedName('make')[0]!.id) + .filter((e) => e.kind === 'calls' && e.metadata?.refName === 'w.run') + .map((e) => `${g.getNode(e.target)!.filePath}::${g.getNode(e.target)!.qualifiedName}`); + expect(runTargets(graph)).toEqual(['src/a.ts::Widget::run']); + write(root, { 'src/index.ts': "export { Widget } from './b';\n" }); + await graph.sync(); + expect(runTargets(graph)).toEqual(['src/b.ts::Widget::run']); + const fullRoot = tempRoot(); + write(fullRoot, { ...before, 'src/index.ts': "export { Widget } from './b';\n" }); + expect(stateOf(root, graph)).toEqual(stateOf(fullRoot, await index(fullRoot))); + }, 120_000); + + it('a body-only edit of the barrel re-resolves nothing', async () => { + const root = tempRoot(); + write(root, { ...BEFORE, 'src/index.ts': "export { foo } from './a';\nexport const version = 1;\n" }); + const graph = await index(root); + const before = stateOf(root, graph); + write(root, { 'src/index.ts': "export { foo } from './a';\nexport const version = 2;\n" }); + const result = await graph.sync(); + expect(result.reExportsChangedFilePaths).toBeUndefined(); + expect(stateOf(root, graph)).toEqual(before); + }, 120_000); + + it('a name dropped from a re-export whose module stays', async () => { + // The barrel still names `./a`, so only the clause's names tell the edit. + const before: Files = { + ...BEFORE, + 'src/a.ts': 'export function foo() { return 1; }\nexport function qux() { return 3; }\n', + 'src/index.ts': "export { foo, qux } from './a';\n", + 'src/c.ts': "import { foo, qux } from './index';\nexport function bar() { return foo(); }\nexport function baz() { return qux(); }\n", + }; + const { root, graph, full } = await syncEdit(before, { 'src/index.ts': "export { foo } from './a';\n" }); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('a plain import edit in a module others import reports nothing', async () => { + // Importers bind through `util.ts`, which passes nothing on: dropping its + // own import moves none of their bindings. + const root = tempRoot(); + write(root, { + ...BEFORE, + 'src/util.ts': "import { foo } from './a';\nexport function helper() { return foo(); }\n", + 'src/c.ts': "import { helper } from './util';\nexport function bar() { return helper(); }\n", + }); + const graph = await index(root); + write(root, { 'src/util.ts': 'export function helper() { return 5; }\n' }); + const result = await graph.sync(); + expect(result.reExportsChangedFilePaths).toBeUndefined(); + const fullRoot = tempRoot(); + write(fullRoot, { ...BEFORE, 'src/util.ts': 'export function helper() { return 5; }\n', 'src/c.ts': "import { helper } from './util';\nexport function bar() { return helper(); }\n" }); + expect(stateOf(root, graph)).toEqual(stateOf(fullRoot, await index(fullRoot))); + }, 120_000); +}); diff --git a/__tests__/sync-module-config-change.test.ts b/__tests__/sync-module-config-change.test.ts new file mode 100644 index 000000000..6a6e53de4 --- /dev/null +++ b/__tests__/sync-module-config-change.test.ts @@ -0,0 +1,199 @@ +/** + * A sync follows a change to the files import resolution is configured by + * (#2977): `tsconfig.json` / `jsconfig.json` `paths` and `baseUrl`, their + * `extends` chain, and a `go.mod` module path. The resolver read them once and + * kept the maps for its lifetime, and the watcher follows source files only, + * so an edit kept resolving imports by the old mapping — in the same process, + * and in later ones too, since a sync re-resolves only the changed files. + * + * Each case indexes the first tree, edits it, syncs, and compares edges and + * unresolved references with a full index of the final tree in a folder of + * its own. + */ +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 { ExtractionOrchestrator } from '../src/extraction'; + +type Files = Record; + +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 }); + roots = []; +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-module-config-')); + 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 { + 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>) + .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(); + } +} + +/** The files the calls written in `caller` land in. */ +function callTargets(graph: CodeGraph, caller: string): string[] { + const node = graph.getNodesByQualifiedName(caller)[0]!; + return graph.getOutgoingEdges(node.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${graph.getNode(e.target)!.filePath}::${graph.getNode(e.target)!.qualifiedName}`) + .sort(); +} + +const tsconfig = (target: string): string => + JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@lib/*': [`${target}/*`] } } }); + +const TS_BEFORE: Files = { + 'package.json': JSON.stringify({ name: 'app' }), + 'tsconfig.json': tsconfig('lib1'), + 'lib1/u.ts': 'export function foo() { return 1; }\n', + 'lib2/u.ts': 'export function foo() { return 2; }\n', + 'c.ts': "import { foo } from '@lib/u';\nexport function bar() { return foo(); }\n", +}; + +const GO_BEFORE: Files = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'util/u.go': 'package util\n\nfunc Foo() int { return 1 }\n', + 'main.go': 'package main\n\nimport "example.com/app/util"\n\nfunc run() int { return util.Foo() }\n', +}; + +/** Index `before`, write `after` over it, sync; return the graph and what a full index of the final tree holds. */ +async function syncEdit(before: Files, after: Files): Promise<{ root: string; graph: CodeGraph; full: string[] }> { + const root = tempRoot(); + write(root, before); + const graph = await index(root); + write(root, after); + await graph.sync(); + const fullRoot = tempRoot(); + write(fullRoot, { ...before, ...after }); + return { root, graph, full: stateOf(fullRoot, await index(fullRoot)) }; +} + +describe('a sync follows a module-config change (#2977)', () => { + it('tsconfig paths retargeted, the importer untouched', async () => { + const { root, graph, full } = await syncEdit(TS_BEFORE, { 'tsconfig.json': tsconfig('lib2') }); + expect(callTargets(graph, 'bar')).toEqual(['lib2/u.ts::foo']); + expect(stateOf(root, graph)).toEqual(full); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 120_000); + + it('tsconfig paths retargeted and the importer touched, as the issue did', async () => { + const { root, graph, full } = await syncEdit(TS_BEFORE, { + 'tsconfig.json': tsconfig('lib2'), + 'c.ts': "import { foo } from '@lib/u';\nexport function bar() { return foo() + 0; }\n", + }); + expect(callTargets(graph, 'bar')).toEqual(['lib2/u.ts::foo']); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('a mapping declared in the extends chain', async () => { + const before: Files = { + ...TS_BEFORE, + 'tsconfig.json': JSON.stringify({ extends: './tsconfig.base.json', compilerOptions: { strict: true } }), + 'tsconfig.base.json': tsconfig('lib1'), + }; + const { root, graph, full } = await syncEdit(before, { 'tsconfig.base.json': tsconfig('lib2') }); + expect(callTargets(graph, 'bar')).toEqual(['lib2/u.ts::foo']); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('a go.mod module path renamed, with the imports written for it', async () => { + const { root, graph, full } = await syncEdit(GO_BEFORE, { + 'go.mod': 'module example.com/other\n\ngo 1.22\n', + 'main.go': 'package main\n\nimport "example.com/other/util"\n\nfunc run() int { return util.Foo() }\n', + }); + expect(callTargets(graph, 'run')).toEqual(['util/u.go::Foo']); + expect(stateOf(root, graph)).toEqual(full); + }, 120_000); + + it('a later process sees the change too', async () => { + const root = tempRoot(); + write(root, TS_BEFORE); + const first = await index(root); + expect(callTargets(first, 'bar')).toEqual(['lib1/u.ts::foo']); + first.close(); + graphs.splice(graphs.indexOf(first), 1); + + write(root, { 'tsconfig.json': tsconfig('lib2') }); + const second = await CodeGraph.open(root); + graphs.push(second); + await second.sync(); + expect(callTargets(second, 'bar')).toEqual(['lib2/u.ts::foo']); + }, 120_000); + + it('an unchanged config re-opens nothing', async () => { + const root = tempRoot(); + write(root, TS_BEFORE); + const graph = await index(root); + const before = stateOf(root, graph); + // A body edit elsewhere: the import map is read again and found the same. + write(root, { 'lib2/u.ts': 'export function foo() { return 3; }\n' }); + const result = await graph.sync(); + expect(result.filesModified).toBe(1); + expect(stateOf(root, graph)).toEqual(before); + }, 120_000); + + it('a config edit that moves no mapping re-opens nothing: a tsconfig flag, a go.mod requirement', async () => { + const tsRoot = tempRoot(); + write(tsRoot, TS_BEFORE); + const ts = await index(tsRoot); + const tsBefore = stateOf(tsRoot, ts); + const goRoot = tempRoot(); + write(goRoot, GO_BEFORE); + const go = await index(goRoot); + const goBefore = stateOf(goRoot, go); + // Every import a language has goes back for the sweep when its map moves; + // an edit resolution never reads must not send them there. + const reopen = vi.spyOn(ExtractionOrchestrator.prototype, 'resurrectResolutionEdgesForLanguage'); + try { + write(tsRoot, { 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@lib/*': ['lib1/*'] }, strict: true } }) }); + await ts.sync(); + write(goRoot, { 'go.mod': 'module example.com/app\n\ngo 1.22\n\nrequire golang.org/x/text v0.14.0\n' }); + await go.sync(); + expect(reopen).not.toHaveBeenCalled(); + } finally { + reopen.mockRestore(); + } + expect(stateOf(tsRoot, ts)).toEqual(tsBefore); + expect(stateOf(goRoot, go)).toEqual(goBefore); + }, 120_000); +}); diff --git a/__tests__/sync-removed-competitor.test.ts b/__tests__/sync-removed-competitor.test.ts new file mode 100644 index 000000000..e30354b6a --- /dev/null +++ b/__tests__/sync-removed-competitor.test.ts @@ -0,0 +1,148 @@ +/** + * A call that only failed because a second candidate shared its name resolves + * once a sync removes that candidate. + * + * `x.go()` on an unannotated receiver, with `go` defined in two classes, is + * ambiguous: a full index leaves it unresolved. Take one of the two away and a + * full index links the call to the other. Sync didn't: its failed-reference + * retry looked failed refs up by the names in the files it changed, so a + * deleted file (no names left) or an edit that renames the competitor away + * never brought the call up again, and a sync that only deleted files skipped + * the retry altogether. + * + * Fork adaptation: upstream's fixtures annotated the receiver `x: any` + * (TypeScript) and inferred `x = make()` (Python). The kernel claims an + * annotated `any` receiver outright — terminal `unknown-receiver` — and reads + * a Python member call only through a typed receiver or a receiver named for + * the class, so neither shape is ambiguity-parked under the member's name + * here. An unannotated receiver reaches the same name scan upstream's + * resolver ran: a score tie on two `go`s declines, one `go` binds. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; + +/** Every node and edge by natural key, so two indexes of the same files compare directly. */ +function graphOf(cg: CodeGraph): { nodes: string[]; edges: string[] } { + const keys = new Map( + cg.getFiles() + .flatMap((file) => cg.getNodesInFile(file.path)) + .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const) + ); + const edges = cg.getOutgoingEdgesFrom([...keys.keys()]) + .map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target}`); + return { nodes: [...keys.values()].sort(), edges: edges.sort() }; +} + +async function reindexFromScratch(cg: CodeGraph): Promise { + const root = cg.getProjectRoot(); + cg.close(); + const rebuilt = await CodeGraph.recreate(root); + await rebuilt.indexAll(); + return rebuilt; +} + +const REINDEX_TIMEOUT = 60_000; + +describe('sync re-resolves a call whose competing candidate went away', () => { + let dir: string; + let cg: CodeGraph; + const write = (rel: string, content: string) => fs.writeFileSync(path.join(dir, rel), content); + + function callTargets(caller: string): string[] { + const node = cg.searchNodes(caller).map((r) => r.node).find((n) => n.name === caller && n.kind === 'function'); + return node ? cg.getCallees(node.id).map((c) => c.node.qualifiedName).sort() : []; + } + + afterEach(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe('JavaScript', () => { + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-competitor-js-')); + write('a.js', 'export class A {\n go() { return 1; }\n}\n'); + write('b.js', 'export class B {\n go() { return 2; }\n}\n'); + write('t.js', 'export function use(x) {\n x.go();\n}\n'); + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + // Two candidates: the call stays unresolved. + expect(callTargets('use')).toEqual([]); + }); + + it('after the file holding the other candidate is deleted', async () => { + fs.unlinkSync(path.join(dir, 'b.js')); + const result = await cg.sync(); + expect(result.filesRemoved).toBe(1); + expect(callTargets('use')).toEqual(['A::go']); + const synced = graphOf(cg); + cg = await reindexFromScratch(cg); + expect(synced).toEqual(graphOf(cg)); + }, REINDEX_TIMEOUT); + + it('after an edit renames the other candidate away', async () => { + write('b.js', 'export class B {\n stop() { return 2; }\n}\n'); + await cg.sync(); + expect(callTargets('use')).toEqual(['A::go']); + const synced = graphOf(cg); + cg = await reindexFromScratch(cg); + expect(synced).toEqual(graphOf(cg)); + }, REINDEX_TIMEOUT); + + it('a deletion that leaves the call ambiguous changes nothing', async () => { + write('c.js', 'export class C {\n go() { return 3; }\n}\n'); + await cg.sync(); + fs.unlinkSync(path.join(dir, 'b.js')); + await cg.sync(); + expect(callTargets('use')).toEqual([]); + const synced = graphOf(cg); + cg = await reindexFromScratch(cg); + expect(synced).toEqual(graphOf(cg)); + }, REINDEX_TIMEOUT); + }); + + describe('TypeScript', () => { + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-competitor-ts-')); + write('a.ts', 'export class A {\n go(): number { return 1; }\n}\n'); + write('b.ts', 'export class B {\n go(): number { return 2; }\n}\n'); + write('t.ts', 'export function use(x) {\n x.go();\n}\n'); + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + expect(callTargets('use')).toEqual([]); + }); + + it('after the file holding the other candidate is deleted', async () => { + fs.unlinkSync(path.join(dir, 'b.ts')); + await cg.sync(); + expect(callTargets('use')).toEqual(['A::go']); + const synced = graphOf(cg); + cg = await reindexFromScratch(cg); + expect(synced).toEqual(graphOf(cg)); + }, REINDEX_TIMEOUT); + + it('after an edit renames the other candidate away', async () => { + write('b.ts', 'export class B {\n stop(): number { return 2; }\n}\n'); + await cg.sync(); + expect(callTargets('use')).toEqual(['A::go']); + const synced = graphOf(cg); + cg = await reindexFromScratch(cg); + expect(synced).toEqual(graphOf(cg)); + }, REINDEX_TIMEOUT); + + it('a deletion that leaves the call ambiguous changes nothing', async () => { + write('c.ts', 'export class C {\n go(): number { return 3; }\n}\n'); + await cg.sync(); + fs.unlinkSync(path.join(dir, 'b.ts')); + await cg.sync(); + expect(callTargets('use')).toEqual([]); + const synced = graphOf(cg); + cg = await reindexFromScratch(cg); + expect(synced).toEqual(graphOf(cg)); + }, REINDEX_TIMEOUT); + }); +}); diff --git a/__tests__/sync-size-cap-shrink.test.ts b/__tests__/sync-size-cap-shrink.test.ts new file mode 100644 index 000000000..675cbc4a2 --- /dev/null +++ b/__tests__/sync-size-cap-shrink.test.ts @@ -0,0 +1,148 @@ +/** + * A file that shrinks back under the source-size limit links its importers as + * a fresh index does. + * + * Over the limit, a file is tracked with no nodes and a `size_exceeded` error, + * so `import { f1 } from './big'` has no file to land on and its refs fail. + * When an edit brings it under the limit, sync indexes it — but as a modified + * file, and only an ADDED file is looked up by what it can be imported as, so + * the failed `./big` import of an unchanged importer stayed failed: the call + * and the named import bound (they wait by name), the file-level import edge + * a full index makes did not. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { MAX_SOURCE_FILE_SIZE_BYTES } from '../src/file-limits'; + +/** Every node and edge by natural key, so two indexes of the same files compare directly. */ +function graphOf(cg: CodeGraph): { nodes: string[]; edges: string[] } { + const keys = new Map( + cg.getFiles() + .flatMap((file) => cg.getNodesInFile(file.path)) + .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const) + ); + const edges = cg.getOutgoingEdgesFrom([...keys.keys()]) + .map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target}`); + return { nodes: [...keys.values()].sort(), edges: edges.sort() }; +} + +const BIG = (padding: number) => + `export function f1(): number { return 1; }\n// ${'x'.repeat(padding)}\n`; + +describe('sync links the importers of a file that shrinks under the size limit', () => { + let dir: string; + let cg: CodeGraph; + const write = (rel: string, content: string) => fs.writeFileSync(path.join(dir, rel), content); + const importsOfUser = (): string[] => { + const ids = cg.getNodesInFile('user.ts').map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'imports') + .map((e) => `${cg.getNode(e.target)?.kind}:${cg.getNode(e.target)?.name}`) + .sort(); + }; + const matchesFullIndex = async () => { + const synced = graphOf(cg); + cg.close(); + cg = await CodeGraph.recreate(dir); + await cg.indexAll(); + expect(synced).toEqual(graphOf(cg)); + }; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sizecap-')); + write('big.ts', BIG(MAX_SOURCE_FILE_SIZE_BYTES + 1000)); + write('user.ts', "import { f1 } from './big';\nexport const x = f1();\n"); + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + // Over the limit: nothing in big.ts to import. + expect(importsOfUser()).toEqual([]); + }); + + afterEach(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('including the import of the file itself', async () => { + write('big.ts', BIG(10)); + await cg.sync(); + expect(importsOfUser()).toEqual(['file:big.ts', 'function:f1']); + await matchesFullIndex(); + }, 60_000); + + it('and again after it grows over the limit and back', async () => { + write('big.ts', BIG(10)); + await cg.sync(); + write('big.ts', BIG(MAX_SOURCE_FILE_SIZE_BYTES + 2000)); + await cg.sync(); + expect(importsOfUser()).toEqual([]); + write('big.ts', BIG(20)); + await cg.sync(); + expect(importsOfUser()).toEqual(['file:big.ts', 'function:f1']); + await matchesFullIndex(); + }, 60_000); + + it('and the same for a file stored after its parse failed', async () => { + // Recorded as a parse failure instead of an oversize skip: still no + // nodes, so the same importers wait on it. + cg.close(); + const { DatabaseSync } = require('node:sqlite') as typeof import('node:sqlite'); + const db = new DatabaseSync(path.join(dir, '.codegraph', 'codegraph.db')); + db.prepare(`UPDATE files SET errors = ? WHERE path = 'big.ts'`) + .run(JSON.stringify([{ message: 'parse failed', filePath: 'big.ts', severity: 'error', code: 'parse_error' }])); + db.close(); + cg = await CodeGraph.open(dir); + write('big.ts', BIG(10)); + const result = await cg.sync(); + expect(result.addedFilePaths).toEqual(['big.ts']); + expect(importsOfUser()).toEqual(['file:big.ts', 'function:f1']); + await matchesFullIndex(); + }, 60_000); + + it('and a change that keeps it over the limit is not taken for an added file', async () => { + write('big.ts', BIG(MAX_SOURCE_FILE_SIZE_BYTES + 5000)); + const result = await cg.sync(); + expect(result.filesModified).toBe(1); + expect(result.addedFilePaths).toBeUndefined(); + expect(importsOfUser()).toEqual([]); + await matchesFullIndex(); + }, 60_000); +}); + +describe('sync links what a barrel passes on from a file that shrinks under the size limit', () => { + // `user.ts` imports through `lib/index.ts`, which re-exports `lib/big.ts`: + // while big.ts was over the limit, the barrel's `export *` of it failed and + // so did the names user.ts reads through the barrel. + let dir: string; + let cg: CodeGraph; + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + + afterEach(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('the barrel and the files importing through it', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sizecap-barrel-')); + write('lib/index.ts', "export * from './big';\nexport * from './small';\n"); + write('lib/big.ts', BIG(MAX_SOURCE_FILE_SIZE_BYTES + 1000)); + write('lib/small.ts', 'export function s1(): number { return 2; }\n'); + write('user.ts', "import { f1, s1 } from './lib';\nexport const x = f1() + s1();\n"); + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + write('lib/big.ts', BIG(10)); + await cg.sync(); + const synced = graphOf(cg); + cg.close(); + cg = await CodeGraph.recreate(dir); + await cg.indexAll(); + expect(synced).toEqual(graphOf(cg)); + }, 60_000); +}); diff --git a/docs/benchmarks/index-drift-cg33.md b/docs/benchmarks/index-drift-cg33.md index 2778beaf0..2c97af8bb 100644 --- a/docs/benchmarks/index-drift-cg33.md +++ b/docs/benchmarks/index-drift-cg33.md @@ -78,7 +78,10 @@ identical graph still picked a different candidate. edges targeting a symbol of that name whose source is in an *unchanged* file, and re-inserts each as the reference that created it (the `metadata.refName` stamp). The existing orphan sweep then resolves them against the post-sync - graph — the same input a rebuild resolves from. Kill switch: + graph — the same input a rebuild resolves from. +- Failed references parked under a delta name go back to pending for the same + sweep: a call that failed only because a second definition shared its name + links once that definition is deleted or renamed away. Kill switch for both: `CODEGRAPH_NO_REBIND=1`. The delta is compared **per file**, not as one name set over the whole batch: a diff --git a/src/codegraph.ts b/src/codegraph.ts index f4c7a3406..d7c0a5312 100644 --- a/src/codegraph.ts +++ b/src/codegraph.ts @@ -68,7 +68,7 @@ import { CodeGraphPackageVersion } from './mcp/version'; import { extractSegmentSearchWords, segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments'; import { seedLiteralsInQuery, seedRouteNamesInQuery } from './extraction/literal-capture'; import { createYielder } from './resolution/cooperative-yield'; -import { createImportReader, reopenNameMatchedImports, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughChangedDefaults, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers'; +import { createImportReader, reopenNameMatchedImports, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughChangedDefaults, reopenReferencesThroughDeclaredPackages, reopenReferencesThroughRetargetedModules } from './resolution/module-importers'; import { ESM_IMPORT_LANGUAGES } from './resolution/import-resolver'; import { minRefsForPool } from './resolution/resolver-pool'; @@ -151,6 +151,8 @@ export interface IndexOptions { /** project_metadata key for the Python package-root digest (see sync). */ const PYTHON_ROOTS_METADATA_KEY = 'python_root_fingerprint'; const WORKSPACE_METADATA_KEY = 'js_workspace_fingerprint'; +/** The tsconfig / jsconfig and go.mod files the index was resolved by, with a digest of each (#2977). */ +const MODULE_CONFIG_METADATA_KEY = 'module_config_snapshot'; /** Metadata key: the frameworks (sorted JSON names) the last full index extracted every file with. */ const FRAMEWORKS_AT_INDEX_KEY = 'frameworks_at_index'; /** @@ -882,6 +884,7 @@ export class CodeGraph { // extraction stamp (the bulk would still be stale). See extraction-version.ts. if (result.success) this.stampPythonRootFingerprint(); if (result.success) this.stampWorkspaceFingerprint(); + if (result.success) this.stampModuleConfigSnapshot(); if (result.success) this.stampIndexedFrameworks(); if (result.success && result.filesIndexed > 0) { @@ -1012,6 +1015,23 @@ export class CodeGraph { } catch { /* metadata is advisory */ } } + /** Record the module-config files the index was resolved by, for a later sync to compare against (see sync). */ + private stampModuleConfigSnapshot(): void { + try { + const stored = this.queries.getMetadata(MODULE_CONFIG_METADATA_KEY); + this.queries.setMetadata(MODULE_CONFIG_METADATA_KEY, JSON.stringify(this.resolver.getModuleConfigSnapshot(stored))); + } catch { /* metadata is advisory */ } + } + + /** The same, only when the snapshot differs from the stored one: a config changed, or resolution read one the index had not recorded. */ + private stampModuleConfigSnapshotIfMoved(): void { + try { + const stored = this.queries.getMetadata(MODULE_CONFIG_METADATA_KEY); + const now = JSON.stringify(this.resolver.getModuleConfigSnapshot(stored)); + if (now !== stored) this.queries.setMetadata(MODULE_CONFIG_METADATA_KEY, now); + } catch { /* metadata is advisory */ } + } + /** Record the frameworks a full index extracted every file with; none has come or gone since. */ private stampIndexedFrameworks(): void { const names = this.orchestrator.getDetectedFrameworkNames(); @@ -1441,6 +1461,23 @@ export class CodeGraph { (ref) => this.resolver.resolveNow(ref) )); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-name-matched-imports: ${Date.now() - tNamed}ms (${nameMatched} refs re-opened)`); + + // A module whose `export … from` points elsewhere now, or passes + // a name on no longer: `export { foo } from './a'` made `'./b'` + // keeps `import { foo } from './index'` on `a.ts` (#2978). The + // barrel declares nothing of `foo`, so no delta names it, and the + // walks above read files below a barrel, not the barrel's own + // importers, and the name-matched pass only bindings a name match + // took. What they bind through it goes back for the sweep, after + // that pass has read the bindings it is about. + const tRetargeted = Date.now(); + const retargeted = (result.reExportsChangedFilePaths ?? []).flatMap((filePath) => { + const file = this.queries.getFileByPath(filePath); + return file ? [{ path: filePath, language: file.language }] : []; + }); + const throughRetargeted = retargeted.length === 0 ? 0 + : reopenReferencesThroughRetargetedModules(this.queries, importReader, retargeted); + if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-retargeted-modules: ${Date.now() - tRetargeted}ms (${throughRetargeted} refs re-opened)`); } finally { this.resolver.closeKernel(); walValve?.start(); @@ -1487,7 +1524,8 @@ export class CodeGraph { // as a full index resolves. // // `definitionDelta` is empty for a body-only edit, so the overwhelmingly - // common sync pays one branch. CODEGRAPH_NO_REBIND=1 disables it. + // common sync pays one branch. CODEGRAPH_NO_REBIND=1 disables it, and + // the failed-ref re-open that follows the rebind with it. if (result.definitionDelta && process.env.CODEGRAPH_NO_REBIND !== '1') { const tRebind = Date.now(); const rebound = this.orchestrator.resurrectStaleResolutionEdges( @@ -1495,9 +1533,19 @@ export class CodeGraph { result.changedFilePaths ?? [], edgesBeforeRetry ); + // And the failed refs under those names: a call that failed only + // because two definitions shared its name (`x.go()` with `go` in two + // classes) resolves once one of them is gone. The retry above looks + // failed refs up by the names the changed files carry now, which a + // deleted file or a name renamed away no longer has, and a sync that + // only deletes files skips it. The sweep below resolves what this + // re-opens. + const reopenedFailed = this.queries.reopenFailedReferences( + this.queries.getRetryableFailedReferences(result.definitionDelta).flatMap((ref) => (ref.rowId === undefined ? [] : [ref.rowId])) + ); if (process.env.CODEGRAPH_SYNTH_TIMINGS) { console.error( - `[phase-timing] sync-rebind: ${Date.now() - tRebind}ms (${result.definitionDelta.length} changed names, ${rebound} edges re-opened)` + `[phase-timing] sync-rebind: ${Date.now() - tRebind}ms (${result.definitionDelta.length} changed names, ${rebound} edges and ${reopenedFailed} failed refs re-opened)` ); } } @@ -1590,6 +1638,40 @@ export class CodeGraph { } } + // tsconfig / jsconfig `paths` and `baseUrl`, and go.mod's module path + // (#2977). The resolver reads them once and keeps the maps for its + // lifetime, and the watcher follows source files only, so an edit to + // one kept resolving imports by the old mapping: in this process, and + // in later ones too, since a sync re-resolves only the changed files. + // The index carries a digest of every config file resolution read + // (`extends` chains included) plus the root's candidates; when one + // differs, the maps are dropped and every import the file governs is + // re-opened for the sweep below, as a moved workspace map re-opens + // the ES-module imports. A handful of small files, read on every sync. + // An index stamped by an older engine has no snapshot: record one. + { + const tConfig = Date.now(); + if (walValve) { walValve.stop(); await walValve.drain(); } + try { + const stored = this.queries.getMetadata(MODULE_CONFIG_METADATA_KEY); + const snapshot = this.resolver.getModuleConfigSnapshot(stored); + const was = resolution().parseModuleConfigSnapshot(stored); + const changed = stored === null ? [] : Object.keys(snapshot).filter((rel) => (was[rel] ?? 'absent') !== snapshot[rel]); + if (changed.length > 0) { + this.resolver.resetModuleConfig(); + let reopened = 0; + for (const language of resolution().moduleConfigLanguages(changed)) reopened += this.orchestrator.resurrectResolutionEdgesForLanguage(language); + this.queries.setSynthesisPending(true); + if (process.env.CODEGRAPH_SYNTH_TIMINGS) { + console.error(`[phase-timing] sync-module-config: ${Date.now() - tConfig}ms (${changed.join(', ')} changed, ${reopened} refs re-opened)`); + } + } + } finally { + this.resolver.closeKernel(); + walValve?.start(); + } + } + // Workspace packages and path dependencies (#2456, #2502). A // package.json edit — a member's name, its `main` or `exports`, a // `file:` dependency — moves what an import of the package resolves to @@ -1812,6 +1894,9 @@ export class CodeGraph { } if (fullReconcile && result.filesChecked > 0) this.pendingFullReconcile = false; + // The config files this sync's resolution read join the snapshot, and + // a changed one is recorded as it is now (#2977). + this.stampModuleConfigSnapshotIfMoved(); result.durationMs = Date.now() - startedAt; return result; } finally { diff --git a/src/db/queries.ts b/src/db/queries.ts index 38162791f..96e9385cb 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -5569,6 +5569,28 @@ export class QueryBuilder { return pairs; } + /** + * The `file\0statement` pairs of the `export … from` statements in + * `filePaths` (#2978). Each is an import node named by the module it names, + * with the statement as its signature, so a re-export pointed at another + * module, taken away, added, or passing other names on changes a pair, and a + * plain `import` does not. Same shape as {@link getNodeNamePairsByFiles}. + */ + getReExportStatementsByFiles(filePaths: string[]): Set { + const pairs = new Set(); + if (filePaths.length === 0) return pairs; + for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT DISTINCT file_path, signature FROM nodes + WHERE kind = 'import' AND file_path IN (${placeholders}) AND signature LIKE 'export%'`) + .all(...chunk) as Array<{ file_path: string; signature: string }>; + for (const row of rows) pairs.add(`${row.file_path}\0${row.signature}`); + } + return pairs; + } + // =========================================================================== // Statistics // =========================================================================== diff --git a/src/extraction/index.ts b/src/extraction/index.ts index e8050ae64..ce05f8e20 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -125,6 +125,9 @@ export interface IndexResult { durationMs: number; } +/** The languages whose modules pass bindings on with `export … from` (#2978). */ +const RE_EXPORTING_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); + /** * Result of a sync operation */ @@ -149,7 +152,11 @@ export interface SyncResult { changedFilePaths?: string[]; /** Tracked paths this sync removed from the index. */ removedFilePaths?: string[]; - /** The part of `changedFilePaths` that was not indexed before this sync. */ + /** + * The part of `changedFilePaths` that had nothing indexed before this sync: + * new files, and tracked ones that had no nodes (over the size limit, or a + * parse failure) and now do. The latter are still counted as modified. + */ addedFilePaths?: string[]; /** Paths not absorbed because reading or extraction failed; retain for status/retry. */ failedFilePaths?: string[]; @@ -169,6 +176,15 @@ export interface SyncResult { definitionDelta?: string[]; /** Files whose exported-name set changed (adds, drops, renames, re-exports, default moves). */ exportDelta?: string[]; + /** + * Modified ES modules whose `export … from` statements changed: a re-export + * that points at another module now (`export { foo } from './a'` → + * `'./b'`), one taken away or added, or a clause that passes other names on. + * Their importers' bindings through them must be re-resolved: nothing those + * files declare changed, and the importers never did (#2978). An edit to a + * plain `import` leaves this empty. + */ + reExportsChangedFilePaths?: string[]; /** * The frameworks extraction detected, when this sync had to detect them * again because what detection read had changed (#2516). Absent when the @@ -3735,6 +3751,10 @@ export class ExtractionOrchestrator { const changedFilePaths: string[] = []; const removedFilePaths: string[] = []; const addedFilePaths: string[] = []; + // Tracked with no nodes and the reason recorded (over the size limit, a + // parse or grammar failure): if this sync stores nodes for one, it is new + // to its importers, so it joins `addedFilePaths` once stored. + const indexedFromSkip: string[] = []; // `file\0name` definition pairs for the files this sync touches, sampled // BEFORE their nodes are replaced/deleted. Compared against the post-store // pairs below to derive `definitionDelta` (CG-33). @@ -3940,6 +3960,7 @@ export class ExtractionOrchestrator { onFileChange?.(filePath, content); filesToIndex.push(filePath); changedFilePaths.push(filePath); + if (tracked.nodeCount === 0 && (tracked.errors?.length ?? 0) > 0) indexedFromSkip.push(filePath); filesModified++; } } @@ -4020,6 +4041,9 @@ export class ExtractionOrchestrator { }; const exportsBefore = new Map>(); for (const file of filesToIndex) exportsBefore.set(file, exportSignatures(file)); + // The `export … from` statements of each file, before the store replaces + // its import nodes (#2978). + const reExportsBefore = filesToIndex.length > 0 ? this.queries.getReExportStatementsByFiles(filesToIndex) : new Set(); // Load only grammars needed for changed files if (filesToIndex.length > 0) { @@ -4050,6 +4074,10 @@ export class ExtractionOrchestrator { if (pause) await pause; } + for (const filePath of indexedFromSkip) { + if ((this.queries.getFileByPath(filePath)?.nodeCount ?? 0) > 0) addedFilePaths.push(filePath); + } + // Names whose definition set this sync changed: a `file\0name` pair present // before but not after (removed/renamed away) or after but not before // (added). A pair on both sides is untouched as far as resolution's @@ -4075,6 +4103,26 @@ export class ExtractionOrchestrator { if (before.size !== after.size || [...before].some((name) => !after.has(name))) exportDelta.push(file); } + // Modified ES modules whose `export … from` statements changed (#2978): a + // re-export retargeted, removed or passing other names on changes what + // their importers bind, which no definition delta records. A plain import + // edit passes nothing on. An added module's importers are read by the + // added-files pass. + const reExportsChanged: string[] = []; + if (filesToIndex.length > 0) { + const reExportsAfter = this.queries.getReExportStatementsByFiles(filesToIndex); + const fileOf = (pair: string) => pair.slice(0, pair.indexOf('\0')); + const changed = new Set(); + for (const pair of reExportsBefore) if (!reExportsAfter.has(pair)) changed.add(fileOf(pair)); + for (const pair of reExportsAfter) if (!reExportsBefore.has(pair)) changed.add(fileOf(pair)); + const added = new Set(addedFilePaths); + for (const filePath of changed) { + if (added.has(filePath)) continue; + const record = this.queries.getFileByPath(filePath); + if (record && RE_EXPORTING_LANGUAGES.has(record.language)) reExportsChanged.push(filePath); + } + } + return { filesChecked, filesAdded, @@ -4088,6 +4136,7 @@ export class ExtractionOrchestrator { ...(failedFilePaths.length > 0 ? { failedFilePaths } : {}), definitionDelta: definitionDelta.length > 0 ? definitionDelta : undefined, ...(exportDelta.length > 0 ? { exportDelta } : {}), + ...(reExportsChanged.length > 0 ? { reExportsChangedFilePaths: reExportsChanged } : {}), ...(detectedFrameworks ? { detectedFrameworks } : {}), }; } diff --git a/src/resolution/index.ts b/src/resolution/index.ts index aa581c7a6..aa49aa2af 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -40,7 +40,7 @@ import { clearDeclaredDependencies } from './frameworks/package-deps'; import { collectSynthesisInputs, synthesizeCallbackEdges } from './callback-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits'; -import { loadProjectAliases, loadScopedAliases, scopedAliasesForFile, type AliasMap, type AliasScope } from './path-aliases'; +import { aliasConfigFields, loadProjectAliases, loadScopedAliases, scopedAliasesForFile, type AliasMap, type AliasScope } from './path-aliases'; import { findGoModuleForImport, goModulePackageDir, loadGoModule, type GoModule } from './go-module'; import { loadWorkspacePackages, workspaceFingerprint, workspacePackageDirOf, type WorkspacePackages } from './workspace-packages'; import { logDebug } from '../errors'; @@ -107,6 +107,60 @@ function cppConstructorForType(type: Node, queries: QueryBuilder): Node | null { export * from './types'; export { hasSynthesisPattern, isJsxChildCandidate, mayFeedSynthesis } from './callback-synthesizer'; +/** The module-config files at the project root a resolver reads, present or not (#2977). */ +const MODULE_CONFIG_ROOT_FILES = ['tsconfig.json', 'jsconfig.json', 'tsconfig.base.json', 'go.mod'] as const; + +/** Project-relative config path → digest of what resolution reads from it (see `moduleConfigDigest`), 'absent' for one not there. */ +export type ModuleConfigSnapshot = Record; + +/** A stored snapshot's files (see `getModuleConfigSnapshot`); an index without one, or one an older engine wrote, has none. */ +export function parseModuleConfigSnapshot(stored: string | null): ModuleConfigSnapshot { + if (!stored) return {}; + try { + const parsed = JSON.parse(stored) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return Object.fromEntries(Object.entries(parsed as Record).filter(([, v]) => typeof v === 'string')) as ModuleConfigSnapshot; + } catch { + return {}; + } +} + +/** Digest each of `files` (absolute) as the project sees it now, keyed by its project-relative path. */ +function moduleConfigSnapshot(projectRoot: string, files: Iterable): ModuleConfigSnapshot { + const snapshot: ModuleConfigSnapshot = {}; + for (const abs of files) { + const rel = path.relative(projectRoot, abs).replace(/\\/g, '/'); + // A config outside the project (an `extends` into node_modules or a parent + // folder) is read where it is; the key stays relative so the index moves. + snapshot[rel] = moduleConfigDigest(abs); + } + return snapshot; +} + +/** + * A digest of what resolution reads from one config file, 'absent' for one not + * there: a go.mod's module path, a tsconfig / jsconfig's `extends`, `baseUrl` + * and `paths`. Not of its bytes: a dependency bump or a `strict` flag moves no + * import, and would re-open every one. + */ +function moduleConfigDigest(abs: string): string { + if (!fs.existsSync(abs)) return 'absent'; + const read = path.basename(abs) === 'go.mod' + ? loadGoModule(path.dirname(abs))?.modulePath ?? '' + : aliasConfigFields(abs) ?? ''; + return createHash('sha256').update(read).digest('hex').slice(0, 16); +} + +/** The languages whose imports a changed config file governs: the ES-module family for a tsconfig / jsconfig, Go for a go.mod. */ +export function moduleConfigLanguages(changed: readonly string[]): Set { + const languages = new Set(); + for (const rel of changed) { + if (/(?:^|\/)go\.mod$/.test(rel)) languages.add('go'); + else for (const language of ESM_IMPORT_LANGUAGES) languages.add(language); + } + return languages; +} + /** * Reference Resolver * @@ -201,6 +255,10 @@ export class ReferenceResolver { private goPackageDirs = new Map(); // Monorepo workspace member packages. Same lazy/immutable convention. private workspacePackages: WorkspacePackages | null | undefined = undefined; + // The tsconfig / jsconfig files (their `extends` chains included) and go.mod + // files the maps above were read from, as absolute paths. A sync compares + // what they say now with what the index was resolved by (#2977). + private moduleConfigFiles = new Set(); constructor(projectRoot: string, queries: QueryBuilder) { this.projectRoot = projectRoot; @@ -487,6 +545,7 @@ export class ReferenceResolver { if (found !== undefined) break; walked.push(dir); found = loadGoModule(path.join(this.projectRoot, dir)); + if (found) this.moduleConfigFiles.add(path.join(this.projectRoot, dir, 'go.mod')); if (found || dir === '') break; dir = goDirOf(dir); } @@ -928,13 +987,13 @@ export class ReferenceResolver { getProjectAliases: () => { if (this.projectAliases === undefined) { - this.projectAliases = loadProjectAliases(this.projectRoot); + this.projectAliases = loadProjectAliases(this.projectRoot, this.moduleConfigFiles); } return this.projectAliases; }, getAliasScopes: () => { - this.aliasScopes ??= loadScopedAliases(this.projectRoot, this.context.getAllFiles()); + this.aliasScopes ??= loadScopedAliases(this.projectRoot, this.context.getAllFiles(), this.moduleConfigFiles); return this.aliasScopes; }, @@ -1053,6 +1112,37 @@ export class ReferenceResolver { }); } + /** + * The module-config files the index was resolved by, with a digest of each + * (see `moduleConfigSnapshot`): every tsconfig / jsconfig this resolver read + * (`extends` chains included), every go.mod, and the root's candidates + * whether present or not, so one that appears is seen too. `previous` is the + * snapshot the index was stamped with; its files are read again, so a config + * this process never loaded still counts. + */ + getModuleConfigSnapshot(previous: string | null): ModuleConfigSnapshot { + const files = new Set(this.moduleConfigFiles); + for (const name of MODULE_CONFIG_ROOT_FILES) files.add(path.join(this.projectRoot, name)); + for (const rel of Object.keys(parseModuleConfigSnapshot(previous))) files.add(path.join(this.projectRoot, rel)); + return moduleConfigSnapshot(this.projectRoot, files); + } + + /** + * Forget the tsconfig / jsconfig alias maps and the go.mod modules this + * resolver read, and every memo resolved through them, so the next reference + * reads the files as they are now (#2977). The caller re-opens the imports + * the old maps resolved. + */ + resetModuleConfig(): void { + this.projectAliases = undefined; + this.aliasScopes = undefined; + this.moduleConfigFiles.clear(); + this.clearCaches(); + // The kernel resolver takes the alias maps and go modules at construction: + // a moved config must reach a new instance, not just a cleared memo. + this.closeKernel(); + } + /** * The workspace packages and path dependencies as package.json files * declare them now, read again from disk, and their fingerprint (see diff --git a/src/resolution/module-importers.ts b/src/resolution/module-importers.ts index 56b6621ae..c3c1f6f17 100644 --- a/src/resolution/module-importers.ts +++ b/src/resolution/module-importers.ts @@ -472,6 +472,43 @@ export function reopenReferencesThroughBarrels( queries.reopenFailedReferences([...failed]); } +/** + * Re-open, for the sync's sweep, what the files importing any of `modules` + * bind through them, after a module's `export … from` clauses changed what + * they point at (#2978): `export { foo } from './a'` made `'./b'`, an `export + * *` moved the same way, a re-export taken away. The importers did not change + * and the module declares nothing of what it passes on, so no definition + * delta, added file or failed reference names their bindings, and they stay + * on the module the clause used to name. Each importer's edges through the + * names it binds from the module go back, up through the modules that pass + * them on in turn, with the failures parked under them. Returns the number + * re-opened. + */ +export function reopenReferencesThroughRetargetedModules( + queries: QueryBuilder, + reader: ImportReader, + modules: ReadonlyArray<{ path: string; language: Language }>, +): number { + const barrels = modules.filter((module) => BARREL_LANGUAGES.has(module.language)); + if (barrels.length === 0) return 0; + const bindings = bindingsOf(importersOf(queries, barrels, reader), reader); + const failed = [...bindings].flatMap(([file, { modules: named, names }]) => queries.getFailedReferencesThrough(file, named, names)); + // What the bindings pointed at: a value typed by a binding reached the + // members of that (`w.run()` with `w: Widget`), by their own names, and + // those edges follow the binding to its new target. + const bound = new Set(); + for (const [file, { names }] of bindings) { + if (names.length === 0) continue; + for (const e of queries.getResolutionEdgesThrough(file, names)) if (!e.target.startsWith('file:')) bound.add(e.target); + } + let reopened = reopenEdges(queries, bindings, () => true) + queries.reopenFailedReferences(failed); + if (bound.size === 0) return reopened; + const members = queries.getOutgoingEdgesFrom([...bound], ['contains']).map((edge) => edge.target); + if (members.length === 0) return reopened; + reopened += reopenEdgeList(queries, queries.getResolutionEdgesInto(members).filter((e) => bindings.has(e.sourceFilePath))); + return reopened; +} + /** An import's module edge into a file that an added file now outranks. */ interface OutrankedImport { edge: Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }; diff --git a/src/resolution/path-aliases.ts b/src/resolution/path-aliases.ts index e9928b172..4bf0dfab8 100644 --- a/src/resolution/path-aliases.ts +++ b/src/resolution/path-aliases.ts @@ -187,13 +187,15 @@ function resolveExtendsTarget(spec: string, fromDir: string): string | null { function loadEffectiveOptions( filePath: string, stack: Set, - depth: number + depth: number, + read?: Set ): EffectiveOptions | null { const abs = path.resolve(filePath); if (stack.has(abs) || depth > MAX_EXTENDS_DEPTH) { logDebug('path-aliases: extends chain cycle or too deep', { filePath: abs, depth }); return null; } + read?.add(abs); const raw = readTsconfigLike(abs); if (!raw) return null; @@ -209,7 +211,7 @@ function loadEffectiveOptions( logDebug('path-aliases: unresolved extends', { from: abs, spec }); continue; } - const inherited = loadEffectiveOptions(target, stack, depth + 1); + const inherited = loadEffectiveOptions(target, stack, depth + 1, read); if (!inherited) continue; if (inherited.baseUrl !== undefined) effective.baseUrl = inherited.baseUrl; if (inherited.paths !== undefined) { @@ -241,6 +243,19 @@ function readTsconfigLike(filePath: string): RawTsconfig | null { } } +/** + * What {@link loadProjectAliases} reads from one config file, its `extends`, + * `compilerOptions.baseUrl` and `compilerOptions.paths`, as one string, or + * `null` for a file it can't read. A sync compares it to tell an edit that + * moves the alias map from one that doesn't, like a `strict` flag (#2977). + */ +export function aliasConfigFields(filePath: string): string | null { + const raw = readTsconfigLike(path.resolve(filePath)); + if (!raw) return null; + const co = raw.compilerOptions ?? {}; + return JSON.stringify([raw.extends ?? null, co.baseUrl ?? null, co.paths ?? null]); +} + function splitWildcard(pattern: string): { prefix: string; suffix: string; @@ -261,9 +276,11 @@ function splitWildcard(pattern: string): { * `baseUrl`. * * Cheap to call repeatedly — caching is the caller's job (the - * resolver does it via {@link aliasCache}). + * resolver does it via {@link aliasCache}). `read` collects the absolute + * path of every config file the load read, the `extends` chain included, so + * the caller can tell when one of them changes (#2977). */ -export function loadProjectAliases(projectRoot: string): AliasMap | null { +export function loadProjectAliases(projectRoot: string, read?: Set): AliasMap | null { // `tsconfig.base.json` comes last on purpose: when a root `tsconfig.json` // exists it stays authoritative and reaches the base through `extends`. // The fallback is for the Nx layouts where that never happens — a @@ -275,7 +292,7 @@ export function loadProjectAliases(projectRoot: string): AliasMap | null { for (const name of candidates) { const p = path.join(projectRoot, name); if (!fs.existsSync(p)) continue; - const opts = loadEffectiveOptions(p, new Set(), 0); + const opts = loadEffectiveOptions(p, new Set(), 0, read); if (!opts) continue; // Remember the first readable config so a `paths`-less project still // logs the file it was judged on, but keep looking: a config that @@ -356,9 +373,10 @@ const ALIASED_SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/; * `src/`; the root config alone mapped every package's `@/` to one place or * nowhere. A nested config without `paths` adds no scope, leaving its files * on the root aliases as before. Ordered deepest first for - * {@link scopedAliasesForFile}. + * {@link scopedAliasesForFile}. `read` collects every config file read, as + * {@link loadProjectAliases}' does (#2977). */ -export function loadScopedAliases(projectRoot: string, files: readonly string[]): AliasScope[] { +export function loadScopedAliases(projectRoot: string, files: readonly string[], read?: Set): AliasScope[] { const nearest = new Map(); const configFor = (dir: string): string | null => { if (dir === '' || dir === '.') return null; @@ -384,7 +402,7 @@ export function loadScopedAliases(projectRoot: string, files: readonly string[]) } const scopes: AliasScope[] = []; for (const config of configs) { - const options = loadEffectiveOptions(path.join(projectRoot, config), new Set(), 0); + const options = loadEffectiveOptions(path.join(projectRoot, config), new Set(), 0, read); const map = options ? aliasMapFromOptions(options, projectRoot, config) : null; if (map) scopes.push({ dir: config.slice(0, config.lastIndexOf('/')), map }); }