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');