Skip to content

Commit bc1790e

Browse files
committed
fix(resolution): bound name lookup memory during resolving
1 parent 0bea9ab commit bc1790e

12 files changed

Lines changed: 684 additions & 117 deletions

File tree

__tests__/foundation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ describe('Database Connection', () => {
282282

283283
const version = db.getSchemaVersion();
284284
expect(version).not.toBeNull();
285-
expect(version?.version).toBe(5);
285+
expect(version?.version).toBe(6);
286286

287287
db.close();
288288
});

__tests__/pr19-improvements.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => {
299299
describe('Schema v2 Migration', () => {
300300
it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => {
301301
const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations');
302-
expect(CURRENT_SCHEMA_VERSION).toBe(5);
302+
expect(CURRENT_SCHEMA_VERSION).toBe(6);
303303
});
304304

305305
it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => {

__tests__/resolution.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,79 @@ func main() {
13071307
const result = matchReference(ref, baseContext([variable, decorator]));
13081308
expect(result?.targetNodeId).toBe('func:di.ts:Inject:10');
13091309
});
1310+
1311+
it('uses filtered name lookup for exact call refs instead of materializing all same-name nodes', () => {
1312+
const target: Node = {
1313+
id: 'func:src/app.ts:main:10', kind: 'function', name: 'main',
1314+
qualifiedName: 'src/app.ts::main', filePath: 'src/app.ts', language: 'typescript',
1315+
startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
1316+
};
1317+
const ref = {
1318+
fromNodeId: 'func:src/app.ts:bootstrap:1',
1319+
referenceName: 'main',
1320+
referenceKind: 'calls' as const,
1321+
line: 5, column: 0, filePath: 'src/app.ts', language: 'typescript' as const,
1322+
};
1323+
const context: ResolutionContext = {
1324+
getNodesInFile: () => [],
1325+
getNodesByName: () => { throw new Error('full name lookup should not be used'); },
1326+
getNodesByNameFiltered: (name, filters = {}) => {
1327+
expect(name).toBe('main');
1328+
expect(filters.kinds).toContain('function');
1329+
return [target];
1330+
},
1331+
getNodesByQualifiedName: () => [],
1332+
getNodesByKind: () => [],
1333+
fileExists: () => true,
1334+
readFile: () => null,
1335+
getProjectRoot: () => '/test',
1336+
getAllFiles: () => [],
1337+
getNodesByLowerName: () => [],
1338+
getImportMappings: () => [],
1339+
};
1340+
1341+
const result = matchReference(ref, context);
1342+
expect(result?.targetNodeId).toBe(target.id);
1343+
});
1344+
1345+
it('uses filtered lookup for method-call fallback instead of loading every same-name method', () => {
1346+
const target: Node = {
1347+
id: 'method:src/service.ts:PermissionEngine::check:10', kind: 'method', name: 'check',
1348+
qualifiedName: 'src/service.ts::PermissionEngine::check', filePath: 'src/service.ts',
1349+
language: 'typescript', startLine: 10, endLine: 20, startColumn: 0, endColumn: 0,
1350+
updatedAt: Date.now(),
1351+
};
1352+
const ref = {
1353+
fromNodeId: 'func:src/app.ts:run:1',
1354+
referenceName: 'permissionEngine.check',
1355+
referenceKind: 'calls' as const,
1356+
line: 5, column: 0, filePath: 'src/app.ts', language: 'typescript' as const,
1357+
};
1358+
const context: ResolutionContext = {
1359+
getNodesInFile: () => [],
1360+
getNodesByName: () => { throw new Error('full name lookup should not be used'); },
1361+
getNodesByNameFiltered: (name, filters = {}) => {
1362+
if (name === 'permissionEngine' || name === 'PermissionEngine') return [];
1363+
if (filters.qualifiedNameSuffix) return [];
1364+
expect(name).toBe('check');
1365+
expect(filters.kinds).toEqual(['method']);
1366+
expect(filters.language).toBe('typescript');
1367+
return [target];
1368+
},
1369+
getNodesByQualifiedName: () => [],
1370+
getNodesByKind: () => [],
1371+
fileExists: () => true,
1372+
readFile: () => null,
1373+
getProjectRoot: () => '/test',
1374+
getAllFiles: () => [],
1375+
getNodesByLowerName: () => [],
1376+
getImportMappings: () => [],
1377+
};
1378+
1379+
const result = matchReference(ref, context);
1380+
expect(result?.targetNodeId).toBe(target.id);
1381+
expect(result?.resolvedBy).toBe('instance-method');
1382+
});
13101383
});
13111384

13121385
describe('tsconfig path aliases', () => {

src/db/migrations.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
99
/**
1010
* Current schema version
1111
*/
12-
export const CURRENT_SCHEMA_VERSION = 5;
12+
export const CURRENT_SCHEMA_VERSION = 6;
1313

1414
/**
1515
* Migration definition
@@ -75,6 +75,17 @@ const migrations: Migration[] = [
7575
`);
7676
},
7777
},
78+
{
79+
version: 6,
80+
description:
81+
'Add composite node lookup indexes for memory-bounded reference resolution',
82+
up: (db) => {
83+
db.exec(`
84+
CREATE INDEX IF NOT EXISTS idx_nodes_name_language_kind ON nodes(name, language, kind);
85+
CREATE INDEX IF NOT EXISTS idx_nodes_name_language_file ON nodes(name, language, file_path);
86+
`);
87+
},
88+
},
7889
];
7990

8091
/**

src/db/queries.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
NodeKind,
1414
EdgeKind,
1515
Language,
16+
NodeLookupFilters,
1617
GraphStats,
1718
SearchOptions,
1819
SearchResult,
@@ -49,6 +50,10 @@ function isLowValueFile(filePath: string): boolean {
4950

5051
const SQLITE_PARAM_CHUNK_SIZE = 500;
5152

53+
function escapeLikePattern(value: string): string {
54+
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
55+
}
56+
5257
/**
5358
* Database row types (snake_case from SQLite)
5459
*/
@@ -210,9 +215,11 @@ export class QueryBuilder {
210215
deleteUnresolvedByNode?: SqliteStatement;
211216
getUnresolvedByName?: SqliteStatement;
212217
getNodesByName?: SqliteStatement;
218+
getNodeNameCount?: SqliteStatement;
213219
hasNodeName?: SqliteStatement;
214220
getNodesByQualifiedNameExact?: SqliteStatement;
215221
getNodesByLowerName?: SqliteStatement;
222+
getLowerNodeNameCount?: SqliteStatement;
216223
getUnresolvedCount?: SqliteStatement;
217224
getUnresolvedBatch?: SqliteStatement;
218225
getAllFilePaths?: SqliteStatement;
@@ -763,6 +770,109 @@ export class QueryBuilder {
763770
return rows.map(rowToNode);
764771
}
765772

773+
/**
774+
* Count exact-name candidates without materializing them. Resolver-internal
775+
* guardrails use this before legacy unfiltered name lookups.
776+
*/
777+
getNodeNameCount(name: string): number {
778+
if (!this.stmts.getNodeNameCount) {
779+
this.stmts.getNodeNameCount = this.db.prepare('SELECT COUNT(*) AS count FROM nodes WHERE name = ?');
780+
}
781+
const row = this.stmts.getNodeNameCount.get(name) as { count: number };
782+
return row.count;
783+
}
784+
785+
/**
786+
* Get nodes by exact name with resolver-side filters applied in SQL.
787+
*
788+
* This is intentionally separate from public `getNodesByName()`, which must
789+
* keep returning the full set. Resolution often asks for common names like
790+
* `main`, `default`, or `clone`; on large repos those names can have tens of
791+
* thousands of rows, so filtering after `.all()` needlessly explodes the JS
792+
* heap. Push the obvious language/kind/file/qualified-name predicates into
793+
* SQLite and cap low-confidence global fallbacks.
794+
*/
795+
getNodesByNameFiltered(name: string, filters: NodeLookupFilters = {}): Node[] {
796+
const where: string[] = ['name = ?'];
797+
const params: unknown[] = [name];
798+
799+
const languages = filters.languages?.length
800+
? [...new Set(filters.languages)]
801+
: filters.language
802+
? [filters.language]
803+
: [];
804+
if (languages.length === 1) {
805+
where.push('language = ?');
806+
params.push(languages[0]);
807+
} else if (languages.length > 1) {
808+
where.push(`language IN (${languages.map(() => '?').join(',')})`);
809+
params.push(...languages);
810+
}
811+
812+
const kinds = filters.kinds?.length ? [...new Set(filters.kinds)] : [];
813+
if (kinds.length === 1) {
814+
where.push('kind = ?');
815+
params.push(kinds[0]);
816+
} else if (kinds.length > 1) {
817+
where.push(`kind IN (${kinds.map(() => '?').join(',')})`);
818+
params.push(...kinds);
819+
}
820+
821+
if (filters.filePath) {
822+
where.push('file_path = ?');
823+
params.push(filters.filePath);
824+
}
825+
if (filters.filePathPrefix) {
826+
where.push("file_path LIKE ? ESCAPE '\\'");
827+
params.push(`${escapeLikePattern(filters.filePathPrefix)}%`);
828+
}
829+
if (filters.filePathSuffix) {
830+
where.push("file_path LIKE ? ESCAPE '\\'");
831+
params.push(`%${escapeLikePattern(filters.filePathSuffix)}`);
832+
}
833+
if (filters.qualifiedName) {
834+
where.push('qualified_name = ?');
835+
params.push(filters.qualifiedName);
836+
}
837+
if (filters.qualifiedNameSuffix) {
838+
where.push("qualified_name LIKE ? ESCAPE '\\'");
839+
params.push(`%${escapeLikePattern(filters.qualifiedNameSuffix)}`);
840+
}
841+
if (filters.hasReturnType) {
842+
where.push("return_type IS NOT NULL AND return_type <> ''");
843+
}
844+
if (filters.excludeId) {
845+
where.push('id <> ?');
846+
params.push(filters.excludeId);
847+
}
848+
849+
const orderBy: string[] = [];
850+
if (filters.rankFilePath) {
851+
orderBy.push('CASE WHEN file_path = ? THEN 0 ELSE 1 END');
852+
params.push(filters.rankFilePath);
853+
}
854+
if (filters.rankFilePathPrefix) {
855+
orderBy.push("CASE WHEN file_path LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END");
856+
params.push(`${escapeLikePattern(filters.rankFilePathPrefix)}%`);
857+
}
858+
orderBy.push('file_path', 'start_line', 'id');
859+
860+
let sql = `
861+
SELECT * FROM nodes
862+
WHERE ${where.join(' AND ')}
863+
ORDER BY ${orderBy.join(', ')}
864+
`;
865+
866+
if (filters.limit !== undefined) {
867+
const limit = Math.max(1, Math.floor(filters.limit));
868+
sql += ' LIMIT ?';
869+
params.push(limit);
870+
}
871+
872+
const rows = this.db.prepare(sql).all(...params) as NodeRow[];
873+
return rows.map(rowToNode);
874+
}
875+
766876
/**
767877
* True when at least one node has this exact name. Uses idx_nodes_name and
768878
* returns a single row instead of materializing the distinct symbol-name set.
@@ -820,6 +930,95 @@ export class QueryBuilder {
820930
return rows.map(rowToNode);
821931
}
822932

933+
/**
934+
* Count lowercase-name candidates without materializing them. Used as a
935+
* safety check before legacy unfiltered fuzzy lookups.
936+
*/
937+
getLowerNodeNameCount(lowerName: string): number {
938+
if (!this.stmts.getLowerNodeNameCount) {
939+
this.stmts.getLowerNodeNameCount = this.db.prepare(
940+
'SELECT COUNT(*) AS count FROM nodes WHERE lower(name) = ?'
941+
);
942+
}
943+
const row = this.stmts.getLowerNodeNameCount.get(lowerName) as { count: number };
944+
return row.count;
945+
}
946+
947+
/**
948+
* Lowercase-name lookup with the same SQL-side filters as
949+
* getNodesByNameFiltered(). Used by low-confidence fuzzy resolution, where a
950+
* high-fanout lowercase match should never materialize the whole candidate
951+
* set.
952+
*/
953+
getNodesByLowerNameFiltered(lowerName: string, filters: NodeLookupFilters = {}): Node[] {
954+
const where: string[] = ['lower(name) = ?'];
955+
const params: unknown[] = [lowerName];
956+
957+
const languages = filters.languages?.length
958+
? [...new Set(filters.languages)]
959+
: filters.language
960+
? [filters.language]
961+
: [];
962+
if (languages.length === 1) {
963+
where.push('language = ?');
964+
params.push(languages[0]);
965+
} else if (languages.length > 1) {
966+
where.push(`language IN (${languages.map(() => '?').join(',')})`);
967+
params.push(...languages);
968+
}
969+
970+
const kinds = filters.kinds?.length ? [...new Set(filters.kinds)] : [];
971+
if (kinds.length === 1) {
972+
where.push('kind = ?');
973+
params.push(kinds[0]);
974+
} else if (kinds.length > 1) {
975+
where.push(`kind IN (${kinds.map(() => '?').join(',')})`);
976+
params.push(...kinds);
977+
}
978+
979+
if (filters.filePath) {
980+
where.push('file_path = ?');
981+
params.push(filters.filePath);
982+
}
983+
if (filters.filePathPrefix) {
984+
where.push("file_path LIKE ? ESCAPE '\\'");
985+
params.push(`${escapeLikePattern(filters.filePathPrefix)}%`);
986+
}
987+
if (filters.filePathSuffix) {
988+
where.push("file_path LIKE ? ESCAPE '\\'");
989+
params.push(`%${escapeLikePattern(filters.filePathSuffix)}`);
990+
}
991+
if (filters.excludeId) {
992+
where.push('id <> ?');
993+
params.push(filters.excludeId);
994+
}
995+
996+
const orderBy: string[] = [];
997+
if (filters.rankFilePath) {
998+
orderBy.push('CASE WHEN file_path = ? THEN 0 ELSE 1 END');
999+
params.push(filters.rankFilePath);
1000+
}
1001+
if (filters.rankFilePathPrefix) {
1002+
orderBy.push("CASE WHEN file_path LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END");
1003+
params.push(`${escapeLikePattern(filters.rankFilePathPrefix)}%`);
1004+
}
1005+
orderBy.push('file_path', 'start_line', 'id');
1006+
1007+
let sql = `
1008+
SELECT * FROM nodes
1009+
WHERE ${where.join(' AND ')}
1010+
ORDER BY ${orderBy.join(', ')}
1011+
`;
1012+
if (filters.limit !== undefined) {
1013+
const limit = Math.max(1, Math.floor(filters.limit));
1014+
sql += ' LIMIT ?';
1015+
params.push(limit);
1016+
}
1017+
1018+
const rows = this.db.prepare(sql).all(...params) as NodeRow[];
1019+
return rows.map(rowToNode);
1020+
}
1021+
8231022
/**
8241023
* Search nodes by name using FTS with fallback to LIKE for better matching
8251024
*

src/db/schema.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
9191
CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name);
9292
CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path);
9393
CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language);
94+
CREATE INDEX IF NOT EXISTS idx_nodes_name_language_kind ON nodes(name, language, kind);
95+
CREATE INDEX IF NOT EXISTS idx_nodes_name_language_file ON nodes(name, language, file_path);
9496
CREATE INDEX IF NOT EXISTS idx_nodes_file_line ON nodes(file_path, start_line);
9597
CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name));
9698

src/resolution/frameworks/react.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from
99

1010
export const reactResolver: FrameworkResolver = {
1111
name: 'react',
12-
languages: ['javascript', 'typescript'],
12+
languages: ['javascript', 'typescript', 'jsx', 'tsx'],
1313

1414
detect(context: ResolutionContext): boolean {
1515
// Check for React in package.json

0 commit comments

Comments
 (0)