From 9b3e59804db71072ed023f109342f96063c57b45 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:06:33 +0800 Subject: [PATCH] fix(javascript): prove namespace interop before attributing Electron APIs (#1857) * fix(javascript): prove Electron module namespace conversion * refactor(javascript): share AST child field metadata 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> * fix(javascript): retain destructured intrinsic writes Share one pattern-target walker across lexical definitions, member mutation tracking and Electron identity writes. Keep overwritten globals unresolved without treating property keys or shadowed locals as global replacements. 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> * 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> * fix(javascript): retain fresh function and class target identity Recognize stable local function, undecorated class and RegExp targets before widening intrinsic mutations. Keep decorated classes opaque because their binding value can be replaced, and preserve class-initialization writes. Cover fresh targets and replacement decorators in 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> * fix(javascript): preserve intrinsic alias capture timing Validate each alias read after initialization and retain the original function capture across copies. Share declaration initializer facts with RPC and Electron analysis, including class declarations used for fresh-target identity. Accept single-statement control blocks while keeping lexical roles and added operations checked. Cover the capture regressions through inert source and public CLI/MCP workflows. * fix(javascript): respect hoisted fresh function declarations Function declarations provide fresh targets before their textual position. Retain initialization-order checks for variable and class values, including aliases. Cover the reviewed regression with source controls and the public CLI/MCP fixture. * fix(javascript): preserve proven interop result projections * fix(javascript): retain stable declarations and bound intrinsic writes Keep repeated variable declarations with one unconditional initializer while rejecting assignments, conditional initialization and multiple values. Normalize known intrinsic calls, aliases and bound argument prefixes at the semantic owner, retaining opaque targets without tainting unused bindings or fresh local objects. Cover both regressions through source and CLI/MCP tests. 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> * fix(javascript): retain bound intrinsic mutation effects * fix(javascript): preserve bound receivers for intrinsic invocation * fix(javascript): prove generic binding and invocation effects * fix(javascript): retain Reflect set receiver effects --------- 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> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/javascript-artifact-reconstruction.md | 45 +- .../javascript/electronModuleInterop.test.ts | 618 ++++++++++++++ .../electronStaticAnalysisIdentity.test.ts | 51 +- src/domain/javascript/javascriptAstValues.ts | 12 +- .../javascriptElectronMemberWrites.ts | 113 ++- .../javascriptIntrinsicInvocation.ts | 123 +++ .../javascriptModuleInterop.fixture.ts | 24 + .../javascript/javascriptModuleInterop.ts | 774 ++++++++++++++++++ .../javascript/javascriptRpcEndpoints.test.ts | 26 + .../javascript/javascriptSemanticAnalysis.ts | 177 ++-- .../javascriptSemanticMemberMutations.ts | 19 +- .../javascriptSemanticRequireOrigin.ts | 71 -- .../javascript/javascriptSemanticState.ts | 24 + .../javascript/javascriptSemanticTraversal.ts | 7 +- .../electronModuleInteropCli.test.ts | 180 ++++ 15 files changed, 2043 insertions(+), 221 deletions(-) create mode 100644 src/domain/javascript/electronModuleInterop.test.ts create mode 100644 src/domain/javascript/javascriptIntrinsicInvocation.ts create mode 100644 src/domain/javascript/javascriptModuleInterop.fixture.ts create mode 100644 src/domain/javascript/javascriptModuleInterop.ts create mode 100644 tests/acceptance/applications/electronModuleInteropCli.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 82e2cef99a..7442fc746f 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -323,15 +323,42 @@ substitute version-dependent Electron defaults for omitted `webPreferences`. Dynamic option objects, bridge keys, API objects, and IPC channel expressions remain unknown and make coverage partial. -Electron API identity follows proven lexical module aliases. Bundled CommonJS -namespace imports are also followed through the interop helpers that bundlers -and compilers emit (`__toESM`, `__importStar`, `_interopRequireWildcard`), -including the `electron = __toESM(electron)` rewrap that Rolldown emits. This -step is inferred: a helper is recognized by its emitted name and a single -program-scope function definition, and its body is not evaluated, so a local -function that reuses one of these names is trusted to pass the module through. -Other reassigned bindings, dynamic selections, and visibly overwritten namespace -members are unresolved. +Electron API identity follows proven lexical module aliases. Namespace interop +also preserves identity when the complete Rolldown helper operations, lexical +roles, live getters, and captured intrinsic origins match, or when a stable +helper simply returns its module parameter. Harmless single-statement control +blocks preserve recognition; added operations still require proof. Literal +member and destructuring projections are retained from proven results. +Omitted or literal node-mode arguments preserve named exports; a module-valued +default projection requires a truthy literal node mode. Computed or extra +arguments remain unresolved. +Recognition uses structure rather than emitted names; other helper bodies and ambiguous +runtime fallbacks remain unresolved. Captured function identities survive later global-property +replacement, including copies through initialized aliases. Reads before an +alias initializer remain unresolved. Visible intrinsic writes before capture or +conversion are checked in their owning callable scope, including identifier leaves in nested/default/rest +destructuring and loop targets. Property keys and shadowed local bindings do not +overwrite the global intrinsic identity. Cross-call side effects, including +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 and stable bound-function aliases. Calls retain bound +argument prefixes and the original bound receiver through `call`, `apply`, and +`Reflect.apply`, including generic `Function.prototype` invocation and binding +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. `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 +objects when initialized before use; opaque, decorated, reassigned or +not-yet-initialized targets remain unresolved. Writes during class initialization +are still retained. Function declarations are available before their textual +position; variable and class initializers must precede use. 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 new file mode 100644 index 0000000000..bdaf113bf8 --- /dev/null +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -0,0 +1,618 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { ROLLDOWN_MODULE_INTEROP } from "./javascriptModuleInterop.fixture.js"; + +const WINDOW = + 'new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});'; +const IMPORT = 'let electron = require("electron");'; +const WRAP = "electron = __toESM(electron, 1);"; +// The upstream runtime uses control-body braces that its bundled printer omits. +// https://github.com/rolldown/rolldown/blob/22a0ab13732d81f2559ad2b7064b688699324889/crates/rolldown/src/runtime/runtime-base.js +const BLOCK_MODULE_INTEROP = ROLLDOWN_MODULE_INTEROP.replace( + 'if (from && typeof from === "object" || typeof from === "function") for', + 'if (from && typeof from === "object" || typeof from === "function") { for', +) + .replace( + "if (!__hasOwnProp.call(to, key) && key !== except) __defProp", + "if (!__hasOwnProp.call(to, key) && key !== except) { __defProp", + ) + .replace(" });", " });\n }") + .replace(" return to;", " }\n return to;"); + +describe("Electron module interop identity", () => { + it("preserves named APIs through the released Rolldown namespace conversion", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + ${IMPORT} ${WRAP} ${WINDOW} + new electron.BrowserWindow({webPreferences:{preload:runtimePath}}); + electron.ipcMain.handle("ping", () => "pong"); + electron.contextBridge.exposeInMainWorld("api", {ping:true}); + `); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + { preload_path: null }, + ]); + expect(analysis.electron.ipc).toMatchObject([ + { channel: "ping", side: "main" }, + ]); + expect(analysis.electron.context_bridge_apis).toMatchObject([ + { api_key: "api" }, + ]); + }); + + it("follows lexical identities when helper and parameter names change", () => { + const source = `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}` + .replaceAll("__toESM", "namespaceOf") + .replaceAll("__copyProps", "copyNamed") + .replaceAll("mod", "inputModule") + .replaceAll("isNodeMode", "nodeMode") + .replaceAll("target", "namespaceTarget") + .replaceAll("from", "sourceObject"); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows, + ).toMatchObject([{ preload_path: "./preload.js" }]); + }); + + it("preserves the same operations through control-body braces", () => { + expect( + analyzeJavaScriptStaticSource( + `${BLOCK_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toMatchObject([{ preload_path: "./preload.js" }]); + }); + + it("keeps additional operations inside a control block unresolved", () => { + const altered = BLOCK_MODULE_INTEROP.replace( + "{ __defProp", + "{ from = unrelated; __defProp", + ); + expect( + analyzeJavaScriptStaticSource(`${altered}\n${IMPORT} ${WRAP} ${WINDOW}`) + .electron.browser_windows, + ).toEqual([]); + }); +}); + +const BOUND_INTRINSIC_MUTATIONS: [string, string][] = [ + [ + "intrinsic changed through aliased prebound arguments", + `const objectAlias = Object; const key = "create"; const define = Object.defineProperty; const mutate = define.bind(null, objectAlias, key); mutate({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed through nested prebinding", + `const first = Object.defineProperty.bind(null, Object); const second = first.bind(null, "create"); second({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "a replaced bound mutator bind method is unknown", + `const first = Object.defineProperty.bind(null, Object); first.bind = customBind; const second = first.bind(null, "create"); second({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed through a computed prebound call", + `const mutate = Object.defineProperty.bind(null, Object, "create"); mutate["call"](null, {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed through a computed prebound apply", + `const mutate = Object.defineProperty.bind(null, Object, "create"); mutate["apply"](null, [{value:customCreate}]); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "a replaced bind intrinsic makes a prebound mutator unknown", + `Function.prototype.bind = customBind; const mutate = Object.defineProperty.bind(null, Object, "create"); mutate({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], +]; + +describe("Electron module interop uncertainty", () => { + it.each([ + ...BOUND_INTRINSIC_MUTATIONS, + [ + "intrinsic changed through an unresolved mutator target", + `Object.defineProperty(getTarget(), "create", {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via dynamic apply", + `const args = [Object, "getOwnPropertyNames", {value:() => []}]; Object.defineProperty.apply(null, args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via spread call", + `const args = [Object, "getOwnPropertyNames", {value:() => []}]; Object.defineProperty.call(null, ...args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via Reflect.apply", + `const args = [Object, "getOwnPropertyNames", {value:() => []}]; Reflect.apply(Object.defineProperty, null, args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via spread Reflect.apply", + `const args = [null, [Object, "create", {value:customCreate}]]; Reflect.apply(Object.defineProperty, ...args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via call", + `Object.defineProperty.call(null, Object, "getOwnPropertyNames", {value:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via apply", + `Object.defineProperty.apply(null, [Object, "getOwnPropertyNames", {value:() => []}]); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with defineProperty", + `Object.defineProperty(Object, "getOwnPropertyNames", {value:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with Reflect.set", + `Reflect.set(Object, "getOwnPropertyNames", () => []); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with an aliased mutator", + `const change = Object.defineProperty; change(Object, "create", {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with assign", + `Object.assign(Object, {getOwnPropertyNames:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with defineProperties", + `Object.defineProperties(Function.prototype, {bind:{value:customBind}}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten Object global", + `Object = customObject; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten intrinsic through an alias", + `const objectAlias = Object; objectAlias.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten intrinsic through globalThis", + `globalThis.Object.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "helper called before initialization", + `${IMPORT} ${WRAP} ${ROLLDOWN_MODULE_INTEROP}`, + ], + [ + "fake helper", + `${ROLLDOWN_MODULE_INTEROP}\n__toESM = (mod) => ({BrowserWindow:Fake}); ${IMPORT} ${WRAP}`, + ], + [ + "changed getter", + `${ROLLDOWN_MODULE_INTEROP.replace("from[k]", "to[k]")}\n${IMPORT} ${WRAP}`, + ], + [ + "replaced copy helper", + `${ROLLDOWN_MODULE_INTEROP}\n__copyProps = () => ({}); ${IMPORT} ${WRAP}`, + ], + [ + "replaced intrinsic alias", + `${ROLLDOWN_MODULE_INTEROP}\n__defProp = () => ({}); ${IMPORT} ${WRAP}`, + ], + [ + "shadowed Object", + `const Object = customObject; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten intrinsic", + `Object.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten alias call", + `${ROLLDOWN_MODULE_INTEROP}\n__hasOwnProp.call = customCall; ${IMPORT} ${WRAP}`, + ], + [ + "overwritten Function bind", + `${ROLLDOWN_MODULE_INTEROP}\nFunction.prototype.bind = customBind; ${IMPORT} ${WRAP}`, + ], + [ + "conditional conversion", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} if (flag) { ${WRAP} }`, + ], + [ + "compound assignment", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} electron ||= __toESM(electron, 1);`, + ], + [ + "other input", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} electron = __toESM(other, 1);`, + ], + [ + "later overwrite", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} electron = unrelated;`, + ], + [ + "overwritten constructor", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} electron.BrowserWindow = Fake;`, + ], + ])("does not infer Electron identity after %s", (_name, setup) => { + expect( + analyzeJavaScriptStaticSource(`${setup}\n${WINDOW}`).electron + .browser_windows, + ).toEqual([]); + }); +}); + +describe("Electron module interop helper results", () => { + it.each([ + 'const {BrowserWindow} = __toESM(require("electron"), 1); new BrowserWindow({});', + 'const {BrowserWindow: Window} = __toESM(require("electron"), 1); new Window({});', + 'const {default: {BrowserWindow}} = __toESM(require("electron"), 1); new BrowserWindow({});', + 'const Window = __toESM(require("electron"), 1).BrowserWindow; new Window({});', + ])("preserves a proven result projection: %s", (source) => { + expect( + analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}\n${source}`) + .electron.browser_windows, + ).toHaveLength(1); + }); + + it.each([ + 'const {[key]: Window} = __toESM(require("electron"), 1); new Window({});', + 'const {...electron} = __toESM(require("electron"), 1); new electron.BrowserWindow({});', + 'let {BrowserWindow} = __toESM(require("electron"), 1); BrowserWindow = Fake; new BrowserWindow({});', + ])("keeps an uncertain result projection unresolved: %s", (source) => { + expect( + analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP}\n${source}`) + .electron.browser_windows, + ).toEqual([]); + }); + + it.each([ + "", + ", 0", + ", false", + ", true", + ", 1", + ', "node"', + ', ""', + ", null", + ])("preserves named exports with a literal node mode: %s", (mode) => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + const fresh = __toESM(require("electron")${mode}); + new fresh.BrowserWindow({}); + const {BrowserWindow} = __toESM(require("electron")${mode}); + new BrowserWindow({}); + let electron = require("electron"); + electron = __toESM(electron${mode}); ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(3); + }); + + it.each(["", ", 0", ", false"])( + "does not assume a module-valued default with node mode: %s", + (mode) => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + const original = require("electron"); + original.__esModule = true; + original.default = {BrowserWindow: Fake}; + const {default: {BrowserWindow}} = __toESM(require("electron")${mode}); + new BrowserWindow({}); + const other = __toESM(require("electron")${mode}).default; + new other.BrowserWindow({}); + new (__toESM(require("electron")${mode}).default.BrowserWindow)({}); + `); + expect(analysis.electron.browser_windows).toEqual([]); + }, + ); + + it.each(["__toESM", "identity"])( + "rejects side-effecting extra arguments to %s", + (helper) => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + function identity(mod) { return mod; } + const electron = ${helper}(require("electron"), mutate()); ${WINDOW} + `); + expect(analysis.electron.browser_windows).toEqual([]); + }, + ); + + it.each([ + "const __toESM = () => ({BrowserWindow:FakeWindow});", + "const __toESM = async (mod) => mod;", + "function* __toESM(mod) { return mod; }", + "const __toESM = (this && this.__toESM) || ((mod) => mod);", + ])("keeps an unproven fresh result unresolved: %s", (helper) => { + const analysis = analyzeJavaScriptStaticSource(`${helper} + const electron = __toESM(require("electron"), 1); ${WINDOW} + new (__toESM(require("electron"), 1)).BrowserWindow({}); + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it("preserves a proven fresh namespace and a pure identity helper", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + const electron = __toESM(require("electron"), 1); ${WINDOW} + new (__toESM(require("electron"), 1)).BrowserWindow({}); + function passthrough(value) {return value;} + const plain = passthrough(require("electron")); + new plain.BrowserWindow({}); + `); + expect(analysis.electron.browser_windows).toHaveLength(3); + }); +}); + +describe("Electron module interop pattern writes", () => { + it.each([ + "({Object} = replacements);", + "[Function] = replacements;", + "({nested:{Object}} = replacements);", + "({replacement:Object = customObject} = replacements);", + "({...Object} = replacements);", + "[...Function] = replacements;", + "for ({Object} of replacements) {}", + "for ([Function] in replacements) {}", + ])("rejects an intrinsic replaced through %s", (write) => { + const analysis = analyzeJavaScriptStaticSource(` + ${write} ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it.each([ + "({Object:unrelated} = replacements);", + "function decoy(Object) {({Object} = replacements);}", + ])("preserves intrinsic identity for an unrelated target: %s", (write) => { + const analysis = analyzeJavaScriptStaticSource(` + ${write} ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("preserves captured operations after later property destructuring", () => { + const analysis = analyzeJavaScriptStaticSource(` + ${ROLLDOWN_MODULE_INTEROP} ({create:Object.create} = replacements); + ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); +}); + +describe("Electron module interop intrinsic captures", () => { + it.each([ + "var captured = Object.defineProperty;", + "const {defineProperty:captured} = Object;", + "var first = Object.defineProperty; const captured = first;", + ])("retains the original function capture through %s", (capture) => { + const runtime = ROLLDOWN_MODULE_INTEROP.replace( + "__defProp = Object.defineProperty", + "__defProp = captured", + ); + const analysis = analyzeJavaScriptStaticSource(` + ${capture} Object.defineProperty = custom; + ${runtime} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it.each(["var", "const"])( + "keeps an intrinsic alias read before its %s initializer unresolved", + (kind) => { + const runtime = ROLLDOWN_MODULE_INTEROP.replace( + "__defProp = Object.defineProperty", + "__defProp = later", + ); + const analysis = analyzeJavaScriptStaticSource(` + ${runtime} ${kind} later = Object.defineProperty; + ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toEqual([]); + }, + ); + + it("requires the helper's intrinsic binding to initialize before conversion", () => { + const runtime = ROLLDOWN_MODULE_INTEROP.replace( + "var __defProp = Object.defineProperty;", + "", + ); + const analysis = analyzeJavaScriptStaticSource(` + var captured = Object.defineProperty; + ${runtime} ${IMPORT} ${WRAP} ${WINDOW} + var __defProp = captured; + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); +}); + +describe("Electron module interop capture and scope", () => { + it.each(["metadata", "alias"])( + "recognizes a hoisted function target through %s", + (target) => { + const analysis = analyzeJavaScriptStaticSource(` + const alias = metadata; + Object.defineProperty(${target}, "x", {value:1}); + function metadata() {} + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }, + ); + + it.each([ + "var metadata = function () {};", + "const metadata = () => {};", + "class metadata {}", + ])("does not hoist fresh targets from %s", (declaration) => { + const analysis = analyzeJavaScriptStaticSource(` + Object.defineProperty(metadata, "x", {value:1}); + ${declaration} + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + 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;", + "const metadata = () => {};", + "const metadata = function () {};", + "const metadata = async () => {};", + "const metadata = function* () {};", + "const metadata = class {};", + "const metadata = /local/;", + "function metadata() {}", + "class metadata {}", + ])( + "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});", + "const metadata = class {static {Object.create = customCreate;}};", + "@(() => Object) class metadata {}", + ])("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}); + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("keeps opaque targets in a generic helper separate from outer module identity", () => { + const analysis = analyzeJavaScriptStaticSource(` + function genericAssign() { Object.assign.apply(null, arguments); } + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("rejects an opaque intrinsic mutation in the namespace's own callable", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + function openWindow(args) { + Object.defineProperty.apply(null, args); + ${IMPORT} ${WRAP} ${WINDOW} + } + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it("preserves captured intrinsics across later vendor polyfills and callable writes", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + Object.create = customCreate; + ${IMPORT} ${WRAP} + function runLoop(key) { globalThis[key] = this; } + function polyfill() { Object.create = customCreate; } + ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("keeps a shadowed namespace separate from the converted module", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + ${IMPORT} ${WRAP} ${WINDOW} + function decoy(electron) { ${WINDOW} } + `); + 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 = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + 'const first = Function.prototype.call.bind(Object.defineProperty,null); const change = first.bind(customReceiver,Object,"create"); change({value:customCreate});', + 'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([Object,"create",{value:customCreate}]);', + 'Function.prototype.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Function.prototype.apply.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);', + 'const uncurry = Function.prototype.call.bind(Function.prototype.call); uncurry(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.bind.call(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + 'const bind = Function.prototype.call.bind(Function.prototype.bind); const change = bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + 'const change = Function.prototype.bind.apply(Object.defineProperty,[null,Object,"create"]); change({value:customCreate});', + 'const change = Reflect.apply(Function.prototype.bind,Object.defineProperty,[null,Object,"create"]); change({value:customCreate});', + 'const first = Object.defineProperty.bind(null,Object); const change = Function.prototype.bind.call(first,null,"create"); change({value:customCreate});', + '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}]);', + ])("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 = Function.prototype.call.bind(Object.defineProperty,null); change(metadata,"x",{value:1});', + 'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([metadata,"x",{value:1}]);', + 'const change = Function.prototype.bind.call(Object.defineProperty,null,metadata,"x"); change({value:1});', + 'const bind = Function.prototype.call.bind(Function.prototype.bind); const change = bind(Object.defineProperty,null,metadata,"x"); change({value:1});', + 'const change = Function.prototype.bind.apply(Object.defineProperty,[null,metadata,"x"]); change({value:1});', + 'const change = Reflect.apply(Function.prototype.bind,Object.defineProperty,[null,metadata,"x"]); change({value:1});', + 'const apply = Reflect.apply; Reflect.apply = replacement; apply(Object.defineProperty,null,[metadata,"x",{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}]);', + '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( + `const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toHaveLength(1); + }); +}); + +it("keeps unrelated exports after an aliased prebound key write", () => { + const analysis = analyzeJavaScriptStaticSource(` + const electron = require("electron"); + const key = "Unrelated"; + const mutate = Object.defineProperty.bind(null, electron, key); + mutate({value: custom}); + new electron.BrowserWindow({}); + `); + expect(analysis.electron.browser_windows).toHaveLength(1); +}); diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index 99a3805e88..0005a19772 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -77,7 +77,7 @@ describe("Electron API identity", () => { it("follows bundler namespace interop helpers without accepting other writes", () => { const analysis = analyzeJavaScriptStaticSource(` var __toESM = (mod) => mod; - var __importStar = (this && this.__importStar) || ((mod) => mod); + var __importStar = (mod) => mod; let electron = require("electron"); electron = __toESM(electron); new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } }); @@ -120,4 +120,53 @@ describe("Electron API identity", () => { expect(analysis.electron.browser_windows).toEqual([]); }); + + it("follows stable declaration aliases but not an assigned default parameter", () => { + const analysis = analyzeJavaScriptStaticSource(` + const direct = require("electron"); + const constAlias = direct; + new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}}); + + let letAlias = require("electron"); + new letAlias.BrowserWindow({webPreferences:{preload:"./let.js"}}); + + const { BrowserWindow } = require("electron"); + new BrowserWindow({webPreferences:{preload:"./destructured.js"}}); + + function withDefault(electron = require("./not-electron")) { + if (flag) electron = require("electron"); + new electron.BrowserWindow({webPreferences:{preload:"./default.js"}}); + } + `); + + expect( + 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/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index f89eabb051..cb3a533fe4 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -86,16 +86,20 @@ export const semanticObjectPatternKeys = ( return key === null ? [] : [key]; }); -/** Visit member write targets in direct and destructuring assignment forms. */ -export function* semanticMemberWriteTargets( +/** Visit binding and member leaves in direct and destructuring write targets. */ +export function* semanticWriteTargets( root: t.Node, -): Generator { +): Generator { const pending = [root]; while (pending.length > 0) { const next = pending.pop(); if (next === undefined) break; const node = unwrapJavaScriptExpression(next).node; - if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + if ( + t.isIdentifier(node) || + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) + ) yield node; else if (t.isRestElement(node)) pending.push(node.argument); else if (t.isAssignmentPattern(node)) pending.push(node.left); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 696bb68aaa..003b495e05 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -2,10 +2,9 @@ import * as t from "@babel/types"; import { semanticStaticPropertyKey, - semanticMemberWriteTargets, + semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; /** Match Electron's supported module entry points. */ @@ -24,7 +23,7 @@ export const electronExportPath = ( /** Static identity recovery cannot establish the absence of Electron usage. */ export const ELECTRON_IDENTITY_LIMITATION = - "Electron API identity follows proven lexical module aliases, including results of namespace interop helpers (__toESM, __importStar, _interopRequireWildcard) recognized by name and a single program-scope function definition; helper bodies are not evaluated. Other reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated."; + "Electron API identity follows proven lexical module aliases and structurally verified module-namespace interop. Other reassignments, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated."; /** Preserve exact property segments instead of flattening literal dotted keys. */ export const electronMemberPath = ( @@ -42,14 +41,98 @@ export const electronMemberPath = ( return { root, members: members.toReversed() }; }; -/** Index visible writes through proven Electron aliases, without executing them. */ -export function* collectElectronMemberWritesSteps( +/** A known intrinsic callable and its examined positional arguments. */ +export interface JavaScriptIntrinsicInvocation { + readonly kind: "known" | "opaque"; + /** The original bound receiver; rebinding cannot replace it. */ + readonly receiver?: t.Node; + 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, - origin: (root: t.Node) => readonly string[] | undefined, -): Generator { - const writes = new SemanticPropertyPathCoverage(); + origin: ( + root: t.Node, + kind: "binding" | "member" | "opaque-call", + ) => readonly string[] | undefined, + intrinsicInvocation: ( + node: t.CallExpression | t.OptionalCallExpression, + ) => JavaScriptIntrinsicInvocation | undefined, + staticString: (node: t.Node | undefined) => string | undefined, + freshObject: (node: t.Node) => boolean, + retain: (node: t.Node, path: readonly (string | null)[]) => void, +): Generator { + const objectMutators = new Set([ + "defineProperty", + "defineProperties", + "assign", + "setPrototypeOf", + ]); + const reflectMutators = new Set([ + "set", + "deleteProperty", + "defineProperty", + "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") { + const scope = origin(node, "opaque-call"); + if (scope !== undefined) retain(node, scope); + 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)) { + 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); + } + } + } + } + } const target = t.isAssignmentExpression(node) ? node.left : t.isUpdateExpression(node) @@ -60,12 +143,16 @@ export function* collectElectronMemberWritesSteps( ? node.left : undefined; if (target === undefined) return; - for (const member of semanticMemberWriteTargets(target)) { - const { root, members } = electronMemberPath(member); - const exported = origin(root); - if (exported !== undefined) writes.retain([...exported, ...members]); + for (const leaf of semanticWriteTargets(target)) { + if (t.isIdentifier(leaf)) { + const exported = origin(leaf, "binding"); + if (exported !== undefined) retain(node, exported); + continue; + } + const { root, members } = electronMemberPath(leaf); + const exported = origin(root, "member"); + if (exported !== undefined) retain(node, [...exported, ...members]); } }, }); - return writes; } diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts new file mode 100644 index 0000000000..e34efe04a6 --- /dev/null +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -0,0 +1,123 @@ +import * as t from "@babel/types"; + +import type { JavaScriptIntrinsicInvocation } from "./javascriptElectronMemberWrites.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.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, + intrinsicPath: (node: t.Node) => readonly string[] | undefined, + boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined, + intrinsicReadChanged: (node: t.Node, path: readonly string[]) => boolean, +): JavaScriptIntrinsicInvocation | undefined => { + const visited = new Set(); + let callee: t.Node = node.callee; + let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments; + let receiver: t.Node | undefined; + for (;;) { + callee = unwrapJavaScriptExpression(callee).node; + if (visited.has(callee)) return undefined; + visited.add(callee); + let path = intrinsicPath(callee); + if (path !== undefined && intrinsicReadChanged(callee, path)) + return { kind: "opaque", path, arguments: undefined }; + if (path === undefined) { + if ( + t.isMemberExpression(callee) || + t.isOptionalMemberExpression(callee) + ) { + const method = semanticStaticPropertyKey( + callee.property, + callee.computed, + ); + if (method === "call" || method === "apply") { + const bound = boundIntrinsic(callee.object); + if ( + bound !== undefined && + intrinsicReadChanged(callee, [...bound.path, method]) + ) + return { kind: "opaque", path: bound.path, arguments: undefined }; + receiver = arguments_?.[0]; + callee = callee.object; + arguments_ = invocationArguments(method, arguments_); + continue; + } + } + const bound = boundIntrinsic(callee); + if (bound === undefined) return undefined; + if (bound.kind === "opaque") return bound; + path = bound.path; + receiver = bound.receiver; + arguments_ = + bound.arguments === undefined + ? undefined + : [...bound.arguments, ...(arguments_ ?? [])]; + } + // A projected .call/.apply invokes the preceding callable. The actual + // Function.prototype methods instead invoke their receiver, retained by bind. + while (path.length > 3 && !genericInvocationMethod(path)) { + const method = path.at(-1); + if (method !== "call" && method !== "apply") break; + receiver = arguments_?.[0]; + arguments_ = invocationArguments(method, arguments_); + path = path.slice(0, -1); + } + const genericMethod = genericInvocationMethod(path); + if (genericMethod !== undefined) { + if (!t.isExpression(receiver)) return undefined; + callee = receiver; + receiver = arguments_?.[0]; + arguments_ = invocationArguments(genericMethod, arguments_); + continue; + } + if (path.join(".") === "intrinsic.Reflect.apply") { + const callable = arguments_?.[0]; + if (!t.isExpression(callable)) return undefined; + callee = callable; + receiver = arguments_?.[1]; + arguments_ = arrayArguments(arguments_?.[2]); + continue; + } + return { + kind: "known", + path, + arguments: arguments_, + ...(receiver === undefined ? {} : { receiver }), + }; + } +}; + +const genericInvocationMethod = ( + path: readonly string[], +): "call" | "apply" | undefined => + path.length === 4 && + path[0] === "intrinsic" && + path[1] === "Function" && + path[2] === "prototype" && + (path[3] === "call" || path[3] === "apply") + ? path[3] + : undefined; diff --git a/src/domain/javascript/javascriptModuleInterop.fixture.ts b/src/domain/javascript/javascriptModuleInterop.fixture.ts new file mode 100644 index 0000000000..1ed97af906 --- /dev/null +++ b/src/domain/javascript/javascriptModuleInterop.fixture.ts @@ -0,0 +1,24 @@ +// Rolldown runtime from the public T3 Code nightly 20261011.2967 main.cjs. +// The input is inert source: none of these helpers or Electron APIs is executed. +export const ROLLDOWN_MODULE_INTEROP = ` +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { + key = keys[i]; + if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { + get: ((k) => from[k]).bind(null, key), + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable + }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", { + value: mod, + enumerable: true +}) : target, mod)); +`; diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts new file mode 100644 index 0000000000..35e1c9ef12 --- /dev/null +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -0,0 +1,774 @@ +import { parseExpression } from "@babel/parser"; +import * as t from "@babel/types"; + +import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; +import { + isUnshadowedGlobal, + resolveSemanticBindingState, + semanticScopeId, + semanticStableInitializer, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { + childNodes, + javaScriptAstChildKeys, +} from "./javascriptSemanticTraversal.js"; +import { + electronMemberPath, + type JavaScriptIntrinsicInvocation, +} from "./javascriptElectronMemberWrites.js"; +import { resolveJavaScriptIntrinsicInvocation } from "./javascriptIntrinsicInvocation.js"; +import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; +import { + readExactJavaScriptLiteral, + 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. +const TO_ESM = parseExpression(`(mod, isNodeMode, target) => ( + target = mod != null ? __create(__getProtoOf(mod)) : {}, + __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") + ? __defProp(target, "default", {value: mod, enumerable: true}) : target, mod) +)`); +const COPY_PROPS = parseExpression(`(to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") + for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { + key = keys[i]; + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { + get: ((k) => from[k]).bind(null, key), + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable + }); + } + return to; +}`); +const INTRINSICS: Readonly> = { + __create: ["Object", "create"], + __getProtoOf: ["Object", "getPrototypeOf"], + __defProp: ["Object", "defineProperty"], + __getOwnPropNames: ["Object", "getOwnPropertyNames"], + __getOwnPropDesc: ["Object", "getOwnPropertyDescriptor"], + __hasOwnProp: ["Object", "prototype", "hasOwnProperty"], +}; + +interface IntrinsicDependency { + readonly path: readonly string[]; + readonly capturedAt?: t.Node; +} + +interface InteropOrigin { + readonly origin: JavaScriptModuleOrigin; + readonly dependencies: readonly IntrinsicDependency[]; + readonly defaultIsModule: boolean; +} + +interface InteropHelper { + readonly dependencies: readonly IntrinsicDependency[]; + readonly readyAfter: number; + readonly identity: boolean; +} + +/** Prove one inert namespace conversion without relaxing general assignment semantics. */ +export const createSemanticModuleInterop = ( + state: JavaScriptSemanticAnalysisState, +) => { + const helpers = new Map(); + const origins = new Map(); + const calls = new WeakMap(); + const intrinsicBindings = new Map(); + const writes: { + readonly scope: string; + readonly offset: number; + readonly coverage: SemanticPropertyPathCoverage; + }[] = []; + const invalidatedBindings = new Map(); + const invalidatedCalls = new WeakMap(); + const opaqueMutationPath = (node: t.Node): readonly string[] => { + let current: t.Node | undefined = node; + while (current !== undefined) { + if (t.isFunction(current)) + return ["opaque-intrinsic", semanticScopeId("function", current)]; + current = state.parentsByNode.get(current); + } + return ["intrinsic"]; + }; + + const intrinsicPath = (node: t.Node): readonly string[] | undefined => { + let current = node; + const visited = new Set(); + const pending: { id: string; suffixLength: number }[] = []; + let suffix: readonly string[] = []; + const finish = ( + path: readonly string[] | undefined, + ): readonly string[] | undefined => { + for (const binding of pending) + intrinsicBindings.set( + binding.id, + path === undefined + ? null + : path.slice(0, path.length - binding.suffixLength), + ); + return path; + }; + for (;;) { + const { root, members } = electronMemberPath(current); + if (members.some((member) => member === null)) return finish(undefined); + suffix = [...(members as readonly string[]), ...suffix]; + for (const global of ["Object", "Function", "Reflect"]) + if (isUnshadowedGlobal(root, state, global)) + return finish(["intrinsic", global, ...suffix]); + for (const global of ["globalThis", "global", "window", "self"]) + if (isUnshadowedGlobal(root, state, global)) + return finish(["intrinsic", ...suffix]); + if (!t.isIdentifier(root)) return finish(undefined); + const binding = resolveSemanticBindingState(state, root, root.name); + const cached = + binding === undefined + ? undefined + : intrinsicBindings.get(binding.bindingId); + if (cached !== undefined) + return finish(cached === null ? undefined : [...cached, ...suffix]); + const initializer = semanticStableInitializer(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; + } + }; + + // A nominal alias path is sufficient for conservative mutation lookup, but + // helper proof also needs each read to follow initialization. An alias copy + // retains the earlier function capture; a member read captures it anew. + const capturedIntrinsic = (node: t.Node): IntrinsicDependency | undefined => { + const path = intrinsicPath(node); + if (path === undefined) return undefined; + const visited = new Set(); + let current = node; + let capturedAt = node; + let suffixLength = 0; + for (;;) { + const { root, members } = electronMemberPath(current); + suffixLength += members.length; + if (!t.isIdentifier(root)) return undefined; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) return { path, capturedAt }; + const initializer = semanticStableInitializer(binding, state); + if ( + initializer === undefined || + (initializer.node.end ?? Infinity) >= (root.start ?? -1) || + visited.has(binding.bindingId) + ) + return undefined; + visited.add(binding.bindingId); + if (suffixLength === 0) capturedAt = initializer.node; + suffixLength += initializer.projection.length; + current = initializer.node; + } + }; + + const staticString = (node: t.Node | undefined): string | undefined => { + if (node === undefined) return undefined; + const visited = new Set(); + let current = node; + for (;;) { + const literal = readExactJavaScriptLiteral(current); + if (literal.found) + return typeof literal.value === "string" ? literal.value : undefined; + const expression = unwrapJavaScriptExpression(current).node; + if (!t.isIdentifier(expression)) return undefined; + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + const initializer = semanticStableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + initializer.projection.length !== 0 || + (initializer.node.end ?? Infinity) >= (expression.start ?? -1) || + visited.has(binding.bindingId) + ) + return undefined; + visited.add(binding.bindingId); + current = initializer.node; + } + }; + + const intrinsicReadChanged = ( + 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, + ]))), + ); + }; + + const resolveBoundIntrinsic = ( + node: t.Node, + ): JavaScriptIntrinsicInvocation | undefined => { + const visited = new Set(); + const argumentGroups: t.CallExpression["arguments"][] = []; + const bindReads: t.Node[] = []; + let opaqueArguments = false; + let receiver: t.Node | undefined; + let current = node; + for (;;) { + current = unwrapJavaScriptExpression(current).node; + if (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) || + visited.has(binding.bindingId) + ) + return undefined; + visited.add(binding.bindingId); + current = initializer.node; + continue; + } + if (!t.isCallExpression(current)) return undefined; + const { root, members } = electronMemberPath(current.callee); + let bindTarget = root; + let bindingArguments: JavaScriptIntrinsicInvocation["arguments"] = + current.arguments; + let captured = capturedIntrinsic(current.callee); + let terminal = captured?.path.at(-1) === "bind"; + let path: readonly string[] | undefined = captured?.path.slice(0, -1); + 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); + } else { + // Reuse the invocation owner for generic call/apply/Reflect.apply of bind. + const invocation = resolveJavaScriptIntrinsicInvocation( + current, + intrinsicPath, + boundIntrinsic, + intrinsicReadChanged, + ); + if ( + invocation?.kind !== "known" || + invocation.path.join(".") !== "intrinsic.Function.prototype.bind" || + invocation.receiver === undefined + ) + return undefined; + bindTarget = invocation.receiver; + bindingArguments = invocation.arguments; + captured = capturedIntrinsic(bindTarget); + terminal = captured !== undefined; + path = captured?.path; + } + receiver = bindingArguments?.[0]; + const ownArguments = bindingArguments?.slice(1) ?? []; + opaqueArguments ||= + bindingArguments === undefined || t.isSpreadElement(receiver); + opaqueArguments ||= ownArguments.some((argument) => + t.isSpreadElement(argument), + ); + argumentGroups.push(ownArguments); + if (!terminal || path === undefined) { + current = bindTarget; + 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)), + ); + }); + return { + kind: opaqueArguments ? "opaque" : "known", + path, + ...(receiver === undefined ? {} : { receiver }), + // Rebinding appends arguments to the earlier bound argument list. + arguments: opaqueArguments + ? undefined + : argumentGroups.toReversed().flat(), + }; + } + }; + + const activeBoundValues = new WeakSet(); + const boundIntrinsic = ( + node: t.Node, + ): JavaScriptIntrinsicInvocation | undefined => { + if (activeBoundValues.has(node)) return undefined; + activeBoundValues.add(node); + try { + return resolveBoundIntrinsic(node); + } finally { + activeBoundValues.delete(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) || + t.isFunction(current) || + (t.isClass(current) && !current.decorators?.length) || + t.isRegExpLiteral(current) + ) + return true; + if (!t.isIdentifier(current)) return false; + const binding = resolveSemanticBindingState(state, current, current.name); + const initializer = unprojectedInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + (!t.isFunctionDeclaration(initializer) && + (initializer.end ?? Infinity) >= (current.start ?? -1)) || + visited.has(binding.bindingId) + ) + return false; + visited.add(binding.bindingId); + current = initializer; + } + }; + + const helperProof = ( + binding: JavaScriptSemanticBindingState, + ): InteropHelper | undefined => { + const cached = helpers.get(binding.bindingId); + if (cached !== undefined) return cached ?? undefined; + helpers.set(binding.bindingId, null); + if (state.scopesById.get(binding.scopeId)?.kind !== "program") + return undefined; + const wrapper = unprojectedInitializer(binding, state); + if (wrapper === undefined) return undefined; + if (identityFunction(wrapper, state)) { + const proof = { + dependencies: [], + readyAfter: t.isFunctionDeclaration(wrapper) + ? -1 + : (wrapper.end ?? Infinity), + identity: true, + }; + helpers.set(binding.bindingId, proof); + return proof; + } + const wrapperRoles = canonicalRoles(wrapper, TO_ESM, state); + const copyBinding = wrapperRoles?.get("__copyProps"); + const copy = unprojectedInitializer(copyBinding, state); + const copyRoles = + copy === undefined ? undefined : canonicalRoles(copy, COPY_PROPS, state); + if ( + wrapperRoles === undefined || + copyRoles === undefined || + copy === undefined + ) + return undefined; + const dependencies: IntrinsicDependency[] = []; + let readyAfter = Math.max(wrapper.end ?? Infinity, copy.end ?? Infinity); + for (const roles of [wrapperRoles, copyRoles]) + for (const [role, dependency] of roles) { + const expected = INTRINSICS[role]; + if (expected === undefined) continue; + const initializer = unprojectedInitializer(dependency, state); + const captured = + initializer === undefined + ? undefined + : capturedIntrinsic(initializer); + if ( + initializer === undefined || + captured === undefined || + JSON.stringify(captured.path) !== + JSON.stringify(["intrinsic", ...expected]) + ) + return undefined; + readyAfter = Math.max(readyAfter, initializer.end ?? Infinity); + dependencies.push(captured); + if (role === "__hasOwnProp") + dependencies.push({ path: [...captured.path, "call"] }); + } + dependencies.push( + { path: ["intrinsic", "Function", "prototype", "bind"] }, + { path: ["intrinsic", "Function", "prototype", "call"] }, + ); + const proof = { + dependencies, + identity: false, + readyAfter, + }; + helpers.set(binding.bindingId, proof); + return proof; + }; + + const provenCall = (node: t.Node) => { + if (!t.isCallExpression(node) || !t.isIdentifier(node.callee)) + return undefined; + const helper = resolveSemanticBindingState( + state, + node.callee, + node.callee.name, + ); + const proof = helper === undefined ? undefined : helperProof(helper); + const input = node.arguments[0]; + const mode = node.arguments[1]; + const literalMode = + mode === undefined + ? { found: true, value: false } + : t.isExpression(mode) + ? readExactJavaScriptLiteral(mode) + : { found: false }; + if ( + proof === undefined || + proof.readyAfter >= (node.start ?? -1) || + !t.isExpression(input) || + node.arguments.length > 2 || + !literalMode.found + ) + return undefined; + return { + input, + // A truthy node mode forces the module-valued default. Other modes + // preserve named exports without proving the source's default property. + defaultIsModule: !proof.identity && Boolean(literalMode.value), + dependencies: proof.dependencies.map((dependency) => ({ + ...dependency, + capturedAt: dependency.capturedAt ?? node, + })), + }; + }; + + const callOrigin = (node: t.Node): InteropOrigin | undefined => { + const cached = calls.get(node); + if (cached !== undefined) return cached; + const { root, members } = electronMemberPath(node); + if (!members.every((member): member is string => member !== null)) + return undefined; + const converted = provenCall(root); + const required = + converted === undefined + ? undefined + : semanticRequireOrigin(converted.input, state)?.origin; + if ( + converted === undefined || + required === undefined || + required.importedPath.length !== 0 || + (members[0] === "default" && !converted.defaultIsModule) + ) + return undefined; + const result = { + origin: { ...required, importedPath: members }, + dependencies: converted.dependencies, + defaultIsModule: converted.defaultIsModule, + }; + calls.set(node, result); + return result; + }; + + const origin = ( + binding: JavaScriptSemanticBindingState, + ): InteropOrigin | undefined => { + const single = semanticStableInitializer(binding, state); + if (single !== undefined) { + if ( + !single.projection.every( + (key): key is string => typeof key === "string", + ) + ) + return undefined; + const converted = callOrigin(single.node); + if ( + converted?.origin.importedPath.length === 0 && + single.projection[0] === "default" && + !converted.defaultIsModule + ) + return undefined; + const result = + converted === undefined + ? undefined + : { + ...converted, + origin: { + ...converted.origin, + importedPath: [ + ...converted.origin.importedPath, + ...single.projection, + ], + }, + }; + if (result !== undefined) origins.set(binding.bindingId, result); + return result; + } + const [initial, converted] = binding.initializers; + if ( + binding.definitions.length !== 2 || + binding.definitions[0]?.kind !== "variable" || + binding.definitions[1]?.kind !== "assignment" || + binding.initializers.length !== 2 || + initial === undefined || + converted === undefined || + initial.projection.length !== 0 || + converted.projection.length !== 0 || + state.conditionalInitializers.has(initial.node) + ) + return undefined; + const call = converted.node; + const assignment = state.parentsByNode.get(call); + const statement = + assignment === undefined + ? undefined + : state.parentsByNode.get(assignment); + const declaration = state.parentsByNode.get(initial.node); + const declarationStatement = + declaration === undefined + ? undefined + : state.parentsByNode.get(declaration); + if ( + !t.isCallExpression(call) || + !t.isIdentifier(call.callee) || + !t.isAssignmentExpression(assignment, { operator: "=" }) || + assignment.right !== call || + !t.isIdentifier(assignment.left, { name: binding.name }) || + !t.isExpressionStatement(statement) || + !t.isVariableDeclarator(declaration) || + !t.isVariableDeclaration(declarationStatement) || + state.parentsByNode.get(statement) !== + state.parentsByNode.get(declarationStatement) || + (initial.node.start ?? Infinity) >= (call.start ?? -1) + ) + return undefined; + const convertedCall = provenCall(call); + const input = convertedCall?.input; + if ( + !t.isIdentifier(input) || + resolveSemanticBindingState(state, input, input.name) !== binding + ) + return undefined; + const required = semanticRequireOrigin(initial.node, state)?.origin; + if (required === undefined || required.importedPath.length !== 0) + return undefined; + if (convertedCall === undefined) return undefined; + const result = { + origin: required, + dependencies: convertedCall.dependencies, + defaultIsModule: convertedCall.defaultIsModule, + }; + origins.set(binding.bindingId, result); + return result; + }; + const invalidatedOrigin = (record: InteropOrigin | undefined): boolean => + record?.dependencies.some(({ path, capturedAt }) => { + if (capturedAt === undefined) return true; + const scope = opaqueMutationPath(capturedAt).join("."); + // Captured function values survive later replacement of global + // properties. Other callable bodies are not execution evidence. + return writes.some( + (write) => + (write.scope === scope || write.scope === "intrinsic") && + write.offset < (capturedAt.start ?? -1) && + write.coverage.covers(path), + ); + }) ?? false; + return { + origin, + callOrigin, + intrinsicPath, + intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) => + resolveJavaScriptIntrinsicInvocation( + node, + intrinsicPath, + boundIntrinsic, + intrinsicReadChanged, + ), + boundIntrinsic, + staticString, + isFreshObject, + opaqueMutationPath, + retainWrite: (node: t.Node, path: readonly (string | null)[]) => { + const coverage = new SemanticPropertyPathCoverage(); + coverage.retain(path[0] === "opaque-intrinsic" ? ["intrinsic"] : path); + writes.push({ + scope: opaqueMutationPath(node).join("."), + offset: node.start ?? Infinity, + coverage, + }); + }, + invalidated: (binding: JavaScriptSemanticBindingState): boolean => { + const cached = invalidatedBindings.get(binding.bindingId); + if (cached !== undefined) return cached; + const result = invalidatedOrigin(origins.get(binding.bindingId)); + invalidatedBindings.set(binding.bindingId, result); + return result; + }, + invalidatedCall: (node: t.Node): boolean => { + const cached = invalidatedCalls.get(node); + if (cached !== undefined) return cached; + const result = invalidatedOrigin(calls.get(node)); + invalidatedCalls.set(node, result); + return result; + }, + }; +}; + +const identityFunction = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if ( + !t.isFunction(node) || + node.async || + node.generator || + node.params.length !== 1 + ) + return false; + const parameter = node.params[0]; + const result = t.isBlockStatement(node.body) + ? node.body.body.length === 1 && t.isReturnStatement(node.body.body[0]) + ? node.body.body[0].argument + : undefined + : node.body; + return ( + t.isIdentifier(parameter) && + t.isIdentifier(result) && + resolveSemanticBindingState(state, parameter, parameter.name) !== + undefined && + resolveSemanticBindingState(state, parameter, parameter.name) === + resolveSemanticBindingState(state, result, result.name) + ); +}; + +const unprojectedInitializer = ( + binding: JavaScriptSemanticBindingState | undefined, + state: JavaScriptSemanticAnalysisState, +): t.Node | undefined => { + const initializer = semanticStableInitializer(binding, state); + return initializer?.projection.length === 0 ? initializer.node : undefined; +}; + +/** Match operations and lexical roles, allowing single-statement control blocks. */ +const canonicalRoles = ( + actual: t.Node, + pattern: t.Node, + state: JavaScriptSemanticAnalysisState, +): ReadonlyMap | undefined => { + const roles = new Map(); + const used = new Map(); + const pending: { actual: t.Node; pattern: t.Node; parent: t.Node | null }[] = + [{ actual, pattern, parent: null }]; + while (pending.length !== 0) { + const pair = pending.pop(); + if (pair === undefined) return undefined; + const { parent } = pair; + const controlBody = + (t.isIfStatement(parent) && + (parent.consequent === pair.pattern || + parent.alternate === pair.pattern)) || + (t.isForStatement(parent) && parent.body === pair.pattern); + const node = controlBody ? controlBodyOperation(pair.actual) : pair.actual; + const expected = controlBody + ? controlBodyOperation(pair.pattern) + : pair.pattern; + if (node.type !== expected.type) return undefined; + const literalKey = + parent !== null && + ((t.isMemberExpression(parent) && + !parent.computed && + parent.property === expected) || + (t.isObjectProperty(parent) && + !parent.computed && + parent.key === expected)); + if (t.isIdentifier(expected) && t.isIdentifier(node) && !literalKey) { + const binding = resolveSemanticBindingState(state, node, node.name); + if ( + binding === undefined || + (roles.has(expected.name) && roles.get(expected.name) !== binding) || + (used.has(binding.bindingId) && + used.get(binding.bindingId) !== expected.name) + ) + return undefined; + roles.set(expected.name, binding); + used.set(binding.bindingId, expected.name); + } + const visitorKeys = javaScriptAstChildKeys(expected); + for (const [key, field] of Object.entries( + t.NODE_FIELDS[expected.type] ?? {}, + )) { + if ( + visitorKeys.includes(key) || + (t.isIdentifier(expected) && key === "name" && !literalKey) + ) + continue; + const fallback: unknown = Reflect.get(field, "default"); + if ( + JSON.stringify(Reflect.get(node, key) ?? fallback) !== + JSON.stringify(Reflect.get(expected, key) ?? fallback) + ) + return undefined; + } + const children = childNodes(node), + expectedChildren = childNodes(expected); + if (children.length !== expectedChildren.length) return undefined; + for (const [index, child] of children.entries()) { + const expectedChild = expectedChildren[index]; + if (expectedChild !== undefined) + pending.push({ + actual: child, + pattern: expectedChild, + parent: expected, + }); + } + } + return roles; +}; + +// Braces around one control-body statement do not add operations. Keep other +// blocks, directives and extra statements intact; their semantics need proof. +// Lexical declarations/references still pass through canonicalRoles' bijection. +const controlBodyOperation = (node: t.Node): t.Node => { + while ( + t.isBlockStatement(node) && + node.directives.length === 0 && + node.body.length === 1 + ) { + const statement = node.body[0]; + if (statement === undefined) break; + node = statement; + } + return node; +}; diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index 05062b4c76..b086470f3e 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 c4d8153c14..517ed58364 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -18,6 +18,7 @@ import { import { semanticObjectPatternKeys, semanticStaticPropertyKey, + semanticWriteTargets, } from "./javascriptAstValues.js"; import type { JavaScriptSemanticAnalysisState, @@ -31,16 +32,14 @@ import { resolveSemanticBindingFromScope, semanticBindingId, semanticScopeId, + semanticStableInitializer, semanticVariableScope, } from "./javascriptSemanticState.js"; import { completeSemanticSteps, traverseJavaScriptAstSteps, } from "./javascriptSemanticTraversal.js"; -import { - semanticNamespaceInteropOperand, - semanticRequireOrigin, -} from "./javascriptSemanticRequireOrigin.js"; +import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; import { collectSemanticReturns, @@ -49,7 +48,7 @@ import { import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js"; import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js"; import { - collectElectronMemberWritesSteps, + collectJavaScriptIdentityWritesSteps, electronExportPath, electronMemberPath, ELECTRON_MODULE, @@ -64,6 +63,8 @@ import { } from "./javascriptSourceParser.js"; import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js"; +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; interface BindPatternInput { readonly pattern: t.Node; @@ -205,13 +206,10 @@ export function* classifyParsedJavaScriptRpcClientRootsSteps( if (binding === undefined) return; let client = clients.get(binding.bindingId); if (client === undefined) { - const [initializer] = binding.initializers; + const initializer = semanticStableInitializer(binding, state); client = initializer !== undefined && initializer.projection.length === 0 && - binding.initializers.length === 1 && - !binding.definitions.some(({ kind }) => kind === "assignment") && - !state.conditionalInitializers.has(initializer.node) && isJavaScriptRpcClientFactory(initializer.node); clients.set(binding.bindingId, client); } @@ -374,6 +372,9 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( const facts = new Map(); const state = createState(file.program); yield* collectDefinitionsSteps(file.program, state); + const interop = createSemanticModuleInterop(state); + const writes = new SemanticPropertyPathCoverage(); + let collectedWrites = false; const exportsByBinding = new Map(); const rootExport = (root: t.Node): readonly string[] | undefined => { if (t.isIdentifier(root)) { @@ -382,23 +383,45 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( if (binding === undefined) return undefined; let exported = exportsByBinding.get(binding.bindingId); if (exported === undefined) { - exported = electronExport(binding, state); + exported = electronExport(binding, state, interop); exportsByBinding.set(binding.bindingId, exported); } - return exported ?? undefined; + return collectedWrites && interop.invalidated(binding) + ? undefined + : (exported ?? undefined); } - const origin = semanticRequireOrigin( - semanticNamespaceInteropOperand(root, state) ?? root, - state, - )?.origin; + const wrapped = interop.callOrigin(root); + const origin = + wrapped === undefined + ? semanticRequireOrigin(root, state)?.origin + : collectedWrites && interop.invalidatedCall(root) + ? undefined + : wrapped.origin; return origin !== undefined && ELECTRON_MODULE.test(origin.specifier) ? electronExportPath(origin.importedPath) : undefined; }; - const writes = yield* collectElectronMemberWritesSteps( + yield* collectJavaScriptIdentityWritesSteps( file.program, - rootExport, + (root, kind) => { + if (kind === "opaque-call") return interop.opaqueMutationPath(root); + const exported = kind === "binding" ? undefined : rootExport(root); + return exported === undefined + ? (interop.intrinsicPath(root) ?? + (kind === "member" + ? interop.boundIntrinsic(root)?.path + : undefined)) + : ["electron", ...exported]; + }, + interop.intrinsicInvocation, + interop.staticString, + interop.isFreshObject, + (node, path) => { + if (path[0] === "electron") writes.retain(path); + else interop.retainWrite(node, path); + }, ); + collectedWrites = true; yield* traverseJavaScriptAstSteps(file.program, { enter: (node) => { if ( @@ -409,7 +432,10 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( return; const { root, members } = electronMemberPath(node.callee); const exported = rootExport(root); - if (exported !== undefined && !writes.covers([...exported, ...members])) + if ( + exported !== undefined && + !writes.covers(["electron", ...exported, ...members]) + ) facts.set(root.start ?? -1, exported); }, }); @@ -421,19 +447,16 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( const electronExport = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, + interop: ReturnType, ): readonly string[] | null => { - const initializers = namespacePreservingInitializers(binding, state); - if (initializers === undefined) return null; - const directOrigin = bindingOrigin(binding, initializers, state); - const [initializer] = initializers; - if ( - directOrigin === undefined && - (initializer === undefined || - initializers.length !== 1 || - initializers.length !== binding.initializers.length || - state.conditionalInitializers.has(initializer.node)) - ) - return null; + const reassigned = binding.definitions.some( + ({ kind }) => kind === "assignment", + ); + const converted = interop.origin(binding); + if (reassigned && converted === undefined) return null; + const directOrigin = converted?.origin ?? bindingOrigin(binding, state); + const initializer = semanticStableInitializer(binding, state); + if (directOrigin === undefined && initializer === undefined) return null; const provenance = directOrigin === undefined ? evaluateSemanticProvenance(binding, state) @@ -448,66 +471,17 @@ const electronExport = ( : null; }; -/** - * Return the initializers that can change which module a binding names. - * Bundlers emit `name = __toESM(name)` after a CommonJS require; rewrapping - * the binding's own value keeps every member, so it is not a reassignment. - */ -const namespacePreservingInitializers = ( - binding: JavaScriptSemanticBindingState, - state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticBindingState["initializers"] | undefined => { - const initializers = binding.initializers.filter( - ({ node }) => !rewrapsOwnNamespace(binding, node, state), - ); - const rewraps = binding.initializers.length - initializers.length; - const assignments = binding.definitions.filter( - ({ kind }) => kind === "assignment", - ).length; - return assignments === rewraps ? initializers : undefined; -}; - -const rewrapsOwnNamespace = ( - binding: JavaScriptSemanticBindingState, - node: t.Node, - state: JavaScriptSemanticAnalysisState, -): boolean => { - const assignment = state.parentsByNode.get(node); - const operand = semanticNamespaceInteropOperand(node, state); - return ( - t.isAssignmentExpression(assignment) && - assignment.operator === "=" && - assignment.right === node && - t.isIdentifier(assignment.left, { name: binding.name }) && - t.isIdentifier(operand, { name: binding.name }) && - !semanticResolutionBlocked(state, operand, operand.name) && - resolveSemanticBindingState(state, operand, operand.name) === binding - ); -}; - const bindingOrigin = ( binding: JavaScriptSemanticBindingState, - initializers: JavaScriptSemanticBindingState["initializers"], state: JavaScriptSemanticAnalysisState, ): JavaScriptModuleOrigin | undefined => { if (binding.directOrigins.length > 0) return binding.directOrigins.length === 1 ? binding.directOrigins[0] : undefined; - const [initializer] = initializers; - // A var/let declaration permits writes without proving one occurred. - // Require one unconditional initializer and no actual binding writes. - if ( - initializer === undefined || - initializers.length !== 1 || - state.conditionalInitializers.has(initializer.node) - ) - return undefined; - const origin = semanticRequireOrigin( - semanticNamespaceInteropOperand(initializer.node, state) ?? - initializer.node, - state, - )?.origin; + const initializer = semanticStableInitializer(binding, state); + if (initializer === undefined) return undefined; + const origin = semanticRequireOrigin(initializer.node, state)?.origin; const projection = initializer.projection; return origin === undefined || !projection.every((key): key is string => typeof key === "string") @@ -650,10 +624,11 @@ function* collectDefinitionsSteps( for (const [index, { body, scope, parameters }] of functionBodies.entries()) { if (index % 64 === 0) yield; for (const parameter of parameters) - for (const identifier of assignedPatternIdentifiers( + for (const identifier of semanticWriteTargets( t.isTSParameterProperty(parameter) ? parameter.parameter : parameter, )) - copyParameterToBody({ identifier, body, scope, state }); + if (t.isIdentifier(identifier)) + copyParameterToBody({ identifier, body, scope, state }); } } @@ -801,16 +776,18 @@ const bindAssignment = ( state, ); else - for (const identifier of assignedPatternIdentifiers(node.left)) - addAssignment(identifier, node, scope, state); + for (const identifier of semanticWriteTargets(node.left)) + if (t.isIdentifier(identifier)) + addAssignment(identifier, node, scope, state); } else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument)) addAssignment(node.argument, node, scope, state); else if (t.isForOfStatement(node) || t.isForInStatement(node)) for (const pattern of t.isVariableDeclaration(node.left) ? node.left.declarations.map(({ id }) => id) : [node.left]) { - for (const identifier of assignedPatternIdentifiers(pattern)) - addAssignment(identifier, node, scope, state); + for (const identifier of semanticWriteTargets(pattern)) + if (t.isIdentifier(identifier)) + addAssignment(identifier, node, scope, state); if (t.isForOfStatement(node)) // Keep the loop value unknown, but preserve references yielded into // each bound slot so writes/escapes can reach their iterable origins. @@ -825,30 +802,6 @@ const bindAssignment = ( } }; -const assignedPatternIdentifiers = ( - pattern: t.Node, -): readonly t.Identifier[] => { - const identifiers: t.Identifier[] = []; - const pending = [pattern]; - while (pending.length > 0) { - const node = pending.pop(); - if (node === undefined) continue; - if (t.isIdentifier(node)) identifiers.push(node); - else if (t.isRestElement(node)) pending.push(node.argument); - else if (t.isAssignmentPattern(node)) pending.push(node.left); - else if (t.isArrayPattern(node)) { - for (const element of node.elements) - if (element !== null) pending.push(element); - } else if (t.isObjectPattern(node)) { - for (const property of node.properties) - pending.push( - t.isRestElement(property) ? property.argument : property.value, - ); - } - } - return identifiers; -}; - const nestedScope = ( node: t.Node, parent: t.Node | null, diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 62df4c38cb..35f3a0ca9c 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -33,7 +33,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticStaticPropertyKey, - semanticMemberWriteTargets, + semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -401,14 +401,15 @@ export function* collectSemanticMemberMutationsSteps( markEscaped(expression.object, mutation); }; const markTarget = (node: t.Node, mutation: t.Node): void => { - for (const target of semanticMemberWriteTargets(node)) - markValue( - target.object, - [semanticStaticPropertyKey(target.property, target.computed)], - new Set(), - "write", - mutation, - ); + for (const target of semanticWriteTargets(node)) + if (!t.isIdentifier(target)) + markValue( + target.object, + [semanticStaticPropertyKey(target.property, target.computed)], + new Set(), + "write", + mutation, + ); }; yield* traverseJavaScriptAstSteps(program, { enter: (node, parent) => { diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index ec9ec9ae63..00f0805ba1 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -5,7 +5,6 @@ import { resolveSemanticBindingState, semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, - type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { semanticStaticPropertyKey, @@ -59,73 +58,3 @@ export const semanticRequireOrigin = ( call: current, }; }; - -// esbuild/Rolldown, TypeScript, and Babel emit these helpers for namespace -// imports of CommonJS modules; each returns an object exposing every own -// member of its operand, so member paths through the result are unchanged. -// Helper bodies are not evaluated: recognition trusts a single program-scope -// function definition under one of these emitted names. -const NAMESPACE_INTEROP_HELPERS = new Set([ - "__importStar", - "__toESM", - "_interopRequireWildcard", -]); - -/** Return the module operand of a locally declared namespace interop helper. */ -export const semanticNamespaceInteropOperand = ( - node: t.Node, - state: JavaScriptSemanticAnalysisState, -): t.Expression | undefined => { - const call = unwrapJavaScriptExpression(node).node; - if ( - !t.isCallExpression(call) || - !t.isIdentifier(call.callee) || - !NAMESPACE_INTEROP_HELPERS.has(call.callee.name) || - semanticResolutionBlocked(state, call.callee, call.callee.name) - ) - return undefined; - const helper = resolveSemanticBindingState( - state, - call.callee, - call.callee.name, - ); - const [operand] = call.arguments; - return helper !== undefined && - emittedInteropHelper(helper, state) && - t.isExpression(operand) - ? operand - : undefined; -}; - -// Emitters declare each helper once at program scope as a function, an -// arrow/function expression, or `(this && this.name) || `. -const emittedInteropHelper = ( - helper: JavaScriptSemanticBindingState, - state: JavaScriptSemanticAnalysisState, -): boolean => { - const [initializer] = helper.initializers; - if ( - initializer === undefined || - helper.initializers.length !== 1 || - helper.definitions.length !== 1 || - (helper.kind !== "function" && helper.kind !== "variable") || - state.scopesById.get(helper.scopeId)?.kind !== "program" - ) - return false; - const value = unwrapJavaScriptExpression(initializer.node).node; - const emitted = - t.isLogicalExpression(value, { operator: "||" }) && - t.isLogicalExpression(unwrapJavaScriptExpression(value.left).node, { - operator: "&&", - }) - ? unwrapJavaScriptExpression(value.right).node - : value; - return ( - t.isFunctionDeclaration(emitted) || - t.isFunctionExpression(emitted) || - t.isArrowFunctionExpression(emitted) || - (t.isCallExpression(emitted) && - (t.isFunctionExpression(emitted.callee) || - t.isArrowFunctionExpression(emitted.callee))) - ); -}; diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 2024d74867..e07caad996 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -75,6 +75,30 @@ export interface JavaScriptSemanticAnalysisState { readonly conditionalInitializers: WeakSet; } +/** + * 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. + */ +export const semanticStableInitializer = ( + binding: JavaScriptSemanticBindingState | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState["initializers"][number] | undefined => { + const [initializer] = binding?.initializers ?? []; + const [definition] = binding?.definitions ?? []; + return binding !== undefined && + ((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) + ? initializer + : undefined; +}; + /** Return the active scope from a non-empty construction stack. */ export const currentSemanticScope = ( stack: readonly JavaScriptSemanticScopeState[], diff --git a/src/domain/javascript/javascriptSemanticTraversal.ts b/src/domain/javascript/javascriptSemanticTraversal.ts index 16960ba8fe..e2e3512543 100644 --- a/src/domain/javascript/javascriptSemanticTraversal.ts +++ b/src/domain/javascript/javascriptSemanticTraversal.ts @@ -82,8 +82,7 @@ interface TraversalFrame { * not be reintroduced (they diverge on loc/comment fields and ordering). */ export const childNodes = (node: t.Node): t.Node[] => { - const keys = t.VISITOR_KEYS[node.type]; - if (keys === undefined) return []; + const keys = javaScriptAstChildKeys(node); const children: t.Node[] = []; for (const key of keys) { const value: unknown = Reflect.get(node, key); @@ -98,3 +97,7 @@ export const childNodes = (node: t.Node): t.Node[] => { } return children; }; + +/** Child-bearing fields; shared by traversal and structural AST comparison. */ +export const javaScriptAstChildKeys = (node: t.Node): readonly string[] => + t.VISITOR_KEYS[node.type] ?? []; diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts new file mode 100644 index 0000000000..badad8696f --- /dev/null +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -0,0 +1,180 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { ROLLDOWN_MODULE_INTEROP } from "../../../src/domain/javascript/javascriptModuleInterop.fixture.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const INTRINSIC_MUTATIONS = [ + ["poisoned.cjs", "({Object} = replacements);"], + [ + "projected.cjs", + 'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});', + ], + [ + "decorated.cjs", + '@(() => Object) class metadata {}; Object.defineProperty(metadata,"create",{value:customCreate});', + ], + [ + "uncurried.cjs", + 'const change = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + ], + [ + "generic-bind.cjs", + 'const change = Function.prototype.bind.call(Object.defineProperty,null); change(Object,"create",{value:customCreate});', + ], + [ + "replaced-apply.cjs", + 'Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', + ], + [ + "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)( + "retains converted Electron windows and preload evidence through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-electron-interop-"); + await writeFile( + join(root, "package.json"), + JSON.stringify({ main: "main.cjs" }), + ); + await writeFile( + join(root, "preload.js"), + 'throw new Error("must stay inert");', + ); + for (const [name, mutation] of INTRINSIC_MUTATIONS) + 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 = `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 {}; + Object.defineProperty(localClass,"x",{value:1}); + const localRegExp = /local/; + Object.defineProperty(localRegExp,"x",{value:1}); + Object.defineProperty(hoistedFunction,"x",{value:1}); + function hoistedFunction() {} + const captured = Object.defineProperty; + Object.defineProperty = custom; + ${ROLLDOWN_MODULE_INTEROP.replace("__defProp = Object.defineProperty", "__defProp = captured")} + let electron = require("electron"); + electron = __toESM(electron, 1); + new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}}); + new electron.BrowserWindow({webPreferences:{preload:runtimePath}}); + var direct; + var direct = require("electron"); + var direct; + new direct.BrowserWindow({}); + const fresh = __toESM(require("electron"), 1); + new fresh.BrowserWindow({}); + const {BrowserWindow: ProjectedWindow} = __toESM(require("electron")); + new ProjectedWindow({}); + const nonNode = __toESM(require("electron"), 0); + new nonNode.BrowserWindow({}); + function identity(mod) {return mod;} + const passthrough = identity(require("electron")); + new passthrough.BrowserWindow({}); + const __importStar = () => ({BrowserWindow:FakeWindow}); + const fake = __importStar(require("electron")); + new fake.BrowserWindow({}); + function decoy(electron) {new electron.BrowserWindow({});} + require("./poisoned.cjs"); + require("./projected.cjs"); + require("./decorated.cjs"); + require("./bound.cjs"); + 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); + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + expect(result.summary).toMatchObject({ + browser_windows: 7, + preload_entrypoints: 1, + }); + expect(result.statistics.parse_failures).toBe(0); + expect(result.statistics.parsed_javascript_files).toBe(10); + const windows = result.graph.nodes.filter( + ({ kind }) => kind === "browser-window", + ); + expect(windows).toHaveLength(7); + const expectedLines = source + .split("\n") + .flatMap((line, index) => + /^\s*new (?:(?:electron|direct|fresh|passthrough|nonNode)\.BrowserWindow|ProjectedWindow)\(/u.test( + line, + ) + ? [index + 1] + : [], + ); + const observedLines = windows.flatMap((window) => { + const location = window.observations[0]?.evidence.location; + return location?.available && location.value.kind === "source-range" + ? [location.value.start.line] + : []; + }); + expect(observedLines.toSorted()).toEqual(expectedLines.toSorted()); + for (const window of windows) { + expect(window.observations[0]?.evidence.location).toMatchObject({ + available: true, + value: { kind: "source-range", source: "main.cjs" }, + }); + } + expect( + windows.flatMap((window) => + window.observations.map(({ properties }) => properties.web_preferences), + ), + ).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ + name: "preload", + value: { + status: "dynamic", + value: null, + expression: "runtimePath", + }, + }), + ]), + ]), + ); + }, +);