diff --git a/__tests__/bare-call-no-method.test.ts b/__tests__/bare-call-no-method.test.ts index ba5e1d3f8e..144ed33868 100644 --- a/__tests__/bare-call-no-method.test.ts +++ b/__tests__/bare-call-no-method.test.ts @@ -130,6 +130,45 @@ describe('a receiver-less JS/TS call never binds to a method (#1714)', () => { expect(names).toContain('test'); }); + it('reads typed, defaulted parameter lists in linear time', async () => { + // A helper with many `name: T = value` parameters made the parameter scan + // backtrack through every way to split each type from its default — tens + // of seconds per called name on vscode's markersModel.test.ts, which + // stalled a whole resolution batch. + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-')); + fs.writeFileSync( + path.join(tempDir, 'harness.ts'), + 'export function suite(name: string, fn: () => void) { fn(); }\nexport function resolve(p: string) { return p; }\n' + ); + const params = Array.from({ length: 14 }, (_, i) => `p${i}: number = ${i} + ${i}`).join(', '); + fs.writeFileSync( + path.join(tempDir, 'markers.test.ts'), + [ + `function aMarker(${params}, related?: string[]): number {`, + ' return p0;', + '}', + 'function settle(value: number = 1, resolve: Handler = noop) {', + ' resolve(value);', + '}', + 'suite("markers", () => {', + ' aMarker(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14);', + ' settle();', + '});', + '', + ].join('\n') + ); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + const harness = cg.getNodesByKind('function').filter((n) => n.filePath === 'harness.ts'); + const suiteFn = harness.find((n) => n.name === 'suite')!; + const resolveFn = harness.find((n) => n.name === 'resolve')!; + const settle = cg.getNodesByKind('function').find((n) => n.name === 'settle')!; + // `suite` is bound nowhere in the test file, so it still resolves across files. + expect(cg.getIncomingEdges(suiteFn.id).some((e) => e.kind === 'calls')).toBe(true); + // `resolve` is a defaulted parameter of `settle` — a local binding. + expect(cg.getOutgoingEdges(settle.id).filter((e) => e.kind === 'calls').map((e) => e.target)).not.toContain(resolveFn.id); + }, 30_000); + it('keeps `other.serialize()` — a call through a receiver', async () => { const callees = await callsFromMethod( [ diff --git a/src/db/index.ts b/src/db/index.ts index 29ae0887f5..cfe7c10a72 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -394,10 +394,11 @@ export class DatabaseConnection { * One yield per statement keeps every stall to a single index build, which * stays inside the window. */ - async endBulkEdgeLoad(): Promise { + async endBulkEdgeLoad(options: { deferSynthesisSite?: boolean } = {}): Promise { const schemaPath = path.join(__dirname, 'schema.sql'); const schema = fs.readFileSync(schemaPath, 'utf-8'); for (const idx of DatabaseConnection.BULK_EDGE_INDEX_NAMES) { + if (options.deferSynthesisSite && idx === DatabaseConnection.SYNTHESIS_SITE_INDEX) continue; const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`)); if (!m) throw new Error(`schema.sql: edge index ${idx} not found for bulk-load recreation`); this.db.exec(m[0]); @@ -405,6 +406,22 @@ export class DatabaseConnection { } } + /** + * The sync-only synthesis-site index (#1988), which endBulkEdgeLoad can + * leave for later. Its partial predicate runs json_valid over the metadata of + * every edge — ~1.5M rows, several seconds on vscode — while nothing before + * the end of an index reads it, so the resolver builds it while the pool is + * busy with synthesis rather than on the critical path. Idempotent. + */ + createSynthesisSiteIndex(): void { + const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8'); + const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${DatabaseConnection.SYNTHESIS_SITE_INDEX}\\b[^;]*;`)); + if (!m) throw new Error(`schema.sql: edge index ${DatabaseConnection.SYNTHESIS_SITE_INDEX} not found`); + this.db.exec(m[0]); + } + + private static readonly SYNTHESIS_SITE_INDEX = 'idx_edges_synthesis_site'; + /** Recreate the FTS triggers + rebuild if a bulk-load window never closed. */ private healBulkNodeLoad(): void { if (!this.fts5Available) return; diff --git a/src/db/queries.ts b/src/db/queries.ts index 14a6643412..9666a256f5 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -278,6 +278,10 @@ export class QueryBuilder { getUnresolvedByName?: SqliteStatement; getNodesByName?: SqliteStatement; getNodesByNamePrefix?: SqliteStatement; + fileHasExportedNode?: SqliteStatement; + existingNodeIdsFull?: SqliteStatement; + getExportedNodesByFile?: SqliteStatement; + getNodesByFileAndName?: SqliteStatement; getFileNodesByNamePrefix?: SqliteStatement; getNodesByQualifiedNameExact?: SqliteStatement; getNodesByLowerName?: SqliteStatement; @@ -310,6 +314,11 @@ export class QueryBuilder { // (and therefore resolution's insertion-order disambiguation) is identical // to the one-row-per-run path. private batchStmts: Map = new Map(); + // Kind-filtered edge reads build their SQL per call (a variable IN list), + // but from a handful of kind sets: prepare each shape once. Supertype walks + // and the member-lookup passes issue them per node, and preparing cost + // about a third of the read. + private edgeKindStmts: Map = new Map(); private static readonly BATCH_SIZES: readonly number[] = [128, 32, 8, 1]; /** @@ -369,6 +378,17 @@ export class QueryBuilder { this.db = db; this.stmts = {}; this.batchStmts.clear(); + this.edgeKindStmts.clear(); + } + + private edgeKindStmt(sql: string): SqliteStatement { + let stmt = this.edgeKindStmts.get(sql); + if (!stmt) { + if (this.edgeKindStmts.size >= 64) this.edgeKindStmts.delete(this.edgeKindStmts.keys().next().value!); + stmt = this.db.prepare(sql); + this.edgeKindStmts.set(sql, stmt); + } + return stmt; } /** Set the normalized project-name tokens used to down-weight non-discriminative @@ -914,10 +934,17 @@ export class QueryBuilder { const uniqueIds = [...new Set(ids)]; for (let i = 0; i < uniqueIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { const chunk = uniqueIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); - const placeholders = chunk.map(() => '?').join(','); - const rows = this.db - .prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`) - .all(...chunk) as { id: string }[]; + // Every edge insert checks its endpoints here, a chunk at a time: the + // full-size statement is prepared once, the final partial chunk ad hoc. + let stmt: SqliteStatement; + if (chunk.length === SQLITE_PARAM_CHUNK_SIZE) { + stmt = this.stmts.existingNodeIdsFull ??= this.db.prepare( + `SELECT id FROM nodes WHERE id IN (${new Array(SQLITE_PARAM_CHUNK_SIZE).fill('?').join(',')})` + ); + } else { + stmt = this.db.prepare(`SELECT id FROM nodes WHERE id IN (${chunk.map(() => '?').join(',')})`); + } + const rows = stmt.all(...chunk) as { id: string }[]; for (const row of rows) { out.add(row.id); } @@ -947,6 +974,39 @@ export class QueryBuilder { this.nodeCache.clear(); } + /** + * Whether any node in `filePath` is exported — `getNodesByFile(f).some((n) => + * n.isExported)` as one indexed probe, without decoding the file's nodes. + */ + fileHasExportedNode(filePath: string): boolean { + if (!this.stmts.fileHasExportedNode) { + this.stmts.fileHasExportedNode = this.db.prepare( + 'SELECT 1 FROM nodes WHERE file_path = ? AND is_exported = 1 LIMIT 1' + ); + } + return this.stmts.fileHasExportedNode.get(filePath) !== undefined; + } + + /** The exported nodes of a file, in {@link getNodesByFile} order, decoding only those rows. */ + getExportedNodesByFile(filePath: string): Node[] { + if (!this.stmts.getExportedNodesByFile) { + this.stmts.getExportedNodesByFile = this.db.prepare( + 'SELECT * FROM nodes WHERE file_path = ? AND is_exported = 1 ORDER BY start_line, id' + ); + } + return (this.stmts.getExportedNodesByFile.all(filePath) as NodeRow[]).map(rowToNode); + } + + /** The nodes of a file with one name, in {@link getNodesByFile} order, decoding only those rows. */ + getNodesByFileAndName(filePath: string, name: string): Node[] { + if (!this.stmts.getNodesByFileAndName) { + this.stmts.getNodesByFileAndName = this.db.prepare( + 'SELECT * FROM nodes WHERE file_path = ? AND name = ? ORDER BY start_line, id' + ); + } + return (this.stmts.getNodesByFileAndName.all(filePath, name) as NodeRow[]).map(rowToNode); + } + /** * Get all nodes in a file */ @@ -1180,6 +1240,23 @@ export class QueryBuilder { } } + /** + * iterateNodesByKind narrowed to some languages, in the same canonical + * order — the ORDER BY is total (`id` is unique), so this yields exactly the + * nodes a caller filtering iterateNodesByKind by language would keep, in the + * same sequence. A Go pass on a TypeScript monorepo otherwise materialized + * every method in the project to find a couple of Go ones. + */ + *iterateNodesByKindIn(kind: NodeKind, languages: readonly string[]): IterableIterator { + if (languages.length === 0) return; + const stmt = this.db.prepare( + `SELECT * FROM nodes WHERE kind = ? AND language IN (${languages.map(() => '?').join(', ')}) ORDER BY file_path, start_line, id` + ); + for (const row of stmt.iterate(kind, ...languages)) { + yield rowToNode(row as NodeRow); + } + } + /** * Get all nodes in the database */ @@ -1992,7 +2069,7 @@ export class QueryBuilder { } sql += ' ORDER BY kind, target, line, col'; - const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; + const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[]; return rows.map(rowToEdge); } @@ -2012,7 +2089,7 @@ export class QueryBuilder { getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] { if (kinds && kinds.length > 0) { const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY kind, source, line, col`; - const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[]; + const rows = this.edgeKindStmt(sql).all(targetId, ...kinds) as EdgeRow[]; return rows.map(rowToEdge); } diff --git a/src/index.ts b/src/index.ts index df79743519..f61e161ffd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1368,7 +1368,8 @@ export class CodeGraph { // just degrade to scans until the recreate. bulkEdgeLoad: { begin: () => this.db.beginBulkEdgeLoad(), - end: () => this.db.endBulkEdgeLoad(), + end: () => this.db.endBulkEdgeLoad({ deferSynthesisSite: true }), + deferred: () => this.db.createSynthesisSiteIndex(), }, refIndexLoad: { begin: () => this.db.beginBulkRefLoad(), diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 23ede99210..0dc21edf92 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -495,9 +495,8 @@ async function arkuiStateBuildEdges(queries: QueryBuilder, ctx: ResolutionContex let scanned255 = 0; const edges: Edge[] = []; const seen = new Set(); - for (const struct of queries.iterateNodesByKind('struct')) { + for (const struct of queries.iterateNodesByKindIn('struct', ['arkts'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (struct.language !== 'arkts') continue; const children = queries.getOutgoingEdges(struct.id, ['contains']) .map((e) => queries.getNodeById(e.target)) .filter((n): n is Node => !!n); @@ -832,18 +831,15 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr // never the whole struct kind — that array is O(nodes) on struct-heavy // repos like the Linux kernel (#1212). const goStructs: Node[] = []; - for (const s of queries.iterateNodesByKind('struct')) { + for (const s of queries.iterateNodesByKindIn('struct', ['go'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (s.language === 'go') goStructs.push(s); + goStructs.push(s); } const structMethods = new Map>(); for (const s of goStructs) structMethods.set(s.id, methodNameSet(s.id)); - for (const iface of queries.iterateNodesByKind('interface')) { - if ((++scanned255 & 63) === 0) await onYield(); - + for (const iface of queries.iterateNodesByKindIn('interface', ['go'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (iface.language !== 'go') continue; const want = methodNameSet(iface.id); if (want.size === 0) continue; // empty interface (`any`) — would match everything let added = 0; @@ -905,11 +901,8 @@ async function goCrossFileMethodContainsEdges(queries: QueryBuilder, onYield: Ma return i >= 0 ? p.slice(0, i) : ''; }; - for (const method of queries.iterateNodesByKind('method')) { + for (const method of queries.iterateNodesByKindIn('method', ['go'])) { if ((++scanned255 & 63) === 0) await onYield(); - - if ((++scanned255 & 63) === 0) await onYield(); - if (method.language !== 'go') continue; // The receiver type is encoded in the method's qualifiedName as `Recv::name` // (extraction sets `${receiverType}::${name}` for receiver methods). const qn = method.qualifiedName; @@ -1128,9 +1121,8 @@ async function goGrpcStubImplEdges(queries: QueryBuilder, onYield: MaybeYield): const methodNamesByStruct = new Map>(); const methodNodesByStruct = new Map(); const goStructs: Node[] = []; - for (const s of queries.iterateNodesByKind('struct')) { + for (const s of queries.iterateNodesByKindIn('struct', ['go'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (s.language !== 'go') continue; goStructs.push(s); const ms = queries .getOutgoingEdges(s.id, ['contains']) @@ -1762,9 +1754,8 @@ async function rnCrossPlatformEdges(queries: QueryBuilder, onYield: MaybeYield): // impls in ≥2 native languages can pair, so the per-method JS-caller check // below only runs for genuine cross-platform candidates. const byName = new Map(); - for (const m of queries.iterateNodesByKind('method')) { + for (const m of queries.iterateNodesByKindIn('method', [...NATIVE])) { if ((++scanned255 & 63) === 0) await onYield(); - if (!NATIVE.has(m.language)) continue; const key = norm(m.name); const arr = byName.get(key); if (arr) arr.push(m); @@ -1886,17 +1877,16 @@ async function mybatisJavaXmlEdges(queries: QueryBuilder, onYield: MaybeYield): // stream below. Same rowid stream order as matching inline, so the edge // output is byte-identical when mappers do exist. const xmlMethods: Node[] = []; - for (const m of queries.iterateNodesByKind('method')) { + for (const m of queries.iterateNodesByKindIn('method', ['xml'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (m.language === 'xml') xmlMethods.push(m); + xmlMethods.push(m); } if (xmlMethods.length === 0) return edges; // Index Java methods by `::` for O(1) lookup. const javaIndex = new Map(); - for (const m of queries.iterateNodesByKind('method')) { + for (const m of queries.iterateNodesByKindIn('method', ['java', 'kotlin'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (m.language !== 'java' && m.language !== 'kotlin') continue; const parts = m.qualifiedName.split('::'); const last = parts[parts.length - 1]; const cls = parts[parts.length - 2]; @@ -2005,9 +1995,8 @@ async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionCon let scannedFiles = 0; // 1. Find the chain dispatcher(s): a Go method that invokes a `handlers` slice by index. const dispatchers: Node[] = []; - for (const n of queries.iterateNodesByKind('method')) { + for (const n of queries.iterateNodesByKindIn('method', ['go'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (n.language !== 'go') continue; const content = ctx.readFile(n.filePath); const src = content && sliceLines(content, n.startLine, n.endLine); if (src && GIN_DISPATCH_RE.test(src)) dispatchers.push(n); @@ -2233,13 +2222,42 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on // the SAME file (the cross-file barrel-namespace variant, e.g. trezor's getMethod, is // deferred). Gated on a real object literal with ≥2 entries that RESOLVE to callables (a // `{ width: 5 }` literal resolves to nothing → no edges); fan-out capped. -const REGISTRY_ASSIGN_RE = /(?:(?:const|let|var)\s+([A-Za-z_$][\w$]*)|((?:this\.)?[A-Za-z_$][\w$]*))\s*=\s*\{/g; -const REGISTRY_DISPATCH_RE = /(?:\bnew\s+)?((?:this\.)?[A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$.]*)\s*\]\s*(?:\(|\.[A-Za-z_$])/g; +// Both scans only START a name at an identifier's first character (or at a `this.`): the +// engine otherwise retries the greedy name at every later character of every identifier in +// the file. A match found from inside an identifier always has one from the identifier's +// start (same name tail, same continuation), so the result is unchanged — except where the +// scan itself resumes mid-identifier after a dispatch ending in `.method`, which +// `nextRegistryDispatch` covers with the unguarded pattern. +const REGISTRY_NAME_START = String.raw`(?:(? 0 && at < src.length && IDENT_RUN_CHAR.test(src[at - 1]!) && IDENT_RUN_CHAR.test(src[at]!)) { + for (; at < src.length && IDENT_RUN_CHAR.test(src[at]!); at++) { + REGISTRY_DISPATCH_AT.lastIndex = at; + const m = REGISTRY_DISPATCH_AT.exec(src); + if (m) return m; + } + } + REGISTRY_DISPATCH_RE.lastIndex = at; + return REGISTRY_DISPATCH_RE.exec(src); +} + /** From the index of an opening `{`, return the brace-balanced body up to its matching `}`. */ function braceBody(src: string, openIdx: number): string | null { let depth = 0; @@ -2319,13 +2337,12 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): // 1. Dispatch sites: `(new )?[]` followed by a call or a chained method. // A quoted-string key (`['save']`) does NOT match — that's a static access, not dispatch. - REGISTRY_DISPATCH_RE.lastIndex = 0; + const lineOf = makeLineAt(safe, 1); const dispatches: Array<{ ref: string; line: number; chained: string | null }> = []; - let dm: RegExpExecArray | null; - while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) { + for (let dm = nextRegistryDispatch(safe, 0); dm; dm = nextRegistryDispatch(safe, dm.index + dm[0].length)) { const win = safe.slice(dm.index, dm.index + 160); const cm = /\]\s*\([^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win) || /\]\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win); - dispatches.push({ ref: dm[1]!, line: safe.slice(0, dm.index).split('\n').length, chained: cm ? cm[1]! : null }); + dispatches.push({ ref: dm[1]!, line: lineOf(dm.index), chained: cm ? cm[1]! : null }); } if (!dispatches.length) continue; // Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`) @@ -2344,7 +2361,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): if (!body) continue; const names = registryEntryNames(body); // depth-0 `key: Identifier` entries only if (names.length >= REGISTRY_MIN_ENTRIES) { - registries.set(lhs, { names, line: safe.slice(0, am.index).split('\n').length }); + registries.set(lhs, { names, line: lineOf(am.index) }); } } if (!registries.size) continue; @@ -3185,10 +3202,9 @@ async function nixOptionPathEdges(queries: QueryBuilder, onYield: MaybeYield): P const byFile = new Map(); let scanned = 0; for (const kind of ['variable', 'function'] as NodeKind[]) { - for (const node of queries.iterateNodesByKind(kind)) { + for (const node of queries.iterateNodesByKindIn(kind, ['nix'])) { if ((++scanned255 & 63) === 0) await onYield(); if ((++scanned & 0x3fff) === 0 && onYield) await onYield(); - if (node.language !== 'nix') continue; const segs = nixLeadingPlainSegments(node.name); if (segs.length === 0) continue; const rec: Rec = { @@ -3298,9 +3314,9 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti // Cheap language gate: no Erlang modules → no cost beyond one streamed // kind scan (never a materialized array of every namespace — #1212). const erlangModules: Node[] = []; - for (const n of queries.iterateNodesByKind('namespace')) { + for (const n of queries.iterateNodesByKindIn('namespace', ['erlang'])) { if ((++scanned255 & 63) === 0) await onYield(); - if (n.language === 'erlang') erlangModules.push(n); + erlangModules.push(n); } if (erlangModules.length === 0) return []; @@ -3723,6 +3739,21 @@ export const SYNTH_PASSES: SynthPassDef[] = [ { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) }, ]; +/** + * Rough relative cost of the passes that run longest on large repos. Only + * the pooled dispatch ORDER reads it — heaviest first, so the longest passes + * start before the short ones fill the workers; unlisted passes keep registry + * order after these. A wrong hint costs wall time, never edges. + */ +const SYNTH_PASS_COST_HINT: Readonly> = { + cFnPtrEdges: 100, registryEdges: 60, tierEdges: 55, jsxEdges: 25, rnEventEdgesList: 24, + ifaceEdges: 18, flutterEdges: 16, cppEdges: 15, emitterEdges: 12, fieldEdges: 11, + mybatisEdges: 10, vuexEdges: 8, closureCollEdges: 6, piniaEdges: 5, renderEdges: 4, +}; +function synthPassCostHint(name: string): number { + return SYNTH_PASS_COST_HINT[name] ?? 0; +} + /** Fixed non-registry steps: goMethodContains, goImplements, dedupe-merge, insertMergedEdges. */ const FIXED_SYNTH_STEPS = 4; export const SYNTH_PROGRESS_STEPS = SYNTH_PASSES.length + FIXED_SYNTH_STEPS; @@ -3733,11 +3764,14 @@ export async function synthesizeCallbackEdges( // A live resolver pool to fan the independent passes across (structural type // so this file never imports the pool — resolver-worker imports THIS file). // Null/omitted → the sequential path, byte-identical to the pool path. - pool?: { runSynthPass(name: string): Promise<{ edges: Edge[]; ms: number }> } | null, + pool?: { runSynthPass(name: string): Promise<{ edges: Edge[]; ms: number }>; readonly size?: number } | null, // WAL-valve writer backstop (WalCheckpointValve.backpressure), called at // pool-idle points in the edge-insert loops below — the passes themselves // only read; every write in this function happens with the pool idle. - backpressure?: () => Promise | null + backpressure?: () => Promise | null, + // Main-thread work to run while the pool computes the passes (the main + // thread otherwise only waits there) — e.g. building a deferred index. + whilePoolBusy?: () => void ): Promise { // Each sub-pass below is a whole-graph scan, and there are ~30 of them, all // running synchronously on the indexer's main thread. Their AGGREGATE can run @@ -3868,30 +3902,62 @@ export async function synthesizeCallbackEdges( const MAIN_RETRY_MAX_NODES = 1_500_000; const graphNodes = queries.getNodeAndEdgeCount().nodes; + // Files whose text could feed a pass (inputs that produce NO edges included, + // e.g. an over-cap channel: deleting one may make the full pass viable). It + // depends only on the files, so with a pool the main thread reads them while + // the workers run the passes instead of after. + const collectInputs = async (): Promise => { + const found: string[] = []; + for (const file of ctx.getAllFiles()) { + const content = ctx.readFile(file); + if (content !== null && hasSynthesisPattern(file, content)) found.push(file); + await yieldToLoop(); + } + return found; + }; + let inputs: string[] | null = null; + if (pool && gatedIn.length > 1) { - await Promise.all( - gatedIn.map(async (i) => { - const pass = SYNTH_PASSES[i]!; - try { - const out = await pool.runSynthPass(pass.name); - passEdges[i] = out.edges; - markPass(pass.name, out.ms); - } catch (err) { - if (graphNodes > MAIN_RETRY_MAX_NODES) { - // Worker died at a scale where the main-thread retry is a process - // OOM risk: skip the pass, keep the index alive, and say so. - console.error( - `[synthesis] pass '${pass.name}' failed on a worker at ${graphNodes} nodes — skipped (edges from this pass are absent): ${err instanceof Error ? err.message : String(err)}` - ); - markPass(`${pass.name} (skipped at scale)`, 0); - return; - } - // Worker-side failure (crash, OOM, unknown pass after a version - // mismatch): retry this one pass on the main thread. - await runPassOnMain(i); + const runPooled = async (i: number): Promise => { + const pass = SYNTH_PASSES[i]!; + try { + const out = await pool.runSynthPass(pass.name); + passEdges[i] = out.edges; + markPass(pass.name, out.ms); + } catch (err) { + if (graphNodes > MAIN_RETRY_MAX_NODES) { + // Worker died at a scale where the main-thread retry is a process + // OOM risk: skip the pass, keep the index alive, and say so. + console.error( + `[synthesis] pass '${pass.name}' failed on a worker at ${graphNodes} nodes — skipped (edges from this pass are absent): ${err instanceof Error ? err.message : String(err)}` + ); + markPass(`${pass.name} (skipped at scale)`, 0); + return; } - }) + // Worker-side failure (crash, OOM, unknown pass after a version + // mismatch): retry this one pass on the main thread. + await runPassOnMain(i); + } + }; + // One pass per worker at a time, heaviest first. Handing every pass out + // at once split them by count, and a worker interleaves what it holds, so + // a heavy pass finished only with its worker's whole share: on vscode the + // 6s registry pass ended ~16s in. Pulling keeps workers busy until the + // queue drains. Edges merge by registry index below, so order is free. + const lanes = Math.min(pool.size ?? gatedIn.length, gatedIn.length); + const queue = [...gatedIn].sort( + (a, b) => synthPassCostHint(SYNTH_PASSES[b]!.name) - synthPassCostHint(SYNTH_PASSES[a]!.name) || a - b ); + const lane = async (): Promise => { + for (let i = queue.shift(); i !== undefined; i = queue.shift()) await runPooled(i); + }; + const fanOut = Promise.all(Array.from({ length: lanes }, lane)); + // Observed now, awaited below: a rejection while the scan runs must not + // surface as an unhandled one. + fanOut.catch(() => undefined); + whilePoolBusy?.(); + inputs = await collectInputs(); + await fanOut; } else { for (const i of gatedIn) { await runPassOnMain(i); @@ -3915,15 +3981,7 @@ export async function synthesizeCallbackEdges( await yieldToLoop(); await foldIfOver(); } - // Remember source gates, including inputs that currently produce NO edges - // (e.g. an over-cap channel). Deleting one may make the full pass viable. - const inputs: string[] = []; - for (const file of ctx.getAllFiles()) { - const content = ctx.readFile(file); - if (content !== null && hasSynthesisPattern(file, content)) inputs.push(file); - await yieldToLoop(); - } - queries.replaceSynthesisInputs(inputs); + queries.replaceSynthesisInputs(inputs ?? await collectInputs()); __mark('insertMergedEdges'); return merged.length + goImpl.length + goMethodContains.length; } diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index 8980d29e29..7aaa8af7f0 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -319,6 +319,8 @@ export const expressResolver: FrameworkResolver = { const files = context.getAllFiles().filter((f) => /\.(m?js|tsx?|cjs)$/.test(f)); const mounts = new Map>(); for (const file of files) { + // Most files mount nothing: look for `.use(` before decoding the file. + if (context.fileContains && !context.fileContains(file, '.use(')) continue; const content = context.readFile(file); if (!content || !content.includes('.use(')) continue; const lang = detectLanguage(file); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index e94d56a34f..70e018f57a 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -76,8 +76,15 @@ const exportedSymbolMemos = new WeakMap; defaultComponent: Node | undefined; defaultFnClass: Node | undefined; @@ -87,9 +94,16 @@ interface FileExportIndex { * guess. `const Home = () => …; export default Home` and the namespace * object `const UploadApi = { uploadARCapture }; export default UploadApi` * are both invisible to the `isExported` index above: neither declaration - * has an `export_statement` ancestor. + * has an `export_statement` ancestor. `undefined` until first read through + * {@link defaultExportBindingNode}; `null` when there is none. + */ + defaultBinding?: Node | null; + /** + * Names a local export clause (`export { impl as alias }`) binds to a + * declaration the extractor never flagged isExported, for names not in + * `byName`. `undefined` until first read through {@link exportedByName}. */ - defaultBinding: Node | undefined; + clauseAliases?: Map; } const DEFAULT_BINDING_KINDS = new Set(['function', 'class', 'component', 'constant', 'variable']); @@ -113,38 +127,57 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE } let idx = perFile.get(filePath); if (!idx) { - idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined, defaultBinding: undefined }; - const nodesInFile = context.getNodesInFile(filePath); - // Every declaration, exported or not: a local `export { impl as alias }` - // clause exports a declaration the extractor never flagged isExported. - const declared = new Map(); - for (const n of nodesInFile) { - if (!declared.has(n.name)) declared.set(n.name, n); - if (!n.isExported) continue; + idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined }; + const exported = context.getExportedNodesInFile?.(filePath) ?? context.getNodesInFile(filePath).filter((n) => n.isExported); + for (const n of exported) { if (!idx.byName.has(n.name)) idx.byName.set(n.name, n); if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n; if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n; } + perFile.set(filePath, idx); + } + return idx; +} + +/** The file's nodes named `name`, in `getNodesInFile` order. */ +function nodesInFileNamed(filePath: string, name: string, context: ResolutionContext): Node[] { + return context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name); +} + +/** The declaration `export default NAME` names in this file (see FileExportIndex.defaultBinding). */ +function defaultExportBindingNode(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined { + if (idx.defaultBinding === undefined) { const bound = defaultExportBinding(filePath, context); - if (bound !== null) { - idx.defaultBinding = nodesInFile - .filter((n) => n.name === bound && DEFAULT_BINDING_KINDS.has(n.kind)) - .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0]; - } + idx.defaultBinding = + bound === null + ? null + : (nodesInFileNamed(filePath, bound, context) + .filter((n) => DEFAULT_BINDING_KINDS.has(n.kind)) + .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0] ?? null); + } + return idx.defaultBinding ?? undefined; +} + +/** What this file exports as `name`: an exported declaration, else a local export clause's binding. */ +function exportedByName(filePath: string, idx: FileExportIndex, name: string, context: ResolutionContext): Node | undefined { + const direct = idx.byName.get(name); + if (direct) return direct; + if (idx.clauseAliases === undefined) { // Bind names introduced by a local export clause to their declarations, so // an importer asking for the renamed name gets the real symbol instead of // falling through to the name-matcher (which cannot cross the rename). + // The declaration is the file's first node of that name, exported or not. + idx.clauseAliases = new Map(); const content = context.readFile(filePath); if (content && content.includes('export')) { for (const { exportedName, localName } of extractLocalExportAliases(content)) { - if (idx.byName.has(exportedName)) continue; - const decl = declared.get(localName); - if (decl) idx.byName.set(exportedName, decl); + if (idx.byName.has(exportedName) || idx.clauseAliases.has(exportedName)) continue; + const decl = nodesInFileNamed(filePath, localName, context)[0]; + if (decl) idx.clauseAliases.set(exportedName, decl); } } - perFile.set(filePath, idx); } - return idx; + return idx.clauseAliases.get(name); } /** Drop the per-context memo tables (see ReferenceResolver.clearCaches). */ @@ -154,6 +187,7 @@ export function clearImportResolverMemos(context: ResolutionContext): void { fileExportIndexes.delete(context); luaFileBasenameIndexes.delete(context); cobolCopybookIndexes.delete(context); + pythonModuleFileMemos.delete(context); } export function resolveImportPath( @@ -1977,23 +2011,48 @@ function resolveModuleImportToFile( * no edge. Shared by absolute `import a.b.c` and absolute `from a.b import c` * (where `c` is a submodule) resolution. */ +/** + * Per-context memo for findPythonModuleFile: module path → the `.py` and + * `/__init__.py` file nodes whose path ends with it, in name-lookup + * order. Only the importing file's own path is excluded per call, so taking + * the first survivor returns the node the unmemoized scan found. Without it, + * every ref naming a module outside the project (`from unittest import mock`) + * rescanned every `__init__.py` in the tree. Same stable window as the name + * caches; dropped by clearImportResolverMemos. + */ +const pythonModuleFileMemos = new WeakMap>(); + function findPythonModuleFile( mod: string, context: ResolutionContext, excludeFilePath: string ): Node | null { if (!mod || mod.startsWith('.')) return null; // relative imports handled elsewhere - 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`)); - if (moduleFile) return moduleFile; - const pkgFile = context - .getNodesByName('__init__.py') - .find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}/__init__.py`)); - return pkgFile ?? null; + let memo = pythonModuleFileMemos.get(context); + if (!memo) { + memo = new Map(); + pythonModuleFileMemos.set(context, memo); + } + let files = memo.get(mod); + if (!files) { + const rel = mod.replace(/\./g, '/'); + const lastSeg = mod.split('.').pop()!; + const endsWith = (p: string, want: string): boolean => p === want || p.endsWith('/' + want); + files = { + module: context + .getNodesByName(`${lastSeg}.py`) + .filter((n) => n.kind === 'file' && endsWith(n.filePath, `${rel}.py`)), + pkg: context + .getNodesByName('__init__.py') + .filter((n) => n.kind === 'file' && endsWith(n.filePath, `${rel}/__init__.py`)), + }; + memo.set(mod, files); + } + return ( + files.module.find((n) => n.filePath !== excludeFilePath) ?? + files.pkg.find((n) => n.filePath !== excludeFilePath) ?? + null + ); } /** @@ -2353,13 +2412,14 @@ function findExportedSymbolWalk( // resolves and the component shows a false 0 callers (#629). // A component file IS its default export; otherwise the statement that // names the binding beats the first-exported-function guess. - const direct = exportIndex.defaultComponent ?? exportIndex.defaultBinding ?? exportIndex.defaultFnClass; + const direct = + exportIndex.defaultComponent ?? defaultExportBindingNode(filePath, exportIndex, context) ?? exportIndex.defaultFnClass; if (direct) return direct; } else if (want.isNamespace && want.memberName) { - const direct = exportIndex.byName.get(want.memberName); + const direct = exportedByName(filePath, exportIndex, want.memberName, context); if (direct) return direct; } else { - const direct = exportIndex.byName.get(want.exportedName); + const direct = exportedByName(filePath, exportIndex, want.exportedName, context); if (direct) return direct; } diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 98e2473923..17f1300b91 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -45,6 +45,14 @@ const SUPERTYPE_BEARING_KINDS = new Set([ 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', ]); +/** + * The only edges resolving a reference reads (getSupertypes' walk). The batch + * loop commits a batch that made any of these before the next batch fans out; + * its other edges land while that batch resolves. A resolution strategy that + * reads another edge kind must be added here. + */ +const RESOLUTION_READ_EDGE_KINDS: Edge['kind'][] = ['implements', 'extends']; + // SUPERTYPE_TARGET_KINDS (the kinds an extends/implements edge may TARGET) // lives in ./types — the name-matcher needs the same set to restrict its // candidate pool before ranking. It is deliberately wider than @@ -242,6 +250,12 @@ export class ReferenceResolver { private lowerNameCache: LRUCache; // lower(name) → nodes cache private qualifiedNameCache: LRUCache; // qualified_name → nodes cache private fileLinesCache: LRUCache; // file → split lines cache + // id → node for the resolver's own point reads. Every resolved reference + // re-reads its target a few times (kind gate, language gate, alias + // following, edge creation) and targets recur ~5× on large repos; the query + // layer's cache is too small for that working set. Same stable window as the + // name caches above; absent ids are not cached. + private nodeByIdCache: LRUCache; private methodMatchCache: LRUCache; // lang\0Type::method → matching method nodes // Per-(language, methodName) owner index for getMethodMatches: buckets a // method name's candidates by their qualifiedName's last two segments so a @@ -278,6 +292,8 @@ export class ReferenceResolver { // resolution pass (same lifetime assumption as nameCache); clearCaches() resets // it between passes. Callers must treat the returned array as read-only. private nodesByKindCache = new Map(); + // Filesystem existence probes behind context.fileExists (paths not in knownFiles). + private fileExistsMemo = new Map(); private knownNames: Set | null = null; // all known symbol names for fast pre-filtering private knownFiles: Set | null = null; private cachesWarmed = false; @@ -308,6 +324,7 @@ export class ReferenceResolver { // Split-lines arrays are heavier than content strings; refs arrive // file-ordered, so a small cache still hits nearly always. this.fileLinesCache = new LRUCache(contentLimit); + this.nodeByIdCache = new LRUCache(Math.max(limit * 4, 20_000)); this.methodMatchCache = new LRUCache(limit); this.context = this.createContext(); @@ -392,6 +409,15 @@ export class ReferenceResolver { this.cachesWarmed = true; } + /** `queries.getNodeById` through the resolver's own id → node cache. */ + private nodeById(id: string): Node | null { + const cached = this.nodeByIdCache.get(id); + if (cached !== undefined) return cached; + const node = this.queries.getNodeById(id); + if (node) this.nodeByIdCache.set(id, node); + return node; + } + /** * Clear internal caches */ @@ -404,11 +430,13 @@ export class ReferenceResolver { this.lowerNameCache.clear(); this.qualifiedNameCache.clear(); this.fileLinesCache.clear(); + this.nodeByIdCache.clear(); this.methodMatchCache.clear(); this.methodOwnerIndexCache.clear(); this.supertypeMemo.clear(); this.supertypeGen++; this.nodesByKindCache.clear(); + this.fileExistsMemo.clear(); this.knownNames = null; this.knownFiles = null; this.cachesWarmed = false; @@ -446,6 +474,23 @@ export class ReferenceResolver { } } + /** + * `readFileCached(filePath)?.includes(needle)` for an ASCII needle, searched + * in the raw bytes: an ASCII byte sequence survives UTF-8 decoding + * unchanged, and decoding is most of reading a file nobody keeps. + */ + private fileContains(filePath: string, needle: string): boolean { + if (this.fileCache.has(filePath)) return this.fileCache.get(filePath)?.includes(needle) ?? false; + const fullPath = path.join(this.projectRoot, filePath); + try { + const stats = fs.statSync(fullPath); + if (!stats.isFile() || stats.size > MAX_SOURCE_FILE_SIZE_BYTES) return false; + return fs.readFileSync(fullPath).includes(needle); + } catch { + return false; + } + } + /** * Create the resolution context */ @@ -459,6 +504,21 @@ export class ReferenceResolver { return this.nodeCache.get(filePath)!; }, + fileHasExportedNode: (filePath: string) => { + const cached = this.nodeCache.get(filePath); + return cached !== undefined ? cached.some((n) => n.isExported) : this.queries.fileHasExportedNode(filePath); + }, + + getExportedNodesInFile: (filePath: string) => { + const cached = this.nodeCache.get(filePath); + return cached !== undefined ? cached.filter((n) => n.isExported) : this.queries.getExportedNodesByFile(filePath); + }, + + getNodesInFileNamed: (filePath: string, name: string) => { + const cached = this.nodeCache.get(filePath); + return cached !== undefined ? cached.filter((n) => n.name === name) : this.queries.getNodesByFileAndName(filePath, name); + }, + getNodesByName: (name: string) => { const cached = this.nameCache.get(name); if (cached !== undefined) return cached; @@ -568,18 +628,30 @@ export class ReferenceResolver { // calls per probe (~70x slower here). It would also be wrong to apply // — indexing deliberately follows in-root symlinks whose targets live // outside the root (#935), so only the `../` escape is refused. + // Memoized: import resolution probes the same candidate paths (every + // extension of a specifier) from every file that imports it, and the + // tree does not change within a resolution pass (same window as the + // file-content cache; dropped by clearCaches). + const probed = this.fileExistsMemo.get(filePath); + if (probed !== undefined) return probed; const fullPath = lexicalPathWithinRoot(this.projectRoot, filePath); - if (fullPath === null) return false; - try { - return fs.existsSync(fullPath); - } catch (error) { - logDebug('Error checking file existence', { filePath, error: String(error) }); - return false; + let exists = false; + if (fullPath !== null) { + try { + exists = fs.existsSync(fullPath); + } catch (error) { + logDebug('Error checking file existence', { filePath, error: String(error) }); + } } + if (this.fileExistsMemo.size >= 200_000) this.fileExistsMemo.clear(); + this.fileExistsMemo.set(filePath, exists); + return exists; }, readFile: (filePath: string) => this.readFileCached(filePath), + fileContains: (filePath: string, needle: string) => this.fileContains(filePath, needle), + getFileLines: (filePath: string) => { const cached = this.fileLinesCache.get(filePath); if (cached !== undefined) return cached; @@ -622,7 +694,7 @@ export class ReferenceResolver { }, getNodeById: (id: string) => { - return this.queries.getNodeById(id); + return this.nodeById(id); }, getSupertypes: (typeName: string, language) => { @@ -648,8 +720,8 @@ export class ReferenceResolver { } else { const supertypes = new Set(); for (const tn of typeNodes) { - for (const edge of this.queries.getOutgoingEdges(tn.id, ['implements', 'extends'])) { - const target = this.queries.getNodeById(edge.target); + for (const edge of this.queries.getOutgoingEdges(tn.id, RESOLUTION_READ_EDGE_KINDS)) { + const target = this.nodeById(edge.target); if (target?.name && target.name !== typeName) supertypes.add(target.name); } } @@ -903,7 +975,7 @@ export class ReferenceResolver { : this.gateLanguage(candidate, ref); if (!resolved || ref.referenceKind !== 'calls') return resolved; - const target = this.queries.getNodeById(resolved.targetNodeId); + const target = this.nodeById(resolved.targetNodeId); if (!target) return resolved; const dot = ref.referenceName.lastIndexOf('.'); @@ -990,7 +1062,7 @@ export class ReferenceResolver { } const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref); if (viaImport) { - const target = this.queries.getNodeById(viaImport.targetNodeId); + const target = this.nodeById(viaImport.targetNodeId); if ( target && (target.kind === 'function' || @@ -1104,7 +1176,7 @@ export class ReferenceResolver { // matcher would link each `mkOption` call to whichever file's inherit // binding it happened to pick. Same-file matches only. if (nameResult) { - const target = this.queries.getNodeById(nameResult.targetNodeId); + const target = this.nodeById(nameResult.targetNodeId); // A definition its language makes file-local — a C `static`, a Kotlin // `private fun`, a Go unexported name in another package, a Rust // non-`pub` item outside its module subtree — cannot be what a name in @@ -1172,9 +1244,9 @@ export class ReferenceResolver { // Promote "extends" to "implements" when a class/struct targets an interface if (kind === 'extends') { - const targetNode = this.queries.getNodeById(ref.targetNodeId); + const targetNode = this.nodeById(ref.targetNodeId); if (targetNode && (targetNode.kind === 'interface' || targetNode.kind === 'protocol')) { - const sourceNode = this.queries.getNodeById(ref.original.fromNodeId); + const sourceNode = this.nodeById(ref.original.fromNodeId); if (sourceNode && sourceNode.kind !== 'interface' && sourceNode.kind !== 'protocol') { kind = 'implements'; } @@ -1187,7 +1259,7 @@ export class ReferenceResolver { // apart from a function call without symbol info, but resolution // can: if `Foo` resolves to a class, the call IS an instantiation. if (kind === 'calls') { - const targetNode = this.queries.getNodeById(ref.targetNodeId); + const targetNode = this.nodeById(ref.targetNodeId); if ( targetNode && (targetNode.kind === 'class' || targetNode.kind === 'struct' || targetNode.kind === 'union') @@ -1693,7 +1765,12 @@ export class ReferenceResolver { // a 22GB WAL on a 4.6GB DB (migration plan §7a.1). parallel?: { dbPath: string; - bulkEdgeLoad?: { begin: () => void; end: () => void | Promise }; + bulkEdgeLoad?: { + begin: () => void; + end: () => void | Promise; + /** Indexes `end` leaves for later; built while the pool runs synthesis. */ + deferred?: () => void; + }; /** unresolved_refs index window for the batched loop — the loop only * reads the status index + PK; dropping the sync-path ref indexes cuts * each per-batch DELETE's B-tree work (DatabaseConnection.beginBulkRefLoad). */ @@ -1981,16 +2058,23 @@ export class ReferenceResolver { const PERSIST_CHUNK = 1000; const tPersist = Date.now(); - // Persist edges BEFORE fanning out the next batch: later batches read - // this batch's edges — resolveMethodOnType walks supertype chains over - // `extends`/`implements` edges that earlier batches resolved, so a - // receiver typed as a subclass only reaches a method declared on its - // base class if those edges are visible. (Validated on dubbo: fanning - // out first downgraded exactly those supertype-method resolutions from - // the 0.9 typed-receiver path to the 0.65 word-overlap fallback.) + // A batch that made `extends`/`implements` edges persists them BEFORE + // fanning out the next batch: later batches read them — + // resolveMethodOnType walks supertype chains over edges that earlier + // batches resolved, so a receiver typed as a subclass only reaches a + // method declared on its base class if those edges are visible. + // (Validated on dubbo: fanning out first downgraded exactly those + // supertype-method resolutions from the 0.9 typed-receiver path to the + // 0.65 word-overlap fallback.) They are the only edges resolution reads + // (RESOLUTION_READ_EDGE_KINDS), so a batch that made none — every batch + // after the prerequisite phase readNextBatch drains first — fans the + // next batch out now and inserts while it resolves: the same writes in + // the same order, and nothing the workers read differs. tLp = Date.now(); const edges = this.createEdges(result.resolved); lp('createEdges', tLp); + const readEdge = edges.some((e) => RESOLUTION_READ_EDGE_KINDS.includes(e.kind)); + let nextInFlight = !readEdge && nextBatch.length > 0 ? beginBatch(nextBatch) : null; tLp = Date.now(); for (let i = 0; i < edges.length; i += PERSIST_CHUNK) { this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK)); @@ -1998,11 +2082,12 @@ export class ReferenceResolver { } lp('insertEdges', tLp); - // NOW fan the next batch out — workers see exactly the edge state the - // sequential baseline would (every batch ≤ this one committed), while - // the main thread spends the REST of the persist (ref deletes + failed - // parking below) overlapped with their resolution — the double-buffer. - const nextInFlight = nextBatch.length > 0 ? beginBatch(nextBatch) : null; + // NOW fan the next batch out (if not already) — workers see exactly the + // edge state the sequential baseline would (every batch ≤ this one + // committed), while the main thread spends the REST of the persist (ref + // deletes + failed parking below) overlapped with their resolution — + // the double-buffer. + if (!nextInFlight && nextBatch.length > 0) nextInFlight = beginBatch(nextBatch); // Clean up resolved refs so they don't appear in the next batch — // by row id, so a same-key sibling ref in a LATER batch (same caller @@ -2113,6 +2198,19 @@ export class ReferenceResolver { } } + // Indexes the bulk-edge window's end deferred: built once, while the pool + // is busy with synthesis when it is, and in any case before this returns. + let deferredBuilt = !bulkEdgesActive || !parallel?.bulkEdgeLoad?.deferred; + const buildDeferredIndexes = (): void => { + if (deferredBuilt) return; + deferredBuilt = true; + const tDeferred = Date.now(); + try { + parallel!.bulkEdgeLoad!.deferred!(); + } catch { /* healed by schema.sql on the next open, like a crash in the window */ } + if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] deferred-edge-index: ${Date.now() - tDeferred}ms`); + }; + // Dynamic-edge synthesis: now that all base `calls` edges are persisted, // synthesize observer/callback dispatch edges (dispatcher → registered // callbacks) that static parsing leaves out. Best-effort — never fail the @@ -2127,11 +2225,13 @@ export class ReferenceResolver { this.context, onSynthesisProgress, pool, - parallel?.backpressure + parallel?.backpressure, + buildDeferredIndexes ); } catch { // synthesis is additive and optional; ignore failures } + buildDeferredIndexes(); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] callback-synthesis: ${Date.now() - tSynth}ms`); } finally { if (pool) await pool.destroy().catch(() => undefined); @@ -2349,7 +2449,7 @@ export class ReferenceResolver { * Get file path from node ID */ private getFilePathFromNodeId(nodeId: string): string { - const node = this.queries.getNodeById(nodeId); + const node = this.nodeById(nodeId); return node?.filePath || ''; } @@ -2357,7 +2457,7 @@ export class ReferenceResolver { * Get language from node ID */ private getLanguageFromNodeId(nodeId: string): UnresolvedRef['language'] { - const node = this.queries.getNodeById(nodeId); + const node = this.nodeById(nodeId); return node?.language || 'unknown'; } @@ -2515,7 +2615,7 @@ export class ReferenceResolver { private resolveThisMemberFnRef(ref: UnresolvedRef): ResolvedRef | null { const member = ref.referenceName.slice('this.'.length); if (!member) return null; - const fromNode = this.queries.getNodeById(ref.fromNodeId); + const fromNode = this.nodeById(ref.fromNodeId); if (!fromNode) return null; // A hook declared at class-body level (Ruby `before_action :authenticate`) // attributes to the CLASS node itself — its qualified name IS the scope. @@ -2572,10 +2672,38 @@ export class ReferenceResolver { // periodically so the #850 liveness watchdog heartbeat can fire (#1091). const maybeYield = createYielder(); const resolved: ResolvedRef[] = []; + // Refs share supertypes (every `this.dispose` walks the same chain), and + // nothing is written until the loop ends: read each type's supertype + // edges, and each supertype's callable members by name, once. + const supertypeEdges = new Map(); + const callableMembers = new Map>(); + const supertypesOf = (id: string): Edge[] => { + let edges = supertypeEdges.get(id); + if (!edges) { + edges = this.queries.getOutgoingEdges(id, ['implements', 'extends']); + supertypeEdges.set(id, edges); + } + return edges; + }; + const callableMembersOf = (id: string): Map => { + let byName = callableMembers.get(id); + if (!byName) { + byName = new Map(); + for (const c of this.queries.getOutgoingEdges(id, ['contains'])) { + const m = this.nodeById(c.target); + if (!m || (m.kind !== 'function' && m.kind !== 'method')) continue; + const list = byName.get(m.name); + if (list) list.push(m); + else byName.set(m.name, [m]); + } + callableMembers.set(id, byName); + } + return byName; + }; for (const ref of deferred) { await maybeYield(); const member = ref.referenceName.slice('this.'.length); - const fromNode = this.queries.getNodeById(ref.fromNodeId); + const fromNode = this.nodeById(ref.fromNodeId); if (!fromNode || !member) continue; // Class-body-level hooks (Ruby) attribute to the CLASS node itself. let className: string; @@ -2619,24 +2747,16 @@ export class ReferenceResolver { for (let depth = 0; depth < 5 && frontierNodes.length > 0 && !target; depth++) { const next: Node[] = []; for (const typeNode of frontierNodes) { - for (const edge of this.queries.getOutgoingEdges(typeNode.id, ['implements', 'extends'])) { - const superNode = this.queries.getNodeById(edge.target); + for (const edge of supertypesOf(typeNode.id)) { + const superNode = this.nodeById(edge.target); if (!superNode || seenNodes.has(superNode.id)) continue; seenNodes.add(superNode.id); if (!SUPERTYPE_BEARING_KINDS.has(superNode.kind)) continue; // Member lookup anchored on the supertype's contains edges. - for (const c of this.queries.getOutgoingEdges(superNode.id, ['contains'])) { - const m = this.queries.getNodeById(c.target); - if ( - m && - m.name === member && - (m.kind === 'function' || m.kind === 'method') && - sameLanguageFamily(m.language, ref.language) - ) { - target = m; - break; - } - } + target = + callableMembersOf(superNode.id) + .get(member) + ?.find((m) => sameLanguageFamily(m.language, ref.language)) ?? null; if (target) break; next.push(superNode); } @@ -2694,19 +2814,19 @@ export class ReferenceResolver { // A `#define` is a value, never a callee (#1838): a macro defined only in // an unrelated file is not what `NAME(x)` here expands to either. if (ref.referenceKind === 'calls') { - const target = this.queries.getNodeById(result.targetNodeId); + const target = this.nodeById(result.targetNodeId); if (target?.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(target.signature ?? '')) return null; } // An `imports` reference names something importable — never a member that // only exists inside a type. if (ref.referenceKind === 'imports') { - const target = this.queries.getNodeById(result.targetNodeId); + const target = this.nodeById(result.targetNodeId); return target && !isImportableKind(target.kind) ? null : result; } if (!isInheritanceRef(ref)) return result; - const target = this.queries.getNodeById(result.targetNodeId); + const target = this.nodeById(result.targetNodeId); if (target && !isSupertypeTarget(target)) { const type = this.sameNamedTypeOfValue(target); if (!type) return null; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ac2470b17e..d93d54fd27 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -211,6 +211,15 @@ function hasBridgeEvidence(candidate: Node, ref: UnresolvedRef, context: Resolut return false; } +/** + * Per-context memo: node id → its language, for gateLanguageMatch. Matches + * land on ~5 refs per target on vscode, and each check otherwise fetched the + * whole node (a point read + row decode past the query layer's small cache) + * only to read one field. Nodes are fixed within a resolution pass; the memo + * drops with clearNameMatcherMemos. + */ +const TARGET_LANGUAGE = new WeakMap>(); + /** Reject the chosen result without shrinking a pool or trying a replacement. */ export function gateLanguageMatch( result: ResolvedRef | null, @@ -218,6 +227,29 @@ export function gateLanguageMatch( context: ResolutionContext ): ResolvedRef | null { if (!result) return result; + // No code family on the reference's side: no target can cross a boundary. + if (CODE_FAMILY[ref.language] === undefined) return result; + if (context.getNodeById) { + let languages = TARGET_LANGUAGE.get(context); + if (!languages) { + languages = new Map(); + TARGET_LANGUAGE.set(context, languages); + } + let language = languages.get(result.targetNodeId); + if (language === undefined) { + const node = context.getNodeById(result.targetNodeId); + if (node) { + language = node.language as string; + if (languages.size >= 400_000) languages.clear(); + languages.set(result.targetNodeId, language); + } + } + if (language !== undefined) { + if (!crossesCodeBoundary(ref.language, language)) return result; + const target = context.getNodeById(result.targetNodeId); + return target && !hasBridgeEvidence(target, ref, context) ? null : result; + } + } const target = context.getNodeById?.(result.targetNodeId) ?? context.getNodesByName(ref.referenceName).find((n) => n.id === result.targetNodeId); if (target && crossesCodeBoundary(ref.language, target.language) && @@ -677,14 +709,23 @@ function isSealedModule(filePath: string, context: ResolutionContext): boolean { } const hit = memo.get(filePath); if (hit !== undefined) return hit; - const source = context.readFile(filePath); - const code = source === null ? '' : blankStringContents(stripCommentsForRegex(source, 'typescript')); // CommonJS assignments can execute inside template interpolations, which the // masker blanks. Keep the conservative raw-source exemption for those forms. + // Cheapest disqualifiers first: nearly every module exports a node (asked + // without reading or decoding the file), and masking the source is only + // needed to rule out the ones that don't. (The masker only blanks text, so + // no `import` in the source means none in code.) + const exportsNode = context.fileHasExportedNode + ? context.fileHasExportedNode(filePath) + : context.getNodesInFile(filePath).some((n) => n.isExported); + const source = exportsNode ? null : context.readFile(filePath); const sealed = - source !== null && HAS_IMPORT_STATEMENT.test(code) && - !context.getNodesInFile(filePath).some((n) => n.isExported) && - !HAS_ESM_EXPORT.test(code) && !HAS_CJS_EXPORT.test(source); + !exportsNode && source !== null && source.includes('import') && + !HAS_CJS_EXPORT.test(source) && + (() => { + const code = blankStringContents(stripCommentsForRegex(source, 'typescript')); + return HAS_IMPORT_STATEMENT.test(code) && !HAS_ESM_EXPORT.test(code); + })(); memo.set(filePath, sealed); return sealed; } @@ -898,16 +939,106 @@ function isReceiverLessCall(ref: UnresolvedRef, context: ResolutionContext): boo const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; if (line === undefined) return false; - const at = line.slice(ref.column); - const nameEsc = ref.referenceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!new RegExp('^' + nameEsc + '\\s*[(<]').test(at)) return false; - // Nothing but whitespace, an operator or an opener may precede a bare call. - return !/[.\w$\]\)]\s*$/.test(line.slice(0, ref.column)) || /\b(?:return|await|yield|typeof|void|new|else|case|throw|in|of|instanceof|go|defer)\s*$/.test(line.slice(0, ref.column)); + // `^\s*[(<]` at the call's column, without compiling a pattern per call. + if (!line.startsWith(ref.referenceName, ref.column)) return false; + CALL_OPENER.lastIndex = ref.column + ref.referenceName.length; + if (!CALL_OPENER.test(line)) return false; + // Nothing but whitespace, an operator or an opener may precede a bare call: + // read the text before the column backwards, past trailing whitespace, + // instead of end-anchoring a pattern that scans the prefix from its start. + let end = Math.min(ref.column, line.length); + while (end > 0 && WHITESPACE.test(line[end - 1]!)) end--; + if (end === 0 || !RECEIVER_TAIL_CHAR.test(line[end - 1]!)) return true; + let start = end; + while (start > 0 && WORD_CHAR.test(line[start - 1]!)) start--; + return BARE_CALL_KEYWORDS.has(line.slice(start, end)); } +/** `\s*[(<]` from a given index (sticky). */ +const CALL_OPENER = /\s*[(<]/y; +const WHITESPACE = /\s/; +const WORD_CHAR = /\w/; +/** A character that ends a receiver: `.`, a word character, `$`, `]` or `)`. */ +const RECEIVER_TAIL_CHAR = /[.\w$\])]/; +/** Keywords after which a name starts an expression, so the call has no receiver. */ +const BARE_CALL_KEYWORDS: ReadonlySet = new Set([ + 'return', 'await', 'yield', 'typeof', 'void', 'new', 'else', 'case', 'throw', 'in', 'of', 'instanceof', 'go', 'defer', +]); + /** Per-context memo: `file\0name` → "the file binds this name locally". */ const LOCAL_BINDING_MEMO = new WeakMap>(); +/** + * Where a file's local bindings can start, for isLocallyBoundJsName: every + * `const`/`let`/`var` and `function`/`class` keyword and every `=>`. Each + * binding pattern can only match from one of these (or, for a parameter, + * from the `(` before an occurrence of the name), so a lookup tries its + * patterns at those offsets instead of searching the file once per pattern + * per name. Kept for the last few files — calls arrive file by file. + */ +interface LocalBindingSites { + varDecls: number[]; + fnDecls: number[]; + arrows: number[]; +} +const LOCAL_BINDING_SITES = new WeakMap>(); +const LOCAL_BINDING_SITES_KEEP = 16; +const VAR_DECL_SITE = /\b(?:const|let|var)\s/g; +const FN_DECL_SITE = /\b(?:function|class)\s/g; + +function localBindingSites(filePath: string, source: string, context: ResolutionContext): LocalBindingSites { + let cache = LOCAL_BINDING_SITES.get(context); + if (!cache) { + cache = new Map(); + LOCAL_BINDING_SITES.set(context, cache); + } + let sites = cache.get(filePath); + if (!sites) { + const offsets = (re: RegExp): number[] => Array.from(source.matchAll(re), (m) => m.index!); + const arrows: number[] = []; + for (let a = source.indexOf('=>'); a !== -1; a = source.indexOf('=>', a + 2)) arrows.push(a); + sites = { varDecls: offsets(VAR_DECL_SITE), fnDecls: offsets(FN_DECL_SITE), arrows }; + if (cache.size >= LOCAL_BINDING_SITES_KEEP) cache.delete(cache.keys().next().value!); + cache.set(filePath, sites); + } + return sites; +} + +type LocalBindingPatterns = { decl: RegExp; fn: RegExp; param: RegExp }; +/** Sticky binding patterns by name — the same names recur file after file. */ +const LOCAL_BINDING_PATTERNS = new Map(); +const LOCAL_BINDING_PATTERNS_CAP = 4096; +const JS_BINDING_NAME = /^[\w$]+$/; +const ARROW_HEAD_CHAR = /[\w$.]/; +const IMPORT_BINDING_VALUE = /^\s*(?:await\s+)?(?:require|import)\s*\(/; + +function localBindingPatterns(name: string, flags: string): LocalBindingPatterns { + const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return { + // `const { name } = require('./m')` / `= await import('./m')` binds an IMPORT, + // not a shadow: the symbol lives in the other file and the call means it. + decl: new RegExp( + '\\b(?:const|let|var)\\s+(?:' + n + '\\b|[{\\[][^;=]*?\\b' + n + '\\b[^;=]*?[}\\]])\\s*(?:=\\s*([^;\\n]*))?', + flags + ), + fn: new RegExp('\\b(?:function|class)\\s+' + n + '\\b', flags), + // a parameter: every token before the name in the list is itself a + // parameter (identifier, optional type, optional default) — so a string + // argument containing the word cannot match. + // Each earlier parameter has exactly one parse: its first non-space + // character after the identifier picks the type (`?`/`:`), default (`=`) + // or bare alternative. Written as `(type)?(default)?\s*`, the same + // strings split several ways per parameter, and a failing search + // backtracked through every combination — 30-40s per name on a vscode + // test file whose helper takes nine `name: T = value` parameters. + param: new RegExp( + '\\(\\s*(?:(?:\\.\\.\\.)?[\\w$]+(?:\\s*(?:\\?\\s*)?:[^,()]+|\\s*=[^,()]+|\\s*),\\s*)*' + + n + '\\b(?:\\s*\\??\\s*:[^,()]*)?(?:\\s*=[^,()]*)?(?:\\s*,\\s*[^()]*)?\\)\\s*(?::[^=;{]*)?(?:=>|\\{)', + flags + ), + }; +} + /** * Whether a JS/TS file binds `name` itself — as a `const`/`let`/`var`/ * `function`/`class` declaration (destructuring included) or as a parameter @@ -931,33 +1062,73 @@ function isLocallyBoundJsName(name: string, filePath: string, context: Resolutio const hit = memo.get(key); if (hit !== undefined) return hit; const source = context.readFile(filePath) ?? ''; - const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // `const { name } = require('./m')` / `= await import('./m')` binds an IMPORT, - // not a shadow: the symbol lives in the other file and the call means it. - const declRe = new RegExp( - '\\b(?:const|let|var)\\s+(?:' + n + '\\b|[{\\[][^;=]*?\\b' + n + '\\b[^;=]*?[}\\]])\\s*(?:=\\s*([^;\\n]*))?', - 'g' - ); - let bound = false; - for (const m of source.matchAll(declRe)) { - if (!/^\s*(?:await\s+)?(?:require|import)\s*\(/.test(m[1] ?? '')) { bound = true; break; } - } - if (!bound) { - bound = - new RegExp('\\b(?:function|class)\\s+' + n + '\\b').test(source) || - // a parameter: every token before the name in the list is itself a - // parameter (identifier, optional type, optional default) — so a string - // argument containing the word cannot match. - new RegExp( - '\\(\\s*(?:(?:\\.\\.\\.)?[\\w$]+(?:\\s*\\??\\s*:\\s*[^,()]+)?(?:\\s*=\\s*[^,()]+)?\\s*,\\s*)*' + - n + '\\b(?:\\s*\\??\\s*:[^,()]*)?(?:\\s*=[^,()]*)?(?:\\s*,\\s*[^()]*)?\\)\\s*(?::[^=;{]*)?(?:=>|\\{)' - ).test(source) || - new RegExp('(?:^|[^\\w$.])' + n + '\\s*=>').test(source); - } + const bound = JS_BINDING_NAME.test(name) + ? bindsAtSites(source, name, localBindingSites(filePath, source, context)) + : bindsAnywhere(source, name); memo.set(key, bound); return bound; } +/** isLocallyBoundJsName's patterns, searched through the whole source. */ +function bindsAnywhere(source: string, name: string): boolean { + const { decl, fn, param } = localBindingPatterns(name, 'g'); + for (const m of source.matchAll(decl)) { + if (!IMPORT_BINDING_VALUE.test(m[1] ?? '')) return true; + } + const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return fn.test(source) || param.test(source) || new RegExp('(?:^|[^\\w$.])' + n + '\\s*=>').test(source); +} + +/** + * bindsAnywhere for a plain identifier, tried only where a match can start: + * a declaration at its keyword (in order, resuming past each match exactly as + * the global search does), a parameter at the `(` its list opens with — the + * last `(` before an occurrence of the name, with no `)` between, since no + * earlier parameter can hold a parenthesis — and `name =>` at each arrow. + */ +function bindsAtSites(source: string, name: string, sites: LocalBindingSites): boolean { + let patterns = LOCAL_BINDING_PATTERNS.get(name); + if (!patterns) { + patterns = localBindingPatterns(name, 'y'); + if (LOCAL_BINDING_PATTERNS.size >= LOCAL_BINDING_PATTERNS_CAP) { + LOCAL_BINDING_PATTERNS.delete(LOCAL_BINDING_PATTERNS.keys().next().value!); + } + LOCAL_BINDING_PATTERNS.set(name, patterns); + } + const { decl, fn, param } = patterns; + let from = 0; + for (const at of sites.varDecls) { + if (at < from) continue; + decl.lastIndex = at; + const m = decl.exec(source); + if (!m) continue; + from = at + m[0].length; + if (!IMPORT_BINDING_VALUE.test(m[1] ?? '')) return true; + } + for (const at of sites.fnDecls) { + fn.lastIndex = at; + if (fn.test(source)) return true; + } + let tried = -1; + for (let at = source.indexOf(name); at !== -1; at = source.indexOf(name, at + 1)) { + const open = at > 0 ? source.lastIndexOf('(', at - 1) : -1; + if (open < 0 || open === tried || source.lastIndexOf(')', at - 1) > open) continue; + tried = open; + param.lastIndex = open; + if (param.test(source)) return true; + } + // `name =>`: the name ends where the whitespace before the arrow starts. + for (const arrow of sites.arrows) { + let end = arrow; + while (end > 0 && WHITESPACE.test(source[end - 1]!)) end--; + const start = end - name.length; + if (start >= 0 && source.startsWith(name, start) && (start === 0 || !ARROW_HEAD_CHAR.test(source[start - 1]!))) { + return true; + } + } + return false; +} + /** * Try to resolve a reference by exact name match */ @@ -980,27 +1151,24 @@ export function matchByExactName( const storeAction = matchJsStoreBindingCall(ref, context); if (storeAction) return storeAction; } - const candidates = context.getNodesByName(ref.referenceName) + // Every rule below judges one candidate on its own, so they run as ONE pass, + // the kind/language checks before the ones that read source: a common name + // has thousands of same-named nodes, and a chain of filters copied that + // list once per rule for every reference. + const cMacroCall = ref.referenceKind === 'calls' && (ref.language === 'c' || ref.language === 'cpp'); + const valueRef = ref.referenceKind === 'references' || ref.referenceKind === 'function_ref'; + const importRef = ref.referenceKind === 'imports'; + const inheritanceRef = isInheritanceRef(ref); + const candidates = context.getNodesByName(ref.referenceName).filter((n) => // Macro constants are not callees and must not consume the same-name // ceiling (#1839). Keep upstream's language gate on the chosen result. - .filter((n) => !(ref.referenceKind === 'calls' && - (ref.language === 'c' || ref.language === 'cpp') && - n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? ''))) + !(cMacroCall && n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? '')) && // Type/value references retain same-family eligibility: a native namesake // must not hide the actual web type. Calls still gate only the winner. - .filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') || - sameLanguageFamily(n.language, ref.language)) - .filter((n) => n.kind !== 'import') - // Nested locals are only reachable from inside their container (#1230). - .filter((n) => isLexicallyReachable(n, ref, context)) - // Preserve import ranking; calls reject the winner without promoting another. - .filter((n) => ref.referenceKind !== 'imports' || n.filePath === ref.filePath || - !ESM_FAMILY.has(n.language) || !isSealedModule(n.filePath, context)) + (!valueRef || sameLanguageFamily(n.language, ref.language)) && + n.kind !== 'import' && // A receiver-less JS/TS or Go call cannot reach a method (#1714, #1857). - .filter((n) => !((bareJs || bareGo) && n.kind === 'method')) - // A name the file binds itself (a parameter, a const) shadows every other - // file's symbol of that name, so a bare call has no cross-file candidate. - .filter((n) => !(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context))) + !((bareJs || bareGo) && n.kind === 'method') && // An `extends`/`implements` ref names a supertype, so anything that can't // BE one is not a candidate at all. This is eligibility, not // ranking: kind is only a scoring bonus below (and none is awarded for @@ -1008,11 +1176,20 @@ export function matchByExactName( // the real `trait`, and as the sole candidate was adopted outright by the // single-match shortcut. Restricting the pool BEFORE ranking lets the // legitimate supertype win instead of merely dropping the false edge. - .filter((n) => !isInheritanceRef(ref) || isSupertypeTarget(n)) + (!inheritanceRef || isSupertypeTarget(n)) && // Likewise for `imports`: a member that only exists inside a type is not // importable, so it is not a candidate. Without this a `path`/`id`/`url` // import resolved to some interface's same-named property. - .filter((n) => ref.referenceKind !== 'imports' || isImportableKind(n.kind)); + (!importRef || isImportableKind(n.kind)) && + // Nested locals are only reachable from inside their container (#1230). + isLexicallyReachable(n, ref, context) && + // Preserve import ranking; calls reject the winner without promoting another. + (!importRef || n.filePath === ref.filePath || + !ESM_FAMILY.has(n.language) || !isSealedModule(n.filePath, context)) && + // A name the file binds itself (a parameter, a const) shadows every other + // file's symbol of that name, so a bare call has no cross-file candidate. + !(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)) + ); if (candidates.length === 0) { return null; @@ -1159,6 +1336,12 @@ export function matchByQualifiedName( return null; } +/** A node a `Receiver.method()` call can name as the method's owning type. */ +function isMethodOwnerKind(n: Node): boolean { + return n.kind === 'class' || n.kind === 'struct' || n.kind === 'union' || n.kind === 'interface' || + (n.language === 'scala' && n.kind === 'module'); +} + /** * When a symbol name is ambiguous across files, prefer the candidate(s) declared * in the call site's own file, keeping the rest in their original order (#1079). @@ -2069,8 +2252,12 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { RUST_TRAIT_IMPL_MEMO.delete(context); SEALED_MODULES.delete(context); LOCAL_BINDING_MEMO.delete(context); + LOCAL_BINDING_SITES.delete(context); SELECTOR_NAMES.delete(context); GET_STATE_FILES.delete(context); + TS_FIELD_DECL_MEMO.delete(context); + TS_CLASS_LINES.delete(context); + TARGET_LANGUAGE.delete(context); } function memoPatterns(key: string, build: () => RegExp[]): RegExp[] { @@ -2892,7 +3079,8 @@ export function matchMethodCall( // cross-file use reaches the same helper through the import path. if (dotMatch && !objectOrClass!.includes('.') && OBJECT_LITERAL_LANGUAGES.has(ref.language)) { const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null => { - const holders = preferCallSiteFile(context.getNodesByName(objectOrClass!), ref.filePath).filter( + // Same-file holders only, so the call-site-first ordering is moot. + const holders = context.getNodesByName(objectOrClass!).filter( (n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath ); for (const holder of holders) { @@ -2913,31 +3101,29 @@ export function matchMethodCall( // resolves to `a/`'s method (#1079). const strat1 = nmTimedT('mc-class', ref, (): ResolvedRef | null => { const classCandidates = preferCallSiteFile( - context.getNodesByName(objectOrClass!), + context.getNodesByName(objectOrClass!).filter(isMethodOwnerKind), ref.filePath, ); for (const classNode of classCandidates) { - if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface' || (classNode.language === 'scala' && classNode.kind === 'module')) { - // Skip cross-language class matches - if (classNode.language !== ref.language) continue; + // Skip cross-language class matches + if (classNode.language !== ref.language) continue; - const nodesInFile = context.getNodesInFile(classNode.filePath); - const methodNode = nodesInFile.find( - (n) => - n.kind === 'method' && - n.name === methodName && - n.qualifiedName.includes(classNode.name) - ); + const nodesInFile = context.getNodesInFile(classNode.filePath); + const methodNode = nodesInFile.find( + (n) => + n.kind === 'method' && + n.name === methodName && + n.qualifiedName.includes(classNode.name) + ); - if (methodNode) { - return { - original: ref, - targetNodeId: methodNode.id, - confidence: 0.85, - resolvedBy: 'qualified-name', - }; - } + if (methodNode) { + return { + original: ref, + targetNodeId: methodNode.id, + confidence: 0.85, + resolvedBy: 'qualified-name', + }; } } return null; @@ -2958,30 +3144,28 @@ export function matchMethodCall( if (capitalizedReceiver !== objectOrClass) { const strat2 = nmTimedT('mc-capital', ref, (): ResolvedRef | null => { const fuzzyClassCandidates = preferCallSiteFile( - context.getNodesByName(capitalizedReceiver), + context.getNodesByName(capitalizedReceiver).filter(isMethodOwnerKind), ref.filePath, ); for (const classNode of fuzzyClassCandidates) { - if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface' || (classNode.language === 'scala' && classNode.kind === 'module')) { - // Skip cross-language class matches - if (classNode.language !== ref.language) continue; - - const nodesInFile = context.getNodesInFile(classNode.filePath); - const methodNode = nodesInFile.find( - (n) => - n.kind === 'method' && - n.name === methodName && - n.qualifiedName.includes(classNode.name) - ); + // Skip cross-language class matches + if (classNode.language !== ref.language) continue; - if (methodNode) { - return { - original: ref, - targetNodeId: methodNode.id, - confidence: 0.8, - resolvedBy: 'instance-method', - }; - } + const nodesInFile = context.getNodesInFile(classNode.filePath); + const methodNode = nodesInFile.find( + (n) => + n.kind === 'method' && + n.name === methodName && + n.qualifiedName.includes(classNode.name) + ); + + if (methodNode) { + return { + original: ref, + targetNodeId: methodNode.id, + confidence: 0.8, + resolvedBy: 'instance-method', + }; } } return null; @@ -3302,37 +3486,115 @@ function matchRustSelfFieldCall( } /** - * Resolve a TS/JS `this..()` call (#1496) through the field's - * declared type, read off the ENCLOSING class's own declaration lines: - * a field or constructor-parameter property (`private mailer: Mailer`, - * `mailer?: Mailer`, `readonly mailer: Mailer`) or an initializer - * (`mailer = new Mailer()`, `this.mailer = new Mailer()`). The method is then - * VALIDATED on that type by resolveMethodOnType. Null — never a bare-name - * fallback — when the field is not declared there or its type is external, - * a builtin (`this.items.push()`) or not spelled out. + * Per-context memo for matchTsThisFieldCall's declaration scan: `classId\0field` + * → the first line of the class that declares the field (as `: typeof X`, + * `: X`, or `= new X`, tried in that order per line) with its captured type, + * or null. The scan depends only on the class and the field, but ran for every + * `this..()` call — re-splitting the file and compiling three + * patterns each time, a third of all method-call matching on vscode. Derived + * from file source: drops with clearNameMatcherMemos. */ -function matchTsThisFieldCall( - field: string, - methodName: string, - ref: UnresolvedRef, - context: ResolutionContext, -): ResolvedRef | null { - if (!field || field.includes('.')) return null; - const caller = context.getNodeById?.(ref.fromNodeId); - if (!caller) return null; - const sep = caller.qualifiedName.lastIndexOf('::'); - if (sep <= 0) return null; // not inside a class - const owner = caller.qualifiedName.slice(0, sep).split('::').pop(); - if (!owner) return null; +const TS_FIELD_DECL_MEMO = new WeakMap>(); +/** A class's comment-stripped lines, and which of them hold each `[\w$#]` token. */ +interface TsClassDecl { + lines: string[]; + /** Field lookups so far; the token index is built on the second. */ + lookups: number; + linesByToken: Map | null; +} +/** The last few classes' declarations — calls arrive file by file. */ +const TS_CLASS_LINES = new WeakMap>(); +const TS_CLASS_LINES_KEEP = 32; + +function tsClassDecl(cls: Node, context: ResolutionContext): TsClassDecl | null { + let cache = TS_CLASS_LINES.get(context); + if (!cache) { + cache = new Map(); + TS_CLASS_LINES.set(context, cache); + } + const hit = cache.get(cls.id); + if (hit !== undefined) return hit; + const source = context.readFile(cls.filePath); + const decl = source + ? { + lines: source + .split('\n') + .slice(Math.max(0, cls.startLine - 1), cls.endLine) + .map((rawLine) => rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')), + lookups: 0, + linesByToken: null, + } + : null; + if (cache.size >= TS_CLASS_LINES_KEEP) cache.delete(cache.keys().next().value!); + cache.set(cls.id, decl); + return decl; +} - const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter( - (n) => (n.kind === 'class' || n.kind === 'component') && sameLanguageFamily(n.language, ref.language) - ); +const TS_FIELD_TOKEN = /^[\w$#]+$/; +const TS_LINE_TOKEN = /[\w$#]+/g; +const TS_TOKEN_CHAR = /[\w$#]/; + +/** + * tsFieldPatterns with the field spelled `[\w$#]+`, tried sticky at a whole- + * token occurrence of the field. There the run consumes exactly the field (a + * shorter prefix cannot continue: every pattern needs `\s`, `?`, `!`, `:` or + * `=` next), so each matches exactly where the field's own pattern would — + * without compiling three patterns for every field of every class. + */ +const TS_FIELD_PATTERNS_AT: readonly TsFieldPattern[] = [ + { re: /(? 0 && TS_TOKEN_CHAR.test(line[i - 1]!)) || (end < line.length && TS_TOKEN_CHAR.test(line[end]!))) continue; + (at ??= []).push(i); + } + if (!at) return null; + for (const { re, valueType } of TS_FIELD_PATTERNS_AT) { + for (const i of at) { + re.lastIndex = i; + const m = re.exec(line); + if (m && m[1]) return { valueType, type: m[1] }; + } + } + return null; +} + +/** Indices of the class lines holding `token` as a whole `[\w$#]` run, ascending. */ +function tsClassLinesWithToken(decl: TsClassDecl, token: string): readonly number[] { + if (!decl.linesByToken) { + const index = new Map(); + for (let i = 0; i < decl.lines.length; i++) { + for (const m of decl.lines[i]!.matchAll(TS_LINE_TOKEN)) { + const rows = index.get(m[0]); + if (!rows) index.set(m[0], [i]); + else if (rows[rows.length - 1] !== i) rows.push(i); + } + } + decl.linesByToken = index; + } + return decl.linesByToken.get(token) ?? []; +} + +type TsFieldPattern = { re: RegExp; valueType: boolean }; +/** Compiled declaration patterns by field name — fields recur across classes. */ +const TS_FIELD_PATTERNS = new Map(); +const TS_FIELD_PATTERNS_CAP = 4096; + +function tsFieldPatterns(field: string): TsFieldPattern[] { + const hit = TS_FIELD_PATTERNS.get(field); + if (hit) return hit; const fieldEsc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // A word boundary cannot open a private name; it also lets a public // `items` match `#items`. Keep the two field namespaces distinct (#1987). const fieldStart = '(? = [ + const patterns: TsFieldPattern[] = [ // `storage: typeof DraftHubStorage` — the type OF a value: an object // literal used as a namespace. Its members are bare-named functions inside // the constant's extent (#1573), so they are found by containment, not by @@ -3352,60 +3614,128 @@ function matchTsThisFieldCall( // `mailer = new Mailer()` / `this.mailer = new Mailer()` { re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), valueType: false }, ]; - for (const cls of owners) { - const source = context.readFile(cls.filePath); - if (!source) continue; - const declLines = source.split('\n').slice(Math.max(0, cls.startLine - 1), cls.endLine); - for (const rawLine of declLines) { - const line = rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, ''); + if (TS_FIELD_PATTERNS.size >= TS_FIELD_PATTERNS_CAP) TS_FIELD_PATTERNS.delete(TS_FIELD_PATTERNS.keys().next().value!); + TS_FIELD_PATTERNS.set(field, patterns); + return patterns; +} + +function tsFieldDeclaration( + cls: Node, + field: string, + context: ResolutionContext +): { valueType: boolean; type: string } | null { + let memo = TS_FIELD_DECL_MEMO.get(context); + if (!memo) { + memo = new Map(); + TS_FIELD_DECL_MEMO.set(context, memo); + } + const key = cls.id + '\0' + field; + const hit = memo.get(key); + if (hit !== undefined) return hit; + let found: { valueType: boolean; type: string } | null = null; + const decl = tsClassDecl(cls, context); + if (decl && TS_FIELD_TOKEN.test(field)) { + // Every pattern needs the field as a whole `[\w$#]` run — the lookbehind + // bars one before it, and nothing after it but `\s`, `?`, `!`, `:` or `=` + // can continue a match — so only lines holding that token can match. A + // class asked about several fields is indexed by token once. + const rows = ++decl.lookups > 1 ? tsClassLinesWithToken(decl, field) : null; + const count = rows ? rows.length : decl.lines.length; + for (let k = 0; k < count && !found; k++) { + const line = decl.lines[rows ? rows[k]! : k]!; + // Every pattern spells the field literally, so only a line that contains + // it can match — and most of a class's lines never mention a given field. + if (line.includes(field)) found = tsFieldOnLine(line, field); + } + } else if (decl) { + const patterns = tsFieldPatterns(field); + scan: for (const line of decl.lines) { + if (!line.includes(field)) continue; for (const { re, valueType } of patterns) { const m = line.match(re); if (!m || !m[1]) continue; - if (valueType) { - // The value's declaration may live in another file (it is imported); - // the call site's file is preferred when several share the name. - const holderName = m[1].split('.').pop()!; - const holders = preferCallSiteFile(context.getNodesByName(holderName), ref.filePath).filter( - (n) => (n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language) - ); - for (const holder of holders) { - const hit = resolveObjectLiteralMember(holder, methodName, ref, context, 0.85, 'instance-method'); - if (hit) return hit; - } - return null; - } - // `ns.Mailer` → `Mailer`; a primitive or builtin names no project type. - const typeName = m[1].split('.').pop()!; - if (!/^[A-Z]/.test(typeName)) return null; - // Two apps in one repo may each declare a `UserService`. The bare-name - // path this replaces broke that tie by directory proximity, so keep the - // same signal: among the type's declarations of the method, prefer the - // one closest to the call site's directory (its own app), never index - // order. resolveMethodOnType still answers the single-declaration and - // supertype cases. - const declared = context - .getNodesByName(methodName) - .filter( - (n) => - n.kind === 'method' && - sameLanguageFamily(n.language, ref.language) && - (n.qualifiedName === `${typeName}::${methodName}` || n.qualifiedName.endsWith(`::${typeName}::${methodName}`)) - ); - if (declared.length > 1) { - const callDirs = ref.filePath.split('/').slice(0, -1); - const shared = (fp: string) => { - const dirs = fp.split('/').slice(0, -1); - let i = 0; - while (i < dirs.length && i < callDirs.length && dirs[i] === callDirs[i]) i++; - return i; - }; - const nearest = [...declared].sort((a, b) => shared(b.filePath) - shared(a.filePath) || a.filePath.localeCompare(b.filePath))[0]!; - return { original: ref, targetNodeId: nearest.id, confidence: 0.85, resolvedBy: 'instance-method' }; - } - return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method'); + found = { valueType, type: m[1] }; + break scan; } } } + memo.set(key, found); + return found; +} + +/** + * Resolve a TS/JS `this..()` call (#1496) through the field's + * declared type, read off the ENCLOSING class's own declaration lines: + * a field or constructor-parameter property (`private mailer: Mailer`, + * `mailer?: Mailer`, `readonly mailer: Mailer`) or an initializer + * (`mailer = new Mailer()`, `this.mailer = new Mailer()`). The method is then + * VALIDATED on that type by resolveMethodOnType. Null — never a bare-name + * fallback — when the field is not declared there or its type is external, + * a builtin (`this.items.push()`) or not spelled out. + */ +function matchTsThisFieldCall( + field: string, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + if (!field || field.includes('.')) return null; + const caller = context.getNodeById?.(ref.fromNodeId); + if (!caller) return null; + const sep = caller.qualifiedName.lastIndexOf('::'); + if (sep <= 0) return null; // not inside a class + const owner = caller.qualifiedName.slice(0, sep).split('::').pop(); + if (!owner) return null; + + const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter( + (n) => (n.kind === 'class' || n.kind === 'component') && sameLanguageFamily(n.language, ref.language) + ); + for (const cls of owners) { + const decl = tsFieldDeclaration(cls, field, context); + if (!decl) continue; + if (decl.valueType) { + // The value's declaration may live in another file (it is imported); + // the call site's file is preferred when several share the name. + const holderName = decl.type.split('.').pop()!; + const holders = preferCallSiteFile(context.getNodesByName(holderName), ref.filePath).filter( + (n) => (n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language) + ); + for (const holder of holders) { + const hit = resolveObjectLiteralMember(holder, methodName, ref, context, 0.85, 'instance-method'); + if (hit) return hit; + } + return null; + } + // `ns.Mailer` → `Mailer`; a primitive or builtin names no project type. + const typeName = decl.type.split('.').pop()!; + if (!/^[A-Z]/.test(typeName)) return null; + // Two apps in one repo may each declare a `UserService`. The bare-name + // path this replaces broke that tie by directory proximity, so keep the + // same signal: among the type's declarations of the method, prefer the + // one closest to the call site's directory (its own app), never index + // order. resolveMethodOnType still answers the single-declaration and + // supertype cases. + const declared = context + .getNodesByName(methodName) + .filter( + (n) => + n.kind === 'method' && + sameLanguageFamily(n.language, ref.language) && + (n.qualifiedName === `${typeName}::${methodName}` || n.qualifiedName.endsWith(`::${typeName}::${methodName}`)) + ); + if (declared.length > 1) { + const callDirs = ref.filePath.split('/').slice(0, -1); + const shared = (fp: string) => { + const dirs = fp.split('/').slice(0, -1); + let i = 0; + while (i < dirs.length && i < callDirs.length && dirs[i] === callDirs[i]) i++; + return i; + }; + const nearest = [...declared].sort((a, b) => shared(b.filePath) - shared(a.filePath) || a.filePath.localeCompare(b.filePath))[0]!; + return { original: ref, targetNodeId: nearest.id, confidence: 0.85, resolvedBy: 'instance-method' }; + } + return resolveMethodOnType(typeName, methodName, ref, context, 0.85, 'instance-method'); + } return null; } diff --git a/src/resolution/resolver-pool.ts b/src/resolution/resolver-pool.ts index eac808ba48..56b9785a66 100644 --- a/src/resolution/resolver-pool.ts +++ b/src/resolution/resolver-pool.ts @@ -222,6 +222,11 @@ export class ResolverPool { this.recycleWaiters.clear(); } + /** Number of workers — how many passes the synthesis fan-out keeps in flight. */ + get size(): number { + return this.workers.length; + } + /** Whether this batch is worth fanning out. */ static worthParallel(batchLength: number): boolean { return batchLength >= MIN_PARALLEL_BATCH; diff --git a/src/resolution/tier-synthesizer.ts b/src/resolution/tier-synthesizer.ts index b146fdce09..be119f0a7f 100644 --- a/src/resolution/tier-synthesizer.ts +++ b/src/resolution/tier-synthesizer.ts @@ -248,12 +248,13 @@ function decorators(safe: string, name: string): Array<{ args: string; index: nu interface FileFacts { file: string; safe: string; - nodes: Node[]; + /** Read on first use: most files that pass a gate hold no site, and never need them. */ + readonly nodes: Node[]; lineOf: (idx: number) => number; /** The 0-based column of an index on its line — where the site reader looks for the call. */ columnOf: (idx: number) => number; /** Lines a framework resolver made a route node on — registrations, never client calls. */ - routeLines: Set; + readonly routeLines: Set; /** Local names bound to an HTTP client instance, with their literal base URL when written. */ clients: Map; /** The module's default export is a client instance. */ @@ -284,9 +285,8 @@ function readFacts(ctx: ResolutionContext, file: string): FileFacts | null { const content = ctx.readFile(file); if (!content) return null; const safe = stripCommentsForRegex(content, 'typescript'); - const nodes = ctx.getNodesInFile(file); - const routeLines = new Set(); - for (const n of nodes) if (n.kind === 'route') routeLines.add(n.startLine); + let nodes: Node[] | null = null; + let routeLines: Set | null = null; const clients = new Map(); CLIENT_FACTORY.lastIndex = 0; let m: RegExpExecArray | null; @@ -303,10 +303,18 @@ function readFacts(ctx: ResolutionContext, file: string): FileFacts | null { return { file, safe, - nodes, + get nodes() { + return (nodes ??= ctx.getNodesInFile(file)); + }, lineOf: makeLineAt(safe, 1), columnOf: (idx: number) => idx - (safe.lastIndexOf('\n', idx - 1) + 1), - routeLines, + get routeLines() { + if (!routeLines) { + routeLines = new Set(); + for (const n of this.nodes) if (n.kind === 'route') routeLines.add(n.startLine); + } + return routeLines; + }, clients, defaultClient, queues, @@ -347,9 +355,20 @@ const CLIENT_NAMES = const SERVER_NAMES = /^(?:app|router|route|routes|express|fastify|koa|hono|elysia|apiRouter|v1|v2|r)$/; /** A type argument between the callee and its `(` — `useSWR('/api/team')`, `ky.get('/x')`. */ const GENERIC = String.raw`(?:<[^()<>]*(?:<[^()<>]*>[^()<>]*)*>)?`; +/** + * Where a receiver chain (`this.api.client`) may begin: not after an earlier + * identifier-start character of the same word (the second lookbehind is the + * whole rule; the first is its one-character fast path). A match from the + * middle of a word implies one from its first letter — the chain regexes read + * the rest of the word either way — and each of their matches ends on a + * non-word character, so a scan never resumes mid-word: this only skips starts + * bound to fail. Without it every letter of every identifier re-read the + * dotted chain behind it, which on vscode made this the slowest pass. + */ +const CHAIN_START = String.raw`(?): void { - const { safe, nodes, lineOf } = facts; + const { safe, lineOf } = facts; const add = (index: number, open: number, verb: string | null, baseURL: string | null): void => { const line = lineOf(index); const callee = safe.slice(index, open).replace(/\s+/g, '').replace(/<.*>$/, ''); if (facts.routeLines.has(line)) return; // a registration the resolver already read - const fn = enclosingFn(nodes, line); + const fn = enclosingFn(facts.nodes, line); if (!fn) return; const args = argumentsAt(safe, open); if (!args || !args[0]) return; @@ -556,10 +575,10 @@ function collectHttpSites(ctx: ResolutionContext, facts: FileFacts, sites: HttpS // 2. Queue job → consumer // ============================================================================= -const QUEUE_ADD = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*add\s*\(\s*(['"`])([^'"`]+)\2/g; +const QUEUE_ADD = new RegExp(CHAIN_START + /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*add\s*\(\s*(['"`])([^'"`]+)\2/.source, 'g'); const QUEUE_SHAPED = /queue|jobs?$|worker|bull|flow|producer/i; const NEW_WORKER = /\bnew\s+Worker\s*(?:<[^>]*>)?\s*\(\s*(['"`])([^'"`]+)\1\s*,\s*/g; -const QUEUE_PROCESS = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*process\s*\(\s*(?:(['"`])([^'"`]+)\2\s*,\s*)?(?:\d+\s*,\s*)?/g; +const QUEUE_PROCESS = new RegExp(CHAIN_START + /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*process\s*\(\s*(?:(['"`])([^'"`]+)\2\s*,\s*)?(?:\d+\s*,\s*)?/.source, 'g'); /** A handler argument: a named function (group 1), or an inline function. */ const HANDLER_ARG = /^(?:(?:async\s+)?([A-Za-z_$][\w$.]*)\s*(?:[,)]|$)|(?:async\s*)?(?:\(|function\b|[A-Za-z_$][\w$]*\s*=>))/; @@ -644,7 +663,7 @@ function decoratedMethod(facts: FileFacts, end: number, cls: Node | null): Node } function collectQueue(ctx: ResolutionContext, facts: FileFacts, producers: QueueProducer[], consumers: QueueConsumer[], cache: Map): void { - const { safe, nodes, lineOf } = facts; + const { safe, lineOf } = facts; let m: RegExpExecArray | null; QUEUE_ADD.lastIndex = 0; while ((m = QUEUE_ADD.exec(safe)) !== null) { @@ -653,7 +672,7 @@ function collectQueue(ctx: ResolutionContext, facts: FileFacts, producers: Queue const last = receiver.replace(/\s+/g, '').split('.').pop()!; if (queue === null && !QUEUE_SHAPED.test(last)) continue; const line = lineOf(m.index); - const fn = enclosingFn(nodes, line); + const fn = enclosingFn(facts.nodes, line); if (!fn) continue; producers.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${receiver.replace(/\s+/g, '')}.add`, queue, job: m[3]! }); } @@ -674,7 +693,7 @@ function collectQueue(ctx: ResolutionContext, facts: FileFacts, producers: Queue consumers.push({ node: method, file: facts.file, line, queue, job: firstLiteral(job.args) }); } if (!any) { - const process = nodes.find((n) => n.kind === 'method' && n.name === 'process' && n.startLine >= cls.startLine && n.endLine <= cls.endLine); + const process = facts.nodes.find((n) => n.kind === 'method' && n.name === 'process' && n.startLine >= cls.startLine && n.endLine <= cls.endLine); if (process) consumers.push({ node: process, file: facts.file, line: process.startLine, queue, job: null }); } } @@ -745,8 +764,10 @@ function pairQueue(producers: readonly QueueProducer[], consumers: readonly Queu // 3. Events: a bus, and sockets both ways // ============================================================================= -const EMIT = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(emit|emitAsync)\s*\(\s*(['"`])([^'"`\n]+)\3/g; -const SOCKET_ON = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(?:on|once)\s*\(\s*(['"`])([^'"`\n]+)\2\s*,\s*/g; +// A word character opens these chains, so a word's first character is the +// only start that can match (same argument as CHAIN_START). +const EMIT = /(?): void { - const { safe, nodes, lineOf } = facts; + const { safe, lineOf } = facts; const side: 'server' | 'client' = facts.socketServer ? 'server' : 'client'; let m: RegExpExecArray | null; EMIT.lastIndex = 0; @@ -790,7 +811,7 @@ function collectEvents(ctx: ResolutionContext, facts: FileFacts, dispatches: Dis const shape = shapeOf(m[1]!); if (!shape || GENERIC_EVENT.test(m[4]!)) continue; const line = lineOf(m.index); - const fn = enclosingFn(nodes, line); + const fn = enclosingFn(facts.nodes, line); if (!fn) continue; dispatches.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${m[1]!.replace(/\s+/g, '')}.${m[2]!}`, event: m[4]!, shape, side }); } diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 75d7ab5ebb..f3ff14a075 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -90,6 +90,12 @@ export interface ResolutionResult { export interface ResolutionContext { /** Get all nodes in a file */ getNodesInFile(filePath: string): Node[]; + /** Whether any node in the file is exported (`getNodesInFile(f).some(n => n.isExported)`), as one indexed probe. */ + fileHasExportedNode?(filePath: string): boolean; + /** `getNodesInFile(f).filter(n => n.isExported)`, without decoding the rest of the file. */ + getExportedNodesInFile?(filePath: string): Node[]; + /** `getNodesInFile(f).filter(n => n.name === name)`, without decoding the rest of the file. */ + getNodesInFileNamed?(filePath: string, name: string): Node[]; /** Get all nodes by name */ getNodesByName(name: string): Node[]; /** Get all nodes by qualified name */ @@ -109,6 +115,8 @@ export interface ResolutionContext { fileExists(filePath: string): boolean; /** Read file content */ readFile(filePath: string): string | null; + /** `readFile(filePath)?.includes(needle) ?? false` for an ASCII `needle`, without decoding a file that lacks it. */ + fileContains?(filePath: string, needle: string): boolean; /** * `readFile(filePath)` split into lines, LRU-cached per file. Receiver-type * inference scans source lines for EVERY `receiver.method()` ref; splitting