Files
rea/src/domain/javascript/javascriptSemanticAsyncEffects.ts
T
amyanger 7436ac8e20 fix(javascript): drop built-in effects for visibly replaced globals (#4565)
The semantic collectors admitted Node timers, fetch, WebSocket, JSON.parse
and global coercions from the identifier spelling alone, so a source that
replaced those globals (`setTimeout = () => null`) still produced resolved
built-in effects. Require the existing isUnmodifiedGlobalReference guard,
which the static analyzer already uses for fetch and WebSocket, before
admitting these effects.

The guard treats any visible possible write as a replacement without
evaluating order or reachability, so computed global writes and guarded
polyfills also remove the resolved effect. The workflow docs now say so.

Fixes #3367
2026-10-23 02:28:53 +08:00

367 lines
12 KiB
TypeScript

import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticProjectedResultBinding,
JavaScriptSemanticEventOperation,
JavaScriptSemanticTimerOperation,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
builtinDataEffectMethod,
dataEffectArgumentBindingId,
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;
/**
* Methods whose argument position 1 names a listener. `removeAllListeners`
* takes only an optional event name, and `emit` passes its remaining
* arguments as data to listeners that already exist.
*/
const EVENT_LISTENER_METHODS: readonly JavaScriptSemanticEventOperation["method"][] =
[...EVENT_REGISTER_METHODS, "off", "removeListener"];
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 JavaScriptSemanticProjectedResultBinding[];
readonly handleRead: {
readonly origins: readonly t.Node[];
readonly complete: boolean;
} | null;
}
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 = createSemanticCallableIdStack(callableIds);
const ancestors: t.Node[] = [];
traverseJavaScriptAst(program, {
enter: (node, parent) => {
callableStack.enter(node, parent);
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const ownerCallableId = callableStack.callables.at(-1) ?? null;
collectEvent(node, ownerCallableId, state, events);
collectTimer({
node,
ownerCallableId,
state,
output: timers,
ancestors,
});
}
ancestors.push(node);
},
exit: (node) => {
ancestors.pop();
callableStack.exit(node);
},
});
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 = EVENT_LISTENER_METHODS.includes(method)
? node.arguments[1]
: undefined;
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: dataEffectArgumentBindingId(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,
);
const handle =
kind === "cancel" && t.isIdentifier(node.arguments[0])
? resolveSemanticBindingState(
state,
node.arguments[0],
node.arguments[0].name,
)
: undefined;
const handleRead =
handle === undefined || !t.isIdentifier(node.arguments[0])
? null
: (() => {
const snapshot = semanticBindingAtRead(
handle,
node.arguments[0],
state.parentsByNode,
);
// A const handle has the same value inside a later closure even
// when the source positions belong to different function bodies.
const stableClosureCapture =
!handle.mutable &&
handle.initializers.length === 1 &&
handle.referenceInitializers.length === 0;
const origins = [
...snapshot.binding.initializers,
...snapshot.binding.referenceInitializers,
];
return {
origins: origins.map(({ node: origin }) => origin),
complete: snapshot.status === "ordered" || stableClosureCapture,
};
})();
output.push({
node,
method,
kind,
ownerCallableId,
handleBindingId:
kind === "schedule"
? scalarAssignedResultBinding(projectedResultBindings)
: dataEffectArgumentBindingId(node.arguments[0], state),
projectedResultBindings:
kind === "schedule"
? semanticProjectedBindingFacts(
projectedResultBindings.filter(
({ projectionPath }) => projectionPath.length > 0,
),
)
: [],
handleRead,
});
};
const resolveTimers = (
candidates: readonly TimerCandidate[],
): JavaScriptSemanticTimerOperation[] => {
const schedules = new Map<t.Node, TimerCandidate>(
candidates
.filter(({ kind }) => kind === "schedule")
.map((candidate) => [candidate.node, candidate] as const),
);
return candidates.map((candidate) => {
// Unordered reads retain possible initializers as candidate edges. Only an
// ordered or immutable read can establish one complete cancellation link.
const linked =
candidate.handleRead?.origins.flatMap((origin) => {
const schedule = schedules.get(origin);
return schedule === undefined ? [] : [schedule];
}) ?? [];
const incompleteOrigins =
candidate.handleRead !== null &&
candidate.handleRead.origins.length !== linked.length;
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 &&
candidate.handleRead?.complete &&
!incompleteOrigins &&
!linked[0]?.projectedResultBindings.some(
({ bindingId }) => bindingId === candidate.handleBindingId,
)
? "complete"
: linked.length > 0
? "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) ||
!isUnmodifiedGlobalReference(callee, state, callee.name)
? null
: method;
}
}
return admittedTimerMethod(
builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "",
);
};
const admittedTimerMethod = (
value: string,
): JavaScriptSemanticTimerOperation["method"] | null =>
[...TIMER_SCHEDULE_METHODS, ...TIMER_CANCEL_METHODS].find(
(candidate) => candidate === value,
) ?? null;
const timerDelay = (candidate: TimerCandidate): number | null => {
if (candidate.kind !== "schedule" || candidate.method === "setImmediate")
return null;
const delay = candidate.node.arguments[1];
// An overflowing literal such as 1e999 parses to Infinity, which JSON
// Evidence cannot carry; keep that delay unknown like any other.
return t.isNumericLiteral(delay) && Number.isFinite(delay.value)
? delay.value
: null;
};
const timerId = (candidate: TimerCandidate): string =>
`timer:${candidate.kind}:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;