(ctx: ResolutionContext, onYield: MaybeYield)
| 2562 | const CELERY_DECORATOR_LOOKBACK = 12; // max lines above a `def` to scan for its decorators |
| 2563 | |
| 2564 | async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> { |
| 2565 | let scannedFiles = 0; |
| 2566 | // Memoize the decorator check per task-candidate node: it reads the file and scans a few |
| 2567 | // lines above the def. Only called on names that are actually `.delay`/`.apply_async` |
| 2568 | // receivers, so the candidate set stays small. |
| 2569 | const taskCache = new Map<string, boolean>(); |
| 2570 | const isCeleryTask = (node: Node): boolean => { |
| 2571 | let v = taskCache.get(node.id); |
| 2572 | if (v !== undefined) return v; |
| 2573 | v = false; |
| 2574 | if (node.kind === 'function' && CELERY_PY_EXT.test(node.filePath)) { |
| 2575 | const content = ctx.readFile(node.filePath); |
| 2576 | if (content) { |
| 2577 | const lines = content.split('\n'); |
| 2578 | // startLine is the `def` line (decorators sit ABOVE it). Walk upward, stopping at the |
| 2579 | // previous declaration so a non-task def can never inherit the prior def's decorator. |
| 2580 | const stop = Math.max(0, node.startLine - 1 - CELERY_DECORATOR_LOOKBACK); |
| 2581 | for (let i = node.startLine - 2; i >= stop; i--) { |
| 2582 | const t = (lines[i] ?? '').trim(); |
| 2583 | if (/^(?:async\s+def|def|class)\b/.test(t)) break; // previous decl → stop |
| 2584 | if (CELERY_TASK_DECORATOR_RE.test(t)) { v = true; break; } |
| 2585 | } |
| 2586 | } |
| 2587 | } |
| 2588 | taskCache.set(node.id, v); |
| 2589 | return v; |
| 2590 | }; |
| 2591 | |
| 2592 | const resolve = (name: string, dispatchFile: string): Node | null => { |
| 2593 | const cands = ctx.getNodesByName(name).filter((n) => n.kind === 'function' && isCeleryTask(n)); |
| 2594 | if (!cands.length) return null; |
| 2595 | if (cands.length === 1) return cands[0]!; |
| 2596 | // Cross-module name collision: prefer a task defined in the dispatching file, else bail |
| 2597 | // (ambiguous — precision over recall, like vuex's root-key resolution). |
| 2598 | return cands.find((c) => c.filePath === dispatchFile) ?? null; |
| 2599 | }; |
| 2600 | |
| 2601 | const edges: Edge[] = []; |
| 2602 | const seen = new Set<string>(); |
| 2603 | for (const file of ctx.getAllFiles()) { |
| 2604 | if ((++scannedFiles & 15) === 0) await onYield(); |
| 2605 | if (!CELERY_PY_EXT.test(file)) continue; |
| 2606 | const content = ctx.readFile(file); |
| 2607 | if (!content || (!content.includes('.delay(') && !content.includes('.apply_async('))) continue; |
| 2608 | const safe = stripCommentsForRegex(content, 'python'); |
| 2609 | const nodesInFile = ctx.getNodesInFile(file); |
| 2610 | CELERY_DISPATCH_RE.lastIndex = 0; |
| 2611 | let m: RegExpExecArray | null; |
| 2612 | let added = 0; |
| 2613 | while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) { |
| 2614 | const name = m[1]!; |
| 2615 | const line = safe.slice(0, m.index).split('\n').length; |
| 2616 | const disp = enclosingFn(nodesInFile, line); |
| 2617 | if (!disp) continue; // module-level dispatch — no source symbol to attribute |
| 2618 | const target = resolve(name, file); |
| 2619 | if (!target || target.id === disp.id) continue; |
| 2620 | const key = `${disp.id}>${target.id}`; |
| 2621 | if (seen.has(key)) continue; |
no test coverage detected