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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"timestamp": "2026-10-28T16:17:05.201Z",
"corpus": "svelte",
"repo": "https://github.com/sveltejs/svelte.git",
"commit": "5895c63",
"codegraphSha": "dd02b58a",
"summary": {
"absentCases": 1,
"absentHeld": 1,
"presentCases": 0,
"presentHeld": 0,
"unjudgeable": 0
},
"resolvedBy": {
"(none)": 30920,
"import": 20057,
"exact-match": 10539,
"qualified-name": 1874,
"instance-method": 390,
"framework": 220,
"function-ref": 196,
"fuzzy": 73,
"file-path": 58
},
"edges": 64327,
"results": [
{
"caseId": "svelte-bare-import-bundle",
"expect": "absent",
"pass": true,
"found": [],
"missingEndpoints": []
}
]
}
95 changes: 95 additions & 0 deletions __tests__/evaluation/results/precision-vite-8492422-dd02b58a.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{
"timestamp": "2026-10-28T16:16:50.912Z",
"corpus": "vite",
"repo": "https://github.com/vitejs/vite.git",
"commit": "8492422",
"codegraphSha": "dd02b58a",
"summary": {
"absentCases": 6,
"absentHeld": 6,
"presentCases": 2,
"presentHeld": 2,
"unjudgeable": 0
},
"resolvedBy": {
"(none)": 13392,
"import": 11109,
"exact-match": 3413,
"qualified-name": 1009,
"instance-method": 457,
"file-path": 197,
"function-ref": 165,
"framework": 2
},
"edges": 29744,
"results": [
{
"caseId": "vite-factory-receiver-control",
"expect": "present",
"pass": true,
"found": [
{
"from": "function:d321dd128d90578bc69867835ec73ef8",
"to": "method:db73e5f5d9c7af61d0f22c5b9971ced3",
"resolvedBy": "instance-method"
}
],
"missingEndpoints": []
},
{
"caseId": "vite-external-receiver-not-project-method",
"expect": "absent",
"pass": true,
"found": [],
"missingEndpoints": []
},
{
"caseId": "vite-bare-import-self-edge",
"expect": "absent",
"pass": true,
"found": [],
"missingEndpoints": []
},
{
"caseId": "vite-bare-import-getEnv",
"expect": "absent",
"pass": true,
"found": [],
"missingEndpoints": []
},
{
"caseId": "vite-fuzzy-nested-resolveConfig-getEnv",
"expect": "absent",
"pass": true,
"found": [],
"missingEndpoints": []
},
{
"caseId": "vite-fuzzy-nested-build-scan",
"expect": "absent",
"pass": true,
"found": [],
"missingEndpoints": []
},
{
"caseId": "vite-sealed-module-defineConfig",
"expect": "absent",
"pass": true,
"found": [],
"missingEndpoints": []
},
{
"caseId": "vite-control-relative-import",
"expect": "present",
"pass": true,
"found": [
{
"from": "file:packages/vite/src/node/config.ts",
"to": "function:0c2f36c32c5742aee412978220fd5b23",
"resolvedBy": "import"
}
],
"missingEndpoints": []
}
]
}
62 changes: 62 additions & 0 deletions __tests__/sync-resynthesis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,29 @@ describe('synthesized edges on incremental sync', () => {
await expect(cg.sync({ requireComplete: true })).rejects.toThrow('requires synthesized-edge refresh');
});

it('skips re-synthesis for an edit that touches no synthesized wiring or input pattern', async () => {
write('src/util.ts', 'export function add(a: number, b: number) {\n return a + b;\n}\n');
await cg.sync({ requireComplete: true });
const resolver = (cg as unknown as { resolver: { synthesisRuns: number } }).resolver;
const before = resolver.synthesisRuns;
write('src/util.ts', 'export function add(a: number, b: number) {\n // sum\n return a + b;\n}\n');
await cg.sync({ requireComplete: true });
expect(resolver.synthesisRuns).toBe(before);
// Edges the skipped run left alone are still served.
expect(callees('publish')).toContain('onSaved');
expect(callees('loadRepo')).toContain('GET https://api.github.com/repos/${…}');
});

it('re-synthesizes when an edited file matches an input pattern it had no edge for', async () => {
write('src/ticker.ts', "import { bus } from './bus';\n\nexport function tick() {\n return 1;\n}\n");
await cg.sync({ requireComplete: true });
const resolver = (cg as unknown as { resolver: { synthesisRuns: number } }).resolver;
const before = resolver.synthesisRuns;
write('src/ticker.ts', "import { bus } from './bus';\n\nexport function tick() {\n bus.emit('tick', 1);\n}\n");
await cg.sync({ requireComplete: true });
expect(resolver.synthesisRuns).toBeGreaterThan(before);
});

it('acknowledges current wiring across every refresh-ending sequence of up to three events', async () => {
process.env.CODEGRAPH_SYNTH_REFRESH_MS = '60000';
const attached = "import { bus } from './bus';\nimport { onSaved } from './handlers';\nexport function wire() { bus.on('saved', onSaved); }\n";
Expand Down Expand Up @@ -203,3 +226,42 @@ describe('synthesized edges on incremental sync', () => {
expect(failures).toEqual([]);
}, 20000);
});

describe('synthesis gate on JSX definition deltas', () => {
let dir: string;
let cg: CodeGraph;
const write = (rel: string, body: string) => {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
};
const callees = (fn: string) => {
const node = cg.getNodesByName(fn).find((n) => n.kind === 'function')!;
return cg.getCallees(node.id).map((c) => c.node.name).sort();
};

beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resynth-jsx-'));
write('src/App.tsx', "import { Team } from './components';\n\nexport function App() {\n return <Team />;\n}\n");
write('src/components.ts', 'export {};\n');
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.{ts,tsx}'], exclude: [] } });
await cg.indexAll();
});

afterEach(() => {
vi.restoreAllMocks();
syncBuiltinESMExports();
try { cg.close(); } catch { /* ignore */ }
fs.rmSync(dir, { recursive: true, force: true });
});

it('re-synthesizes when a changed file adds a component a tag elsewhere names', async () => {
const resolver = (cg as unknown as { resolver: { synthesisRuns: number } }).resolver;
const before = resolver.synthesisRuns;
// Non-pattern content (a .ts file, no markup, not a barrel): only the
// definition delta — `Team` is a JSX child candidate — can gate this in.
write('src/components.ts', "const label = 'team';\nexport function Team() {\n return label;\n}\n");
await cg.sync({ requireComplete: true });
expect(resolver.synthesisRuns).toBeGreaterThan(before);
expect(callees('App')).toContain('Team');
});
});
45 changes: 42 additions & 3 deletions src/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,8 +1062,25 @@ export class CodeGraph {
const pythonFilesBefore = this.pythonFilePaths();
const watchRoutes = this.resolver.hasNavigationRouters();
let routesBefore: Node[] | null = null;
const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure, () => {
// Files touching synthesized edges, snapshotted once on first use: a
// per-file lookup would rescan the edges table for every changed file.
let touchedBySynthesis: Set<string> | null = null;
const touchesSynthesis = (p: string) =>
(touchedBySynthesis ??= this.queries.filesTouchingSynthesizedEdges()).has(p);
const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure, (filePath, content) => {
if (watchRoutes && routesBefore === null) routesBefore = this.queries.getNodesByKind('route');
// A changed file only dirties synthesis when it carried synthesized
// wiring (read BEFORE replacement cascades its edges), its last-seen
// content fed a pass (the input row is still here pre-delete; edges
// may already be gone, e.g. a channel dropped over the fan-out cap),
// or its new content still feeds a pass — the unconditional mark cost
// a full resynthesis on every save. Removed files get the same check.
if (process.env.CODEGRAPH_SYNC_RESYNTHESIS !== '0' &&
(touchesSynthesis(filePath) ||
this.queries.wasSynthesisInput(filePath) ||
(content !== undefined && resolution().mayFeedSynthesis(filePath, content)))) {
this.synthesisDirty.add(filePath);
}
});

// Fold the store phase's WAL BEFORE the post-store reads below
Expand Down Expand Up @@ -1458,8 +1475,24 @@ export class CodeGraph {
const pureRemoval = !filesChanged && result.filesRemoved > 0;
const resynthesis = (scopedChange || pureRemoval) && process.env.CODEGRAPH_SYNC_RESYNTHESIS !== '0';
if (resynthesis) {
for (const p of result.changedFilePaths ?? []) this.synthesisDirty.add(p);
for (const p of result.removedFilePaths ?? []) this.synthesisDirty.add(p);
// A removed file's endpoint nodes are gone by now, but registrations
// it held still name it in edge metadata until reconcile — that is
// #138/#197's case exactly, and hasSynthesizedEdgesTouchingFile's
// registeredAt leg still answers it.
for (const p of result.removedFilePaths ?? []) {
if (touchesSynthesis(p)) this.synthesisDirty.add(p);
}
// A component a tag in an unchanged file may render: `<Team />` links
// to the `Team` this sync adds. A component with no markup of its own
// (`return null`, a wrapper) matches none of the input patterns; the
// definition delta gates it instead. Removing one needs no check: a
// component a tag rendered had synthesized edges into its file.
if (result.definitionDelta && result.changedFilePaths) {
const delta = new Set(result.definitionDelta);
if (this.queries.getNodesByFiles(result.changedFilePaths).some((n) => delta.has(n.name) && resolution().isJsxChildCandidate(n))) {
for (const p of result.changedFilePaths) this.synthesisDirty.add(p);
}
}
}

// Refs the scoped path deferred to the passes below are still pending
Expand Down Expand Up @@ -1504,6 +1537,7 @@ export class CodeGraph {
result.pendingRefsUnresolved = recovery.stats.unresolved;
if (this.resolver.synthesisRuns > synthesisRuns) {
this.queries.reconcileSynthesizedEdges(this.resolver.lastSynthesizedEdges);
this.resolver.lastSynthesizedEdges = [];
this.synthesisDirty.clear();
} else if (this.synthesisDirty.size > 0) {
// Refresh attempted and failed: drop the stale synthesized edges,
Expand Down Expand Up @@ -1904,6 +1938,11 @@ export class CodeGraph {
const edges = await this.resolver.resynthesize((current, total) =>
onProgress?.({ phase: 'linking', current, total }));
const { deleted, updated } = this.queries.reconcileSynthesizedEdges(this.resolver.lastSynthesizedEdges);
// The emitted set and the warm caches a daemon's pass just filled are
// dead weight until the next rebuild — drop them rather than let the
// daemon's heap hold a whole-graph working set at idle.
this.resolver.lastSynthesizedEdges = [];
this.resolver.clearCaches();
this.lastSynthesisMs = Date.now() - t;
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] sync-resynthesis: ${Date.now() - t}ms (${files.length} files, ${deleted} pruned, ${updated} updated, ${edges} synthesized)`);
} catch (error) {
Expand Down
18 changes: 17 additions & 1 deletion src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { referenceNameTail, MEMBER_TAIL_SQL, MODULE_PARKED_SQL } from './referen
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 22;
export const CURRENT_SCHEMA_VERSION = 23;

/**
* Migration definition
Expand Down Expand Up @@ -432,6 +432,22 @@ CREATE TABLE synth_skips (
`);
},
},
{
version: 23,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
description:
'Add synthesis_inputs — files whose content fed the last synthesis run, so a later sync can tell an input was removed',
up: (db) => {
// DDL only. No backfill: the rows come from file CONTENT the migration
// cannot see, so the table stays empty until the next synthesis run and
// the gate falls back on the edge-touching check until then. Keep in
// lockstep with schema.sql.
db.exec(`
CREATE TABLE IF NOT EXISTS synthesis_inputs (
file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE
);
`);
},
},
];

