import * as t from "@babel/types"; import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; import type { JavaScriptSemanticCallable, JavaScriptSemanticEventOperation, JavaScriptSemanticTimerOperation, } from "./javascriptSemanticIr.js"; import { semanticCallableIdForNode, semanticStaticPropertyName, } from "./javascriptSemanticProjection.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { dataEffectLiteralString, dataEffectMemberCallee, assignedSemanticResultBindings, scalarAssignedResultBinding, semanticProjectedBindingFacts, } from "./javascriptSemanticDataEffectHelpers.js"; const EVENT_REGISTER_METHODS = [ "on", "once", "addListener", "prependListener", "prependOnceListener", ] as const; const EVENT_REMOVE_METHODS = [ "off", "removeListener", "removeAllListeners", ] as const; const EVENT_METHODS = [ ...EVENT_REGISTER_METHODS, ...EVENT_REMOVE_METHODS, "emit", ] as const; const TIMER_SCHEDULE_METHODS = [ "setTimeout", "setInterval", "setImmediate", ] as const; const TIMER_CANCEL_METHODS = [ "clearTimeout", "clearInterval", "clearImmediate", ] as const; interface AsyncEffectAnalysis { readonly eventOperations: readonly JavaScriptSemanticEventOperation[]; readonly timerOperations: readonly JavaScriptSemanticTimerOperation[]; } interface TimerCandidate { readonly node: t.CallExpression | t.OptionalCallExpression; readonly method: JavaScriptSemanticTimerOperation["method"]; readonly kind: JavaScriptSemanticTimerOperation["kind"]; readonly ownerCallableId: string | null; readonly handleBindingId: string | null; readonly projectedResultBindings: readonly { readonly bindingId: string; readonly projectionPath: readonly (string | number | null)[]; readonly resolution: "complete" | "partial"; }[]; } interface TimerCollectionInput { readonly node: t.CallExpression | t.OptionalCallExpression; readonly ownerCallableId: string | null; readonly state: JavaScriptSemanticAnalysisState; readonly output: TimerCandidate[]; readonly ancestors: readonly t.Node[]; } /** Recover explicit EventEmitter and Node timer candidates from inert syntax. */ export const collectJavaScriptSemanticAsyncEffects = ( program: t.Program, state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], ): AsyncEffectAnalysis => { const events: JavaScriptSemanticEventOperation[] = []; const timers: TimerCandidate[] = []; const callableIds = new Set(callables.map(({ callableId }) => callableId)); const callableStack: string[] = []; const ancestors: t.Node[] = []; traverseJavaScriptAst(program, { enter: (node) => { const callableId = semanticCallableIdForNode(node); if (callableId !== null && callableIds.has(callableId)) callableStack.push(callableId); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const ownerCallableId = callableStack.at(-1) ?? null; collectEvent(node, ownerCallableId, state, events); collectTimer({ node, ownerCallableId, state, output: timers, ancestors, }); } ancestors.push(node); }, exit: (node) => { const callableId = semanticCallableIdForNode(node); ancestors.pop(); if (callableId !== null && callableStack.at(-1) === callableId) callableStack.pop(); }, }); return { eventOperations: events, timerOperations: resolveTimers(timers), }; }; const collectEvent = ( node: t.CallExpression | t.OptionalCallExpression, ownerCallableId: string | null, state: JavaScriptSemanticAnalysisState, output: JavaScriptSemanticEventOperation[], ): void => { const member = dataEffectMemberCallee(node); if (member === null) return; const method = EVENT_METHODS.find( (candidate) => candidate === semanticStaticPropertyName(member.property, member.computed), ); if (method === undefined) return; const kind = eventKind(method); const eventArgument = node.arguments[0]; const listenerArgument = node.arguments[1]; const emitter = emitterIdentity(member.object, state); const eventName = dataEffectLiteralString(eventArgument); output.push({ eventId: `event:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind, method, location: range(node), ownerCallableId, emitterKey: emitter.key, emitterBindingId: emitter.bindingId, eventName, listenerBindingId: bindingId(listenerArgument, state), listenerLocation: t.isNode(listenerArgument) ? range(listenerArgument) : null, resolution: eventName === null ? "unresolved" : emitter.complete ? "complete" : "partial", }); }; const eventKind = ( method: JavaScriptSemanticEventOperation["method"], ): JavaScriptSemanticEventOperation["kind"] => { if (EVENT_REGISTER_METHODS.some((candidate) => candidate === method)) return "register"; if (EVENT_REMOVE_METHODS.some((candidate) => candidate === method)) return "remove"; return "dispatch"; }; const emitterIdentity = ( node: t.Expression | t.Super, state: JavaScriptSemanticAnalysisState, ): { readonly key: string; readonly bindingId: string | null; readonly complete: boolean; } => { if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); if (binding !== undefined) return { key: `binding:${binding.bindingId}`, bindingId: binding.bindingId, complete: true, }; return { key: `global:${node.name}`, bindingId: null, complete: !semanticResolutionBlocked(state, node, node.name), }; } if (t.isThisExpression(node)) return { key: "this", bindingId: null, complete: true }; return { key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, bindingId: null, complete: false, }; }; const collectTimer = (input: TimerCollectionInput): void => { const { node, ownerCallableId, state, output, ancestors } = input; const method = timerMethod(node.callee, state); if (method === null) return; const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) ? "schedule" : "cancel"; const projectedResultBindings = assignedSemanticResultBindings( node, ancestors, state, ); output.push({ node, method, kind, ownerCallableId, handleBindingId: kind === "schedule" ? scalarAssignedResultBinding(projectedResultBindings) : bindingId(node.arguments[0], state), projectedResultBindings: kind === "schedule" ? semanticProjectedBindingFacts( projectedResultBindings.filter( ({ projectionPath }) => projectionPath.length > 0, ), ) : [], }); }; const resolveTimers = ( candidates: readonly TimerCandidate[], ): JavaScriptSemanticTimerOperation[] => { const schedulesByBinding = new Map(); for (const candidate of candidates) { if (candidate.kind !== "schedule") continue; const ids = [ ...(candidate.handleBindingId === null ? [] : [candidate.handleBindingId]), ...candidate.projectedResultBindings.map(({ bindingId }) => bindingId), ]; for (const id of ids) { const existing = schedulesByBinding.get(id) ?? []; existing.push(candidate); schedulesByBinding.set(id, existing); } } return candidates.map((candidate) => { const linked = candidate.kind === "cancel" && candidate.handleBindingId !== null ? (schedulesByBinding.get(candidate.handleBindingId) ?? []) : []; return { timerId: timerId(candidate), kind: candidate.kind, method: candidate.method, location: range(candidate.node), ownerCallableId: candidate.ownerCallableId, handleBindingId: candidate.handleBindingId, projectedResultBindings: candidate.projectedResultBindings, linkedTimerId: linked.length === 1 && linked[0] !== undefined ? timerId(linked[0]) : null, delayMilliseconds: timerDelay(candidate), resolution: candidate.kind === "schedule" ? "complete" : linked.length === 1 && !linked[0]?.projectedResultBindings.some( ({ bindingId }) => bindingId === candidate.handleBindingId, ) ? "complete" : linked.length > 1 ? "partial" : "unresolved", }; }); }; const timerMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticTimerOperation["method"] | null => { if (t.isIdentifier(callee)) { const binding = resolveSemanticBindingState(state, callee, callee.name); if (binding === undefined) { const method = admittedTimerMethod(callee.name); if (method === null) return null; return semanticResolutionBlocked(state, callee, callee.name) ? null : method; } return timerMethodFromBinding(binding); } if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return null; const method = admittedTimerMethod( semanticStaticPropertyName(callee.property, callee.computed), ); return method !== null && t.isIdentifier(callee.object) && timerNamespaceBinding( resolveSemanticBindingState(state, callee.object, callee.object.name), ) ? method : null; }; const admittedTimerMethod = ( value: string, ): JavaScriptSemanticTimerOperation["method"] | null => [...TIMER_SCHEDULE_METHODS, ...TIMER_CANCEL_METHODS].find( (candidate) => candidate === value, ) ?? null; const timerMethodFromBinding = ( binding: JavaScriptSemanticBindingState, ): JavaScriptSemanticTimerOperation["method"] | null => { for (const { specifier, importedPath } of binding.directOrigins) { if (!timerModule(specifier)) continue; const method = admittedTimerMethod(importedPath.at(-1) ?? ""); if (method !== null) return method; } return null; }; const timerNamespaceBinding = ( binding: JavaScriptSemanticBindingState | undefined, ): boolean => binding?.directOrigins.some( ({ specifier, importedPath }) => timerModule(specifier) && semanticBuiltinNamespacePath(importedPath), ) ?? false; const timerModule = (specifier: string): boolean => specifier === "timers" || specifier === "node:timers"; const bindingId = ( node: | t.Expression | t.SpreadElement | t.JSXNamespacedName | t.ArgumentPlaceholder | undefined, state: JavaScriptSemanticAnalysisState, ): string | null => t.isIdentifier(node) ? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null) : null; const timerDelay = (candidate: TimerCandidate): number | null => { if (candidate.kind !== "schedule" || candidate.method === "setImmediate") return null; const delay = candidate.node.arguments[1]; return t.isNumericLiteral(delay) ? delay.value : null; }; const timerId = (candidate: TimerCandidate): string => `timer:${candidate.kind}:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;