From c98d32651436fffba5ecb595dc12cc0feccf2b5b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:01:18 +0800 Subject: [PATCH] fix(javascript): retain Reflect set receiver effects --- docs/javascript-artifact-reconstruction.md | 3 +- .../javascript/electronModuleInterop.test.ts | 10 ++++ .../javascriptElectronMemberWrites.ts | 47 ++++++++++++------- .../electronModuleInteropCli.test.ts | 4 +- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index b8a6e5c6d..7442fc746 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -349,7 +349,8 @@ argument prefixes and the original bound receiver through `call`, `apply`, and creation. 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. Creating an unused binding does not itself mutate +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, diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index c06b87ac3..bdaf113bf 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -560,6 +560,14 @@ describe("Electron module interop bound mutators", () => { 'Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', 'const apply = Reflect.apply; Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', 'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);', + 'Reflect.set({},"create",customCreate,Object);', + 'const metadata = {}; Reflect.set(metadata,"create",customCreate,Object);', + 'const change = Reflect.set.bind(null,{},"create",customCreate); change(Object);', + 'Reflect.apply(Reflect.set,null,[{},"create",customCreate,Object]);', + 'Reflect.set({},"create",customCreate,...receivers);', + 'Reflect.set({},"create",...valuesAndReceivers);', + "Reflect.set({},...keysValuesAndReceivers);", + 'Reflect.set({},"create",customCreate,receiver);', 'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});', 'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);', 'const change = Reflect.apply.call; change(null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', @@ -587,6 +595,8 @@ describe("Electron module interop bound mutators", () => { 'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);', 'const change = Object.defineProperty.call; change(null,metadata,"x",{value:1});', 'const {apply:change} = Object.defineProperty; change(null,[metadata,"x",{value:1}]);', + 'Reflect.set(metadata,"x",1,{});', + 'const receiver = {}; Reflect.set({},"x",1,receiver);', ])("keeps a fresh bound target or unused binding separate: %s", (write) => { expect( analyzeJavaScriptStaticSource( diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 3ee0b5044..003b495e0 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -96,25 +96,40 @@ export function* collectJavaScriptIdentityWritesSteps( (owner === "Object" && objectMutators.has(method ?? "")) || (owner === "Reflect" && reflectMutators.has(method ?? "")) ) { - const target = arguments_?.[0]; - // 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)) { - const scope = origin(node, "opaque-call"); - if (scope !== undefined) retain(node, scope); - } - if (t.isNode(target) && !t.isSpreadElement(target)) { - const { root, members } = electronMemberPath(target); - const exported = origin(root, "member"); - if (exported !== undefined) { - const key = keyedMutators.has(method ?? "") - ? [staticString(arguments_?.[1]) ?? null] - : []; - retain(node, [...exported, ...members, ...key]); - } else if (!freshObject(target)) { + // 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)) { const scope = origin(node, "opaque-call"); if (scope !== undefined) retain(node, scope); } + if (t.isNode(target) && !t.isSpreadElement(target)) { + const { root, members } = electronMemberPath(target); + const exported = origin(root, "member"); + if (exported !== undefined) { + const key = keyedMutators.has(method ?? "") + ? [staticString(arguments_?.[1]) ?? null] + : []; + retain(node, [...exported, ...members, ...key]); + } else if (!freshObject(target)) { + const scope = origin(node, "opaque-call"); + if (scope !== undefined) retain(node, scope); + } + } } } } diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index 9e6387c98..badad8696 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -36,6 +36,7 @@ const INTRINSIC_MUTATIONS = [ "bound.cjs", 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', ], + ["receiver.cjs", 'Reflect.set({},"create",customCreate,Object);'], ] as const; cliTest.for(["CLI", "stdio MCP"] as const)( @@ -103,6 +104,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( require("./uncurried.cjs"); require("./generic-bind.cjs"); require("./replaced-apply.cjs"); + require("./receiver.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -129,7 +131,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(9); + expect(result.statistics.parsed_javascript_files).toBe(10); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );