Files
rea/src/domain/javascript/javascriptSemanticChildProcesses.ts
T
星闌andCodex 6d2eeeaed0 fix(javascript): resolve child ownership at receiver reads (#3819)
* fix(javascript): resolve child ownership at receiver reads

* fix(javascript): preserve deferred field child candidates

---------

Co-authored-by: Codex <codex@openai.com>
2026-10-17 00:22:26 +08:00

425 lines
14 KiB
TypeScript

import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticChildProcessInteraction,
JavaScriptSemanticChildProcessSpawn,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import {
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
isUnshadowedGlobal,
resolveSemanticBindingState,
semanticStableInitializer,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
builtinDataEffectMethod,
dataEffectLiteralString,
dataEffectMemberCallee,
assignedSemanticResultBindings,
scalarAssignedResultBinding,
semanticProjectedBindingFacts,
} from "./javascriptSemanticDataEffectHelpers.js";
const CHILD_PROCESS_METHODS = ["spawn", "exec", "execFile", "fork"] as const;
const CHILD_LISTENER_METHODS = ["on", "once", "addListener"] as const;
interface ChildProcessAnalysis {
readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[];
readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[];
}
interface SpawnCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly projectedResultBindings: readonly {
readonly bindingId: string;
readonly projectionPath: readonly (string | number | null)[];
readonly resolution: "complete" | "partial";
}[];
}
interface ChildInteractionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly spawnByNode: WeakMap<t.Node, SpawnCandidate>;
readonly spawnsByBinding: ReadonlyMap<string, readonly SpawnCandidate[]>;
}
/** Recover explicit asynchronous node:child_process ownership candidates. */
export const collectJavaScriptSemanticChildProcesses = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): ChildProcessAnalysis => {
const candidates = collectSpawns(program, state, callables);
const childProcessSpawns = candidates.map((candidate) =>
immutableSpawn(candidate),
);
return {
childProcessSpawns,
childProcessInteractions: collectInteractions(
program,
state,
callables,
candidates,
),
};
};
const collectSpawns = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): SpawnCandidate[] => {
const output: SpawnCandidate[] = [];
const admittedCallables = new Set(
callables.map(({ callableId }) => callableId),
);
const callableStack = createSemanticCallableIdStack(admittedCallables);
const ancestors: t.Node[] = [];
traverseJavaScriptAst(program, {
enter: (node, parent) => {
callableStack.enter(node, parent);
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const method = childProcessMethod(node.callee, state);
if (method !== null) {
const projectedResultBindings = assignedSemanticResultBindings(
node,
ancestors,
state,
);
output.push({
node,
method,
ownerCallableId: callableStack.callables.at(-1) ?? null,
resultBindingId: scalarAssignedResultBinding(
projectedResultBindings,
),
projectedResultBindings: semanticProjectedBindingFacts(
projectedResultBindings.filter(
({ projectionPath }) => projectionPath.length > 0,
),
),
});
}
}
ancestors.push(node);
},
exit: (node) => {
ancestors.pop();
callableStack.exit(node);
},
});
return output;
};
const immutableSpawn = (
candidate: SpawnCandidate,
): JavaScriptSemanticChildProcessSpawn => {
const options = spawnOptions(candidate);
const command = dataEffectLiteralString(candidate.node.arguments[0]);
return {
processId: spawnId(candidate),
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
resultBindingId: candidate.resultBindingId,
projectedResultBindings: candidate.projectedResultBindings,
command,
argvCount: spawnArgvCount(candidate),
environmentSupplied: objectHasProperty(options, "env"),
stdioMode: stdioMode(options),
resolution: command === null ? "partial" : "complete",
};
};
const collectInteractions = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
spawns: readonly SpawnCandidate[],
): JavaScriptSemanticChildProcessInteraction[] => {
const output: JavaScriptSemanticChildProcessInteraction[] = [];
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack = createSemanticCallableIdStack(callableIds);
const spawnByNode = new WeakMap<t.Node, SpawnCandidate>();
const spawnsByBinding = new Map<string, SpawnCandidate[]>();
for (const spawn of spawns) {
spawnByNode.set(spawn.node, spawn);
const ids = [
...(spawn.resultBindingId === null ? [] : [spawn.resultBindingId]),
...spawn.projectedResultBindings.map(({ bindingId }) => bindingId),
];
for (const id of ids) {
const existing = spawnsByBinding.get(id) ?? [];
existing.push(spawn);
spawnsByBinding.set(id, existing);
}
}
const context: ChildInteractionContext = {
state,
spawnByNode,
spawnsByBinding,
};
traverseJavaScriptAst(program, {
enter: (node, parent) => {
callableStack.enter(node, parent);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
const interaction = childInteraction(
node,
callableStack.callables.at(-1) ?? null,
context,
);
if (interaction === null) return;
output.push(interaction);
},
exit: (node) => callableStack.exit(node),
});
return output;
};
const childInteraction = (
node: t.CallExpression | t.OptionalCallExpression,
ownerCallableId: string | null,
context: ChildInteractionContext,
): JavaScriptSemanticChildProcessInteraction | null => {
const { state } = context;
const member = dataEffectMemberCallee(node);
if (member === null) return null;
const method = semanticStaticPropertyName(member.property, member.computed);
const classification = classifyInteraction(method, node.arguments[0]);
if (classification === null) return null;
const binding = objectBinding(member.object, state);
const { candidates: linked, complete } = resolveChildCandidates(
member.object,
context,
);
if (linked.length === 0) return null;
const { kind, interactionMethod, eventName } = classification;
const listener = node.arguments[1];
return {
interactionId: `child:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind,
method: interactionMethod,
location: range(node),
ownerCallableId,
processBindingId: binding?.bindingId ?? null,
linkedProcessIds: linked
.map((candidate) => spawnId(candidate))
.sort(compareUnicodeCodePoints),
eventName: kind === "listener" ? eventName : null,
signalName:
kind === "signal" ? killSignalName(node.arguments, state) : null,
listenerLocation:
kind === "listener" && t.isNode(listener) ? range(listener) : null,
resolution: complete ? "complete" : "partial",
};
};
// Only an omitted argument or the unshadowed global undefined selects the
// SIGTERM default; an unresolved argument leaves the signal unknown.
const killSignalName = (
args: t.CallExpression["arguments"],
state: JavaScriptSemanticAnalysisState,
): string | null => {
const [signal] = args;
if (signal === undefined) return "SIGTERM";
const expression = unwrapJavaScriptExpression(signal).node;
if (
t.isUnaryExpression(expression, { operator: "void" }) ||
isUnshadowedGlobal(expression, state, "undefined")
)
return "SIGTERM";
return dataEffectLiteralString(signal);
};
const classifyInteraction = (
method: string,
argument:
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| undefined,
): {
readonly kind: JavaScriptSemanticChildProcessInteraction["kind"];
readonly interactionMethod: JavaScriptSemanticChildProcessInteraction["method"];
readonly eventName: "exit" | "error" | null;
} | null => {
if (method === "kill")
return { kind: "signal", interactionMethod: "kill", eventName: null };
const listenerMethod = CHILD_LISTENER_METHODS.find(
(candidate) => candidate === method,
);
const eventName = dataEffectLiteralString(argument);
return listenerMethod !== undefined &&
(eventName === "exit" || eventName === "error")
? { kind: "listener", interactionMethod: listenerMethod, eventName }
: null;
};
const childProcessMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticChildProcessSpawn["method"] | null => {
const method = builtinDataEffectMethod(callee, state, [
"child_process",
"node:child_process",
]);
return (
CHILD_PROCESS_METHODS.find((candidate) => candidate === method) ?? null
);
};
const resolveChildCandidates = (
node: t.Node,
context: ChildInteractionContext,
): {
readonly candidates: readonly SpawnCandidate[];
readonly complete: boolean;
} => {
const { state, spawnByNode, spawnsByBinding } = context;
const candidates = new Set<SpawnCandidate>();
const fallback = new Set<SpawnCandidate>();
const pending = [node];
const visited = new Set<t.Node>();
let complete = true;
while (pending.length > 0) {
const current = unwrapJavaScriptExpression(pending.pop()!).node;
if (visited.has(current)) {
complete = false;
continue;
}
visited.add(current);
// Spawn-result assignment already admits await as an identity wrapper.
if (t.isAwaitExpression(current)) {
pending.push(current.argument);
continue;
}
const direct = spawnByNode.get(current);
if (direct !== undefined) {
candidates.add(direct);
continue;
}
const binding = t.isIdentifier(current)
? resolveSemanticBindingState(state, current, current.name)
: undefined;
if (binding === undefined) {
complete = false;
continue;
}
for (const candidate of spawnsByBinding.get(binding.bindingId) ?? [])
fallback.add(candidate);
// An alias captures its source at its initializer read, not at the later
// interaction. The shared selector retains all sources across uncertain
// control flow, loops and writes in other activations.
const snapshot = semanticBindingAtRead(
binding,
current,
state.parentsByNode,
);
const selected = snapshot.binding;
const initializers = [
...selected.initializers,
...selected.referenceInitializers,
];
// Defaults and other reference sources do not exclude a caller-supplied
// value, even when they are the binding's only retained source.
if (initializers.length !== 1 || selected.referenceInitializers.length > 0)
complete = false;
const stable = semanticStableInitializer(binding, state);
for (const initializer of initializers) {
if (state.conditionallySkippedExpressions.has(initializer.node))
complete = false;
if (initializer.projection.length > 0) {
complete = false;
continue;
}
// A lone assignment can still be later or belong to another activation.
// Only initialized lexical declarations exclude an earlier undefined value
// without a same-body ordering proof.
if (
!(stable === initializer && !binding.declarationKinds.has("var")) &&
snapshot.status !== "ordered"
)
complete = false;
pending.push(initializer.node);
}
}
// Preserve historical candidates with explicit uncertainty when the selected
// value cannot be established; a binding inventory alone never proves identity.
if (!complete) for (const candidate of fallback) candidates.add(candidate);
return {
candidates: [...candidates],
complete: complete && candidates.size === 1,
};
};
const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => {
const args = candidate.node.arguments;
const value =
candidate.method === "exec"
? args[1]
: t.isObjectExpression(args[1])
? args[1]
: args[2];
return t.isObjectExpression(value) ? value : null;
};
const spawnArgvCount = (candidate: SpawnCandidate): number | null => {
if (candidate.method === "exec") return null;
const args = candidate.node.arguments[1];
return t.isArrayExpression(args) &&
!args.elements.some((element) => t.isSpreadElement(element))
? args.elements.length
: null;
};
const objectHasProperty = (
object: t.ObjectExpression | null,
name: string,
): boolean => objectProperty(object, name) !== null;
const stdioMode = (options: t.ObjectExpression | null): string => {
const property = objectProperty(options, "stdio");
if (property === null || !t.isObjectProperty(property)) return "default";
if (t.isStringLiteral(property.value)) return property.value.value;
if (t.isArrayExpression(property.value)) return "array";
return "dynamic";
};
const objectProperty = (
object: t.ObjectExpression | null,
name: string,
): t.ObjectMethod | t.ObjectProperty | null =>
object?.properties.find(
(property): property is t.ObjectMethod | t.ObjectProperty =>
(t.isObjectMethod(property) || t.isObjectProperty(property)) &&
// `{[env]: vars}` has a dynamic key; treating it as `env` would claim
// the caller supplied an environment when the real key is unknown.
semanticStaticPropertyName(property.key, property.computed) === name,
) ?? null;
const objectBinding = (
node: t.Expression | t.Super,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticBindingState | undefined =>
t.isIdentifier(node)
? resolveSemanticBindingState(state, node, node.name)
: undefined;
const spawnId = (candidate: SpawnCandidate): string =>
`child:spawn:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;