Compare commits

...
9 changed files with 528 additions and 124 deletions

No files matched your search

+8 -4
View File
@@ -352,10 +352,14 @@ creation. Arguments follow each actual callable invocation rather than property
chain depth; rebinding retains the original bound receiver. chain depth; rebinding retains the original bound receiver.
Visibly replaced invocation intrinsics produce unknown effects instead Visibly replaced invocation intrinsics produce unknown effects instead
of using their former call semantics; captured function aliases retain their of using their former call semantics; captured function aliases retain their
earlier identity. Unexamined targets remain unresolved. `Reflect.set` examines earlier identity. Invocation member reads are checked separately from the earlier
its explicit receiver as well as its target, retaining unknown receiver effects. function capture. Comma-expression callees use their final value while all
Creating an unused binding does not itself mutate an intrinsic. Variable preceding expressions remain in the effect traversal. Call effects are
redeclarations retain a single unconditional initializer classified after callee and argument effects have been collected. Unexamined
targets remain unresolved. `Reflect.set` examines its explicit receiver as well
as its target, retaining unknown receiver effects. Creating an unused binding
does not itself mutate an intrinsic. Variable redeclarations retain a single
unconditional initializer
when they introduce no assignment or second initializer. when they introduce no assignment or second initializer.
Stable identifier chains to fresh object, array, function, Stable identifier chains to fresh object, array, function,
undecorated class or RegExp syntax keep those targets separate from intrinsic undecorated class or RegExp syntax keep those targets separate from intrinsic
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import { ROLLDOWN_MODULE_INTEROP } from "./javascriptModuleInterop.fixture.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const CAPTURED_INTEROP = ROLLDOWN_MODULE_INTEROP.replace(
"__defProp = Object.defineProperty",
"__defProp = define",
);
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;
const metadata = {};
Object.defineProperty = (Object.defineProperty(metadata, "x", {value:1}), replacement);
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toHaveLength(1);
});
it("retains whole-object mutations at a captured function's later member read", () => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
Object.assign(define, {call: replacement});
const metadata = {}; define.call(null, metadata, "x", {value:1});
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toEqual([]);
});
it.each([
'(0, Object.defineProperty.call)(null,Object,"create",{value:replacement});',
'(0, Object.defineProperty.apply)(null,[Object,"create",{value:replacement}]);',
])(
"does not invent a receiver for a detached invocation member: %s",
(invoke) => {
const result = analyzeJavaScriptStaticSource(`
${invoke}
${ROLLDOWN_MODULE_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toHaveLength(1);
},
);
it("retains callee-prefix effects before classifying the invocation", () => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
const metadata = {};
(Object.defineProperty = replacement, Object.defineProperty)(metadata,"x",{value:1});
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toEqual([]);
});
it("retains a callee selected before an argument replaces its global property", () => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
const metadata = {};
Object.defineProperty(metadata,"x",(Object.defineProperty = replacement,{value:1}));
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toHaveLength(1);
});
it.each([
'define.call(null,metadata,"x",{value:1});',
'define.apply(null,[metadata,"x",{value:1}]);',
'const change = define.bind(null,metadata,"x"); change({value:1});',
'const also = define; also.call(null,metadata,"x",{value:1});',
])("retains a function captured before global replacement: %s", (invoke) => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
Object.defineProperty = replacement;
const metadata = {}; ${invoke}
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toHaveLength(1);
});
it.each([
'const invoke = define.call; invoke(null,metadata,"x",{value:1});',
'const invoke = define.call; invoke.call(define,null,metadata,"x",{value:1});',
'(0, define.call).call(define,null,metadata,"x",{value:1});',
'const invoke = (0, define.call); const again = invoke; again.call(define,null,metadata,"x",{value:1});',
])("preserves the base capture through an invocation value: %s", (invoke) => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
Object.defineProperty = replacement;
const metadata = {}; ${invoke}
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toHaveLength(1);
});
it.each(["define", "Function.prototype"])(
"retains an overwritten invocation member through an alias from %s",
(owner) => {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
Object.defineProperty = replacement;
${owner}.call = replacement;
const invoke = define.call;
const metadata = {}; invoke.call(define,null,metadata,"x",{value:1});
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows).toEqual([]);
},
);
it.each([
["call", 'define.call(null,metadata,"x",{value:1});'],
["apply", 'define.apply(null,[metadata,"x",{value:1}]);'],
[
"bind",
'const change = define.bind(null,metadata,"x"); change({value:1});',
],
])(
"checks the later %s property read on a captured function",
(method, invoke) => {
for (const owner of ["define", "Function.prototype"]) {
const result = analyzeJavaScriptStaticSource(`
const define = Object.defineProperty;
${owner}.${method} = replacement;
const metadata = {}; ${invoke}
${CAPTURED_INTEROP} ${WINDOW}
`);
expect(result.electron.browser_windows, owner).toEqual([]);
}
},
);
});
@@ -615,6 +615,10 @@ describe("Electron module interop bound mutators", () => {
'Reflect.set({},"create",...valuesAndReceivers);', 'Reflect.set({},"create",...valuesAndReceivers);',
"Reflect.set({},...keysValuesAndReceivers);", "Reflect.set({},...keysValuesAndReceivers);",
'Reflect.set({},"create",customCreate,receiver);', 'Reflect.set({},"create",customCreate,receiver);',
'(0, Object.defineProperty)(Object,"create",{value:customCreate});',
'const change = Object.defineProperty.bind(null,Object,"create"); (0, change)({value:customCreate});',
'(0, Reflect.apply)(Object.defineProperty,null,[Object,"create",{value:customCreate}]);',
'Reflect.apply((0,Function.prototype.call),Object.defineProperty,[null,Object,"create",{value:customCreate}]);',
])("rejects intrinsic writes through a bound mutator: %s", (write) => { ])("rejects intrinsic writes through a bound mutator: %s", (write) => {
expect( expect(
analyzeJavaScriptStaticSource( analyzeJavaScriptStaticSource(
@@ -641,6 +645,7 @@ describe("Electron module interop bound mutators", () => {
'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[metadata,"x",{value:1}]);', 'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[metadata,"x",{value:1}]);',
'Reflect.set(metadata,"x",1,{});', 'Reflect.set(metadata,"x",1,{});',
'const receiver = {}; Reflect.set({},"x",1,receiver);', 'const receiver = {}; Reflect.set({},"x",1,receiver);',
'(0, Object.defineProperty)(metadata,"x",{value:1});',
])("keeps a fresh bound target or unused binding separate: %s", (write) => { ])("keeps a fresh bound target or unused binding separate: %s", (write) => {
expect( expect(
analyzeJavaScriptStaticSource( analyzeJavaScriptStaticSource(
@@ -5,7 +5,10 @@ import {
semanticWriteTargets, semanticWriteTargets,
unwrapJavaScriptExpression, unwrapJavaScriptExpression,
} from "./javascriptAstValues.js"; } from "./javascriptAstValues.js";
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; import {
javaScriptAstChildKeys,
traverseJavaScriptAstSteps,
} from "./javascriptSemanticTraversal.js";
/** Match Electron's supported module entry points. */ /** Match Electron's supported module entry points. */
export const ELECTRON_MODULE = export const ELECTRON_MODULE =
@@ -52,6 +55,24 @@ export interface JavaScriptIntrinsicInvocation {
| undefined; | undefined;
} }
/** Distinguish replacing a value from mutating members of the captured object. */
export type JavaScriptIdentityWriteKind =
| "replace-value"
| "mutate-object"
| "opaque";
/** The source boundary after the operands required for this effect are evaluated. */
export const javaScriptIdentityWriteOffset = (node: t.Node): number =>
(t.isForOfStatement(node) || t.isForInStatement(node)
? node.right.end
: t.isCallExpression(node) ||
t.isOptionalCallExpression(node) ||
t.isAssignmentExpression(node) ||
t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.end
: node.start) ?? Infinity;
/** /**
* Index writes through possible lexical object identities. Resolve the complete * Index writes through possible lexical object identities. Resolve the complete
* object before appending the written key: self-reference properties can denote * object before appending the written key: self-reference properties can denote
@@ -68,7 +89,12 @@ export function* collectJavaScriptIdentityWritesSteps(
) => JavaScriptIntrinsicInvocation | undefined, ) => JavaScriptIntrinsicInvocation | undefined,
staticString: (node: t.Node | undefined) => string | undefined, staticString: (node: t.Node | undefined) => string | undefined,
freshObject: (node: t.Node) => boolean, freshObject: (node: t.Node) => boolean,
retain: (node: t.Node, path: readonly (string | null)[]) => void, retain: (
node: t.Node,
path: readonly (string | null)[],
kind: JavaScriptIdentityWriteKind,
) => void,
observeEvaluation?: (node: t.Node) => void,
): Generator<void> { ): Generator<void> {
const objectMutators = new Set([ const objectMutators = new Set([
"defineProperty", "defineProperty",
@@ -83,85 +109,141 @@ export function* collectJavaScriptIdentityWritesSteps(
"setPrototypeOf", "setPrototypeOf",
]); ]);
const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]); const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]);
yield* traverseJavaScriptAstSteps(program, { const retainCallEffects = (
enter: (node) => { node: t.CallExpression | t.OptionalCallExpression,
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { ): void => {
const invocation = intrinsicInvocation(node); const invocation = intrinsicInvocation(node);
if (invocation?.kind === "opaque") { if (invocation?.kind === "opaque") {
for (const scope of origin(node, "opaque-call")) retain(node, scope); for (const scope of origin(node, "opaque-call"))
return; retain(node, scope, "opaque");
return;
}
const callee = invocation?.path;
const arguments_ = invocation?.arguments;
const method = callee?.at(-1);
const owner = callee?.length === 3 ? callee[1] : undefined;
if (
(owner === "Object" && objectMutators.has(method ?? "")) ||
(owner === "Reflect" && reflectMutators.has(method ?? ""))
) {
// Reflect.set can write through its explicit receiver as well as
// dispatching through its target. Both identities need examination.
const targets =
owner === "Reflect" &&
method === "set" &&
arguments_ !== undefined &&
(arguments_.length >= 4 || arguments_.some(t.isSpreadElement))
? [
arguments_[0],
arguments_.some(t.isSpreadElement) ? undefined : arguments_[3],
]
: [arguments_?.[0]];
for (const target of targets) {
// A known mutator with an opaque argument list can overwrite any
// captured intrinsic. Do not turn an unexamined target into proof.
if (target === undefined || t.isSpreadElement(target)) {
for (const scope of origin(node, "opaque-call"))
retain(node, scope, "opaque");
} }
const callee = invocation?.path; if (t.isNode(target) && !t.isSpreadElement(target)) {
const arguments_ = invocation?.arguments; const identities = origin(target, "member");
const method = callee?.at(-1); if (identities.length > 0) {
const owner = callee?.length === 3 ? callee[1] : undefined; const key = keyedMutators.has(method ?? "")
if ( ? [staticString(arguments_?.[1]) ?? null]
(owner === "Object" && objectMutators.has(method ?? "")) || : [];
(owner === "Reflect" && reflectMutators.has(method ?? "")) for (const identity of identities)
) { retain(
// Reflect.set can write through its explicit receiver as well as node,
// dispatching through its target. Both identities need examination. [...identity, ...key],
const targets = keyedMutators.has(method ?? "")
owner === "Reflect" && ? "replace-value"
method === "set" && : "mutate-object",
arguments_ !== undefined && );
(arguments_.length >= 4 || arguments_.some(t.isSpreadElement)) } else if (!freshObject(target)) {
? [ for (const scope of origin(node, "opaque-call"))
arguments_[0], retain(node, scope, "opaque");
arguments_.some(t.isSpreadElement)
? undefined
: arguments_[3],
]
: [arguments_?.[0]];
for (const target of targets) {
// A known mutator with an opaque argument list can overwrite any
// captured intrinsic. Do not turn an unexamined target into proof.
if (target === undefined || t.isSpreadElement(target)) {
for (const scope of origin(node, "opaque-call"))
retain(node, scope);
}
if (t.isNode(target) && !t.isSpreadElement(target)) {
const identities = origin(target, "member");
if (identities.length > 0) {
const key = keyedMutators.has(method ?? "")
? [staticString(arguments_?.[1]) ?? null]
: [];
for (const identity of identities)
retain(node, [...identity, ...key]);
} else if (!freshObject(target)) {
for (const scope of origin(node, "opaque-call"))
retain(node, scope);
}
}
} }
} }
} }
const target = t.isAssignmentExpression(node) }
? node.left };
: t.isUpdateExpression(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
: t.isUnaryExpression(node, { operator: "delete" })
? node.argument ? node.argument
: t.isUnaryExpression(node, { operator: "delete" }) : t.isForOfStatement(node) || t.isForInStatement(node)
? node.argument ? node.left
: t.isForOfStatement(node) || t.isForInStatement(node) : undefined;
? node.left const isPattern = (node: t.Node | undefined): boolean =>
: undefined; t.isArrayPattern(node) || t.isObjectPattern(node);
if (target === undefined) return; const retainTargets = (node: t.Node): void => {
for (const leaf of semanticWriteTargets(target)) { const target = targetOf(node);
if (t.isIdentifier(leaf)) { if (target !== undefined)
for (const identity of origin(leaf, "binding")) for (const leaf of semanticWriteTargets(target)) retainTarget(leaf, node);
retain(node, identity); };
continue; const patternLeaves = new WeakSet<t.Node>();
} const loopBodies = new WeakMap<t.Node, t.ForOfStatement | t.ForInStatement>();
const member = unwrapJavaScriptExpression(leaf).node; yield* traverseJavaScriptAstSteps(program, {
if ( childKeys: (node) => {
!t.isMemberExpression(member) && // Assignment patterns are lexically before their RHS, but execute after it.
!t.isOptionalMemberExpression(member) // A simple default target evaluates its reference before its initializer;
) // a nested pattern starts destructuring only after that initializer.
continue; if (t.isAssignmentExpression(node) && isPattern(node.left))
const key = semanticStaticPropertyKey(member.property, member.computed); return ["right", "left"];
for (const identity of origin(member.object, "member")) if (t.isForOfStatement(node) || t.isForInStatement(node))
retain(node, [...identity, key]); 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 && !isPattern(loop.left)) {
observeEvaluation?.(loop);
retainTargets(loop);
} }
}, },
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) &&
!isPattern(targetOf(node))
)
retainTargets(node);
},
}); });
} }
@@ -81,6 +81,15 @@ export const resolveJavaScriptIntrinsicInvocation = (
let explicitReceiver = false; let explicitReceiver = false;
for (;;) { for (;;) {
callee = unwrapJavaScriptExpression(callee).node; callee = unwrapJavaScriptExpression(callee).node;
if (t.isSequenceExpression(callee)) {
const finalExpression = callee.expressions.at(-1);
if (finalExpression === undefined) return undefined;
// Comma yields a value, not a member reference. An outer invocation
// can still supply an explicit receiver for that value.
explicitReceiver = true;
callee = finalExpression;
continue;
}
if (visited.has(callee)) return undefined; if (visited.has(callee)) return undefined;
visited.add(callee); visited.add(callee);
let path = intrinsicPath(callee); let path = intrinsicPath(callee);
@@ -97,6 +106,7 @@ export const resolveJavaScriptIntrinsicInvocation = (
); );
if (method === "call" || method === "apply") { if (method === "call" || method === "apply") {
const bound = boundIntrinsic(callee.object); const bound = boundIntrinsic(callee.object);
if (explicitReceiver && !t.isExpression(receiver)) return undefined;
if ( if (
bound !== undefined && bound !== undefined &&
intrinsicReadChanged(callee, [...bound.path, method]) intrinsicReadChanged(callee, [...bound.path, method])
+150 -39
View File
@@ -17,13 +17,15 @@ import {
} from "./javascriptSemanticTraversal.js"; } from "./javascriptSemanticTraversal.js";
import { import {
electronMemberPath, electronMemberPath,
javaScriptIdentityWriteOffset,
type JavaScriptIdentityWriteKind,
type JavaScriptIntrinsicInvocation, type JavaScriptIntrinsicInvocation,
} from "./javascriptElectronMemberWrites.js"; } from "./javascriptElectronMemberWrites.js";
import { import {
expandJavaScriptInvocationArguments, expandJavaScriptInvocationArguments,
resolveJavaScriptIntrinsicInvocation, resolveJavaScriptIntrinsicInvocation,
} from "./javascriptIntrinsicInvocation.js"; } from "./javascriptIntrinsicInvocation.js";
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
import { import {
readExactJavaScriptLiteral, readExactJavaScriptLiteral,
unwrapJavaScriptExpression, unwrapJavaScriptExpression,
@@ -74,6 +76,16 @@ interface InteropHelper {
readonly identity: boolean; readonly identity: boolean;
} }
const intrinsicWriteCovers = (
written: readonly (string | null)[],
read: readonly string[],
): boolean =>
written.length <= read.length &&
written.every((key, index) => {
const name = read[index];
return name !== undefined && semanticPropertyPathKeyMatches(key, name);
});
/** Prove one inert namespace conversion without relaxing general assignment semantics. */ /** Prove one inert namespace conversion without relaxing general assignment semantics. */
export const createSemanticModuleInterop = ( export const createSemanticModuleInterop = (
state: JavaScriptSemanticAnalysisState, state: JavaScriptSemanticAnalysisState,
@@ -81,6 +93,14 @@ export const createSemanticModuleInterop = (
readonly intrinsicPath: (node: t.Node) => readonly string[] | undefined; 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 helpers = new Map<string, InteropHelper | null>();
const origins = new Map<string, InteropOrigin>(); const origins = new Map<string, InteropOrigin>();
const calls = new WeakMap<t.Node, InteropOrigin>(); const calls = new WeakMap<t.Node, InteropOrigin>();
@@ -88,8 +108,18 @@ export const createSemanticModuleInterop = (
const writes: { const writes: {
readonly scope: string; readonly scope: string;
readonly offset: number; readonly offset: number;
readonly coverage: SemanticPropertyPathCoverage; readonly path: readonly (string | null)[];
readonly kind: JavaScriptIdentityWriteKind;
}[] = []; }[] = [];
const invocationMemberWriteCovers = (
write: (typeof writes)[number],
read: readonly string[],
): boolean =>
(write.kind === "opaque" ||
(write.kind === "mutate-object" &&
write.path.length === read.length - 1) ||
(write.kind === "replace-value" && write.path.length === read.length)) &&
intrinsicWriteCovers(write.path, read);
const invalidatedBindings = new Map<string, boolean>(); const invalidatedBindings = new Map<string, boolean>();
const invalidatedCalls = new WeakMap<t.Node, boolean>(); const invalidatedCalls = new WeakMap<t.Node, boolean>();
const opaqueMutationPath = (node: t.Node): readonly string[] => { const opaqueMutationPath = (node: t.Node): readonly string[] => {
@@ -125,6 +155,12 @@ export const createSemanticModuleInterop = (
const { root, members } = electronMemberPath(current); const { root, members } = electronMemberPath(current);
if (members.some((member) => member === null)) return finish(undefined); if (members.some((member) => member === null)) return finish(undefined);
suffix = [...(members as readonly string[]), ...suffix]; suffix = [...(members as readonly string[]), ...suffix];
if (t.isSequenceExpression(root)) {
const value = root.expressions.at(-1);
if (value === undefined) return finish(undefined);
current = value;
continue;
}
for (const global of ["Object", "Function", "Reflect"]) for (const global of ["Object", "Function", "Reflect"])
if (isUnshadowedGlobal(root, state, global)) if (isUnshadowedGlobal(root, state, global))
return finish(["intrinsic", global, ...suffix]); return finish(["intrinsic", global, ...suffix]);
@@ -165,8 +201,20 @@ export const createSemanticModuleInterop = (
let capturedAt = node; let capturedAt = node;
let suffixLength = 0; let suffixLength = 0;
for (;;) { for (;;) {
current = unwrapJavaScriptExpression(current).node;
if (suffixLength === 0)
capturedAt =
t.isMemberExpression(current) || t.isOptionalMemberExpression(current)
? current.property
: current;
const { root, members } = electronMemberPath(current); const { root, members } = electronMemberPath(current);
suffixLength += members.length; suffixLength += members.length;
if (t.isSequenceExpression(root)) {
const value = root.expressions.at(-1);
if (value === undefined) return undefined;
current = value;
continue;
}
if (!t.isIdentifier(root)) return undefined; if (!t.isIdentifier(root)) return undefined;
const binding = resolveSemanticBindingState(state, root, root.name); const binding = resolveSemanticBindingState(state, root, root.name);
if (binding === undefined) return { path, capturedAt }; if (binding === undefined) return { path, capturedAt };
@@ -217,24 +265,84 @@ export const createSemanticModuleInterop = (
node: t.Node, node: t.Node,
path: readonly string[], path: readonly string[],
): boolean => { ): boolean => {
const capture = capturedIntrinsic(node)?.capturedAt ?? node; let current = unwrapJavaScriptExpression(node).node;
const scope = opaqueMutationPath(capture).join("."); let currentPath = path;
const method = path.at(-1); const aliases = new Set<string>();
const invocationMethod = for (;;) {
method === "bind" || method === "call" || method === "apply"; current = unwrapJavaScriptExpression(current).node;
return writes.some( if (t.isSequenceExpression(current)) {
(write) => const value = current.expressions.at(-1);
(write.scope === scope || write.scope === "intrinsic") && if (value === undefined) return true;
write.offset < (capture.start ?? -1) && current = value;
(write.coverage.covers(path) || continue;
(invocationMethod && }
write.coverage.covers([ const method = currentPath.at(-1);
"intrinsic", const invocationMethod =
"Function", method === "bind" || method === "call" || method === "apply";
"prototype", // Recover the member read at an alias initializer before examining its
method, // base capture. Copying a callable does not recapture its original owner.
]))), if (invocationMethod && t.isIdentifier(current)) {
); const binding = resolveSemanticBindingState(
state,
current,
current.name,
);
const initializer = semanticStableInitializer(binding, state);
if (
binding !== undefined &&
initializer !== undefined &&
initializer.projection.length === 0 &&
(initializer.node.end ?? Infinity) < (current.start ?? -1) &&
!aliases.has(binding.bindingId)
) {
aliases.add(binding.bindingId);
current = initializer.node;
continue;
}
}
if (
invocationMethod &&
(t.isMemberExpression(current) || t.isOptionalMemberExpression(current))
) {
// 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 = evaluationOffset(current);
if (
writes.some(
(write) =>
(write.scope === scope || write.scope === "intrinsic") &&
write.offset < offset &&
(invocationMemberWriteCovers(write, currentPath) ||
invocationMemberWriteCovers(write, [
"intrinsic",
"Function",
"prototype",
method,
])),
)
)
return true;
current = unwrapJavaScriptExpression(current.object).node;
currentPath = currentPath.slice(0, -1);
continue;
}
const capture = capturedIntrinsic(current)?.capturedAt ?? current;
const scope = opaqueMutationPath(capture).join(".");
return writes.some(
(write) =>
(write.scope === scope || write.scope === "intrinsic") &&
write.offset < evaluationOffset(capture) &&
(intrinsicWriteCovers(write.path, currentPath) ||
(invocationMethod &&
intrinsicWriteCovers(write.path, [
"intrinsic",
"Function",
"prototype",
method,
]))),
);
}
}; };
const resolveBoundIntrinsic = ( const resolveBoundIntrinsic = (
@@ -278,7 +386,7 @@ export const createSemanticModuleInterop = (
if (members.at(-1) === "bind") { if (members.at(-1) === "bind") {
// A direct .bind may continue a bound callable, without discarding members. // A direct .bind may continue a bound callable, without discarding members.
if (!terminal && members.length !== 1) return undefined; if (!terminal && members.length !== 1) return undefined;
bindReads.push(captured?.capturedAt ?? current.callee); bindReads.push(current.callee);
} else { } else {
// Reuse the invocation owner for generic call/apply/Reflect.apply of bind. // Reuse the invocation owner for generic call/apply/Reflect.apply of bind.
const invocation = resolveJavaScriptIntrinsicInvocation( const invocation = resolveJavaScriptIntrinsicInvocation(
@@ -298,6 +406,8 @@ export const createSemanticModuleInterop = (
captured = capturedIntrinsic(bindTarget); captured = capturedIntrinsic(bindTarget);
terminal = captured !== undefined; terminal = captured !== undefined;
path = captured?.path; path = captured?.path;
opaqueArguments ||=
path !== undefined && intrinsicReadChanged(bindTarget, path);
} }
receiver = bindingArguments?.[0]; receiver = bindingArguments?.[0];
const ownArguments = bindingArguments?.slice(1) ?? []; const ownArguments = bindingArguments?.slice(1) ?? [];
@@ -312,17 +422,9 @@ export const createSemanticModuleInterop = (
continue; continue;
} }
const boundPath = [...path, "bind"]; const boundPath = [...path, "bind"];
const functionBind = ["intrinsic", "Function", "prototype", "bind"]; opaqueArguments ||= bindReads.some((capture) =>
opaqueArguments ||= bindReads.some((capture) => { intrinsicReadChanged(capture, boundPath),
const scope = opaqueMutationPath(capture).join("."); );
return writes.some(
(write) =>
(write.scope === scope || write.scope === "intrinsic") &&
write.offset < (capture.start ?? -1) &&
(write.coverage.covers(boundPath) ||
write.coverage.covers(functionBind)),
);
});
return { return {
kind: opaqueArguments ? "opaque" : "known", kind: opaqueArguments ? "opaque" : "known",
path, path,
@@ -609,8 +711,8 @@ export const createSemanticModuleInterop = (
return writes.some( return writes.some(
(write) => (write) =>
(write.scope === scope || write.scope === "intrinsic") && (write.scope === scope || write.scope === "intrinsic") &&
write.offset < (capturedAt.start ?? -1) && write.offset < evaluationOffset(capturedAt) &&
write.coverage.covers(path), intrinsicWriteCovers(write.path, path),
); );
}) ?? false; }) ?? false;
return { return {
@@ -628,13 +730,22 @@ export const createSemanticModuleInterop = (
staticString, staticString,
isFreshObject, isFreshObject,
opaqueMutationPath, opaqueMutationPath,
retainWrite: (node: t.Node, path: readonly (string | null)[]) => { observeEvaluation: (node: t.Node): void => {
const coverage = new SemanticPropertyPathCoverage(); evaluationOrder.set(node, ++evaluationClock);
coverage.retain(path[0] === "opaque-intrinsic" ? ["intrinsic"] : path); },
retainWrite: (
node: t.Node,
path: readonly (string | null)[],
kind: JavaScriptIdentityWriteKind = "replace-value",
) => {
writes.push({ writes.push({
scope: opaqueMutationPath(node).join("."), scope: opaqueMutationPath(node).join("."),
offset: node.start ?? Infinity, offset:
coverage, evaluationClock === 0
? javaScriptIdentityWriteOffset(node)
: evaluationOffset(node),
path: path[0] === "opaque-intrinsic" ? ["intrinsic"] : path,
kind,
}); });
}, },
invalidated: (binding: JavaScriptSemanticBindingState): boolean => { invalidated: (binding: JavaScriptSemanticBindingState): boolean => {
@@ -462,10 +462,11 @@ export function* indexJavaScriptElectronIdentitySteps(
interop.intrinsicInvocation, interop.intrinsicInvocation,
interop.staticString, interop.staticString,
interop.isFreshObject, interop.isFreshObject,
(node, path) => { (node, path, kind) => {
if (path[0] === "electron") writes.retain(path); if (path[0] === "electron") writes.retain(path);
else interop.retainWrite(node, path); else interop.retainWrite(node, path, kind);
}, },
interop.observeEvaluation,
); );
collectedWrites = true; collectedWrites = true;
const intactExport = (path: readonly (string | null)[]): boolean => const intactExport = (path: readonly (string | null)[]): boolean =>
@@ -19,6 +19,8 @@ export const traverseJavaScriptAst = (
export interface JavaScriptAstVisitor { export interface JavaScriptAstVisitor {
/** Omit a child and its subtree without entering or exiting it. */ /** Omit a child and its subtree without entering or exiting it. */
readonly skip?: (node: t.Node) => boolean; 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: ( readonly enter: (
node: t.Node, node: t.Node,
parent: t.Node | null, parent: t.Node | null,
@@ -40,7 +42,12 @@ export function* traverseJavaScriptAstSteps(
const enter = (node: t.Node, parent: t.Node | null): TraversalFrame => { const enter = (node: t.Node, parent: t.Node | null): TraversalFrame => {
visitor.enter(node, parent, () => [...ancestors]); visitor.enter(node, parent, () => [...ancestors]);
ancestors.push(node); 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)]; const pending = [enter(root, null)];
let visited = 1; let visited = 1;
@@ -84,8 +91,10 @@ interface TraversalFrame {
* expansion; bespoke `Object.values` / duplicated `childNodes` walkers must * expansion; bespoke `Object.values` / duplicated `childNodes` walkers must
* not be reintroduced (they diverge on loc/comment fields and ordering). * not be reintroduced (they diverge on loc/comment fields and ordering).
*/ */
export const childNodes = (node: t.Node): t.Node[] => { export const childNodes = (
const keys = javaScriptAstChildKeys(node); node: t.Node,
keys: readonly string[] = javaScriptAstChildKeys(node),
): t.Node[] => {
const children: t.Node[] = []; const children: t.Node[] = [];
for (const key of keys) { for (const key of keys) {
const value: unknown = Reflect.get(node, key); const value: unknown = Reflect.get(node, key);
@@ -37,6 +37,10 @@ const INTRINSIC_MUTATIONS = [
'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});',
], ],
["receiver.cjs", 'Reflect.set({},"create",customCreate,Object);'], ["receiver.cjs", 'Reflect.set({},"create",customCreate,Object);'],
[
"sequence.cjs",
'(0,Object.defineProperty)(Object,"create",{value:customCreate});',
],
[ [
"deep-call.cjs", "deep-call.cjs",
'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', 'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});',
@@ -89,6 +93,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
function hoistedFunction() {} function hoistedFunction() {}
const captured = Object.defineProperty; const captured = Object.defineProperty;
Object.defineProperty = custom; Object.defineProperty = custom;
captured.call(null,metadata,"captured",{value:1});
${ROLLDOWN_MODULE_INTEROP.replace("__defProp = Object.defineProperty", "__defProp = captured")} ${ROLLDOWN_MODULE_INTEROP.replace("__defProp = Object.defineProperty", "__defProp = captured")}
let electron = require("electron"); let electron = require("electron");
electron = __toESM(electron, 1); electron = __toESM(electron, 1);
@@ -119,6 +124,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
require("./generic-bind.cjs"); require("./generic-bind.cjs");
require("./replaced-apply.cjs"); require("./replaced-apply.cjs");
require("./receiver.cjs"); require("./receiver.cjs");
require("./sequence.cjs");
require("./deep-call.cjs"); require("./deep-call.cjs");
require("./deep-apply.cjs"); require("./deep-apply.cjs");
throw new Error("must not execute main"); throw new Error("must not execute main");
@@ -147,7 +153,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
preload_entrypoints: 1, preload_entrypoints: 1,
}); });
expect(result.statistics.parse_failures).toBe(0); expect(result.statistics.parse_failures).toBe(0);
expect(result.statistics.parsed_javascript_files).toBe(12); expect(result.statistics.parsed_javascript_files).toBe(13);
const windows = result.graph.nodes.filter( const windows = result.graph.nodes.filter(
({ kind }) => kind === "browser-window", ({ kind }) => kind === "browser-window",
); );