From 5569e96162a9705f7da8ee9a6d1e832186bd337f Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Sun, 11 Oct 2026 15:22:12 +0000 Subject: [PATCH] fix(javascript): preserve intrinsic projections and fresh target identity Resolve known mutators through static binding projections and prove stable identifier chains to fresh literals before widening unknown mutation targets. Keep reassigned, uninitialized and member-projected targets conservative. Cover both review regressions through source tests and inert CLI/MCP fixtures. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- docs/javascript-artifact-reconstruction.md | 4 ++ .../javascript/electronModuleInterop.test.ts | 42 +++++++++++++++++++ .../javascriptElectronMemberWrites.ts | 6 +-- .../javascript/javascriptModuleInterop.ts | 39 +++++++++++++++-- .../javascript/javascriptSemanticAnalysis.ts | 1 + .../electronModuleInteropCli.test.ts | 22 +++++++--- 6 files changed, 101 insertions(+), 13 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 08b6202c13..638cf8fbf6 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -335,6 +335,10 @@ destructuring and loop targets. Property keys and shadowed local bindings do not overwrite the global intrinsic identity. Arbitrary cross-call side effects are not evaluated. Other reassignments, dynamic selections, and visibly overwritten namespace members are unresolved. +Known Object/Reflect mutators remain recognizable through literal destructuring +of intrinsic objects. Stable identifier chains to object/array literals keep those +targets separate from intrinsic objects when initialized before use; opaque, +reassigned or not-yet-initialized targets remain unresolved. Direct and destructuring assignment, loop, update, and delete targets are checked through namespace aliases; unrelated member writes do not discard observed API identity. This static check does diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index 06d3be51f7..653668d009 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -233,6 +233,48 @@ describe("Electron module interop pattern writes", () => { }); describe("Electron module interop capture and scope", () => { + it.each([ + 'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});', + "const objectAlias = Object; const {assign:change} = objectAlias; change(Object,{create:customCreate});", + 'const {set:change} = Reflect; change(Object,"create",customCreate);', + 'const {Object:{defineProperty:change}} = globalThis; change(Object,"create",{value:customCreate});', + ])("rejects a projected intrinsic mutator alias: %s", (write) => { + const source = `${write} ${ROLLDOWN_MODULE_INTEROP} + ${IMPORT} ${WRAP} ${WINDOW} + `.replaceAll("__toESM", "namespaceOf"); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it.each([ + "const metadata = {};", + "const metadata = [];", + "const initial = {}; const metadata = initial;", + ])( + "keeps a fresh mutator target separate from intrinsic identity: %s", + (setup) => { + const analysis = analyzeJavaScriptStaticSource(` + ${setup} Object.defineProperty(metadata,"x",{value:1}); + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }, + ); + + it.each([ + "const metadata = getMetadata();", + "let metadata = {}; metadata = Object;", + "Object.defineProperty(metadata,'x',{value:1}); const metadata = {};", + "const metadata = {intrinsic:Object}; Object.defineProperty(metadata.intrinsic,'create',{value:customCreate});", + ])("keeps an unproven mutator target unresolved: %s", (setup) => { + const analysis = analyzeJavaScriptStaticSource(` + ${setup} Object.defineProperty(metadata,"x",{value:1}); + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + it("does not reject unrelated intrinsic property definitions", () => { const analysis = analyzeJavaScriptStaticSource(` Object.defineProperty(Object, "unrelated", {value:1}); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 6264e769e2..c5439a20a8 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -50,6 +50,7 @@ export function* collectJavaScriptIdentityWritesSteps( kind: "binding" | "member" | "opaque-call", ) => readonly string[] | undefined, intrinsic: (node: t.Node) => readonly string[] | undefined, + freshObject: (node: t.Node) => boolean, retain: (node: t.Node, path: readonly (string | null)[]) => void, ): Generator { const objectMutators = new Set([ @@ -115,10 +116,7 @@ export function* collectJavaScriptIdentityWritesSteps( ? [stringValue(arguments_[1]) ?? null] : []; retain(node, [...exported, ...members, ...key]); - } else if ( - !t.isObjectExpression(target) && - !t.isArrayExpression(target) - ) { + } else if (!freshObject(target)) { const scope = origin(node, "opaque-call"); if (scope !== undefined) retain(node, scope); } diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index b24da41a12..73c13c4127 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -16,6 +16,7 @@ import { } from "./javascriptSemanticTraversal.js"; import { electronMemberPath } from "./javascriptElectronMemberWrites.js"; import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; // Match the complete Rolldown runtime operations, including its live getters. // Names and formatting are irrelevant; lexical roles, operators and keys are not. @@ -121,15 +122,39 @@ export const createSemanticModuleInterop = ( : intrinsicBindings.get(binding.bindingId); if (cached !== undefined) return finish(cached === null ? undefined : [...cached, ...suffix]); - const initializer = stableInitializer(binding, state); + const initializer = stableBindingInitializer(binding, state); if ( binding === undefined || initializer === undefined || + initializer.projection.some((segment) => segment === null) || visited.has(binding.bindingId) ) return finish(undefined); visited.add(binding.bindingId); pending.push({ id: binding.bindingId, suffixLength: suffix.length }); + suffix = [...initializer.projection.map(String), ...suffix]; + current = initializer.node; + } + }; + + const isFreshObject = (node: t.Node): boolean => { + const visited = new Set(); + let current = node; + for (;;) { + current = unwrapJavaScriptExpression(current).node; + if (t.isObjectExpression(current) || t.isArrayExpression(current)) + return true; + if (!t.isIdentifier(current)) return false; + const binding = resolveSemanticBindingState(state, current, current.name); + const initializer = stableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + (initializer.end ?? Infinity) >= (current.start ?? -1) || + visited.has(binding.bindingId) + ) + return false; + visited.add(binding.bindingId); current = initializer; } }; @@ -330,6 +355,7 @@ export const createSemanticModuleInterop = ( origin, callOrigin, intrinsicPath, + isFreshObject, opaqueMutationPath, retainWrite: (node: t.Node, path: readonly (string | null)[]) => { const coverage = new SemanticPropertyPathCoverage(); @@ -388,14 +414,21 @@ const stableInitializer = ( binding: JavaScriptSemanticBindingState | undefined, state: JavaScriptSemanticAnalysisState, ): t.Node | undefined => { + const initializer = stableBindingInitializer(binding, state); + return initializer?.projection.length === 0 ? initializer.node : undefined; +}; + +const stableBindingInitializer = ( + binding: JavaScriptSemanticBindingState | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState["initializers"][number] | undefined => { const [initializer] = binding?.initializers ?? []; return binding !== undefined && binding.definitions.length === 1 && binding.initializers.length === 1 && initializer !== undefined && - initializer.projection.length === 0 && !state.conditionalInitializers.has(initializer.node) - ? initializer.node + ? initializer : undefined; }; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 8cfa440205..50342f6295 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -413,6 +413,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( : ["electron", ...exported]; }, interop.intrinsicPath, + interop.isFreshObject, (node, path) => { if (path[0] === "electron") writes.retain(path); else interop.retainWrite(node, path); diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index 8104cde05f..758b5ef115 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -22,16 +22,25 @@ cliTest.for(["CLI", "stdio MCP"] as const)( join(root, "preload.js"), 'throw new Error("must stay inert");', ); - await writeFile( - join(root, "poisoned.cjs"), - `({Object} = replacements); + for (const [name, mutation] of [ + ["poisoned.cjs", "({Object} = replacements);"], + [ + "projected.cjs", + 'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});', + ], + ] as const) + await writeFile( + join(root, name), + `${mutation} ${ROLLDOWN_MODULE_INTEROP} let poisoned = require("electron"); poisoned = __toESM(poisoned, 1); new poisoned.BrowserWindow({}); throw new Error("must not execute poisoned source");`, - ); - const source = `${ROLLDOWN_MODULE_INTEROP} + ); + const source = `const metadata = {}; + Object.defineProperty(metadata,"x",{value:1}); + ${ROLLDOWN_MODULE_INTEROP} let electron = require("electron"); electron = __toESM(electron, 1); new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}}); @@ -48,6 +57,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( new fake.BrowserWindow({}); function decoy(electron) {new electron.BrowserWindow({});} require("./poisoned.cjs"); + require("./projected.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -74,7 +84,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(3); + expect(result.statistics.parsed_javascript_files).toBe(4); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );