Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6934372
perf(resolution): read JS/TS parameter lists without exponential back…
colbymchenry Sep 28, 2026
af510af
perf(resolution): memoize Python module-file lookups per resolution pass
colbymchenry Sep 28, 2026
c31e022
perf(resolution): test sealed-module candidates cheapest-first
colbymchenry Sep 28, 2026
2e2c618
perf(resolution): scan a class for a field's declared type once per f…
colbymchenry Sep 28, 2026
1d0bd69
perf(synthesis): start cross-tier receiver chains only at a word's start
colbymchenry Sep 28, 2026
46b8f4f
perf(resolution): filter same-named candidates in one pass, cheap che…
colbymchenry Sep 28, 2026
09330b8
Merge remote-tracking branch 'origin/main' into perf/resolve-regressi…
colbymchenry Sep 28, 2026
5341d12
perf(synthesis): scope language-specific node scans in SQL
colbymchenry Sep 28, 2026
042a9e8
perf(synthesis): run the synthesis-site index and source-gate scan be…
colbymchenry Sep 28, 2026
0e6076c
perf(resolution): stop re-reading resolved targets and re-probing mis…
colbymchenry Sep 28, 2026
1e5b217
perf(resolution): cheaper sealed-module and receiver-less call checks
colbymchenry Sep 28, 2026
7e1f146
perf(synthesis): pull passes onto workers heaviest-first; cheaper reg…
colbymchenry Sep 28, 2026
3c9909d
perf(resolution): lean export indexes; no per-name regex compiles in …
colbymchenry Sep 28, 2026
2c2f3c6
perf(resolution): prepare kind-filtered edge reads once; memoize the …
colbymchenry Sep 28, 2026
ae5630d
perf(resolution): fan the next batch out before inserting edges resol…
colbymchenry Sep 28, 2026
3ee2dad
perf(db): prepare the edge-endpoint existence check once
colbymchenry Sep 28, 2026
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
39 changes: 39 additions & 0 deletions __tests__/bare-call-no-method.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,45 @@ describe('a receiver-less JS/TS call never binds to a method (#1714)', () => {
expect(names).toContain('test');
});

it('reads typed, defaulted parameter lists in linear time', async () => {
// A helper with many `name: T = value` parameters made the parameter scan
// backtrack through every way to split each type from its default — tens
// of seconds per called name on vscode's markersModel.test.ts, which
// stalled a whole resolution batch.
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
fs.writeFileSync(
path.join(tempDir, 'harness.ts'),
'export function suite(name: string, fn: () => void) { fn(); }\nexport function resolve(p: string) { return p; }\n'
);
const params = Array.from({ length: 14 }, (_, i) => `p${i}: number = ${i} + ${i}`).join(', ');
fs.writeFileSync(
path.join(tempDir, 'markers.test.ts'),
[
`function aMarker(${params}, related?: string[]): number {`,
' return p0;',
'}',
'function settle(value: number = 1, resolve: Handler = noop) {',
' resolve(value);',
'}',
'suite("markers", () => {',
' aMarker(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14);',
' settle();',
'});',
'',
].join('\n')
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const harness = cg.getNodesByKind('function').filter((n) => n.filePath === 'harness.ts');
const suiteFn = harness.find((n) => n.name === 'suite')!;
const resolveFn = harness.find((n) => n.name === 'resolve')!;
const settle = cg.getNodesByKind('function').find((n) => n.name === 'settle')!;
// `suite` is bound nowhere in the test file, so it still resolves across files.
expect(cg.getIncomingEdges(suiteFn.id).some((e) => e.kind === 'calls')).toBe(true);
// `resolve` is a defaulted parameter of `settle` — a local binding.
expect(cg.getOutgoingEdges(settle.id).filter((e) => e.kind === 'calls').map((e) => e.target)).not.toContain(resolveFn.id);
}, 30_000);

it('keeps `other.serialize()` — a call through a receiver', async () => {
const callees = await callsFromMethod(
[
Expand Down
19 changes: 18 additions & 1 deletion src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,17 +394,34 @@ export class DatabaseConnection {
* One yield per statement keeps every stall to a single index build, which
* stays inside the window.
*/
async endBulkEdgeLoad(): Promise<void> {
async endBulkEdgeLoad(options: { deferSynthesisSite?: boolean } = {}): Promise<void> {
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
for (const idx of DatabaseConnection.BULK_EDGE_INDEX_NAMES) {
if (options.deferSynthesisSite && idx === DatabaseConnection.SYNTHESIS_SITE_INDEX) continue;
const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`));
if (!m) throw new Error(`schema.sql: edge index ${idx} not found for bulk-load recreation`);
this.db.exec(m[0]);
await new Promise((resolve) => setImmediate(resolve));
}
}

/**
* The sync-only synthesis-site index (#1988), which endBulkEdgeLoad can
* leave for later. Its partial predicate runs json_valid over the metadata of
* every edge — ~1.5M rows, several seconds on vscode — while nothing before
* the end of an index reads it, so the resolver builds it while the pool is
* busy with synthesis rather than on the critical path. Idempotent.
*/
createSynthesisSiteIndex(): void {
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8');
const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${DatabaseConnection.SYNTHESIS_SITE_INDEX}\\b[^;]*;`));
if (!m) throw new Error(`schema.sql: edge index ${DatabaseConnection.SYNTHESIS_SITE_INDEX} not found`);
this.db.exec(m[0]);
}

private static readonly SYNTHESIS_SITE_INDEX = 'idx_edges_synthesis_site';

/** Recreate the FTS triggers + rebuild if a bulk-load window never closed. */
private healBulkNodeLoad(): void {
if (!this.fts5Available) return;
Expand Down
89 changes: 83 additions & 6 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,10 @@ export class QueryBuilder {
getUnresolvedByName?: SqliteStatement;
getNodesByName?: SqliteStatement;
getNodesByNamePrefix?: SqliteStatement;
fileHasExportedNode?: SqliteStatement;
existingNodeIdsFull?: SqliteStatement;
getExportedNodesByFile?: SqliteStatement;
getNodesByFileAndName?: SqliteStatement;
getFileNodesByNamePrefix?: SqliteStatement;
getNodesByQualifiedNameExact?: SqliteStatement;
getNodesByLowerName?: SqliteStatement;
Expand Down Expand Up @@ -310,6 +314,11 @@ export class QueryBuilder {
// (and therefore resolution's insertion-order disambiguation) is identical
// to the one-row-per-run path.
private batchStmts: Map<string, SqliteStatement> = new Map();
// Kind-filtered edge reads build their SQL per call (a variable IN list),
// but from a handful of kind sets: prepare each shape once. Supertype walks
// and the member-lookup passes issue them per node, and preparing cost
// about a third of the read.
private edgeKindStmts: Map<string, SqliteStatement> = new Map();
private static readonly BATCH_SIZES: readonly number[] = [128, 32, 8, 1];

/**
Expand Down Expand Up @@ -369,6 +378,17 @@ export class QueryBuilder {
this.db = db;
this.stmts = {};
this.batchStmts.clear();
this.edgeKindStmts.clear();
}

private edgeKindStmt(sql: string): SqliteStatement {
let stmt = this.edgeKindStmts.get(sql);
if (!stmt) {
if (this.edgeKindStmts.size >= 64) this.edgeKindStmts.delete(this.edgeKindStmts.keys().next().value!);
stmt = this.db.prepare(sql);
this.edgeKindStmts.set(sql, stmt);
}
return stmt;
}

/** Set the normalized project-name tokens used to down-weight non-discriminative
Expand Down Expand Up @@ -914,10 +934,17 @@ export class QueryBuilder {
const uniqueIds = [...new Set(ids)];
for (let i = 0; i < uniqueIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = uniqueIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`)
.all(...chunk) as { id: string }[];
// Every edge insert checks its endpoints here, a chunk at a time: the
// full-size statement is prepared once, the final partial chunk ad hoc.
let stmt: SqliteStatement;
if (chunk.length === SQLITE_PARAM_CHUNK_SIZE) {
stmt = this.stmts.existingNodeIdsFull ??= this.db.prepare(
`SELECT id FROM nodes WHERE id IN (${new Array(SQLITE_PARAM_CHUNK_SIZE).fill('?').join(',')})`
);
} else {
stmt = this.db.prepare(`SELECT id FROM nodes WHERE id IN (${chunk.map(() => '?').join(',')})`);
}
const rows = stmt.all(...chunk) as { id: string }[];
for (const row of rows) {
out.add(row.id);
}
Expand Down Expand Up @@ -947,6 +974,39 @@ export class QueryBuilder {
this.nodeCache.clear();
}

/**
* Whether any node in `filePath` is exported — `getNodesByFile(f).some((n) =>
* n.isExported)` as one indexed probe, without decoding the file's nodes.
*/
fileHasExportedNode(filePath: string): boolean {
if (!this.stmts.fileHasExportedNode) {
this.stmts.fileHasExportedNode = this.db.prepare(
'SELECT 1 FROM nodes WHERE file_path = ? AND is_exported = 1 LIMIT 1'
);
}
return this.stmts.fileHasExportedNode.get(filePath) !== undefined;
}

/** The exported nodes of a file, in {@link getNodesByFile} order, decoding only those rows. */
getExportedNodesByFile(filePath: string): Node[] {
if (!this.stmts.getExportedNodesByFile) {
this.stmts.getExportedNodesByFile = this.db.prepare(
'SELECT * FROM nodes WHERE file_path = ? AND is_exported = 1 ORDER BY start_line, id'
);
}
return (this.stmts.getExportedNodesByFile.all(filePath) as NodeRow[]).map(rowToNode);
}

/** The nodes of a file with one name, in {@link getNodesByFile} order, decoding only those rows. */
getNodesByFileAndName(filePath: string, name: string): Node[] {
if (!this.stmts.getNodesByFileAndName) {
this.stmts.getNodesByFileAndName = this.db.prepare(
'SELECT * FROM nodes WHERE file_path = ? AND name = ? ORDER BY start_line, id'
);
}
return (this.stmts.getNodesByFileAndName.all(filePath, name) as NodeRow[]).map(rowToNode);
}

/**
* Get all nodes in a file
*/
Expand Down Expand Up @@ -1180,6 +1240,23 @@ export class QueryBuilder {
}
}

/**
* iterateNodesByKind narrowed to some languages, in the same canonical
* order — the ORDER BY is total (`id` is unique), so this yields exactly the
* nodes a caller filtering iterateNodesByKind by language would keep, in the
* same sequence. A Go pass on a TypeScript monorepo otherwise materialized
* every method in the project to find a couple of Go ones.
*/
*iterateNodesByKindIn(kind: NodeKind, languages: readonly string[]): IterableIterator<Node> {
if (languages.length === 0) return;
const stmt = this.db.prepare(
`SELECT * FROM nodes WHERE kind = ? AND language IN (${languages.map(() => '?').join(', ')}) ORDER BY file_path, start_line, id`
);
for (const row of stmt.iterate(kind, ...languages)) {
yield rowToNode(row as NodeRow);
}
}

/**
* Get all nodes in the database
*/
Expand Down Expand Up @@ -1992,7 +2069,7 @@ export class QueryBuilder {
}

sql += ' ORDER BY kind, target, line, col';
const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[];
return rows.map(rowToEdge);
}

Expand All @@ -2012,7 +2089,7 @@ export class QueryBuilder {
getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
if (kinds && kinds.length > 0) {
const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY kind, source, line, col`;
const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
const rows = this.edgeKindStmt(sql).all(targetId, ...kinds) as EdgeRow[];
return rows.map(rowToEdge);
}

Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1368,7 +1368,8 @@ export class CodeGraph {
// just degrade to scans until the recreate.
bulkEdgeLoad: {
begin: () => this.db.beginBulkEdgeLoad(),
end: () => this.db.endBulkEdgeLoad(),
end: () => this.db.endBulkEdgeLoad({ deferSynthesisSite: true }),
deferred: () => this.db.createSynthesisSiteIndex(),
},
refIndexLoad: {
begin: () => this.db.beginBulkRefLoad(),
Expand Down
Loading