fix(javascript): retain destructuring effects in evaluation order

This commit is contained in:
morluto 2026-10-13 21:29:16 +08:00
parent 55ca7d8347
commit 0a66e595bb
6 changed files with 137 additions and 30 deletions

View File

@ -11,6 +11,48 @@ const WINDOW = `let electron = require("electron");
electron = __toESM(electron, 1); new electron.BrowserWindow({});`;
describe("captured intrinsic invocation reads", () => {
it.each(["call", "apply"])(
"does not invent a bound receiver erased by %s sequence syntax",
(method) => {
const result = analyzeJavaScriptStaticSource(`
const change = Object.defineProperty.bind(null,Object,"create");
(0, change.${method})(null, ${method === "apply" ? "[{value:replacement}]" : "{value:replacement}"});
${ROLLDOWN_MODULE_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toHaveLength(1);
},
);
it.each([
'[Object.defineProperty, ignored = Object.defineProperty(metadata,"x",{value:1})] = [replacement];',
'({first: Object.defineProperty, second: ignored = Object.defineProperty(metadata,"x",{value:1})} = values);',
'[change.call, ignored = change.call(null,metadata,"x",{value:1})] = [replacement];',
'({first: change.call, [change.call(null,metadata,"x",{value:1})]: ignored} = values);',
'for ([change.call, ignored = change.call(null,metadata,"x",{value:1})] of values) {}',
])(
"retains destructuring writes before later defaults and keys: %s",
(mutation) => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
const change = define.bind(null);
const metadata = {}; let ignored;
${mutation}
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toEqual([]);
},
);
it("evaluates a destructuring RHS before any target writes", () => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
const metadata = {};
[Object.defineProperty] = [Object.defineProperty(metadata,"x",{value:1}), replacement];
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toHaveLength(1);
});
it("evaluates assignment operands before recording their writes", () => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;

View File

@ -5,7 +5,10 @@ import {
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
import {
javaScriptAstChildKeys,
traverseJavaScriptAstSteps,
} from "./javascriptSemanticTraversal.js";
/** Match Electron's supported module entry points. */
export const ELECTRON_MODULE =
@ -91,6 +94,7 @@ export function* collectJavaScriptIdentityWritesSteps(
path: readonly (string | null)[],
kind: JavaScriptIdentityWriteKind,
) => void,
observeEvaluation?: (node: t.Node) => void,
): Generator<void> {
const objectMutators = new Set([
"defineProperty",
@ -163,8 +167,21 @@ export function* collectJavaScriptIdentityWritesSteps(
}
}
};
const retainTargets = (node: t.Node): void => {
const target = t.isAssignmentExpression(node)
const retainTarget = (leaf: t.Node, effect: t.Node): void => {
if (t.isIdentifier(leaf)) {
for (const identity of origin(leaf, "binding"))
retain(effect, identity, "replace-value");
return;
}
const member = unwrapJavaScriptExpression(leaf).node;
if (!t.isMemberExpression(member) && !t.isOptionalMemberExpression(member))
return;
const key = semanticStaticPropertyKey(member.property, member.computed);
for (const identity of origin(member.object, "member"))
retain(effect, [...identity, key], "replace-value");
};
const targetOf = (node: t.Node): t.Node | undefined =>
t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node)
? node.argument
@ -173,36 +190,59 @@ export function* collectJavaScriptIdentityWritesSteps(
: t.isForOfStatement(node) || t.isForInStatement(node)
? node.left
: undefined;
if (target === undefined) return;
for (const leaf of semanticWriteTargets(target)) {
if (t.isIdentifier(leaf)) {
for (const identity of origin(leaf, "binding"))
retain(node, identity, "replace-value");
continue;
}
const member = unwrapJavaScriptExpression(leaf).node;
if (
!t.isMemberExpression(member) &&
!t.isOptionalMemberExpression(member)
)
continue;
const key = semanticStaticPropertyKey(member.property, member.computed);
for (const identity of origin(member.object, "member"))
retain(node, [...identity, key], "replace-value");
}
const isPattern = (node: t.Node | undefined): boolean =>
t.isArrayPattern(node) || t.isObjectPattern(node);
const retainTargets = (node: t.Node): void => {
const target = targetOf(node);
if (target !== undefined)
for (const leaf of semanticWriteTargets(target)) retainTarget(leaf, node);
};
const patternLeaves = new WeakSet<t.Node>();
const loopBodies = new WeakMap<t.Node, t.ForOfStatement | t.ForInStatement>();
yield* traverseJavaScriptAstSteps(program, {
childKeys: (node) => {
// Assignment patterns are lexically before their RHS, but execute after it.
// A simple default target evaluates its reference before its initializer;
// a nested pattern starts destructuring only after that initializer.
if (t.isAssignmentExpression(node) && isPattern(node.left))
return ["right", "left"];
if (t.isForOfStatement(node) || t.isForInStatement(node))
return ["right", "left", "body"];
if (t.isAssignmentPattern(node) && isPattern(node.left))
return ["right", "left"];
if (t.isVariableDeclarator(node) && isPattern(node.id))
return ["init", "id"];
return javaScriptAstChildKeys(node);
},
enter: (node) => {
const target = targetOf(node);
if (isPattern(target) && target !== undefined)
for (const leaf of semanticWriteTargets(target))
patternLeaves.add(leaf);
if (t.isForOfStatement(node) || t.isForInStatement(node))
loopBodies.set(node.body, node);
const loop = loopBodies.get(node);
if (loop !== undefined) retainTargets(loop);
if (loop !== undefined && !isPattern(loop.left)) {
observeEvaluation?.(loop);
retainTargets(loop);
}
},
exit: (node) => {
exit: (node, parent) => {
observeEvaluation?.(node);
if (patternLeaves.has(node)) {
// The default expression runs after the target reference is evaluated,
// but before its value is written. Later leaves see this completed write.
if (!t.isAssignmentPattern(parent) || parent.left !== node)
retainTarget(node, node);
} else if (t.isAssignmentPattern(node) && patternLeaves.has(node.left))
retainTarget(node.left, node);
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
retainCallEffects(node);
else if (!t.isForOfStatement(node) && !t.isForInStatement(node))
else if (
!t.isForOfStatement(node) &&
!t.isForInStatement(node) &&
!isPattern(targetOf(node))
)
retainTargets(node);
},
});

View File

@ -106,6 +106,7 @@ export const resolveJavaScriptIntrinsicInvocation = (
);
if (method === "call" || method === "apply") {
const bound = boundIntrinsic(callee.object);
if (explicitReceiver && !t.isExpression(receiver)) return undefined;
if (
bound !== undefined &&
intrinsicReadChanged(callee, [...bound.path, method])

View File

@ -93,6 +93,14 @@ export const createSemanticModuleInterop = (
readonly intrinsicPath: (node: t.Node) => readonly string[] | undefined;
},
) => {
// Evaluation order, unlike source offsets, preserves RHS-before-pattern and
// each destructuring write before subsequent defaults and computed keys.
const evaluationOrder = new WeakMap<t.Node, number>();
let evaluationClock = 0;
const evaluationOffset = (node: t.Node): number =>
evaluationClock === 0
? (node.start ?? -1)
: (evaluationOrder.get(node) ?? Infinity);
const helpers = new Map<string, InteropHelper | null>();
const origins = new Map<string, InteropOrigin>();
const calls = new WeakMap<t.Node, InteropOrigin>();
@ -299,7 +307,7 @@ export const createSemanticModuleInterop = (
// Reading an invocation member does not recapture the stored function.
// Check member writes now, then check its base at the earlier capture.
const scope = opaqueMutationPath(current).join(".");
const offset = current.property.start ?? -1;
const offset = evaluationOffset(current);
if (
writes.some(
(write) =>
@ -324,7 +332,7 @@ export const createSemanticModuleInterop = (
return writes.some(
(write) =>
(write.scope === scope || write.scope === "intrinsic") &&
write.offset < (capture.start ?? -1) &&
write.offset < evaluationOffset(capture) &&
(intrinsicWriteCovers(write.path, currentPath) ||
(invocationMethod &&
intrinsicWriteCovers(write.path, [
@ -703,7 +711,7 @@ export const createSemanticModuleInterop = (
return writes.some(
(write) =>
(write.scope === scope || write.scope === "intrinsic") &&
write.offset < (capturedAt.start ?? -1) &&
write.offset < evaluationOffset(capturedAt) &&
intrinsicWriteCovers(write.path, path),
);
}) ?? false;
@ -722,6 +730,9 @@ export const createSemanticModuleInterop = (
staticString,
isFreshObject,
opaqueMutationPath,
observeEvaluation: (node: t.Node): void => {
evaluationOrder.set(node, ++evaluationClock);
},
retainWrite: (
node: t.Node,
path: readonly (string | null)[],
@ -729,7 +740,10 @@ export const createSemanticModuleInterop = (
) => {
writes.push({
scope: opaqueMutationPath(node).join("."),
offset: javaScriptIdentityWriteOffset(node),
offset:
evaluationClock === 0
? javaScriptIdentityWriteOffset(node)
: evaluationOffset(node),
path: path[0] === "opaque-intrinsic" ? ["intrinsic"] : path,
kind,
});

View File

@ -466,6 +466,7 @@ export function* indexJavaScriptElectronIdentitySteps(
if (path[0] === "electron") writes.retain(path);
else interop.retainWrite(node, path, kind);
},
interop.observeEvaluation,
);
collectedWrites = true;
const intactExport = (path: readonly (string | null)[]): boolean =>

View File

@ -19,6 +19,8 @@ export const traverseJavaScriptAst = (
export interface JavaScriptAstVisitor {
/** Omit a child and its subtree without entering or exiting it. */
readonly skip?: (node: t.Node) => boolean;
/** Override child field order while retaining the shared child expansion owner. */
readonly childKeys?: (node: t.Node) => readonly string[];
readonly enter: (
node: t.Node,
parent: t.Node | null,
@ -40,7 +42,12 @@ export function* traverseJavaScriptAstSteps(
const enter = (node: t.Node, parent: t.Node | null): TraversalFrame => {
visitor.enter(node, parent, () => [...ancestors]);
ancestors.push(node);
return { node, parent, children: childNodes(node), nextIndex: 0 };
return {
node,
parent,
children: childNodes(node, visitor.childKeys?.(node)),
nextIndex: 0,
};
};
const pending = [enter(root, null)];
let visited = 1;
@ -84,8 +91,10 @@ interface TraversalFrame {
* expansion; bespoke `Object.values` / duplicated `childNodes` walkers must
* not be reintroduced (they diverge on loc/comment fields and ordering).
*/
export const childNodes = (node: t.Node): t.Node[] => {
const keys = javaScriptAstChildKeys(node);
export const childNodes = (
node: t.Node,
keys: readonly string[] = javaScriptAstChildKeys(node),
): t.Node[] => {
const children: t.Node[] = [];
for (const key of keys) {
const value: unknown = Reflect.get(node, key);