diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb9e73572..61d70e39e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a React or Next.js monorepo where a component lazily loads a workspace package by its name, like `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))`. Before, once a sync had to redraw links between components, that sync and every later one failed, so links between components stopped updating. A full index of such a project could also miss which files to watch for those links; run `codegraph index` once after upgrading (#2565). - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. - In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. - In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading. diff --git a/__tests__/sync-synthesis-input-package.test.ts b/__tests__/sync-synthesis-input-package.test.ts new file mode 100644 index 0000000000..51833e62fd --- /dev/null +++ b/__tests__/sync-synthesis-input-package.test.ts @@ -0,0 +1,137 @@ +/** + * A lazy value that loads a workspace package by its name — `dynamic(() => + * import('icons').then((mod) => mod.EdgeFunctions))` — records the module it + * loads as a synthesis input, and the import resolves a workspace package to + * its directory, `packages/icons`, which is no indexed file. + * `synthesis_inputs.file_path` references `files(path)`, so recording it failed + * the commit with "FOREIGN KEY constraint failed" and rolled back every sync + * after an edit to that file (#2565). A full index writes its inputs through + * the same call but swallows the failure (synthesis is best-effort there), so + * it only left the inputs empty; both are pinned. + */ +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'; + +const APP = `import dynamic from 'next/dynamic'; +const Chart = dynamic(() => import('icons').then((mod) => mod.EdgeFunctions)); +export function App() { return ; } +`; + +const FILES: Record = { + 'pnpm-workspace.yaml': "packages:\n - 'packages/*'\n", + 'package.json': JSON.stringify({ name: 'app', workspaces: ['packages/*'], dependencies: { react: '^18', next: '^14' } }), + 'packages/icons/package.json': JSON.stringify({ name: 'icons', main: './src/icons/index.ts' }), + 'packages/icons/src/icons/index.ts': 'export function EdgeFunctions() { return null; }\n', + 'src/App.tsx': APP, +}; + +let root: string | undefined; +let cg: CodeGraph | undefined; + +afterEach(() => { + cg?.close(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +/** The files recorded as synthesis inputs. */ +function inputs(graph: CodeGraph): string[] { + const db = (graph as any).db.db; + return db.prepare('SELECT file_path FROM synthesis_inputs ORDER BY file_path').all().map((r: any) => r.file_path); +} + + +function write(dir: string, files: Record): void { + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text); + } +} + +/** ` -> ::` for every synthesized JSX render edge. */ +function renders(graph: CodeGraph): string[] { + const out: string[] = []; + for (const file of graph.getFiles()) { + for (const node of graph.getNodesInFile(file.path)) { + for (const e of graph.getOutgoingEdges(node.id)) { + if (e.metadata?.synthesizedBy !== 'jsx-render') continue; + const child = graph.getNode(e.target); + out.push(`${node.name} -> ${child?.filePath}::${child?.name}`); + } + } + } + return out.sort(); +} + +let fresh: CodeGraph | undefined; +let freshRoot: string | undefined; +afterEach(() => { + fresh?.close(); + fresh = undefined; + if (freshRoot) fs.rmSync(freshRoot, { recursive: true, force: true }); + freshRoot = undefined; +}); + +describe('synthesis inputs from a lazily loaded workspace package (#2565)', () => { + it('records the loading file as an input, and a sync after an edit to it succeeds', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-2565-')); + write(root, FILES); + cg = await CodeGraph.init(root, { index: true }); + expect(inputs(cg)).toContain('src/App.tsx'); + expect(inputs(cg)).not.toContain('packages/icons'); + + fs.appendFileSync(path.join(root, 'src/App.tsx'), '// touched\n'); + await expect(cg.sync()).resolves.toBeDefined(); + + const indexed = new Set(cg.getFiles().map((f) => f.path)); + expect(indexed.has('src/App.tsx')).toBe(true); + expect(indexed.has('packages/icons')).toBe(false); + expect(inputs(cg)).toContain('src/App.tsx'); + expect(inputs(cg)).not.toContain('packages/icons'); + }); + // The issue's own trigger: the edit is to ANOTHER file with markup, not the + // one that loads the package, and every such sync failed. + it('a sync after an edit to another file with markup succeeds', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-2565-')); + write(root, { ...FILES, 'src/Other.tsx': 'export function Other() { return
; }\n' }); + cg = await CodeGraph.init(root, { index: true }); + + fs.appendFileSync(path.join(root, 'src/Other.tsx'), '// touched\n'); + await expect(cg.sync()).resolves.toBeDefined(); + }); + + // An input is matched to the indexed file by its path, so a module spelled + // with a trailing slash or a `.` segment (`import('@/widgets/Chart/')`) must + // still be recorded under the file's own path: dropping it left the barrel + // out of the inputs, and rewiring the barrel never redrew the tag. + it.each([['@/widgets/Chart/'], ['src/widgets/./Chart']])( + 'records a module spelled %s under its indexed path, so rewiring it redraws the tag', + async (spec) => { + const files: Record = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', next: '^14' } }), + 'src/App.tsx': `import dynamic from 'next/dynamic';\nconst Chart = dynamic(() => import('${spec}'));\nexport function App() { return ; }\n`, + 'src/widgets/Chart/index.ts': "export { default } from './Impl';\n", + 'src/widgets/Chart/Impl.tsx': 'export default function ImplA() { return null; }\n', + 'src/widgets/Chart/Other.tsx': 'export default function Other() { return null; }\n', + }; + const rewired = { ...files, 'src/widgets/Chart/index.ts': "export { default } from './Other';\n" }; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-2565-')); + write(root, files); + cg = await CodeGraph.init(root, { index: true }); + expect(inputs(cg)).toContain('src/widgets/Chart/index.ts'); + + write(root, rewired); + await cg.sync(); + + freshRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-2565-fresh-')); + write(freshRoot, rewired); + fresh = await CodeGraph.init(freshRoot, { index: true }); + expect(renders(fresh)).toEqual(['App -> src/widgets/Chart/Other.tsx::Other']); + expect(renders(cg)).toEqual(renders(fresh)); + } + ); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 6421eec360..9eabce7224 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -4,6 +4,7 @@ * Prepared statements for CRUD operations on the knowledge graph. */ +import * as path from 'path'; import { SqliteDatabase, SqliteStatement } from './sqlite-adapter'; import { Node, @@ -2112,8 +2113,14 @@ export class QueryBuilder { replaceSynthesisInputs(files: string[]): void { this.db.transaction(() => { this.db.exec('DELETE FROM synthesis_inputs'); - const insert = this.db.prepare('INSERT INTO synthesis_inputs(file_path) VALUES (?)'); - for (const file of files) insert.run(file); + // The column references files(path), so an input is stored under the + // indexed file's own spelling, and a path that is no indexed file is + // skipped rather than failing the write (#2565). On a SynthesisStage + // connection this writes the temp table that publish copies to main. + const insert = this.db.prepare( + 'INSERT OR IGNORE INTO synthesis_inputs(file_path) SELECT path FROM files WHERE path = ?' + ); + for (const file of files) insert.run(indexedPathOf(file)); })(); } @@ -4700,3 +4707,14 @@ function foldModuleRows( return { links: [...links.values()], pairs }; } + +/** + * A path as the index stores it: forward slashes, no `.` or empty segments, + * no trailing slash. A synthesis pass can be handed a module path spelled the + * way its import wrote it (`import('@/widgets/Chart/')` → + * `src/widgets/Chart//index.ts`), which names an indexed file under another + * spelling. + */ +function indexedPathOf(file: string): string { + return path.posix.normalize(file.replace(/\\/g, '/')).replace(/\/+$/, ''); +} diff --git a/src/db/synthesis-stage.ts b/src/db/synthesis-stage.ts index 23c914d2a3..8ee4f16877 100644 --- a/src/db/synthesis-stage.ts +++ b/src/db/synthesis-stage.ts @@ -70,8 +70,12 @@ export class SynthesisStage { insert.run(i, i + 2000); await yieldToLoop(); } + // Re-checked against files inside the write lock: the inputs were staged + // before it, and a file deleted since would fail the FK and roll back + // every synthesized edge with it (#2565). this.db.exec(`DELETE FROM main.synthesis_inputs; - INSERT INTO main.synthesis_inputs SELECT * FROM temp.synthesis_inputs`); + INSERT INTO main.synthesis_inputs(file_path) SELECT file_path FROM temp.synthesis_inputs + WHERE file_path IN (SELECT path FROM main.files)`); this.db.exec('COMMIT'); } catch (error) { this.db.exec('ROLLBACK');