Compare commits

...
9 changed files with 528 additions and 124 deletions
+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.
Visibly replaced invocation intrinsics produce unknown effects instead
of using their former call semantics; captured function aliases retain their
earlier identity. 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
earlier identity. Invocation member reads are checked separately from the earlier
function capture. Comma-expression callees use their final value while all
preceding expressions remain in the effect traversal. Call effects are
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.
Stable identifier chains to fresh object, array, function,
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({},...keysValuesAndReceivers);",
'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) => {
expect(
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}]);',
'Reflect.set(metadata,"x",1,{});',
'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) => {
expect(
analyzeJavaScriptStaticSource(
@@ -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 =
@@ -52,6 +55,24 @@ export interface JavaScriptIntrinsicInvocation {
| 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
* object before appending the written key: self-reference properties can denote
@@ -68,7 +89,12 @@ export function* collectJavaScriptIdentityWritesSteps(
) => JavaScriptIntrinsicInvocation | undefined,
staticString: (node: t.Node | undefined) => string | undefined,
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> {
const objectMutators = new Set([
"defineProperty",
@@ -83,85 +109,141 @@ export function* collectJavaScriptIdentityWritesSteps(
"setPrototypeOf",
]);
const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]);
yield* traverseJavaScriptAstSteps(program, {
enter: (node) => {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const invocation = intrinsicInvocation(node);
if (invocation?.kind === "opaque") {
for (const scope of origin(node, "opaque-call")) retain(node, scope);
return;
const retainCallEffects = (
node: t.CallExpression | t.OptionalCallExpression,
): void => {
const invocation = intrinsicInvocation(node);
if (invocation?.kind === "opaque") {
for (const scope of origin(node, "opaque-call"))
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;
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);
}
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);
}
}
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],
keyedMutators.has(method ?? "")
? "replace-value"
: "mutate-object",
);
} else if (!freshObject(target)) {
for (const scope of origin(node, "opaque-call"))
retain(node, scope, "opaque");
}
}
}
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
: t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: 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);
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]);
: t.isForOfStatement(node) || t.isForInStatement(node)
? node.left
: undefined;
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 && !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;
for (;;) {
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;
visited.add(callee);
let path = intrinsicPath(callee);
@@ -97,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])
+150 -39
View File
@@ -17,13 +17,15 @@ import {
} from "./javascriptSemanticTraversal.js";
import {
electronMemberPath,
javaScriptIdentityWriteOffset,
type JavaScriptIdentityWriteKind,
type JavaScriptIntrinsicInvocation,
} from "./javascriptElectronMemberWrites.js";
import {
expandJavaScriptInvocationArguments,
resolveJavaScriptIntrinsicInvocation,
} from "./javascriptIntrinsicInvocation.js";
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
import {
readExactJavaScriptLiteral,
unwrapJavaScriptExpression,
@@ -74,6 +76,16 @@ interface InteropHelper {
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. */
export const createSemanticModuleInterop = (
state: JavaScriptSemanticAnalysisState,
@@ -81,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>();
@@ -88,8 +108,18 @@ export const createSemanticModuleInterop = (
const writes: {
readonly scope: string;
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 invalidatedCalls = new WeakMap<t.Node, boolean>();
const opaqueMutationPath = (node: t.Node): readonly string[] => {
@@ -125,6 +155,12 @@ export const createSemanticModuleInterop = (
const { root, members } = electronMemberPath(current);
if (members.some((member) => member === null)) return finish(undefined);
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"])
if (isUnshadowedGlobal(root, state, global))
return finish(["intrinsic", global, ...suffix]);
@@ -165,8 +201,20 @@ export const createSemanticModuleInterop = (
let capturedAt = node;
let suffixLength = 0;
for (;;) {
current = unwrapJavaScriptExpression(current).node;
if (suffixLength === 0)
capturedAt =
t.isMemberExpression(current) || t.isOptionalMemberExpression(current)
? current.property
: current;
const { root, members } = electronMemberPath(current);
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;
const binding = resolveSemanticBindingState(state, root, root.name);
if (binding === undefined) return { path, capturedAt };
@@ -217,24 +265,84 @@ export const createSemanticModuleInterop = (
node: t.Node,
path: readonly string[],
): boolean => {
const capture = capturedIntrinsic(node)?.capturedAt ?? node;
const scope = opaqueMutationPath(capture).join(".");
const method = path.at(-1);
const invocationMethod =
method === "bind" || method === "call" || method === "apply";
return writes.some(
(write) =>
(write.scope === scope || write.scope === "intrinsic") &&
write.offset < (capture.start ?? -1) &&
(write.coverage.covers(path) ||
(invocationMethod &&
write.coverage.covers([
"intrinsic",
"Function",
"prototype",
method,
]))),
);
let current = unwrapJavaScriptExpression(node).node;
let currentPath = path;
const aliases = new Set<string>();
for (;;) {
current = unwrapJavaScriptExpression(current).node;
if (t.isSequenceExpression(current)) {
const value = current.expressions.at(-1);
if (value === undefined) return true;
current = value;
continue;
}
const method = currentPath.at(-1);
const invocationMethod =
method === "bind" || method === "call" || method === "apply";
// Recover the member read at an alias initializer before examining its
// 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 = (
@@ -278,7 +386,7 @@ export const createSemanticModuleInterop = (
if (members.at(-1) === "bind") {
// A direct .bind may continue a bound callable, without discarding members.
if (!terminal && members.length !== 1) return undefined;
bindReads.push(captured?.capturedAt ?? current.callee);
bindReads.push(current.callee);
} else {
// Reuse the invocation owner for generic call/apply/Reflect.apply of bind.
const invocation = resolveJavaScriptIntrinsicInvocation(
@@ -298,6 +406,8 @@ export const createSemanticModuleInterop = (
captured = capturedIntrinsic(bindTarget);
terminal = captured !== undefined;
path = captured?.path;
opaqueArguments ||=
path !== undefined && intrinsicReadChanged(bindTarget, path);
}
receiver = bindingArguments?.[0];
const ownArguments = bindingArguments?.slice(1) ?? [];
@@ -312,17 +422,9 @@ export const createSemanticModuleInterop = (
continue;
}
const boundPath = [...path, "bind"];
const functionBind = ["intrinsic", "Function", "prototype", "bind"];
opaqueArguments ||= bindReads.some((capture) => {
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)),
);
});
opaqueArguments ||= bindReads.some((capture) =>
intrinsicReadChanged(capture, boundPath),
);
return {
kind: opaqueArguments ? "opaque" : "known",
path,
@@ -609,8 +711,8 @@ export const createSemanticModuleInterop = (
return writes.some(
(write) =>
(write.scope === scope || write.scope === "intrinsic") &&
write.offset < (capturedAt.start ?? -1) &&
write.coverage.covers(path),
write.offset < evaluationOffset(capturedAt) &&
intrinsicWriteCovers(write.path, path),
);
}) ?? false;
return {
@@ -628,13 +730,22 @@ export const createSemanticModuleInterop = (
staticString,
isFreshObject,
opaqueMutationPath,
retainWrite: (node: t.Node, path: readonly (string | null)[]) => {
const coverage = new SemanticPropertyPathCoverage();
coverage.retain(path[0] === "opaque-intrinsic" ? ["intrinsic"] : path);
observeEvaluation: (node: t.Node): void => {
evaluationOrder.set(node, ++evaluationClock);
},
retainWrite: (
node: t.Node,
path: readonly (string | null)[],
kind: JavaScriptIdentityWriteKind = "replace-value",
) => {
writes.push({
scope: opaqueMutationPath(node).join("."),
offset: node.start ?? Infinity,
coverage,
offset:
evaluationClock === 0
? javaScriptIdentityWriteOffset(node)
: evaluationOffset(node),
path: path[0] === "opaque-intrinsic" ? ["intrinsic"] : path,
kind,
});
},
invalidated: (binding: JavaScriptSemanticBindingState): boolean => {
@@ -462,10 +462,11 @@ export function* indexJavaScriptElectronIdentitySteps(
interop.intrinsicInvocation,
interop.staticString,
interop.isFreshObject,
(node, path) => {
(node, path, kind) => {
if (path[0] === "electron") writes.retain(path);
else interop.retainWrite(node, path);
else interop.retainWrite(node, path, kind);
},
interop.observeEvaluation,
);
collectedWrites = true;
const intactExport = (path: readonly (string | null)[]): boolean =>
@@ -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);
@@ -37,6 +37,10 @@ const INTRINSIC_MUTATIONS = [
'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});',
],
["receiver.cjs", 'Reflect.set({},"create",customCreate,Object);'],
[
"sequence.cjs",
'(0,Object.defineProperty)(Object,"create",{value:customCreate});',
],
[
"deep-call.cjs",
'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() {}
const captured = Object.defineProperty;
Object.defineProperty = custom;
captured.call(null,metadata,"captured",{value:1});
${ROLLDOWN_MODULE_INTEROP.replace("__defProp = Object.defineProperty", "__defProp = captured")}
let electron = require("electron");
electron = __toESM(electron, 1);
@@ -119,6 +124,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
require("./generic-bind.cjs");
require("./replaced-apply.cjs");
require("./receiver.cjs");
require("./sequence.cjs");
require("./deep-call.cjs");
require("./deep-apply.cjs");
throw new Error("must not execute main");
@@ -147,7 +153,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
preload_entrypoints: 1,
});
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(
({ kind }) => kind === "browser-window",
);