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", + }, + }), + ]), + ]), + ); + }, +);