diff --git a/src/domain/javascript/electronIntrinsicCapture.test.ts b/src/domain/javascript/electronIntrinsicCapture.test.ts index 2679dd55f..1a6a909ce 100644 --- a/src/domain/javascript/electronIntrinsicCapture.test.ts +++ b/src/domain/javascript/electronIntrinsicCapture.test.ts @@ -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; diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index bfa4d77fc..589341bbf 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -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 { 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(); const loopBodies = new WeakMap(); 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); }, }); diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index e6beb4d39..1a97ca3e8 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -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]) diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 1506e462b..6a62a07cc 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -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(); + let evaluationClock = 0; + const evaluationOffset = (node: t.Node): number => + evaluationClock === 0 + ? (node.start ?? -1) + : (evaluationOrder.get(node) ?? Infinity); const helpers = new Map(); const origins = new Map(); const calls = new WeakMap(); @@ -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, }); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 60005be44..425f00d67 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -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 => diff --git a/src/domain/javascript/javascriptSemanticTraversal.ts b/src/domain/javascript/javascriptSemanticTraversal.ts index 581d7d464..331671811 100644 --- a/src/domain/javascript/javascriptSemanticTraversal.ts +++ b/src/domain/javascript/javascriptSemanticTraversal.ts @@ -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);