/**
Expand Down
50 changes: 50 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5733,6 +5733,56 @@ export class QueryBuilder {
return Number(r.changes ?? 0);
}

/** Must run before file replacement/deletion cascades the endpoint edges. */
hasSynthesizedEdgesTouchingFile(filePath: string): boolean {
const owned = "CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.synthesizedBy') END IS NOT NULL";
for (const endpoint of ['source', 'target']) {
if (this.db.prepare(`SELECT 1 FROM nodes n JOIN edges e ON e.${endpoint} = n.id
WHERE n.file_path = ? AND ${owned} LIMIT 1`).get(filePath)) return true;
}
// Wiring often lives in a third file, with neither endpoint in it.
return !!this.db.prepare(`SELECT 1 FROM edges e WHERE ${owned}
AND CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.registeredAt') END >= ?
AND CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.registeredAt') END < ? LIMIT 1`
).get(`${filePath}:`, `${filePath};`);
}

/**
* Every file carrying or registering synthesized edges — the bulk form of
* hasSynthesizedEdgesTouchingFile for a sync that checks many files: one
* edges scan instead of one per file.
*/
filesTouchingSynthesizedEdges(): Set<string> {
const owned = "CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.synthesizedBy') END IS NOT NULL";
const registeredAt = "CASE WHEN json_valid(e.metadata) THEN json_extract(e.metadata, '$.registeredAt') END";
const rows = this.db.prepare(
`SELECT DISTINCT n.file_path AS f FROM nodes n JOIN edges e ON e.source = n.id OR e.target = n.id WHERE ${owned}
UNION SELECT DISTINCT rtrim(rtrim(rtrim(rtrim(${registeredAt}, '0123456789'), ':'), '0123456789'), ':') AS f
FROM edges e WHERE ${owned} AND ${registeredAt} IS NOT NULL`
).all() as { f: string }[];
return new Set(rows.map((r) => r.f));
}

/**
* Whether this file's content fed the last completed synthesis run. Asked
* BEFORE the file row deletes: the input row cascades away with it.
*/
wasSynthesisInput(filePath: string): boolean {
return !!this.db.prepare('SELECT 1 FROM synthesis_inputs WHERE file_path = ?').get(filePath);
}

/** Replace the synthesis-input set with the files a completed run read. */
replaceSynthesisInputs(files: readonly string[]): void {
const tx = this.db.transaction((list: readonly string[]) => {
this.db.exec('DELETE FROM synthesis_inputs');
const insert = this.db.prepare(
'INSERT OR IGNORE INTO synthesis_inputs(file_path) SELECT path FROM files WHERE path = ?'
);
for (const f of list) insert.run(f);
});
tx(files);
}

/**
* Reconcile stored synthesized edges with the set a refresh just emitted,
* keyed on the idx_edges_identity columns. Kept edges stay put — unlike
Expand Down
6 changes: 6 additions & 0 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,9 @@ CREATE TABLE IF NOT EXISTS synth_skips (
content_hash TEXT NOT NULL,
passes TEXT NOT NULL -- space-separated pass names
) WITHOUT ROWID;

-- Files whose last-seen content fed a synthesis pass, so a later sync can tell
-- an input was removed or dropped its pattern after its edges are already gone.
CREATE TABLE IF NOT EXISTS synthesis_inputs (
file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE
);
Loading
Loading