perf(synthesis): line numbers from a newline index, not a split per match

Two quadratic idioms in callback-synthesizer.ts. Twelve passes computed
each match's line as `safe.slice(0, m.index).split('\n').length`, which
copies and splits the file's prefix for every match, and `sliceLines`
split the whole file again for every node a pass looked at (7 callers,
one per node of the same file in a row).

The twelve sites now call `lineOf(src, idx)`, which keeps the newline
index of the last text (built with synth-utils' `makeLineAt`, which
counts the newlines strictly before `idx`, exactly like the old idiom),
and `sliceLines` keeps the last file's lines.

Ours on top: `releaseLineCaches()` at the end of synthesizeCallbackEdges.
The pool workers' copies go with the workers (pool.destroy() after
synthesis), but a sync runs synthesis on the main thread, so a long-lived
MCP server or `watch` kept the last file's text, its lines and its newline
index until the next synthesis. And a celery test with two files of the
same length scanned in a row, which fails if the line index is reused
across texts (checked by keying the cache on length).

Validation, main 81559dcc vs this:
- dump-graph of every table (nodes, edges with metadata, refs, files)
  byte-identical on pretix c9f7bf86 (79,452 edges), trezor-suite
  505aece5 (493,138 edges), excalidraw d3a9c2a1 (46 react-render),
  vben 61c07936 (128 pinia-store), gin and eShopOnWeb (Go and C#
  controls with gin-middleware-chain and mediatr-dispatch edges).
- The pass outputs are identical, edge for edge and in the same order,
  when each touched pass runs alone over the same index. Their CPU, the
  median of 3 runs after a warm-up: pretix 1,018 -> 845 ms (React render
  204 -> 32 ms, field channels 235 -> 172 ms), trezor-suite 7,686 vs
  7,841 ms (noise; its Pinia and Vuex scans dominate and are untouched).
- Whole-index CPU on pretix, median of 3 interleaved: 99.8 vs 99.4 s.
  No CHANGELOG entry: the gain is per pass, not visible per index; the
  PR's bullet sat in the released 1.6.1 section and is dropped.

Original PR #1985 by @bompus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Queen
2026-10-27 16:45:47 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 0b194448dc
commit 4eb8c2a5f2
2 changed files with 74 additions and 13 deletions
+46 -13
View File
@@ -132,9 +132,38 @@ function nuxtComponentName(filePath: string): string | null {
return out.join('');
}
// Passes call sliceLines once per node, so splitting the whole file each
// time is quadratic in a file's node count. Keep the last file's lines.
let sliceLinesSource: string | null = null;
let sliceLinesSplit: string[] = [];
function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
if (!startLine || !endLine) return null;
return content.split('\n').slice(startLine - 1, endLine).join('\n');
if (content !== sliceLinesSource) {
sliceLinesSource = content;
sliceLinesSplit = content.split('\n');
}
return sliceLinesSplit.slice(startLine - 1, endLine).join('\n');
}
// `src.slice(0, idx).split('\n').length` per match is quadratic in a file's
// match count. The newline index is built once per text, and the last text's
// index is kept.
let lineOfSource: string | null = null;
let lineOfIndex: (idx: number) => number = () => 1;
function lineOf(src: string, idx: number): number {
if (src !== lineOfSource) {
lineOfSource = src;
lineOfIndex = makeLineAt(src, 1);
}
return lineOfIndex(idx);
}
/** Forget the last file sliceLines and lineOf keep, once a synthesis run is over. */
function releaseLineCaches(): void {
sliceLinesSource = null;
sliceLinesSplit = [];
lineOfSource = null;
lineOfIndex = () => 1;
}
function registrarField(src: string): string | null {
@@ -611,7 +640,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 = lineOf(safe, 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];
@@ -715,7 +744,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 = lineOf(safe, 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];
@@ -3462,7 +3491,7 @@ async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionCon
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 = lineOf(safe, m.index);
for (const arg of goSplitArgs(argStr)) {
const name = goHandlerIdent(arg);
if (name && !registered.has(name)) registered.set(name, `${file}:${line}`);
@@ -3639,7 +3668,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 = node.startLine + lineOf(safe, m.index) - 1;
edges.push({
source: node.id,
target: target.id,
@@ -3957,7 +3986,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 = lineOf(safe, cm.index);
const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher;
if (!disp) continue;
const target = ctx
@@ -4049,7 +4078,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 = lineOf(safe, m.index);
const disp = enclosingFn(nodesInFile, line) ?? fallback;
if (!disp) continue;
const target = resolve(key, file);
@@ -4145,7 +4174,7 @@ async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield):
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 = lineOf(safe, m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue; // module-level dispatch — no source symbol to attribute
const target = resolve(name, file);
@@ -4267,7 +4296,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 = lineOf(safe, m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets) {
@@ -4380,7 +4409,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 = lineOf(safe, m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line);
@@ -4476,7 +4505,7 @@ async function sidekiqDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
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 = lineOf(safe, m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
const target = resolve(m[1]!);
@@ -4855,7 +4884,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
const behaviour = behaviours[0]!;
const targets = targetsOf(behaviour, fn, arity);
if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue;
const line = safe.slice(0, m.index).split('\n').length;
const line = lineOf(safe, m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets) {
@@ -5002,7 +5031,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 = lineOf(safe, m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets.values()) {
@@ -5439,6 +5468,10 @@ export async function synthesizeCallbackEdges(
await runPassOnMain(i);
}
}
// The passes are done. In a long-lived process (the MCP server, `watch`),
// the two line caches would otherwise keep the last file read until the next
// synthesis; a pool worker's go with the worker.
releaseLineCaches();
// A Jest or Vitest manual mock answers only for test code (#2455): whatever
// pass paired them, production code never links into a `__mocks__/` file.