From 759548b9ae7b782f6c662aa485893413f0daf914 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 11:29:18 +0300 Subject: [PATCH] fix: avoid quadratic synthesis line lookups --- CHANGELOG.md | 1 + __tests__/object-registry-synthesizer.test.ts | 5 ++- __tests__/synthesis-tail-scaling.test.ts | 17 +++++++- src/resolution/callback-synthesizer.ts | 41 ++++++++++++------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f44be8edd..3fbe0ec34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Dynamic-dispatch analysis no longer repeatedly copies every source prefix while scanning match-dense files, avoiding quadratic work and excessive peak memory during the final resolution pass. - Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored. - Plainly-worded `codegraph_explore` questions now find camelCase code: a query like "auto-scroll to bottom" can reach a function named `scrollFeedToBottom`, because query words are matched against the words inside identifiers, not just whole names. - Variables and constants now count when `codegraph_explore` picks its starting symbols, so state held in plain variables — `$state`-style variables in Svelte, for example — no longer gets overlooked. diff --git a/__tests__/object-registry-synthesizer.test.ts b/__tests__/object-registry-synthesizer.test.ts index 9b7ac66f7..7060a5ee3 100644 --- a/__tests__/object-registry-synthesizer.test.ts +++ b/__tests__/object-registry-synthesizer.test.ts @@ -63,7 +63,8 @@ export function direct() { return new table.add().execute(); } const db = (cg as any).db.db; const rows = db .prepare( - `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file + `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file, + e.line edge_line, json_extract(e.metadata,'$.registeredAt') registered_at FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target @@ -77,6 +78,8 @@ export function direct() { return new table.add().execute(); } expect(rows.every((r: any) => r.source_name === 'executeCommand')).toBe(true); expect(rows.every((r: any) => r.target_kind === 'method' && r.target_name === 'execute')).toBe(true); expect(rows.every((r: any) => /commands\.ts$/.test(r.target_file))).toBe(true); + expect(rows.every((r: any) => r.edge_line === 13)).toBe(true); + expect(rows.every((r: any) => /manager\.ts:6$/.test(r.registered_at))).toBe(true); // The statically-accessed look-alike registry contributed nothing. expect(rows.some((r: any) => /static\.ts$/.test(r.target_file))).toBe(false); }); diff --git a/__tests__/synthesis-tail-scaling.test.ts b/__tests__/synthesis-tail-scaling.test.ts index 98729a632..d8276981c 100644 --- a/__tests__/synthesis-tail-scaling.test.ts +++ b/__tests__/synthesis-tail-scaling.test.ts @@ -9,7 +9,7 @@ * SQL-side, and language-gates passes off the files table. * * These tests pin the query-level building blocks and the end-to-end kotlin - * bridge so the memory fix can't silently change what gets synthesized. + * bridge so the memory fixes can't silently change what gets synthesized. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; @@ -100,4 +100,19 @@ class C { expect(langs.has('kotlin')).toBe(false); cg.close(); }); + + it('does not rescan source prefixes to locate every synthesized edge', () => { + const source = fs.readFileSync( + path.resolve('src/resolution/callback-synthesizer.ts'), + 'utf8' + ); + const prefixRescans = source + .split('\n') + .filter((line) => !/^(?:\/\/|\*)/.test(line.trimStart())) + .filter((line) => line.includes('.slice(0,') && line.includes(".split('\\n').length")); + + // Repeating this expression for every regex match makes a match-dense file O(n²). + // Wall-clock thresholds are too noisy for CI, so pin the allocation pattern directly. + expect(prefixRescans).toEqual([]); + }); }); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 60b389937..c2d201a80 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -609,6 +609,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P const content = ctx.readFile(file); if (!content || !content.includes('emitter.')) continue; const safe = stripCommentsForRegex(content, 'typescript'); + const lineAt = makeLineAt(safe, 1); const nodes = ctx.getNodesInFile(file) .filter((n) => n.kind === 'method' || n.kind === 'function'); @@ -617,7 +618,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) { const verb = m[1]!; const arg = m[2]!.trim(); - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const encl = nodes .filter((n) => n.startLine <= line && n.endLine >= line) .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; @@ -714,6 +715,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr const content = ctx.readFile(file); if (!content || !content.includes('router.')) continue; const safe = stripCommentsForRegex(content, 'typescript'); + const lineAt = makeLineAt(safe, 1); const nodes = ctx.getNodesInFile(file) .filter((n) => n.kind === 'method' || n.kind === 'function'); @@ -721,7 +723,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr let m: RegExpExecArray | null; while ((m = ARKUI_ROUTER_RE.exec(safe))) { const url = m[1]!; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const encl = nodes .filter((n) => n.startLine <= line && n.endLine >= line) .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; @@ -1961,13 +1963,14 @@ async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionCon const content = ctx.readFile(file); if (!content || (!content.includes('.Use(') && !/\.(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\(/.test(content))) continue; const safe = stripCommentsForRegex(content, 'go'); + const lineAt = makeLineAt(safe, 1); GIN_REG_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = GIN_REG_RE.exec(safe))) { const parenIdx = m.index + m[0].length - 1; const argStr = goBalancedArgs(safe, parenIdx); if (!argStr) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); for (const arg of goSplitArgs(argStr)) { const name = goHandlerIdent(arg); if (name && !registered.has(name)) registered.set(name, `${file}:${line}`); @@ -2115,6 +2118,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on if (!src) continue; // Thunks are TS/JS-family (same // and /* */ comment syntax); map to a CommentLang. const safe = stripCommentsForRegex(src, node.language === 'javascript' || node.language === 'jsx' ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, node.startLine); THUNK_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; let added = 0; @@ -2139,7 +2143,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on const key = `${node.id}>${target.id}`; if (seen.has(key)) continue; seen.add(key); - const line = node.startLine + safe.slice(0, m.index).split('\n').length - 1; + const line = lineAt(m.index); edges.push({ source: node.id, target: target.id, @@ -2248,6 +2252,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): const newlines = (content.match(/\n/g)?.length ?? 0) + 1; if (content.length / newlines > 200) continue; const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, 1); // 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. @@ -2257,7 +2262,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) { 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: lineAt(dm.index), chained: cm ? cm[1]! : null }); } if (!dispatches.length) continue; // Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`) @@ -2276,7 +2281,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: lineAt(am.index) }); } } if (!registries.size) continue; @@ -2408,6 +2413,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro const content = ctx.readFile(file); if (!content || !content.includes('Store')) continue; const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, 1); // 2. Bind store vars in this file: `const = (...)`. const varStore = new Map(); @@ -2429,7 +2435,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro const storeFile = varStore.get(cm[1]!); if (!storeFile) continue; const method = cm[2]!; - const line = safe.slice(0, cm.index).split('\n').length; + const line = lineAt(cm.index); const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher; if (!disp) continue; const target = ctx @@ -2514,6 +2520,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P const content = ctx.readFile(file); if (!content || (!content.includes('dispatch(') && !content.includes('commit('))) continue; const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); const fallback = nodesInFile.find((n) => n.kind === 'component'); // .vue top-level VUEX_DISPATCH_RE.lastIndex = 0; @@ -2521,7 +2528,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P let added = 0; while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) { const key = m[1]!; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line) ?? fallback; if (!disp) continue; const target = resolve(key, file); @@ -2611,13 +2618,14 @@ async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): const content = ctx.readFile(file); if (!content || (!content.includes('.delay(') && !content.includes('.apply_async('))) continue; const safe = stripCommentsForRegex(content, 'python'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); CELERY_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; let added = 0; while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) { const name = m[1]!; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; // module-level dispatch — no source symbol to attribute const target = resolve(name, file); @@ -2732,6 +2740,7 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr const content = ctx.readFile(file); if (!content || !content.includes('.publishEvent(')) continue; const safe = stripCommentsForRegex(content, 'java'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); SPRING_PUBLISH_RE.lastIndex = 0; let m: RegExpExecArray | null; @@ -2739,7 +2748,7 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr while ((m = SPRING_PUBLISH_RE.exec(safe)) && added < SPRING_FANOUT_CAP) { const targets = listeners.get(m[1]!); if (!targets || !targets.length) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; for (const target of targets) { @@ -2845,6 +2854,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield) const content = ctx.readFile(file); if (!content || (!content.includes('.Send(') && !content.includes('.Publish('))) continue; const safe = stripCommentsForRegex(content, 'csharp'); + const lineAt = makeLineAt(safe, 1); const safeLines = safe.split('\n'); const nodesInFile = ctx.getNodesInFile(file); MEDIATR_DISPATCH_RE.lastIndex = 0; @@ -2852,7 +2862,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield) let added = 0; while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) { if (!MEDIATR_RECEIVER_RE.test(m[1]!)) continue; // not a mediator (MessagingCenter, HttpClient, …) - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line); @@ -2943,12 +2953,13 @@ async function sidekiqDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield) const content = ctx.readFile(file); if (!content || !/\.perform_(?:async|in|at)\b/.test(content)) continue; const safe = stripCommentsForRegex(content, 'ruby'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); SIDEKIQ_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; let added = 0; while ((m = SIDEKIQ_DISPATCH_RE.exec(safe)) && added < SIDEKIQ_FANOUT_CAP) { - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; const target = resolve(m[1]!); @@ -3296,6 +3307,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti const content = ctx.readFile(file); if (!content || !/[A-Z][A-Za-z0-9_@]*:[a-z]/.test(content)) continue; const safe = stripCommentsForRegex(content, 'erlang'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); ERLANG_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; @@ -3310,7 +3322,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti const behaviour = behaviours[0]!; const targets = targetsOf(behaviour, fn); if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; for (const target of targets) { @@ -3450,6 +3462,7 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P const content = ctx.readFile(file); if (!content || !content.includes('event(')) continue; const safe = stripCommentsForRegex(content, 'php'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); LARAVEL_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; @@ -3457,7 +3470,7 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) { const targets = listeners.get(phpSimpleName(m[1]!)); if (!targets) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; for (const target of targets.values()) {