diff --git a/__tests__/celery-dispatch-synthesizer.test.ts b/__tests__/celery-dispatch-synthesizer.test.ts index c94f9d6a5..e70cffca4 100644 --- a/__tests__/celery-dispatch-synthesizer.test.ts +++ b/__tests__/celery-dispatch-synthesizer.test.ts @@ -103,6 +103,34 @@ def handle_request(req): cg.close?.(); }); + it('numbers each dispatch by its own file, when two files of one length are scanned in a row', async () => { + // The pass reuses a line index while the text is the same; two files of the + // same length but different line breaks must still each get their own lines. + fs.writeFileSync(path.join(dir, 'tasks.py'), 'from celery import shared_task\n\n\n@shared_task\ndef send_email(to):\n return to\n'); + const a = 'from tasks import send_email\n\n\n\ndef first(req):\n send_email.delay(req.addr)\n'; + const b = 'from tasks import send_email\ndef second(req):\n send_email.delay(req.addr)\n'; + const padTo = (s: string, n: number): string => `${s}#${'x'.repeat(n - s.length - 2)}\n`; + const len = Math.max(a.length, b.length) + 8; + fs.writeFileSync(path.join(dir, 'a.py'), padTo(a, len)); + fs.writeFileSync(path.join(dir, 'b.py'), padTo(b, len)); + expect(fs.statSync(path.join(dir, 'a.py')).size).toBe(fs.statSync(path.join(dir, 'b.py')).size); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const rows = (cg as any).db.db + .prepare( + `SELECT s.name source, e.line line, json_extract(e.metadata,'$.registeredAt') at + FROM edges e JOIN nodes s ON s.id = e.source + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'celery-dispatch' ORDER BY s.name` + ) + .all(); + expect(rows).toEqual([ + { source: 'first', line: 6, at: 'a.py:6' }, + { source: 'second', line: 3, at: 'b.py:3' }, + ]); + cg.close?.(); + }); + it('produces no edges in a Celery-free project (clean control)', async () => { fs.writeFileSync( path.join(dir, 'app.py'), diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index d747898ab..db842d158 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -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.