diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 14c77ccc9..192a650f9 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -343,7 +343,12 @@ writes inside directly invoked local functions, 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 fresh object, array, function, +of intrinsic objects and stable bound-function aliases. Calls retain bound +argument prefixes through `call`, `apply`, and `Reflect.apply`; unexamined +targets remain unresolved. 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 objects when initialized before use; opaque, decorated, reassigned or not-yet-initialized targets remain unresolved. Writes during class initialization diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index 545397b68..a9f436a82 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -505,3 +505,45 @@ describe("Electron module interop capture and scope", () => { expect(analysis.electron.browser_windows).toHaveLength(1); }); }); + +describe("Electron module interop bound mutators", () => { + it.each([ + 'const change = Object.defineProperty.bind(null); change(Object,"create",{value:customCreate});', + 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', + 'const first = Object.defineProperty.bind(null,Object); const change = first; change("create",{value:customCreate});', + 'const change = Object.defineProperty.bind(null,Object); change.call(null,"create",{value:customCreate});', + 'const change = Object.defineProperty.bind(null,Object); change.apply(null,["create",{value:customCreate}]);', + 'const change = Object.defineProperty.bind(null,Object); Reflect.apply(change,null,["create",{value:customCreate}]);', + 'Object.defineProperty.bind(null,Object,"create")({value:customCreate});', + 'const change = Object.defineProperty.bind(null,...args); change(Object,"create",{value:customCreate});', + 'const metadata = {}; Object.defineProperty.call(...args,metadata,"x",{value:1});', + 'const metadata = {}; Object.defineProperty.apply(...args,[metadata,"x",{value:1}]);', + 'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);', + '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}]);', + ])("rejects intrinsic writes through a bound mutator: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toEqual([]); + }); + + it.each([ + 'const change = Object.defineProperty.bind(null); change(metadata,"x",{value:1});', + 'const change = Object.defineProperty.bind(null,metadata,"x"); change({value:1});', + 'const first = Object.defineProperty.bind(null,metadata); const change = first.bind(null,"x"); change({value:1});', + 'const change = Object.defineProperty.bind(null,Object,"create");', + 'Object.defineProperty.call(null,metadata,"x",{value:1});', + '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}]);', + ])("keeps a fresh bound target or unused binding separate: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toHaveLength(1); + }); +}); diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index cf4531652..0005a1977 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -143,4 +143,30 @@ describe("Electron API identity", () => { analysis.electron.browser_windows.map(({ preload_path }) => preload_path), ).toEqual(["./const.js", "./let.js", "./destructured.js"]); }); + + it("preserves one initializer across variable redeclarations without accepting writes", () => { + const analysis = analyzeJavaScriptStaticSource(` + var electron; + var electron = require("electron"); + var electron; + new electron.BrowserWindow({webPreferences:{preload:"./redeclared.js"}}); + + var replaced; + var replaced = require("electron"); + replaced = fake; + new replaced.BrowserWindow({webPreferences:{preload:"./replaced.js"}}); + + var initializedTwice = require("electron"); + var initializedTwice = fake; + new initializedTwice.BrowserWindow({}); + + var conditional; + if (flag) { var conditional = require("electron"); } + new conditional.BrowserWindow({}); + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./redeclared.js"]); + }); }); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index c5439a20a..a265b94fa 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -42,6 +42,14 @@ export const electronMemberPath = ( return { root, members: members.toReversed() }; }; +/** A known intrinsic callable and its examined positional arguments. */ +export interface JavaScriptIntrinsicInvocation { + readonly path: readonly string[]; + readonly arguments: + | readonly t.CallExpression["arguments"][number][] + | undefined; +} + /** Index member and binding writes through caller-proven lexical identities. */ export function* collectJavaScriptIdentityWritesSteps( program: t.Program, @@ -49,7 +57,9 @@ export function* collectJavaScriptIdentityWritesSteps( root: t.Node, kind: "binding" | "member" | "opaque-call", ) => readonly string[] | undefined, - intrinsic: (node: t.Node) => readonly string[] | undefined, + intrinsicInvocation: ( + node: t.CallExpression | t.OptionalCallExpression, + ) => JavaScriptIntrinsicInvocation | undefined, freshObject: (node: t.Node) => boolean, retain: (node: t.Node, path: readonly (string | null)[]) => void, ): Generator { @@ -69,39 +79,16 @@ export function* collectJavaScriptIdentityWritesSteps( yield* traverseJavaScriptAstSteps(program, { enter: (node) => { if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { - let callee = intrinsic(node.callee); - let arguments_ = node.arguments; - let applied: t.Node | undefined; - let usesApply = false; - if (callee?.join(".") === "intrinsic.Reflect.apply") { - usesApply = true; - const callable = arguments_[0]; - callee = t.isNode(callable) ? intrinsic(callable) : undefined; - applied = arguments_[2]; - } - if (callee?.at(-1) === "call") { - callee = callee.slice(0, -1); - arguments_ = arguments_.slice(1); - } else if (callee?.at(-1) === "apply") { - usesApply = true; - callee = callee.slice(0, -1); - applied = arguments_[1]; - } - if (usesApply) - arguments_ = - t.isArrayExpression(applied) && - applied.elements.every((element) => t.isExpression(element)) - ? applied.elements.filter((element): element is t.Expression => - t.isExpression(element), - ) - : []; + const invocation = intrinsicInvocation(node); + 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 ?? "")) ) { - const target = arguments_[0]; + 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)) { @@ -113,7 +100,7 @@ export function* collectJavaScriptIdentityWritesSteps( const exported = origin(root, "member"); if (exported !== undefined) { const key = keyedMutators.has(method ?? "") - ? [stringValue(arguments_[1]) ?? null] + ? [stringValue(arguments_?.[1]) ?? null] : []; retain(node, [...exported, ...members, ...key]); } else if (!freshObject(target)) { diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts new file mode 100644 index 000000000..32bab79f7 --- /dev/null +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -0,0 +1,103 @@ +import * as t from "@babel/types"; + +import type { JavaScriptIntrinsicInvocation } from "./javascriptElectronMemberWrites.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +const arrayArguments = (array: t.Node | undefined) => + t.isArrayExpression(array) && + array.elements.every((element) => t.isExpression(element)) + ? array.elements.filter((element): element is t.Expression => + t.isExpression(element), + ) + : undefined; + +const invocationArguments = ( + method: "call" | "apply", + arguments_: JavaScriptIntrinsicInvocation["arguments"], +) => + t.isSpreadElement(arguments_?.[0]) + ? undefined + : method === "apply" + ? arrayArguments(arguments_?.[1]) + : arguments_?.slice(1); + +/** + * Normalize known invocation syntax once, retaining bound argument prefixes. + * Binding a function alone is not a write; only its eventual call is examined. + */ +export const resolveJavaScriptIntrinsicInvocation = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, + intrinsicPath: (node: t.Node) => readonly string[] | undefined, +): JavaScriptIntrinsicInvocation | undefined => { + const visited = new Set(); + let callee: t.Node = node.callee; + let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments; + for (;;) { + callee = unwrapJavaScriptExpression(callee).node; + let path = intrinsicPath(callee); + // Static projections can capture call/apply themselves as aliases. + while (path !== undefined && path.length > 3) { + const method = path.at(-1); + if (method !== "call" && method !== "apply") break; + arguments_ = invocationArguments(method, arguments_); + path = path.slice(0, -1); + } + if (path?.join(".") === "intrinsic.Reflect.apply") { + const callable = arguments_?.[0]; + if (!t.isExpression(callable)) return undefined; + callee = callable; + arguments_ = arrayArguments(arguments_?.[2]); + continue; + } + if (path !== undefined) return { path, arguments: arguments_ }; + if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) { + const method = semanticStaticPropertyKey( + callee.property, + callee.computed, + ); + if (method === "call" || method === "apply") { + callee = callee.object; + arguments_ = invocationArguments(method, arguments_); + continue; + } + } + if (t.isCallExpression(callee) || t.isOptionalCallExpression(callee)) { + const binder: t.Node = unwrapJavaScriptExpression(callee.callee).node; + if ( + (!t.isMemberExpression(binder) && + !t.isOptionalMemberExpression(binder)) || + semanticStaticPropertyKey(binder.property, binder.computed) !== "bind" + ) + return undefined; + const prefix = callee.arguments.slice(1); + arguments_ = t.isSpreadElement(callee.arguments[0]) + ? undefined + : prefix.length === 0 + ? arguments_ + : [...prefix, ...(arguments_ ?? [])]; + callee = binder.object; + continue; + } + if (!t.isIdentifier(callee)) return undefined; + const binding = resolveSemanticBindingState(state, callee, callee.name); + const initializer = semanticStableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + initializer.projection.length !== 0 || + visited.has(binding.bindingId) + ) + return undefined; + visited.add(binding.bindingId); + callee = initializer.node; + } +}; diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 634a63933..542fbbd39 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -16,6 +16,7 @@ import { javaScriptAstChildKeys, } from "./javascriptSemanticTraversal.js"; import { electronMemberPath } from "./javascriptElectronMemberWrites.js"; +import { resolveJavaScriptIntrinsicInvocation } from "./javascriptIntrinsicInvocation.js"; import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; import { readExactJavaScriptLiteral, @@ -441,6 +442,8 @@ export const createSemanticModuleInterop = ( origin, callOrigin, intrinsicPath, + intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) => + resolveJavaScriptIntrinsicInvocation(node, state, intrinsicPath), isFreshObject, opaqueMutationPath, retainWrite: (node: t.Node, path: readonly (string | null)[]) => { diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index 05062b4c7..b086470f3 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -195,6 +195,32 @@ describe("tRPC binding identity and bundled syntax", () => { ]); }); + it("retains one client initializer across variable redeclarations", () => { + expect( + rpcEndpoints(` + var c; + var c = createTRPCClient({}); + var c; + c.users.query(); + + var replaced; + var replaced = createTRPCClient({}); + replaced = pool; + replaced.posts.query(); + + var initializedTwice = createTRPCClient({}); + var initializedTwice = pool; + initializedTwice.comments.query(); + + var conditional; + if (flag) { var conditional = createTRPCClient({}); } + conditional.groups.query(); + `), + ).toEqual([ + { kind: "network", value: "users", mechanism: "trpc:procedure:query" }, + ]); + }); + it("recognizes surviving factory names inside bundled call wrappers", () => { expect( rpcEndpoints(` diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 5bec5355b..6c07340cd 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -410,7 +410,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( ? interop.intrinsicPath(root) : ["electron", ...exported]; }, - interop.intrinsicPath, + interop.intrinsicInvocation, interop.isFreshObject, (node, path) => { if (path[0] === "electron") writes.retain(path); diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index cd44c5225..e07caad99 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -77,6 +77,7 @@ export interface JavaScriptSemanticAnalysisState { /** * Return one unconditional source initializer for a declared lexical binding. + * Repeated variable declarations without another initializer do not write it. * This establishes a stable source fact only; it does not prove immutability or * that the declaration has executed at a particular program point. */ @@ -87,10 +88,10 @@ export const semanticStableInitializer = ( const [initializer] = binding?.initializers ?? []; const [definition] = binding?.definitions ?? []; return binding !== undefined && - binding.definitions.length === 1 && - (definition?.kind === "variable" || - definition?.kind === "function" || - definition?.kind === "class") && + ((definition?.kind === "variable" && + binding.definitions.every(({ kind }) => kind === "variable")) || + (binding.definitions.length === 1 && + (definition?.kind === "function" || definition?.kind === "class"))) && binding.initializers.length === 1 && initializer !== undefined && !state.conditionalInitializers.has(initializer.node) diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index baa30872e..ed988df0f 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -32,6 +32,10 @@ cliTest.for(["CLI", "stdio MCP"] as const)( "decorated.cjs", '@(() => Object) class metadata {}; Object.defineProperty(metadata,"create",{value:customCreate});', ], + [ + "bound.cjs", + 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', + ], ] as const) await writeFile( join(root, name), @@ -44,6 +48,8 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ); const source = `const metadata = {}; Object.defineProperty(metadata,"x",{value:1}); + const decorateMetadata = Object.defineProperty.bind(null,metadata,"bound"); + decorateMetadata({value:1}); const localFunction = () => {}; Object.defineProperty(localFunction,"x",{value:1}); const localClass = class {}; @@ -59,7 +65,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( electron = __toESM(electron, 1); new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}}); new electron.BrowserWindow({webPreferences:{preload:runtimePath}}); - const direct = require("electron"); + var direct; + var direct = require("electron"); + var direct; new direct.BrowserWindow({}); const fresh = __toESM(require("electron"), 1); new fresh.BrowserWindow({}); @@ -77,6 +85,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( require("./poisoned.cjs"); require("./projected.cjs"); require("./decorated.cjs"); + require("./bound.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -103,7 +112,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(5); + expect(result.statistics.parsed_javascript_files).toBe(6); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );