diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 05fa79804..6ee093f3f 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -282,7 +282,7 @@ describe('Database Connection', () => { const version = db.getSchemaVersion(); expect(version).not.toBeNull(); - expect(version?.version).toBe(5); + expect(version?.version).toBe(6); db.close(); }); diff --git a/__tests__/index-command.test.ts b/__tests__/index-command.test.ts index 9e0ce7953..5b0e2584c 100644 --- a/__tests__/index-command.test.ts +++ b/__tests__/index-command.test.ts @@ -19,6 +19,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; +import { getCodeGraphDir } from '../src/directory'; +import { FileLock } from '../src/utils'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); @@ -104,4 +106,101 @@ describe('codegraph index — full re-index keeps the graph populated (#874)', ( expect(afterIndex.nodes).toBe(afterInit.nodes); expect(afterIndex.edges).toBe(afterInit.edges); }); + + it('does not reset the DB when the write lock is unavailable', async () => { + runCodegraph(['init'], tempDir); + const before = graphCounts(tempDir); + + const lock = new FileLock(path.join(getCodeGraphDir(tempDir), 'codegraph.lock')); + lock.acquire(); + try { + const cg = await CodeGraph.open(tempDir); + try { + const result = await cg.reindexAll(); + expect(result.success).toBe(false); + expect(result.errors[0]?.message).toMatch(/Could not acquire file lock/); + } finally { + cg.close(); + } + } finally { + lock.release(); + } + + const after = graphCounts(tempDir); + expect(after.nodes).toBe(before.nodes); + expect(after.edges).toBe(before.edges); + }); + + it('resumes a parsed-but-unresolved full index instead of parsing everything again', async () => { + const cg = CodeGraph.initSync(tempDir); + const q = (cg as unknown as { queries: any }).queries; + const now = Date.now(); + + q.upsertFile({ + path: 'a.ts', + contentHash: 'parsed-before-crash', + language: 'typescript', + size: 1, + modifiedAt: now, + indexedAt: now, + nodeCount: 2, + }); + q.insertNodes([ + { + id: 'a.ts::caller', + kind: 'function', + name: 'caller', + qualifiedName: 'caller', + filePath: 'a.ts', + language: 'typescript', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: now, + }, + { + id: 'a.ts::target', + kind: 'function', + name: 'target', + qualifiedName: 'target', + filePath: 'a.ts', + language: 'typescript', + startLine: 2, + endLine: 2, + startColumn: 0, + endColumn: 0, + updatedAt: now, + }, + ]); + q.insertUnresolvedRefsBatch([ + { + fromNodeId: 'a.ts::caller', + referenceName: 'target', + referenceKind: 'calls', + line: 1, + column: 0, + filePath: 'a.ts', + language: 'typescript', + }, + ]); + + const orchestrator = (cg as unknown as { orchestrator: { indexAll: () => Promise } }).orchestrator; + orchestrator.indexAll = async () => { + throw new Error('resume path should not parse'); + }; + + try { + const result = await cg.reindexAll(); + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(1); + expect(result.nodesCreated).toBe(2); + expect(result.edgesCreated).toBeGreaterThan(0); + expect(q.getUnresolvedReferencesCount()).toBe(0); + expect(q.getMetadata('indexed_with_version')).not.toBeNull(); + expect(q.getMetadata('index_phase')).toBeNull(); + } finally { + cg.close(); + } + }); }); diff --git a/__tests__/iterate-nodes-by-kind.test.ts b/__tests__/iterate-nodes-by-kind.test.ts index 19ee05f67..fd72daee9 100644 --- a/__tests__/iterate-nodes-by-kind.test.ts +++ b/__tests__/iterate-nodes-by-kind.test.ts @@ -59,4 +59,18 @@ describe('iterateNodesByKind (#610 streaming)', () => { } expect(seen).toBe(q.getNodesByKind('function').length); }); + + it('does not materialize every distinct node name before resolving', () => { + const q = (cg as unknown as { queries: any }).queries; + const original = q.getAllNodeNames.bind(q); + q.getAllNodeNames = () => { + throw new Error('resolver should use indexed name-exists lookups'); + }; + + try { + expect(() => cg.resolveReferences()).not.toThrow(); + } finally { + q.getAllNodeNames = original; + } + }); }); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index 8e8ca8177..286dc2b78 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => { describe('Schema v2 Migration', () => { it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); - expect(CURRENT_SCHEMA_VERSION).toBe(5); + expect(CURRENT_SCHEMA_VERSION).toBe(6); }); it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { diff --git a/__tests__/react-native-bridge.test.ts b/__tests__/react-native-bridge.test.ts index dec3ce5cb..b27394b4d 100644 --- a/__tests__/react-native-bridge.test.ts +++ b/__tests__/react-native-bridge.test.ts @@ -27,6 +27,9 @@ function makeContext(nodes: Node[], fileContents: Record = {}): getNodesByName: (name) => byName.get(name) ?? [], getNodesByQualifiedName: () => { throw new Error('not used'); }, getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind), + iterateNodesByKind: function* (kind) { + yield* nodes.filter((n) => n.kind === kind); + }, getNodesByLowerName: () => { throw new Error('not used'); }, fileExists: (fp) => allFiles.has(fp), readFile: (fp) => fileContents[fp] ?? null, @@ -121,6 +124,27 @@ describe('React Native bridge resolver', () => { expect(result?.resolvedBy).toBe('framework'); }); + it('streams method nodes when building the bridge map instead of materializing every method', () => { + const native = method('getCurrentPosition:', 'objc', 'RCTGeolocation.m'); + const ctx = { + ...makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'RCTGeolocation.m': + '@implementation RCTGeolocation\n' + + 'RCT_EXPORT_MODULE()\n' + + 'RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) {}\n' + + '@end', + }), + getNodesByKind: () => { throw new Error('full method scan should not be used'); }, + } satisfies ResolutionContext; + + const result = reactNativeBridgeResolver.resolve( + ref('getCurrentPosition', 'javascript', 'App.js'), + ctx + ); + expect(result?.targetNodeId).toBe(native.id); + }); + it('resolves via explicit module name in RCT_EXPORT_MODULE(name)', () => { const native = method('startScan:', 'objc', 'Bluetooth.m'); const ctx = makeContext([native], { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index caf41c7df..01737210c 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1307,6 +1307,79 @@ func main() { const result = matchReference(ref, baseContext([variable, decorator])); expect(result?.targetNodeId).toBe('func:di.ts:Inject:10'); }); + + it('uses filtered name lookup for exact call refs instead of materializing all same-name nodes', () => { + const target: Node = { + id: 'func:src/app.ts:main:10', kind: 'function', name: 'main', + qualifiedName: 'src/app.ts::main', filePath: 'src/app.ts', language: 'typescript', + startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const ref = { + fromNodeId: 'func:src/app.ts:bootstrap:1', + referenceName: 'main', + referenceKind: 'calls' as const, + line: 5, column: 0, filePath: 'src/app.ts', language: 'typescript' as const, + }; + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => { throw new Error('full name lookup should not be used'); }, + getNodesByNameFiltered: (name, filters = {}) => { + expect(name).toBe('main'); + expect(filters.kinds).toContain('function'); + return [target]; + }, + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + const result = matchReference(ref, context); + expect(result?.targetNodeId).toBe(target.id); + }); + + it('uses filtered lookup for method-call fallback instead of loading every same-name method', () => { + const target: Node = { + id: 'method:src/service.ts:PermissionEngine::check:10', kind: 'method', name: 'check', + qualifiedName: 'src/service.ts::PermissionEngine::check', filePath: 'src/service.ts', + language: 'typescript', startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, + updatedAt: Date.now(), + }; + const ref = { + fromNodeId: 'func:src/app.ts:run:1', + referenceName: 'permissionEngine.check', + referenceKind: 'calls' as const, + line: 5, column: 0, filePath: 'src/app.ts', language: 'typescript' as const, + }; + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => { throw new Error('full name lookup should not be used'); }, + getNodesByNameFiltered: (name, filters = {}) => { + if (name === 'permissionEngine' || name === 'PermissionEngine') return []; + if (filters.qualifiedNameSuffix) return []; + expect(name).toBe('check'); + expect(filters.kinds).toEqual(['method']); + expect(filters.language).toBe('typescript'); + return [target]; + }, + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + const result = matchReference(ref, context); + expect(result?.targetNodeId).toBe(target.id); + expect(result?.resolvedBy).toBe('instance-method'); + }); }); describe('tsconfig path aliases', () => { diff --git a/__tests__/swift-objc-bridge-resolver.test.ts b/__tests__/swift-objc-bridge-resolver.test.ts index 75c5d07ee..ec205eae6 100644 --- a/__tests__/swift-objc-bridge-resolver.test.ts +++ b/__tests__/swift-objc-bridge-resolver.test.ts @@ -21,6 +21,9 @@ function makeContext(nodes: Node[], fileContents: Record = {}): getNodesByName: (name) => byName.get(name) ?? [], getNodesByQualifiedName: () => { throw new Error('not used'); }, getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind), + iterateNodesByKind: function* (kind) { + yield* nodes.filter((n) => n.kind === kind); + }, getNodesByLowerName: () => { throw new Error('not used'); }, fileExists: (fp) => allFiles.has(fp), readFile: (fp) => fileContents[fp] ?? null, @@ -113,6 +116,20 @@ describe('swiftObjcBridgeResolver integration', () => { expect(result?.confidence).toBe(0.6); }); + it('streams ObjC method nodes when building the bridge map', () => { + const objcTarget = method('fetchEntryForKey:', 'objc', 'Cache.m'); + const ctx = { + ...makeContext([objcTarget]), + getNodesByKind: () => { throw new Error('full method scan should not be used'); }, + } satisfies ResolutionContext; + + const result = swiftObjcBridgeResolver.resolve( + ref('fetchEntry', 'swift', 'Caller.swift'), + ctx + ); + expect(result?.targetNodeId).toBe(objcTarget.id); + }); + it('does NOT bridge generic Cocoa names like "init" or "description"', () => { // Bridging Swift `init()` calls to arbitrary ObjC `init*:` methods is // noise — every NSObject subclass has them. The regular name-matcher diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index b0c2f4b48..98bee9fa4 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -628,10 +628,7 @@ program const cg = await CodeGraph.open(projectPath); if (options.quiet) { - // Quiet mode: no UI, just run. `index` is a full re-index, so clear the - // existing graph and rebuild from scratch (see the note below — #874). - cg.clear(); - const result = await cg.indexAll(); + const result = await cg.reindexAll(); if (!result.success) process.exit(1); cg.destroy(); return; @@ -640,24 +637,21 @@ program const clack = await importESM('@clack/prompts'); clack.intro('Indexing project'); - // `index` is a FULL re-index: clear the existing graph and rebuild it from - // scratch so the result is identical to a fresh `init`. Without the clear, - // indexAll() skips every unchanged file by its content hash and reports - // "0 nodes, 0 edges" against the already-populated graph — which reads as - // "index wiped my index" (#874). For fast incremental updates use `sync`. - cg.clear(); + // `index` is a FULL re-index: drop and recreate the DB from scratch so + // the result is identical to a fresh `init` (much faster than row-by-row + // DELETE on a large DB — see #874). For fast incremental updates use `sync`. let result: IndexResult; if (options.verbose) { - result = await cg.indexAll({ + result = await cg.reindexAll({ onProgress: createVerboseProgress(), verbose: true, }); } else { process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); - result = await cg.indexAll({ + result = await cg.reindexAll({ onProgress: progress.onProgress, }); await progress.stop(); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index bfea9024d..69f36dee2 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 5; +export const CURRENT_SCHEMA_VERSION = 6; /** * Migration definition @@ -75,6 +75,17 @@ const migrations: Migration[] = [ `); }, }, + { + version: 6, + description: + 'Add composite node lookup indexes for memory-bounded reference resolution', + up: (db) => { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_nodes_name_language_kind ON nodes(name, language, kind); + CREATE INDEX IF NOT EXISTS idx_nodes_name_language_file ON nodes(name, language, file_path); + `); + }, + }, ]; /** diff --git a/src/db/queries.ts b/src/db/queries.ts index adf239268..940ba63fd 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -13,6 +13,7 @@ import { NodeKind, EdgeKind, Language, + NodeLookupFilters, GraphStats, SearchOptions, SearchResult, @@ -49,6 +50,10 @@ function isLowValueFile(filePath: string): boolean { const SQLITE_PARAM_CHUNK_SIZE = 500; +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (ch) => `\\${ch}`); +} + /** * Database row types (snake_case from SQLite) */ @@ -210,8 +215,11 @@ export class QueryBuilder { deleteUnresolvedByNode?: SqliteStatement; getUnresolvedByName?: SqliteStatement; getNodesByName?: SqliteStatement; + getNodeNameCount?: SqliteStatement; + hasNodeName?: SqliteStatement; getNodesByQualifiedNameExact?: SqliteStatement; getNodesByLowerName?: SqliteStatement; + getLowerNodeNameCount?: SqliteStatement; getUnresolvedCount?: SqliteStatement; getUnresolvedBatch?: SqliteStatement; getAllFilePaths?: SqliteStatement; @@ -719,6 +727,30 @@ export class QueryBuilder { } } + /** + * Stream nodes of a specific kind AND language lazily. Like + * {@link iterateNodesByKind} but narrowed by language so callers that only + * care about one language (e.g. Go structs) don't materialize nodes from + * every other language. + */ + *iterateNodesByKindAndLanguage(kind: NodeKind, language: string): IterableIterator { + const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ? AND language = ?'); + for (const row of stmt.iterate(kind, language)) { + yield rowToNode(row as NodeRow); + } + } + + /** + * Get nodes filtered by kind + id prefix. Used by synthesizers + * that need a narrow subset (e.g. Expo methods with id starting + * 'expo-module:') without materializing all nodes of that kind. + */ + getNodesByKindAndIdPrefix(kind: NodeKind, idPrefix: string): Node[] { + const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ? AND id LIKE ?'); + const rows = stmt.all(kind, `${idPrefix}%`) as NodeRow[]; + return rows.map(rowToNode); + } + /** * Get all nodes in the database */ @@ -738,6 +770,140 @@ export class QueryBuilder { return rows.map(rowToNode); } + /** + * Count exact-name candidates without materializing them. Resolver-internal + * guardrails use this before legacy unfiltered name lookups. + */ + getNodeNameCount(name: string): number { + if (!this.stmts.getNodeNameCount) { + this.stmts.getNodeNameCount = this.db.prepare('SELECT COUNT(*) AS count FROM nodes WHERE name = ?'); + } + const row = this.stmts.getNodeNameCount.get(name) as { count: number }; + return row.count; + } + + /** + * Get nodes by exact name with resolver-side filters applied in SQL. + * + * This is intentionally separate from public `getNodesByName()`, which must + * keep returning the full set. Resolution often asks for common names like + * `main`, `default`, or `clone`; on large repos those names can have tens of + * thousands of rows, so filtering after `.all()` needlessly explodes the JS + * heap. Push the obvious language/kind/file/qualified-name predicates into + * SQLite and cap low-confidence global fallbacks. + */ + getNodesByNameFiltered(name: string, filters: NodeLookupFilters = {}): Node[] { + const where: string[] = ['name = ?']; + const params: unknown[] = [name]; + + const languages = filters.languages?.length + ? [...new Set(filters.languages)] + : filters.language + ? [filters.language] + : []; + if (languages.length === 1) { + where.push('language = ?'); + params.push(languages[0]); + } else if (languages.length > 1) { + where.push(`language IN (${languages.map(() => '?').join(',')})`); + params.push(...languages); + } + + const kinds = filters.kinds?.length ? [...new Set(filters.kinds)] : []; + if (kinds.length === 1) { + where.push('kind = ?'); + params.push(kinds[0]); + } else if (kinds.length > 1) { + where.push(`kind IN (${kinds.map(() => '?').join(',')})`); + params.push(...kinds); + } + + if (filters.filePath) { + where.push('file_path = ?'); + params.push(filters.filePath); + } + if (filters.filePathPrefix) { + where.push("file_path LIKE ? ESCAPE '\\'"); + params.push(`${escapeLikePattern(filters.filePathPrefix)}%`); + } + if (filters.filePathSuffix) { + where.push("file_path LIKE ? ESCAPE '\\'"); + params.push(`%${escapeLikePattern(filters.filePathSuffix)}`); + } + if (filters.qualifiedName) { + where.push('qualified_name = ?'); + params.push(filters.qualifiedName); + } + if (filters.qualifiedNameSuffix) { + where.push("qualified_name LIKE ? ESCAPE '\\'"); + params.push(`%${escapeLikePattern(filters.qualifiedNameSuffix)}`); + } + if (filters.hasReturnType) { + where.push("return_type IS NOT NULL AND return_type <> ''"); + } + if (filters.excludeId) { + where.push('id <> ?'); + params.push(filters.excludeId); + } + + const orderBy: string[] = []; + if (filters.rankFilePath) { + orderBy.push('CASE WHEN file_path = ? THEN 0 ELSE 1 END'); + params.push(filters.rankFilePath); + } + if (filters.rankFilePathPrefix) { + orderBy.push("CASE WHEN file_path LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END"); + params.push(`${escapeLikePattern(filters.rankFilePathPrefix)}%`); + } + orderBy.push('file_path', 'start_line', 'id'); + + let sql = ` + SELECT * FROM nodes + WHERE ${where.join(' AND ')} + ORDER BY ${orderBy.join(', ')} + `; + + if (filters.limit !== undefined) { + const limit = Math.max(1, Math.floor(filters.limit)); + sql += ' LIMIT ?'; + params.push(limit); + } + + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * True when at least one node has this exact name. Uses idx_nodes_name and + * returns a single row instead of materializing the distinct symbol-name set. + */ + hasNodeName(name: string): boolean { + if (!this.stmts.hasNodeName) { + this.stmts.hasNodeName = this.db.prepare('SELECT 1 FROM nodes WHERE name = ? LIMIT 1'); + } + return this.stmts.hasNodeName.get(name) !== undefined; + } + + /** + * Return the subset of names that exist as node names, using bounded IN-list + * chunks. This keeps resolver pre-filtering batch-oriented without ever + * materializing every distinct name from a large graph. + */ + getExistingNodeNames(names: Iterable): Set { + const unique = [...new Set([...names].filter(Boolean))]; + const existing = new Set(); + for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + if (chunk.length === 0) continue; + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT DISTINCT name FROM nodes WHERE name IN (${placeholders})`) + .all(...chunk) as Array<{ name: string }>; + for (const row of rows) existing.add(row.name); + } + return existing; + } + /** * Get nodes by exact qualified name match (uses idx_nodes_qualified_name index) */ @@ -764,6 +930,95 @@ export class QueryBuilder { return rows.map(rowToNode); } + /** + * Count lowercase-name candidates without materializing them. Used as a + * safety check before legacy unfiltered fuzzy lookups. + */ + getLowerNodeNameCount(lowerName: string): number { + if (!this.stmts.getLowerNodeNameCount) { + this.stmts.getLowerNodeNameCount = this.db.prepare( + 'SELECT COUNT(*) AS count FROM nodes WHERE lower(name) = ?' + ); + } + const row = this.stmts.getLowerNodeNameCount.get(lowerName) as { count: number }; + return row.count; + } + + /** + * Lowercase-name lookup with the same SQL-side filters as + * getNodesByNameFiltered(). Used by low-confidence fuzzy resolution, where a + * high-fanout lowercase match should never materialize the whole candidate + * set. + */ + getNodesByLowerNameFiltered(lowerName: string, filters: NodeLookupFilters = {}): Node[] { + const where: string[] = ['lower(name) = ?']; + const params: unknown[] = [lowerName]; + + const languages = filters.languages?.length + ? [...new Set(filters.languages)] + : filters.language + ? [filters.language] + : []; + if (languages.length === 1) { + where.push('language = ?'); + params.push(languages[0]); + } else if (languages.length > 1) { + where.push(`language IN (${languages.map(() => '?').join(',')})`); + params.push(...languages); + } + + const kinds = filters.kinds?.length ? [...new Set(filters.kinds)] : []; + if (kinds.length === 1) { + where.push('kind = ?'); + params.push(kinds[0]); + } else if (kinds.length > 1) { + where.push(`kind IN (${kinds.map(() => '?').join(',')})`); + params.push(...kinds); + } + + if (filters.filePath) { + where.push('file_path = ?'); + params.push(filters.filePath); + } + if (filters.filePathPrefix) { + where.push("file_path LIKE ? ESCAPE '\\'"); + params.push(`${escapeLikePattern(filters.filePathPrefix)}%`); + } + if (filters.filePathSuffix) { + where.push("file_path LIKE ? ESCAPE '\\'"); + params.push(`%${escapeLikePattern(filters.filePathSuffix)}`); + } + if (filters.excludeId) { + where.push('id <> ?'); + params.push(filters.excludeId); + } + + const orderBy: string[] = []; + if (filters.rankFilePath) { + orderBy.push('CASE WHEN file_path = ? THEN 0 ELSE 1 END'); + params.push(filters.rankFilePath); + } + if (filters.rankFilePathPrefix) { + orderBy.push("CASE WHEN file_path LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END"); + params.push(`${escapeLikePattern(filters.rankFilePathPrefix)}%`); + } + orderBy.push('file_path', 'start_line', 'id'); + + let sql = ` + SELECT * FROM nodes + WHERE ${where.join(' AND ')} + ORDER BY ${orderBy.join(', ')} + `; + if (filters.limit !== undefined) { + const limit = Math.max(1, Math.floor(filters.limit)); + sql += ' LIMIT ?'; + params.push(limit); + } + + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + return rows.map(rowToNode); + } + /** * Search nodes by name using FTS with fallback to LIKE for better matching * @@ -1307,6 +1562,17 @@ export class QueryBuilder { this.stmts.deleteEdgesBySource.run(sourceId); } + /** + * Delete synthesized/dynamic edges before resuming an interrupted resolution + * pass. These edges are recomputed from the persisted base graph, and the + * edges table intentionally has no uniqueness constraint, so recomputing + * without clearing would duplicate them. + */ + deleteEdgesByProvenance(provenance: Edge['provenance']): void { + if (!provenance) return; + this.db.prepare('DELETE FROM edges WHERE provenance = ?').run(provenance); + } + /** * Get outgoing edges from a node */ @@ -1743,6 +2009,21 @@ export class QueryBuilder { .get() as { nodes: number; edges: number }; } + /** + * Lightweight count snapshot for interrupted-index recovery. + */ + getIndexRecordCounts(): { files: number; nodes: number; edges: number; unresolvedRefs: number } { + return this.db + .prepare(` + SELECT + (SELECT COUNT(*) FROM files) AS files, + (SELECT COUNT(*) FROM nodes) AS nodes, + (SELECT COUNT(*) FROM edges) AS edges, + (SELECT COUNT(*) FROM unresolved_refs) AS unresolvedRefs + `) + .get() as { files: number; nodes: number; edges: number; unresolvedRefs: number }; + } + /** * Get graph statistics */ @@ -1812,6 +2093,13 @@ export class QueryBuilder { ).run(key, value, Date.now()); } + /** + * Delete a metadata key when a transient state marker no longer applies. + */ + deleteMetadata(key: string): void { + this.db.prepare('DELETE FROM project_metadata WHERE key = ?').run(key); + } + /** * Get all metadata as a key-value record */ diff --git a/src/db/schema.sql b/src/db/schema.sql index 292981c82..d4a68efca 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -91,6 +91,8 @@ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name); CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path); CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language); +CREATE INDEX IF NOT EXISTS idx_nodes_name_language_kind ON nodes(name, language, kind); +CREATE INDEX IF NOT EXISTS idx_nodes_name_language_file ON nodes(name, language, file_path); CREATE INDEX IF NOT EXISTS idx_nodes_file_line ON nodes(file_path, start_line); CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name)); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 643634d66..10cb11b07 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -867,6 +867,8 @@ export class ExtractionOrchestrator { getNodesByName: () => [], getNodesByQualifiedName: () => [], getNodesByKind: () => [], + iterateNodesByKind: () => [][Symbol.iterator]() as IterableIterator, + getNodesByKindAndIdPrefix: () => [], getNodesByLowerName: () => [], getImportMappings: () => [], getAllFiles: () => files, diff --git a/src/index.ts b/src/index.ts index 91ea9c074..d24cfe915 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ */ import * as path from 'path'; +import * as fs from 'fs'; import { Node, Edge, @@ -52,6 +53,10 @@ import { getCodeGraphDir } from './directory'; import { deriveProjectNameTokens } from './search/query-utils'; import { CodeGraphPackageVersion } from './mcp/version'; +const INDEX_PHASE_METADATA_KEY = 'index_phase'; +const INDEX_PHASE_PARSING = 'parsing'; +const INDEX_PHASE_RESOLVING = 'resolving'; + // Re-export types for consumers export * from './types'; // Storage building blocks for embedded/SDK consumers that drive the graph @@ -158,7 +163,7 @@ export class CodeGraph { // Down-weight the project name as a query term in search ranking — it names // the whole repo, not a symbol, so it has no discriminative value (#720). try { - this.queries.setProjectNameTokens(deriveProjectNameTokens(projectRoot)); + this.configureQueries(); } catch { // Best-effort: ranking still works without it. } @@ -338,87 +343,182 @@ export class CodeGraph { try { this.fileLock.acquire(); } catch { - return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 }; + return this.lockFailureIndexResult(); } try { - const before = this.queries.getNodeAndEdgeCount(); - const result = await this.orchestrator.indexAll(options.onProgress, options.signal, options.verbose); - - // Re-detect frameworks now that the index is populated. The resolver - // is constructed with createResolver() before any files exist, so - // framework resolvers whose detect() consults the indexed file list - // (e.g. UIKit/SwiftUI scanning for imports, swift-objc-bridge looking - // for both Swift and ObjC files) all return false on that initial pass - // and silently drop themselves. Re-initializing here gives them a - // chance to see the actual project before resolution runs. - if (result.success && result.filesIndexed > 0) { - this.resolver.initialize(); - // Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs - // before resolution so updated names show up in subsequent reads. - this.resolver.runPostExtract(); + return await this.indexAllUnlocked(options); + } finally { + this.fileLock.release(); + } + }); + } + + /** + * Full re-index from a fresh database. + * + * The reset and index run under the same cross-process lock so a competing + * writer cannot lose the existing DB before the rebuild starts. + */ + async reindexAll(options: IndexOptions = {}): Promise { + return this.indexMutex.withLock(async () => { + try { + this.fileLock.acquire(); + } catch { + return this.lockFailureIndexResult(); + } + try { + if (this.canResumeInterruptedIndex()) { + return await this.resumeInterruptedIndexUnlocked(options); } + this.resetDbUnlocked(); + return await this.indexAllUnlocked(options); + } finally { + this.fileLock.release(); + } + }); + } - // Resolve references to create call/import/extends edges - if (result.success && result.filesIndexed > 0) { - // Get count without loading all refs into memory - const unresolvedCount = this.queries.getUnresolvedReferencesCount(); + private async indexAllUnlocked(options: IndexOptions): Promise { + const before = this.queries.getNodeAndEdgeCount(); + try { + this.queries.setMetadata(INDEX_PHASE_METADATA_KEY, INDEX_PHASE_PARSING); + } catch { /* metadata is advisory — recovery just won't trigger */ } + const result = await this.orchestrator.indexAll(options.onProgress, options.signal, options.verbose); - options.onProgress?.({ - phase: 'resolving', - current: 0, - total: unresolvedCount, - }); + if (result.success && result.filesIndexed > 0) { + try { + this.queries.setMetadata(INDEX_PHASE_METADATA_KEY, INDEX_PHASE_RESOLVING); + } catch { /* metadata is advisory — recovery just won't trigger */ } + await this.resolveIndexedDbUnlocked(options, result, before, false); + } else { + try { + this.queries.deleteMetadata(INDEX_PHASE_METADATA_KEY); + } catch { /* metadata is advisory */ } + } - await this.resolveReferencesBatched((current, total) => { - options.onProgress?.({ - phase: 'resolving', - current, - total, - }); - }); + return result; + } + + private async resolveIndexedDbUnlocked( + options: IndexOptions, + result: IndexResult, + before: { nodes: number; edges: number }, + reportTotals: boolean, + ): Promise { + // Re-detect frameworks now that the index is populated. The resolver + // is constructed with createResolver() before any files exist, so + // framework resolvers whose detect() consults the indexed file list + // (e.g. UIKit/SwiftUI scanning for imports, swift-objc-bridge looking + // for both Swift and ObjC files) all return false on that initial pass + // and silently drop themselves. Re-initializing here gives them a + // chance to see the actual project before resolution runs. + this.resolver.initialize(); + // Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs + // before resolution so updated names show up in subsequent reads. + this.resolver.runPostExtract(); - // Second pass: chained calls whose method lives on a supertype the - // receiver conforms to (protocol-extension / inherited / default- - // interface). Needs the implements/extends edges the main pass just - // built, so it runs after resolution (#750). - this.resolver.resolveChainedCallsViaConformance(); - // Same lifecycle for `this.` callback registrations whose - // member is inherited from a supertype (#808). - this.resolver.resolveDeferredThisMemberRefs(); - } + // Resolve references to create call/import/extends edges. + const unresolvedCount = this.queries.getUnresolvedReferencesCount(); - // Refresh planner stats + checkpoint the WAL after bulk writes. - // Cheap and non-blocking; never load-bearing for correctness. - if (result.success && result.filesIndexed > 0) { - this.db.runMaintenance(); - } + options.onProgress?.({ + phase: 'resolving', + current: 0, + total: unresolvedCount, + }); - // The orchestrator only sees extraction-phase counts; resolution and - // synthesizer edges (often >50% of the graph on JVM repos) come later. - // Recompute against the DB so the CLI summary reports the true totals. - if (result.success && result.filesIndexed > 0) { - const after = this.queries.getNodeAndEdgeCount(); - result.nodesCreated = after.nodes - before.nodes; - result.edgesCreated = after.edges - before.edges; - } + await this.resolveReferencesBatched((current, total) => { + options.onProgress?.({ + phase: 'resolving', + current, + total, + }); + }); - // Stamp the index with the engine that built it, so `codegraph status` - // and `codegraph upgrade` can recommend a re-index when the running - // engine produces richer extraction than the one on disk. Only on a - // real full index — a sync touches a subset, so it must NOT advance the - // extraction stamp (the bulk would still be stale). See extraction-version.ts. - if (result.success && result.filesIndexed > 0) { - try { - this.queries.setMetadata('indexed_with_version', CodeGraphPackageVersion); - this.queries.setMetadata('indexed_with_extraction_version', String(EXTRACTION_VERSION)); - } catch { /* metadata is advisory — never fail an index over it */ } - } + // Second pass: chained calls whose method lives on a supertype the + // receiver conforms to (protocol-extension / inherited / default- + // interface). Needs the implements/extends edges the main pass just + // built, so it runs after resolution (#750). + this.resolver.resolveChainedCallsViaConformance(); + // Same lifecycle for `this.` callback registrations whose + // member is inherited from a supertype (#808). + this.resolver.resolveDeferredThisMemberRefs(); + + // Refresh planner stats + checkpoint the WAL after bulk writes. + // Cheap and non-blocking; never load-bearing for correctness. + this.db.runMaintenance(); + + // The orchestrator only sees extraction-phase counts; resolution and + // synthesizer edges (often >50% of the graph on JVM repos) come later. + // Recompute against the DB so the CLI summary reports the true totals. + const after = this.queries.getNodeAndEdgeCount(); + result.nodesCreated = reportTotals ? after.nodes : after.nodes - before.nodes; + result.edgesCreated = reportTotals ? after.edges : after.edges - before.edges; + + // Stamp the index with the engine that built it, so `codegraph status` + // and `codegraph upgrade` can recommend a re-index when the running + // engine produces richer extraction than the one on disk. Only on a + // real full index — a sync touches a subset, so it must NOT advance the + // extraction stamp (the bulk would still be stale). See extraction-version.ts. + try { + this.queries.setMetadata('indexed_with_version', CodeGraphPackageVersion); + this.queries.setMetadata('indexed_with_extraction_version', String(EXTRACTION_VERSION)); + this.queries.deleteMetadata(INDEX_PHASE_METADATA_KEY); + } catch { /* metadata is advisory — never fail an index over it */ } + } + + private canResumeInterruptedIndex(): boolean { + const counts = this.queries.getIndexRecordCounts(); + if (counts.files === 0 || counts.nodes === 0) return false; + + const phase = this.queries.getMetadata(INDEX_PHASE_METADATA_KEY); + if (phase === INDEX_PHASE_RESOLVING) return true; + if (phase === INDEX_PHASE_PARSING) return false; + + // Compatibility with DBs left behind by older builds that crashed after + // parsing but before resolution could finish: no completion stamp, parsed + // files/nodes present, and unresolved refs still queued. + const hasCompletionStamp = + this.queries.getMetadata('indexed_with_version') !== null || + this.queries.getMetadata('indexed_with_extraction_version') !== null; + return !hasCompletionStamp && counts.unresolvedRefs > 0; + } + + private async resumeInterruptedIndexUnlocked(options: IndexOptions): Promise { + const start = Date.now(); + const counts = this.queries.getIndexRecordCounts(); + this.queries.deleteEdgesByProvenance('heuristic'); + const result: IndexResult = { + success: true, + filesIndexed: counts.files, + filesSkipped: 0, + filesErrored: 0, + nodesCreated: counts.nodes, + edgesCreated: counts.edges, + errors: [], + durationMs: 0, + }; + + await this.resolveIndexedDbUnlocked( + options, + result, + { nodes: 0, edges: 0 }, + true, + ); + result.durationMs = Date.now() - start; + return result; + } - return result; - } finally { - this.fileLock.release(); - } - }); + private lockFailureIndexResult(): IndexResult { + return { + success: false, + filesIndexed: 0, + filesSkipped: 0, + filesErrored: 0, + nodesCreated: 0, + edgesCreated: 0, + errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' }], + durationMs: 0, + }; } /** @@ -1133,6 +1233,32 @@ export class CodeGraph { this.queries.clear(); } + private resetDbUnlocked(): void { + const dbPath = this.db.getPath(); + this.db.close(); + fs.unlinkSync(dbPath); + for (const ext of ['-shm', '-wal']) { + const p = dbPath + ext; + try { fs.unlinkSync(p); } catch {} + } + this.db = DatabaseConnection.initialize(dbPath); + this.queries = new QueryBuilder(this.db.getDb()); + this.configureQueries(); + this.orchestrator = new ExtractionOrchestrator(this.projectRoot, this.queries); + this.resolver = createResolver(this.projectRoot, this.queries); + this.graphManager = new GraphQueryManager(this.queries); + this.traverser = new GraphTraverser(this.queries); + this.contextBuilder = createContextBuilder( + this.projectRoot, + this.queries, + this.traverser + ); + } + + private configureQueries(): void { + this.queries.setProjectNameTokens(deriveProjectNameTokens(this.projectRoot)); + } + /** * Alias for close() for backwards compatibility. * @deprecated Use close() instead diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ad3f61213..82bd055f5 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -339,7 +339,7 @@ function eventEmitterEdges(ctx: ResolutionContext): Edge[] { function reactRenderEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { const edges: Edge[] = []; const seen = new Set(); - for (const cls of queries.getNodesByKind('class')) { + for (const cls of queries.iterateNodesByKind('class')) { const children = queries.getOutgoingEdges(cls.id, ['contains']) .map((e) => queries.getNodeById(e.target)) .filter((n): n is Node => !!n && n.kind === 'method'); @@ -378,7 +378,7 @@ function reactRenderEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] function flutterBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { const edges: Edge[] = []; const seen = new Set(); - for (const cls of queries.getNodesByKind('class')) { + for (const cls of queries.iterateNodesByKind('class')) { const children = queries.getOutgoingEdges(cls.id, ['contains']) .map((e) => queries.getNodeById(e.target)) .filter((n): n is Node => !!n && n.kind === 'method'); @@ -423,7 +423,7 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] { .getOutgoingEdges(classId, ['contains']) .map((e) => queries.getNodeById(e.target)) .filter((n): n is Node => !!n && n.kind === 'method'); - for (const cls of queries.getNodesByKind('class')) { + for (const cls of queries.iterateNodesByKind('class')) { const subMethods = methodsOf(cls.id).filter((n) => n.language === 'cpp'); if (subMethods.length === 0) continue; for (const ext of queries.getOutgoingEdges(cls.id, ['extends'])) { @@ -499,12 +499,14 @@ function goImplementsEdges(queries: QueryBuilder): Edge[] { .map((n) => n.name), ); - const goStructs = queries.getNodesByKind('struct').filter((s) => s.language === 'go'); + const goStructs: Node[] = []; + for (const s of queries.iterateNodesByKindAndLanguage('struct', 'go')) { + goStructs.push(s); + } const structMethods = new Map>(); for (const s of goStructs) structMethods.set(s.id, methodNameSet(s.id)); - for (const iface of queries.getNodesByKind('interface')) { - if (iface.language !== 'go') continue; + for (const iface of queries.iterateNodesByKindAndLanguage('interface', 'go')) { const want = methodNameSet(iface.id); if (want.size === 0) continue; // empty interface (`any`) — would match everything let added = 0; @@ -564,8 +566,7 @@ function goCrossFileMethodContainsEdges(queries: QueryBuilder): Edge[] { return i >= 0 ? p.slice(0, i) : ''; }; - for (const method of queries.getNodesByKind('method')) { - if (method.language !== 'go') continue; + for (const method of queries.iterateNodesByKindAndLanguage('method', 'go')) { // The receiver type is encoded in the method's qualifiedName as `Recv::name` // (extraction sets `${receiverType}::${name}` for receiver methods). const qn = method.qualifiedName; @@ -635,9 +636,19 @@ function kmpKindsCompatible(a: string, b: string): boolean { function kotlinExpectActualEdges(queries: QueryBuilder): Edge[] { const edges: Edge[] = []; const seen = new Set(); - const actuals = queries - .getAllNodes() - .filter((n) => n.language === 'kotlin' && !!n.decorators?.includes('actual')); + const actuals: Node[] = []; + for (const n of queries.iterateNodesByKindAndLanguage('function', 'kotlin')) { + if (n.decorators?.includes('actual')) actuals.push(n); + } + for (const n of queries.iterateNodesByKindAndLanguage('class', 'kotlin')) { + if (n.decorators?.includes('actual')) actuals.push(n); + } + for (const n of queries.iterateNodesByKindAndLanguage('interface', 'kotlin')) { + if (n.decorators?.includes('actual')) actuals.push(n); + } + for (const n of queries.iterateNodesByKindAndLanguage('type_alias', 'kotlin')) { + if (n.decorators?.includes('actual')) actuals.push(n); + } for (const act of actuals) { let added = 0; for (const cand of queries.getNodesByQualifiedNameExact(act.qualifiedName)) { @@ -681,7 +692,7 @@ function interfaceOverrideEdges(queries: QueryBuilder): Edge[] { // types that conform to protocols. Iterate both. const concreteKinds = ['class', 'struct'] as const; for (const kind of concreteKinds) { - for (const cls of queries.getNodesByKind(kind)) { + for (const cls of queries.iterateNodesByKind(kind)) { const implMethods = methodsOf(cls.id).filter((n) => IFACE_OVERRIDE_LANGS.has(n.language)); if (implMethods.length === 0) continue; for (const sup of queries.getOutgoingEdges(cls.id, ['implements', 'extends'])) { @@ -761,8 +772,7 @@ function goGrpcStubImplEdges(queries: QueryBuilder): Edge[] { const methodNamesByStruct = new Map>(); const methodNodesByStruct = new Map(); const goStructs: Node[] = []; - for (const s of queries.getNodesByKind('struct')) { - if (s.language !== 'go') continue; + for (const s of queries.iterateNodesByKindAndLanguage('struct', 'go')) { goStructs.push(s); const ms = queries .getOutgoingEdges(s.id, ['contains']) @@ -903,7 +913,7 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] { // misses it (flat components match by basename and don't need this). Map each // nested component's Nuxt name → node so those template usages resolve. const nuxtComponents = new Map(); - for (const c of ctx.getNodesByKind('component')) { + for (const c of ctx.iterateNodesByKind('component')) { const nn = nuxtComponentName(c.filePath); if (nn && !nuxtComponents.has(nn)) nuxtComponents.set(nn, c); } @@ -1226,8 +1236,7 @@ function expoCrossPlatformEdges(queries: QueryBuilder): Edge[] { const edges: Edge[] = []; const seen = new Set(); const byKey = new Map(); - for (const m of queries.getNodesByKind('method')) { - if (!m.id.startsWith('expo-module:')) continue; + for (const m of queries.getNodesByKindAndIdPrefix('method', 'expo-module:')) { const key = m.qualifiedName.split('::').pop(); // `.` if (!key) continue; const arr = byKey.get(key); @@ -1335,19 +1344,20 @@ function rnCrossPlatformEdges(queries: QueryBuilder): Edge[] { return edges; } -function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] { +function fabricNativeImplEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { const edges: Edge[] = []; const seen = new Set(); // The Fabric extractor IDs are prefixed `fabric-component:` so we can // filter to just those without iterating all `component` nodes. - const components = ctx.getNodesByKind('component').filter((n) => n.id.startsWith('fabric-component:')); + const components = ctx.getNodesByKindAndIdPrefix('component', 'fabric-component:'); if (components.length === 0) return edges; // Pre-index native classes by name for O(1) lookup. const nativeClassesByName = new Map(); - for (const n of ctx.getNodesByKind('class')) { - if (n.language !== 'objc' && n.language !== 'kotlin' && n.language !== 'java' && n.language !== 'cpp') continue; + const NATIVE_LANGS = new Set(['objc', 'kotlin', 'java', 'cpp']); + for (const n of queries.iterateNodesByKind('class')) { + if (!NATIVE_LANGS.has(n.language)) continue; const arr = nativeClassesByName.get(n.name); if (arr) arr.push(n); else nativeClassesByName.set(n.name, [n]); @@ -1668,54 +1678,38 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const goImpl = goImplementsEdges(queries); if (goImpl.length > 0) queries.insertEdges(goImpl); - const fieldEdges = fieldChannelEdges(queries, ctx); - const closureCollEdges = closureCollectionEdges(queries, ctx); - const emitterEdges = eventEmitterEdges(ctx); - const renderEdges = reactRenderEdges(queries, ctx); - const jsxEdges = reactJsxChildEdges(ctx); - const vueEdges = vueTemplateEdges(ctx); - const svelteKitEdges = svelteKitLoadEdges(ctx); - const pascalEdges = pascalFormEdges(ctx); - const flutterEdges = flutterBuildEdges(queries, ctx); - const cppEdges = cppOverrideEdges(queries); - const ifaceEdges = interfaceOverrideEdges(queries); - const kotlinExpectActual = kotlinExpectActualEdges(queries); - const goGrpcEdges = goGrpcStubImplEdges(queries); - const rnEventEdgesList = rnEventEdges(ctx); - const fabricNativeEdges = fabricNativeImplEdges(ctx); - const expoXPlatEdges = expoCrossPlatformEdges(queries); - const rnXPlatEdges = rnCrossPlatformEdges(queries); - const mybatisEdges = mybatisJavaXmlEdges(queries); - const ginEdges = ginMiddlewareChainEdges(queries, ctx); - const merged: Edge[] = []; const seen = new Set(); - for (const e of [ - ...fieldEdges, - ...closureCollEdges, - ...emitterEdges, - ...renderEdges, - ...jsxEdges, - ...vueEdges, - ...svelteKitEdges, - ...pascalEdges, - ...flutterEdges, - ...cppEdges, - ...ifaceEdges, - ...kotlinExpectActual, - ...goGrpcEdges, - ...rnEventEdgesList, - ...fabricNativeEdges, - ...expoXPlatEdges, - ...rnXPlatEdges, - ...mybatisEdges, - ...ginEdges, - ]) { - const key = `${e.source}>${e.target}`; - if (seen.has(key)) continue; - seen.add(key); - merged.push(e); - } + const mergeUnique = (edges: Edge[]): void => { + if (edges.length === 0) return; + for (const e of edges) { + const key = `${e.source}>${e.target}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(e); + } + }; + + mergeUnique(fieldChannelEdges(queries, ctx)); + mergeUnique(closureCollectionEdges(queries, ctx)); + mergeUnique(eventEmitterEdges(ctx)); + mergeUnique(reactRenderEdges(queries, ctx)); + mergeUnique(reactJsxChildEdges(ctx)); + mergeUnique(vueTemplateEdges(ctx)); + mergeUnique(svelteKitLoadEdges(ctx)); + mergeUnique(pascalFormEdges(ctx)); + mergeUnique(flutterBuildEdges(queries, ctx)); + mergeUnique(cppOverrideEdges(queries)); + mergeUnique(interfaceOverrideEdges(queries)); + mergeUnique(kotlinExpectActualEdges(queries)); + mergeUnique(goGrpcStubImplEdges(queries)); + mergeUnique(rnEventEdges(ctx)); + mergeUnique(fabricNativeImplEdges(queries, ctx)); + mergeUnique(expoCrossPlatformEdges(queries)); + mergeUnique(rnCrossPlatformEdges(queries)); + mergeUnique(mybatisJavaXmlEdges(queries)); + mergeUnique(ginMiddlewareChainEdges(queries, ctx)); + if (merged.length > 0) queries.insertEdges(merged); return merged.length + goImpl.length + goMethodContains.length; } diff --git a/src/resolution/frameworks/react-native.ts b/src/resolution/frameworks/react-native.ts index f55fa4e18..294b80bcd 100644 --- a/src/resolution/frameworks/react-native.ts +++ b/src/resolution/frameworks/react-native.ts @@ -62,6 +62,12 @@ const nativeMethodMaps: WeakMap< { byJsName: Map } > = new WeakMap(); +function methodNodes(context: ResolutionContext): Iterable { + return context.iterateNodesByKind + ? context.iterateNodesByKind('method') + : context.getNodesByKind('method'); +} + // ─── Native-side extraction ───────────────────────────────────────────────── /** @@ -270,7 +276,7 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map(); const jvmMethodsByName = new Map(); - for (const node of context.getNodesByKind('method')) { + for (const node of methodNodes(context)) { if (node.language === 'objc') { const firstKw = node.name.includes(':') ? node.name.split(':')[0] : node.name; if (firstKw) { diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 05b0d8288..269b80312 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -9,7 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from export const reactResolver: FrameworkResolver = { name: 'react', - languages: ['javascript', 'typescript'], + languages: ['javascript', 'typescript', 'jsx', 'tsx'], detect(context: ResolutionContext): boolean { // Check for React in package.json diff --git a/src/resolution/frameworks/swift-objc.ts b/src/resolution/frameworks/swift-objc.ts index e12e9294a..289a0cac8 100644 --- a/src/resolution/frameworks/swift-objc.ts +++ b/src/resolution/frameworks/swift-objc.ts @@ -52,6 +52,12 @@ const objcByCandidateSwiftBase: WeakMap< Map > = new WeakMap(); +function methodNodes(context: ResolutionContext): Iterable { + return context.iterateNodesByKind + ? context.iterateNodesByKind('method') + : context.getNodesByKind('method'); +} + /** * Build the reverse-bridge map: for every ObjC method node in the graph, * compute the Swift base names that would auto-bridge to its selector and @@ -118,10 +124,8 @@ function buildObjcMap(context: ResolutionContext): Map { if (cached) return cached; const map = new Map(); - const objcMethods = context - .getNodesByKind('method') - .filter((n) => n.language === 'objc'); - for (const node of objcMethods) { + for (const node of methodNodes(context)) { + if (node.language !== 'objc') continue; const candidates = swiftBaseNamesForObjcSelector(node.name); for (const c of candidates) { // Skip the trivial case where the Swift base name equals the ObjC @@ -227,9 +231,15 @@ function resolveObjcCallToSwift( const candidates = swiftBaseNamesForObjcSelector(rawSelector); for (const candidate of candidates) { - const matches = context - .getNodesByName(candidate) - .filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function')); + const matches = context.getNodesByNameFiltered + ? context.getNodesByNameFiltered(candidate, { + language: 'swift', + kinds: ['method', 'function'], + limit: 2000, + }) + : context + .getNodesByName(candidate) + .filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function')); for (const match of matches) { const window = declarationSourceWindow(match, context); if (isObjcExposed(window)) { diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index badbe4b02..6dfae30fc 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -113,6 +113,12 @@ const C_CPP_STDLIB_HEADERS = new Set([ 'version', ]); +export function isCppStdlibHeader(importPath: string): boolean { + if (C_CPP_STDLIB_HEADERS.has(importPath)) return true; + const withoutExt = importPath.replace(/\.h$/, ''); + return C_CPP_STDLIB_HEADERS.has(withoutExt); +} + /** * Check if an import is external (npm package, etc.) * @@ -193,10 +199,7 @@ function isExternalImport( // C/C++ standard library headers — both C-style () and // C++-style (, ) forms. Checked against the import // path (which the extractor strips of <> or "" delimiters). - if (C_CPP_STDLIB_HEADERS.has(importPath)) return true; - // C++ headers without .h extension (e.g. "vector", "string") - const withoutExt = importPath.replace(/\.h$/, ''); - if (C_CPP_STDLIB_HEADERS.has(withoutExt)) return true; + if (isCppStdlibHeader(importPath)) return true; } return false; @@ -1132,6 +1135,7 @@ export function resolveViaImport( // edge — resolveViaImport's symbol lookup below would search the // resolved file for a symbol named like the file extension and fail. if ((ref.language === 'c' || ref.language === 'cpp') && ref.referenceKind === 'imports') { + if (isCppStdlibHeader(ref.referenceName)) return null; // C/C++ quoted includes (`#include "X.h"`) resolve relative to the // INCLUDING file's own directory first (the C standard's quoted-include // search order). Prefer a same-directory header over an -I directory or a @@ -1143,17 +1147,20 @@ export function resolveViaImport( const fromDir = slash >= 0 ? ref.filePath.slice(0, slash) : ''; const siblingPath = path.posix.normalize(fromDir ? `${fromDir}/${ref.referenceName}` : ref.referenceName); const siblingBase = siblingPath.split('/').pop()!; - const sibling = context - .getNodesByName(siblingBase) - .find((n) => n.kind === 'file' && n.filePath === siblingPath); + const sibling = (context.getNodesByNameFiltered + ? context.getNodesByNameFiltered(siblingBase, { kinds: ['file'], filePath: siblingPath, limit: 1 }) + : context.getNodesByName(siblingBase).filter((n) => n.kind === 'file' && n.filePath === siblingPath) + )[0]; if (sibling) { return { original: ref, targetNodeId: sibling.id, confidence: 0.92, resolvedBy: 'import' }; } const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context); if (!resolvedPath) return null; const basename = resolvedPath.split('/').pop()!; - const fileNodes = context.getNodesByName(basename).filter((n) => n.kind === 'file'); - const fileNode = fileNodes.find((n) => n.filePath === resolvedPath); + const fileNode = (context.getNodesByNameFiltered + ? context.getNodesByNameFiltered(basename, { kinds: ['file'], filePath: resolvedPath, limit: 1 }) + : context.getNodesByName(basename).filter((n) => n.kind === 'file' && n.filePath === resolvedPath) + )[0]; if (fileNode) { return { original: ref, @@ -1176,9 +1183,10 @@ export function resolveViaImport( const resolvedPath = resolvePhpIncludePath(ref.referenceName, ref.filePath, context); if (resolvedPath) { const basename = resolvedPath.split('/').pop()!; - const fileNode = context - .getNodesByName(basename) - .find((n) => n.kind === 'file' && n.filePath === resolvedPath); + const fileNode = (context.getNodesByNameFiltered + ? context.getNodesByNameFiltered(basename, { kinds: ['file'], filePath: resolvedPath, limit: 1 }) + : context.getNodesByName(basename).filter((n) => n.kind === 'file' && n.filePath === resolvedPath) + )[0]; if (fileNode) { return { original: ref, @@ -1524,13 +1532,23 @@ function findPythonModuleFile( const rel = mod.replace(/\./g, '/'); const lastSeg = mod.split('.').pop()!; const endsWith = (p: string, want: string): boolean => p === want || p.endsWith('/' + want); - const moduleFile = context - .getNodesByName(`${lastSeg}.py`) - .find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}.py`)); + const moduleCandidates = context.getNodesByNameFiltered?.(`${lastSeg}.py`, { + kinds: ['file'], + filePathSuffix: `${rel}.py`, + limit: 100, + }) ?? context.getNodesByName(`${lastSeg}.py`); + const moduleFile = moduleCandidates.find( + (n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}.py`) + ); if (moduleFile) return moduleFile; - const pkgFile = context - .getNodesByName('__init__.py') - .find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}/__init__.py`)); + const pkgCandidates = context.getNodesByNameFiltered?.('__init__.py', { + kinds: ['file'], + filePathSuffix: `${rel}/__init__.py`, + limit: 100, + }) ?? context.getNodesByName('__init__.py'); + const pkgFile = pkgCandidates.find( + (n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}/__init__.py`) + ); return pkgFile ?? null; } @@ -1714,7 +1732,11 @@ function resolveJavaImportedReference( ? imp.localName : ref.referenceName.substring(imp.localName.length + 1); - const candidates = context.getNodesByName(memberName); + const candidates = context.getNodesByNameFiltered?.(memberName, { + language: ref.language, + filePathSuffix: fqnPath, + limit: 100, + }) ?? context.getNodesByName(memberName); for (const node of candidates) { if (node.language !== ref.language) continue; const fp = node.filePath.replace(/\\/g, '/'); @@ -1737,7 +1759,12 @@ function resolveJavaImportedReference( if (dot > 0) { const ownerFqn = imp.source.substring(0, dot); const ownerPath = ownerFqn.replace(/\./g, '/') + ext; - for (const node of candidates) { + const staticCandidates = context.getNodesByNameFiltered?.(memberName, { + language: ref.language, + filePathSuffix: ownerPath, + limit: 100, + }) ?? candidates; + for (const node of staticCandidates) { if (node.language !== ref.language) continue; const fp = node.filePath.replace(/\\/g, '/'); if (fp.endsWith(ownerPath) || fp.endsWith('/' + ownerPath)) { @@ -1793,7 +1820,11 @@ function resolveGoCrossPackageReference( // directly in the package directory. Match the immediate parent dir // exactly so a call to `pkga.FuncX` doesn't accidentally land on a // `FuncX` declared in `pkga/subpkg/`. - const candidates = context.getNodesByName(memberName); + const candidates = context.getNodesByNameFiltered?.(memberName, { + language: 'go', + ...(pkgDir ? { filePathPrefix: `${pkgDir}/` } : {}), + limit: 2000, + }) ?? context.getNodesByName(memberName); for (const node of candidates) { if (node.language !== 'go') continue; if (!node.isExported) continue; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 0d7ec4309..a5ed57219 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { Node, UnresolvedReference, Edge } from '../types'; +import { LANGUAGES, Node, NodeKind, UnresolvedReference, Edge } from '../types'; import { QueryBuilder } from '../db/queries'; import { UnresolvedRef, @@ -17,8 +17,8 @@ import { ImportMapping, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef } from './import-resolver'; -import { detectFrameworks } from './frameworks'; +import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCppStdlibHeader } from './import-resolver'; +import { detectFrameworks, getApplicableFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; import { loadProjectAliases, type AliasMap } from './path-aliases'; import { loadGoModule, type GoModule } from './go-module'; @@ -43,6 +43,10 @@ const SCOPED_CHAIN_LANGUAGES = new Set(['rust']); /** The extractor's chained-receiver encoding: `().`. */ const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/; +function sameLanguageFamilyMembers(language: Node['language']): Node['language'][] { + return LANGUAGES.filter((candidate) => sameLanguageFamily(candidate, language)); +} + /** * Cache size limits. Each per-resolver cache is bounded so memory * stays flat on large codebases (20k+ files). Sizes were chosen to @@ -52,6 +56,8 @@ const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/; * caches) when tuning for very large or very small projects. */ const DEFAULT_CACHE_LIMIT = 5_000; +const MAX_UNFILTERED_NAME_LOOKUP_ROWS = 10_000; +const MAX_CACHED_NAME_LOOKUP_ROWS = 1_000; function resolveCacheLimit(): number { const raw = process.env.CODEGRAPH_RESOLVER_CACHE_SIZE; if (!raw) return DEFAULT_CACHE_LIMIT; @@ -226,7 +232,7 @@ export class ReferenceResolver { private nameCache: LRUCache; // name → nodes cache private lowerNameCache: LRUCache; // lower(name) → nodes cache private qualifiedNameCache: LRUCache; // qualified_name → nodes cache - private knownNames: Set | null = null; // all known symbol names for fast pre-filtering + private nameExistsCache: LRUCache; // exact symbol-name existence pre-filter private knownFiles: Set | null = null; private cachesWarmed = false; // tsconfig/jsconfig path-alias map. `undefined` = not yet computed, @@ -253,6 +259,7 @@ export class ReferenceResolver { this.nameCache = new LRUCache(limit); this.lowerNameCache = new LRUCache(limit); this.qualifiedNameCache = new LRUCache(limit); + this.nameExistsCache = new LRUCache(Math.max(limit * 4, 20_000)); this.context = this.createContext(); } @@ -296,9 +303,9 @@ export class ReferenceResolver { /** * Pre-build lightweight caches for resolution. - * Node lookups are now handled by indexed SQLite queries instead of - * loading all nodes into memory (which caused OOM on large codebases). - * We cache the set of known symbol names for fast pre-filtering. + * Node lookups are handled by indexed SQLite queries instead of loading all + * nodes/names into memory (which caused OOM on large codebases). File paths + * remain lightweight enough to cache as strings for import/path checks. */ warmCaches(): void { if (this.cachesWarmed) return; @@ -306,9 +313,6 @@ export class ReferenceResolver { // Only cache the set of known file paths (lightweight string set) this.knownFiles = new Set(this.queries.getAllFilePaths()); - // Cache all distinct symbol names for fast pre-filtering (just strings, not full nodes) - this.knownNames = new Set(this.queries.getAllNodeNames()); - this.cachesWarmed = true; } @@ -323,7 +327,7 @@ export class ReferenceResolver { this.nameCache.clear(); this.lowerNameCache.clear(); this.qualifiedNameCache.clear(); - this.knownNames = null; + this.nameExistsCache.clear(); this.knownFiles = null; this.cachesWarmed = false; } @@ -343,11 +347,25 @@ export class ReferenceResolver { getNodesByName: (name: string) => { const cached = this.nameCache.get(name); if (cached !== undefined) return cached; + const count = this.queries.getNodeNameCount(name); + if (count > MAX_UNFILTERED_NAME_LOOKUP_ROWS) { + logDebug('Skipping high-fanout unfiltered node-name lookup during resolution', { + name, + count, + }); + return []; + } const result = this.queries.getNodesByName(name); - this.nameCache.set(name, result); + if (count <= MAX_CACHED_NAME_LOOKUP_ROWS) { + this.nameCache.set(name, result); + } return result; }, + getNodesByNameFiltered: (name, filters = {}) => { + return this.queries.getNodesByNameFiltered(name, filters); + }, + getNodesByQualifiedName: (qualifiedName: string) => { const cached = this.qualifiedNameCache.get(qualifiedName); if (cached !== undefined) return cached; @@ -360,6 +378,14 @@ export class ReferenceResolver { return this.queries.getNodesByKind(kind); }, + iterateNodesByKind: (kind: Node['kind']) => { + return this.queries.iterateNodesByKind(kind); + }, + + getNodesByKindAndIdPrefix: (kind: Node['kind'], idPrefix: string) => { + return this.queries.getNodesByKindAndIdPrefix(kind as NodeKind, idPrefix); + }, + fileExists: (filePath: string) => { // Check pre-built known files set first (O(1)) if (this.knownFiles) { @@ -422,11 +448,25 @@ export class ReferenceResolver { getNodesByLowerName: (lowerName: string) => { const cached = this.lowerNameCache.get(lowerName); if (cached !== undefined) return cached; + const count = this.queries.getLowerNodeNameCount(lowerName); + if (count > MAX_UNFILTERED_NAME_LOOKUP_ROWS) { + logDebug('Skipping high-fanout unfiltered lowercase node-name lookup during resolution', { + lowerName, + count, + }); + return []; + } const result = this.queries.getNodesByLowerName(lowerName); - this.lowerNameCache.set(lowerName, result); + if (count <= MAX_CACHED_NAME_LOOKUP_ROWS) { + this.lowerNameCache.set(lowerName, result); + } return result; }, + getNodesByLowerNameFiltered: (lowerName, filters = {}) => { + return this.queries.getNodesByLowerNameFiltered(lowerName, filters); + }, + getNodeById: (id: string) => { return this.queries.getNodeById(id); }, @@ -436,9 +476,14 @@ export class ReferenceResolver { // Matching by simple name (not id) reconciles a type declared in one node // (`KF::Builder`) with conformance declared in a separate extension node // (`KF.Builder: KFOptionSetter`) — both have name `Builder`. - const typeNodes = this.context - .getNodesByName(typeName) - .filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language); + const typeNodes = this.context.getNodesByNameFiltered + ? this.context.getNodesByNameFiltered(typeName, { + language, + kinds: [...SUPERTYPE_BEARING_KINDS], + }) + : this.context + .getNodesByName(typeName) + .filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language); if (typeNodes.length === 0) return []; const supertypes = new Set(); for (const tn of typeNodes) { @@ -538,6 +583,7 @@ export class ReferenceResolver { filePath: ref.filePath || this.getFilePathFromNodeId(ref.fromNodeId), language: ref.language || this.getLanguageFromNodeId(ref.fromNodeId), })); + this.prefetchKnownNamesForRefs(refs); const total = refs.length; let lastReportedPercent = -1; @@ -582,38 +628,36 @@ export class ReferenceResolver { /** * Check if a reference name has any possible match in the codebase. - * Uses the pre-built knownNames set to skip expensive resolution - * for names that definitely don't exist as symbols. + * Uses indexed point lookups so large repos don't materialize every + * distinct symbol name just to run the pre-filter. */ private hasAnyPossibleMatch(name: string): boolean { - if (!this.knownNames) return true; // no pre-filter available - // Direct name match - if (this.knownNames.has(name)) return true; + if (this.hasKnownName(name)) return true; // For qualified names like "obj.method" or "Class::method", check the parts const dotIdx = name.indexOf('.'); if (dotIdx > 0) { const receiver = name.substring(0, dotIdx); const member = name.substring(dotIdx + 1); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (this.hasKnownName(receiver) || this.hasKnownName(member)) return true; // Also check capitalized receiver (instance-method resolution) const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (this.hasKnownName(capitalized)) return true; // JVM FQN: `com.example.foo.Bar` — the only useful segment is the // last one (`Bar`); the earlier check finds `example.foo.Bar` which // never matches a node name. const lastDot = name.lastIndexOf('.'); if (lastDot > dotIdx) { const tail = name.substring(lastDot + 1); - if (tail && this.knownNames.has(tail)) return true; + if (tail && this.hasKnownName(tail)) return true; } } const colonIdx = name.indexOf('::'); if (colonIdx > 0) { const receiver = name.substring(0, colonIdx); const member = name.substring(colonIdx + 2); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (this.hasKnownName(receiver) || this.hasKnownName(member)) return true; // Multi-segment path `a::b::c` (a Rust/C++ module call like // `database::profiles::find`) — the only segment that names a symbol is // the last (`c`); `member` above is `b::c`, which never matches a node @@ -622,7 +666,7 @@ export class ReferenceResolver { const lastColon = name.lastIndexOf('::'); if (lastColon > colonIdx) { const tail = name.substring(lastColon + 2); - if (tail && this.knownNames.has(tail)) return true; + if (tail && this.hasKnownName(tail)) return true; } } @@ -630,12 +674,74 @@ export class ReferenceResolver { const slashIdx = name.lastIndexOf('/'); if (slashIdx > 0) { const fileName = name.substring(slashIdx + 1); - if (this.knownNames.has(fileName)) return true; + if (this.hasKnownName(fileName)) return true; } return false; } + private prefetchKnownNamesForRefs(refs: UnresolvedRef[]): void { + const names = new Set(); + for (const ref of refs) { + this.collectPossibleMatchNames(ref.referenceName, names); + } + if (names.size === 0) return; + + const existing = this.queries.getExistingNodeNames(names); + for (const name of names) { + this.nameExistsCache.set(name, existing.has(name)); + } + } + + private collectPossibleMatchNames(name: string, names: Set): void { + if (!name) return; + names.add(name); + + // Keep this in sync with hasAnyPossibleMatch(). It collects the bounded + // set of indexed lookups that resolver pre-filtering may ask for. + const dotIdx = name.indexOf('.'); + if (dotIdx > 0) { + const receiver = name.substring(0, dotIdx); + const member = name.substring(dotIdx + 1); + names.add(receiver); + names.add(member); + names.add(receiver.charAt(0).toUpperCase() + receiver.slice(1)); + const lastDot = name.lastIndexOf('.'); + if (lastDot > dotIdx) { + const tail = name.substring(lastDot + 1); + if (tail) names.add(tail); + } + } + + const colonIdx = name.indexOf('::'); + if (colonIdx > 0) { + const receiver = name.substring(0, colonIdx); + const member = name.substring(colonIdx + 2); + names.add(receiver); + names.add(member); + const lastColon = name.lastIndexOf('::'); + if (lastColon > colonIdx) { + const tail = name.substring(lastColon + 2); + if (tail) names.add(tail); + } + } + + const slashIdx = name.lastIndexOf('/'); + if (slashIdx > 0) { + const fileName = name.substring(slashIdx + 1); + if (fileName) names.add(fileName); + } + } + + private hasKnownName(name: string): boolean { + if (!name) return false; + const cached = this.nameExistsCache.get(name); + if (cached !== undefined) return cached; + const exists = this.queries.hasNodeName(name); + this.nameExistsCache.set(name, exists); + return exists; + } + /** * Does `ref.referenceName` match an import declared in its containing * file? Used as a pre-filter escape so re-export chain resolution @@ -659,6 +765,8 @@ export class ReferenceResolver { * Resolve a single reference */ resolveOne(ref: UnresolvedRef): ResolvedRef | null { + const applicableFrameworks = getApplicableFrameworks(this.frameworks, ref.language); + // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { return null; @@ -673,7 +781,7 @@ export class ReferenceResolver { if ( !this.hasAnyPossibleMatch(ref.referenceName) && !this.matchesAnyImport(ref) && - !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName)) + !applicableFrameworks.some((f) => f.claimsReference?.(ref.referenceName)) ) { return null; } @@ -722,7 +830,7 @@ export class ReferenceResolver { // JS → native `calls`) — `gateFrameworkLanguage` only drops a type/import // edge between two KNOWN families (see its doc), never a `calls` bridge or // a config↔code edge. - for (const framework of this.frameworks) { + for (const framework of applicableFrameworks) { const result = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref); if (result) { if (result.confidence >= 0.9) return result; // High confidence, return immediately @@ -749,6 +857,17 @@ export class ReferenceResolver { : null; } + // C/C++ #include edges are file-path imports. If import resolution could not + // locate the header, falling through to global name matching only burns heap + // on common header names and risks a wrong basename collision. + if ((ref.language === 'c' || ref.language === 'cpp') && ref.referenceKind === 'imports') { + return candidates.length > 0 + ? candidates.reduce((best, curr) => + curr.confidence > best.confidence ? curr : best + ) + : null; + } + // Strategy 3: Try name matching const nameResult = this.gateLanguage(matchReference(ref, this.context), ref); if (nameResult) { @@ -1060,7 +1179,7 @@ export class ReferenceResolver { // But allow if the capitalized receiver matches a known codebase class if (PYTHON_BUILT_IN_METHODS.has(method)) { const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (!this.knownNames?.has(capitalized)) { + if (!this.hasKnownName(capitalized)) { return true; } } @@ -1071,7 +1190,7 @@ export class ReferenceResolver { // `def get()` — is a real reference target. Mirrors the knownNames guard on // the dotted branch above; without it, every handler named after a builtin // method silently loses its route→handler edge. - if (PYTHON_BUILT_IN_METHODS.has(name) && !this.knownNames?.has(name)) { + if (PYTHON_BUILT_IN_METHODS.has(name) && !this.hasKnownName(name)) { return true; } } @@ -1109,6 +1228,7 @@ export class ReferenceResolver { // when there's no user node with this name — then name-matching would // produce zero edges anyway and the filter just short-circuits work. if (ref.language === 'c' || ref.language === 'cpp') { + if (ref.referenceKind === 'imports' && isCppStdlibHeader(name)) return true; // C++ std:: namespace prefix — safe to filter unconditionally, // since `std::foo` is never a user-defined qualified name in // tree-sitter output. @@ -1287,23 +1407,34 @@ export class ReferenceResolver { // NODES, and look members up through `contains` edges. No name-based // unions anywhere — a name-keyed getSupertypes('Engine') merged every // Engine's parents and produced a cross-class wrong edge on rails. - let frontierNodes = this.context - .getNodesByName(className) - .filter( - (n) => - SUPERTYPE_BEARING_KINDS.has(n.kind) && - n.filePath === ref.filePath - ); + let frontierNodes = this.context.getNodesByNameFiltered + ? this.context.getNodesByNameFiltered(className, { + kinds: [...SUPERTYPE_BEARING_KINDS], + filePath: ref.filePath, + }) + : this.context + .getNodesByName(className) + .filter( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + n.filePath === ref.filePath + ); if (frontierNodes.length === 0) { // The class itself may be declared in another file (partial/reopened // classes); fall back to same-family nodes of that name. - frontierNodes = this.context - .getNodesByName(className) - .filter( - (n) => - SUPERTYPE_BEARING_KINDS.has(n.kind) && - sameLanguageFamily(n.language, ref.language) - ); + frontierNodes = this.context.getNodesByNameFiltered + ? this.context.getNodesByNameFiltered(className, { + languages: sameLanguageFamilyMembers(ref.language), + kinds: [...SUPERTYPE_BEARING_KINDS], + limit: 2000, + }) + : this.context + .getNodesByName(className) + .filter( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + sameLanguageFamily(n.language, ref.language) + ); } const seenNodes = new Set(frontierNodes.map((n) => n.id)); let target: Node | null = null; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9990d690d..4fae7904a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4,9 +4,88 @@ * Handles symbol name matching for reference resolution. */ -import { Node } from '../types'; +import { Language, Node, NodeKind, NodeLookupFilters } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; +const CALLABLE_KINDS: NodeKind[] = ['function', 'method']; +const TYPE_KINDS: NodeKind[] = ['class', 'struct', 'interface', 'trait', 'protocol', 'enum']; +const FUZZY_KINDS: NodeKind[] = ['function', 'method', 'class']; +const EXACT_LOCAL_CANDIDATE_LIMIT = 200; +const EXACT_NEARBY_CANDIDATE_LIMIT = 500; +const EXACT_GLOBAL_CANDIDATE_LIMIT = 200; +const METHOD_MATCH_CANDIDATE_LIMIT = 2000; +const FUZZY_CANDIDATE_LIMIT = 500; + +function sameLanguageFamilyMembers(language: Language): Language[] { + const family = LANGUAGE_FAMILY[language]; + if (!family) return [language]; + return Object.entries(LANGUAGE_FAMILY) + .filter(([, f]) => f === family) + .map(([lang]) => lang as Language); +} + +function referenceLanguageFilters(ref: UnresolvedRef): Pick { + // Calls may deliberately cross language families through framework bridges + // that surface as normal method nodes (Expo Modules, React Native native + // modules). Keep them kind/proximity bounded, but do not pre-filter by + // language before those bridge candidates get a chance. + if (ref.referenceKind === 'calls') { + return {}; + } + if (ref.referenceKind === 'imports' && !isKnownLanguageFamily(ref.language)) { + return {}; + } + if ( + ref.referenceKind === 'references' || + ref.referenceKind === 'function_ref' || + ref.referenceKind === 'imports' || + ref.referenceKind === 'instantiates' || + ref.referenceKind === 'decorates' + ) { + return { languages: sameLanguageFamilyMembers(ref.language) }; + } + return { language: ref.language }; +} + +function filteredByName( + context: ResolutionContext, + name: string, + filters: NodeLookupFilters, +): Node[] { + return context.getNodesByNameFiltered + ? context.getNodesByNameFiltered(name, filters) + : context.getNodesByName(name).filter((n) => nodeMatchesFilters(n, filters)); +} + +function filteredByLowerName( + context: ResolutionContext, + lowerName: string, + filters: NodeLookupFilters, +): Node[] { + return context.getNodesByLowerNameFiltered + ? context.getNodesByLowerNameFiltered(lowerName, filters) + : context.getNodesByLowerName(lowerName).filter((n) => nodeMatchesFilters(n, filters)); +} + +function nodeMatchesFilters(node: Node, filters: NodeLookupFilters): boolean { + if (filters.language && node.language !== filters.language) return false; + if (filters.languages && !filters.languages.includes(node.language)) return false; + if (filters.kinds && !filters.kinds.includes(node.kind)) return false; + if (filters.filePath && node.filePath !== filters.filePath) return false; + if (filters.filePathPrefix && !node.filePath.startsWith(filters.filePathPrefix)) return false; + if (filters.filePathSuffix && !node.filePath.endsWith(filters.filePathSuffix)) return false; + if (filters.qualifiedName && node.qualifiedName !== filters.qualifiedName) return false; + if (filters.qualifiedNameSuffix && !node.qualifiedName.endsWith(filters.qualifiedNameSuffix)) return false; + if (filters.hasReturnType && !node.returnType) return false; + if (filters.excludeId && node.id === filters.excludeId) return false; + return true; +} + +function directoryPrefix(filePath: string): string { + const slash = filePath.lastIndexOf('/'); + return slash >= 0 ? filePath.slice(0, slash + 1) : ''; +} + /** * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid") * by matching the filename against file nodes. @@ -28,8 +107,7 @@ export function matchByFilePath( if (!fileName) return null; // Search for file nodes with this name - const candidates = context.getNodesByName(fileName); - const fileNodes = candidates.filter(n => n.kind === 'file'); + const fileNodes = filteredByName(context, fileName, { kinds: ['file'] }); if (fileNodes.length === 0) return null; @@ -207,16 +285,16 @@ export function matchFunctionRef( // shape is an explicit member reference). Unique-or-drop like everything else. if (ref.referenceName.includes('::')) { const memberName = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2); - const scoped = context - .getNodesByName(memberName) - .filter( - (n) => - (n.kind === 'function' || n.kind === 'method') && - sameLanguageFamily(n.language, ref.language) && - n.id !== ref.fromNodeId && - (n.qualifiedName === ref.referenceName || - n.qualifiedName.endsWith(`::${ref.referenceName}`)) - ); + const scoped = filteredByName(context, memberName, { + ...referenceLanguageFilters(ref), + kinds: CALLABLE_KINDS, + excludeId: ref.fromNodeId, + limit: METHOD_MATCH_CANDIDATE_LIMIT, + }).filter( + (n) => + n.qualifiedName === ref.referenceName || + n.qualifiedName.endsWith(`::${ref.referenceName}`) + ); if (scoped.length === 0) return null; const sameFileScoped = scoped.filter((n) => n.filePath === ref.filePath); const pool = sameFileScoped.length > 0 ? sameFileScoped : scoped; @@ -230,14 +308,12 @@ export function matchFunctionRef( }; } - let candidates = context - .getNodesByName(ref.referenceName) - .filter( - (n) => - (n.kind === 'function' || (!bareFnOnly && n.kind === 'method')) && - sameLanguageFamily(n.language, ref.language) && - n.id !== ref.fromNodeId // a function registering itself is not a dependency edge - ); + let candidates = filteredByName(context, ref.referenceName, { + ...referenceLanguageFilters(ref), + kinds: bareFnOnly ? ['function'] : CALLABLE_KINDS, + excludeId: ref.fromNodeId, + limit: METHOD_MATCH_CANDIDATE_LIMIT, + }); if (candidates.length === 0) return null; // Swift implicit-self: a bare identifier can name a METHOD only of the @@ -317,8 +393,59 @@ export function matchByExactName( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { - const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref); + const candidateKinds: NodeKind[] | undefined = + ref.referenceKind === 'calls' + ? [...CALLABLE_KINDS, 'class', 'struct'] + : ref.referenceKind === 'instantiates' + ? TYPE_KINDS + : undefined; + const baseFilters = { + ...referenceLanguageFilters(ref), + ...(candidateKinds ? { kinds: candidateKinds } : {}), + }; + + // Same-file candidates are both the most precise and the cheapest. Try them + // before any repo-wide fallback so common names (`Output`, `Result`, `run`) + // don't repeatedly materialize hundreds or thousands of distant symbols. + const sameFile = applyLanguageGate(filteredByName(context, ref.referenceName, { + ...baseFilters, + filePath: ref.filePath, + limit: EXACT_LOCAL_CANDIDATE_LIMIT, + }), ref); + const sameFileResult = pickExactNameMatch(ref, sameFile, false); + if (sameFileResult) return sameFileResult; + + const dir = directoryPrefix(ref.filePath); + if (dir) { + const rawNearby = filteredByName(context, ref.referenceName, { + ...baseFilters, + filePathPrefix: dir, + rankFilePath: ref.filePath, + limit: EXACT_NEARBY_CANDIDATE_LIMIT + 1, + }); + const nearbyWasCapped = rawNearby.length > EXACT_NEARBY_CANDIDATE_LIMIT; + const nearby = applyLanguageGate(rawNearby.slice(0, EXACT_NEARBY_CANDIDATE_LIMIT), ref); + const nearbyResult = pickExactNameMatch(ref, nearby, nearbyWasCapped); + if (nearbyResult) return nearbyResult; + } + + const rawCandidates = filteredByName(context, ref.referenceName, { + ...baseFilters, + rankFilePath: ref.filePath, + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: EXACT_GLOBAL_CANDIDATE_LIMIT + 1, + }); + if (rawCandidates.length > EXACT_GLOBAL_CANDIDATE_LIMIT) return null; + const candidates = applyLanguageGate(rawCandidates, ref); + + return pickExactNameMatch(ref, candidates, false); +} +function pickExactNameMatch( + ref: UnresolvedRef, + candidates: Node[], + candidateSetWasCapped: boolean +): ResolvedRef | null { if (candidates.length === 0) { return null; } @@ -335,10 +462,13 @@ export function matchByExactName( } // Multiple matches - try to narrow down - const bestMatch = findBestMatch(ref, candidates, context); + const bestMatch = findBestMatch(ref, candidates); if (bestMatch) { // Lower confidence when the match is from a distant/unrelated module const proximity = computePathProximity(ref.filePath, bestMatch.filePath); + if (candidateSetWasCapped && bestMatch.filePath !== ref.filePath && proximity < 30) { + return null; + } const confidence = proximity >= 30 ? 0.7 : 0.4; return { original: ref, @@ -378,7 +508,12 @@ export function matchByQualifiedName( const parts = ref.referenceName.split(/[:.]/); const lastName = parts[parts.length - 1]; if (lastName) { - const partialCandidates = context.getNodesByName(lastName); + const partialCandidates = filteredByName(context, lastName, { + ...referenceLanguageFilters(ref), + qualifiedNameSuffix: ref.referenceName, + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: 100, + }); for (const candidate of partialCandidates) { if (candidate.qualifiedName.endsWith(ref.referenceName)) { return { @@ -418,17 +553,17 @@ function resolveMethodOnType( // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate // file (`int Foo::bar() { ... }` in foo.cpp while class Foo is in foo.hpp). // The previous same-file approach missed the latter — the typical C++ layout. - const methodCandidates = context.getNodesByName(methodName); const want = `${typeName}::${methodName}`; - const matches: Node[] = []; - for (const m of methodCandidates) { - if (m.kind !== 'method') continue; - if (m.language !== ref.language) continue; + const matches = filteredByName(context, methodName, { + language: ref.language, + kinds: ['method'], + qualifiedNameSuffix: want, + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: METHOD_MATCH_CANDIDATE_LIMIT, + }).filter((m) => { const qn = m.qualifiedName; - if (qn === want || qn.endsWith(`::${want}`)) { - matches.push(m); - } - } + return qn === want || qn.endsWith(`::${want}`); + }); if (matches.length === 0) { // Conformance fallback: the method may be defined on a supertype `typeName` // extends, or on a protocol / trait it conforms to (e.g. a Swift protocol- @@ -592,12 +727,13 @@ function lookupCalleeReturnType( method = parts[parts.length - 1] ?? callee; cls = parts.slice(0, -1).join('::'); } - const candidates = context.getNodesByName(method).filter( - (n) => - (n.kind === 'method' || n.kind === 'function') && - n.language === ref.language && - !!n.returnType, - ); + const candidates = filteredByName(context, method, { + language: ref.language, + kinds: CALLABLE_KINDS, + hasReturnType: true, + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: METHOD_MATCH_CANDIDATE_LIMIT, + }); if (cls) { const want = `${cls}::${method}`; // The call site may name the class with MORE namespace qualification than @@ -620,9 +756,11 @@ function lookupCalleeReturnType( /** Does the graph contain a class/struct named `name`'s last segment? */ function cppClassExists(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean { const last = cppLastSegment(name); - return context - .getNodesByName(last) - .some((n) => (n.kind === 'class' || n.kind === 'struct') && n.language === ref.language); + return filteredByName(context, last, { + language: ref.language, + kinds: ['class', 'struct'], + limit: 1, + }).length > 0; } /** @@ -996,7 +1134,12 @@ export function matchMethodCall( } // Strategy 1: Direct class name match (existing logic) - const classCandidates = context.getNodesByName(objectOrClass!); + const classCandidates = filteredByName(context, objectOrClass!, { + language: ref.language, + kinds: ['class', 'struct', 'interface'], + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: METHOD_MATCH_CANDIDATE_LIMIT, + }); for (const classNode of classCandidates) { if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') { @@ -1026,7 +1169,12 @@ export function matchMethodCall( // e.g., "permissionEngine" → look for classes containing "PermissionEngine" const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1); if (capitalizedReceiver !== objectOrClass) { - const fuzzyClassCandidates = context.getNodesByName(capitalizedReceiver); + const fuzzyClassCandidates = filteredByName(context, capitalizedReceiver, { + language: ref.language, + kinds: ['class', 'struct', 'interface'], + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: METHOD_MATCH_CANDIDATE_LIMIT, + }); for (const classNode of fuzzyClassCandidates) { if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') { // Skip cross-language class matches @@ -1056,32 +1204,42 @@ export function matchMethodCall( // name similarity with the containing class. Handles abbreviated variable // names like permissionEngine → PermissionRuleEngine. if (methodName) { - const methodCandidates = context.getNodesByName(methodName!); - const methods = methodCandidates.filter( - (n) => n.kind === 'method' && n.name === methodName - ); - - // Filter to same-language candidates first - const sameLanguageMethods = methods.filter(m => m.language === ref.language); - const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods; + const sameLanguageMethods = filteredByName(context, methodName!, { + language: ref.language, + kinds: ['method'], + rankFilePath: ref.filePath, + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: METHOD_MATCH_CANDIDATE_LIMIT + 1, + }); + const targetMethods = sameLanguageMethods.length > 0 + ? sameLanguageMethods + : filteredByName(context, methodName!, { + kinds: ['method'], + rankFilePath: ref.filePath, + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: METHOD_MATCH_CANDIDATE_LIMIT + 1, + }); + const candidatesWereCapped = targetMethods.length > METHOD_MATCH_CANDIDATE_LIMIT; + const methods = targetMethods.slice(0, METHOD_MATCH_CANDIDATE_LIMIT); // If only one same-language method with this name exists, use it - if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) { + if (!candidatesWereCapped && methods.length === 1) { + const confidence = methods[0]!.language === ref.language ? 0.7 : 0.65; return { original: ref, - targetNodeId: targetMethods[0]!.id, - confidence: 0.7, + targetNodeId: methods[0]!.id, + confidence, resolvedBy: 'instance-method', }; } // Multiple methods: score by receiver name word overlap with class name - if (targetMethods.length > 1) { + if (methods.length > 1) { const receiverWords = splitCamelCase(objectOrClass!); - let bestMatch: typeof targetMethods[0] | undefined; + let bestMatch: typeof methods[0] | undefined; let bestScore = 0; - for (const method of targetMethods) { + for (const method of methods) { const classWords = splitCamelCase(method.qualifiedName); let score = receiverWords.filter(w => classWords.some(cw => cw.toLowerCase() === w.toLowerCase()) @@ -1094,7 +1252,7 @@ export function matchMethodCall( } } - if (bestMatch && bestScore >= 2) { + if (bestMatch && bestScore >= 2 && (!candidatesWereCapped || bestMatch.filePath === ref.filePath || computePathProximity(ref.filePath, bestMatch.filePath) >= 30)) { return { original: ref, targetNodeId: bestMatch.id, @@ -1145,8 +1303,7 @@ function computePathProximity(filePath1: string, filePath2: string): number { */ function findBestMatch( ref: UnresolvedRef, - candidates: Node[], - _context: ResolutionContext + candidates: Node[] ): Node | null { // Prioritization rules: // 1. Same file > different file @@ -1236,12 +1393,18 @@ export function matchFuzzy( ): ResolvedRef | null { const lowerName = ref.referenceName.toLowerCase(); - // Use pre-built lowercase index for O(1) lookup instead of scanning all nodes - const candidates = context.getNodesByLowerName(lowerName); - - // Filter to callable kinds only (function, method, class) - const callableKinds = new Set(['function', 'method', 'class']); - const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref); + // Use pre-built lowercase index, but keep fuzzy matching bounded. Fuzzy is a + // low-confidence fallback; on high-fanout names a silent miss is safer than + // materializing thousands of rows just to guess. + const candidates = filteredByLowerName(context, lowerName, { + ...referenceLanguageFilters(ref), + kinds: FUZZY_KINDS, + rankFilePath: ref.filePath, + rankFilePathPrefix: directoryPrefix(ref.filePath), + limit: FUZZY_CANDIDATE_LIMIT + 1, + }); + if (candidates.length > FUZZY_CANDIDATE_LIMIT) return null; + const callableCandidates = applyLanguageGate(candidates, ref); // Prefer same-language matches const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language); diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 71366a150..8850a95e4 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -4,7 +4,7 @@ * Types for the reference resolution system. */ -import { Language, Node, ReferenceKind } from '../types'; +import { Language, Node, NodeLookupFilters, ReferenceKind } from '../types'; /** * An unresolved reference from extraction @@ -67,10 +67,16 @@ export interface ResolutionContext { getNodesInFile(filePath: string): Node[]; /** Get all nodes by name */ getNodesByName(name: string): Node[]; + /** Get nodes by name with SQL-side filters for memory-bounded resolution */ + getNodesByNameFiltered?(name: string, filters?: NodeLookupFilters): Node[]; /** Get all nodes by qualified name */ getNodesByQualifiedName(qualifiedName: string): Node[]; /** Get all nodes of a kind */ getNodesByKind(kind: Node['kind']): Node[]; + /** Stream nodes of a kind lazily (O(1) memory) instead of materializing them all */ + iterateNodesByKind(kind: Node['kind']): IterableIterator; + /** Get nodes filtered by kind + id prefix */ + getNodesByKindAndIdPrefix(kind: Node['kind'], idPrefix: string): Node[]; /** Check if a file exists */ fileExists(filePath: string): boolean; /** Read file content */ @@ -81,6 +87,8 @@ export interface ResolutionContext { getAllFiles(): string[]; /** Get nodes by lowercase name (O(1) lookup for fuzzy matching) */ getNodesByLowerName(lowerName: string): Node[]; + /** Get lowercase-name matches with SQL-side filters for bounded fuzzy matching */ + getNodesByLowerNameFiltered?(lowerName: string, filters?: NodeLookupFilters): Node[]; /** * Direct supertypes of the type named `typeName` (same language): the classes * it extends and the interfaces / protocols / traits it implements/conforms to, diff --git a/src/types.ts b/src/types.ts index 656bb1090..8d8194edb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -178,6 +178,26 @@ export interface Node { updatedAt: number; } +/** + * Narrow node-name lookup filters used by the resolver to avoid materializing + * huge same-name candidate sets in JavaScript on symbol-dense projects. + */ +export interface NodeLookupFilters { + language?: Language; + languages?: readonly Language[]; + kinds?: readonly NodeKind[]; + filePath?: string; + filePathPrefix?: string; + filePathSuffix?: string; + rankFilePath?: string; + rankFilePathPrefix?: string; + qualifiedName?: string; + qualifiedNameSuffix?: string; + hasReturnType?: boolean; + excludeId?: string; + limit?: number; +} + /** * An edge representing a relationship between two nodes */