diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index cca3a20c89..e343367d33 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -660,3 +660,44 @@ it("keeps unrelated exports after an aliased prebound key write", () => { `); expect(analysis.electron.browser_windows).toHaveLength(1); }); + +describe("Electron member syntax ownership", () => { + it.each([ + "electron as typeof electron", + "electron!", + "electron satisfies typeof electron", + ])("preserves lexical API identity through %s", (receiver) => { + const source = `const electron = require("electron"); + new ((${receiver}).BrowserWindow as typeof electron.BrowserWindow)({webPreferences:{preload:"./wrapped.js"}}); + ((${receiver}).ipcMain as typeof electron.ipcMain).handle("wrapped:ping", () => "pong"); + (${receiver}).contextBridge.exposeInMainWorld("wrappedApi", {ping:true});`; + const { electron } = analyzeJavaScriptStaticSource(source); + expect(electron.browser_windows).toMatchObject([ + { preload_path: "./wrapped.js" }, + ]); + expect(electron.ipc).toMatchObject([{ channel: "wrapped:ping" }]); + expect(electron.context_bridge_apis).toMatchObject([ + { api_key: "wrappedApi" }, + ]); + }); + + it.each([ + 'const electron = require("other");', + 'function require() { return unknown; } const electron = require("electron");', + 'const electron = require("electron"); electron.BrowserWindow = replacement;', + ])( + "retains rejected lexical identity through erased wrappers: %s", + (setup) => { + const result = analyzeJavaScriptStaticSource(`${setup} + new (electron.BrowserWindow as typeof electron.BrowserWindow)({webPreferences:{preload:"./bad.js"}});`); + expect(result.electron.browser_windows).toEqual([]); + }, + ); + + it("keeps computed unknown member selections unresolved", () => { + const result = + analyzeJavaScriptStaticSource(`const electron = require("electron"); + new (electron as typeof electron)[runtimeKey]({webPreferences:{preload:"./bad.js"}});`); + expect(result.electron.browser_windows).toEqual([]); + }); +}); diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 2ee37443c1..2b232e8f1e 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -5,8 +5,8 @@ import type { ElectronStaticValue } from "./electronStaticAnalysisTypes.js"; import { readExactJavaScriptLiteral, semanticStaticPropertyKey, - semanticStaticPropertyName, } from "./javascriptAstValues.js"; +import { electronMemberPath } from "./javascriptElectronMemberWrites.js"; import { sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; @@ -15,18 +15,11 @@ export const electronCalleePath = ( callee: t.Node, context: JavaScriptFindingContext, ): readonly string[] | undefined => { - const members: string[] = []; - let root = callee; - while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) { - const member = semanticStaticPropertyName(root.property, root.computed); - if (member === "" || !t.isNode(root.object)) return undefined; - members.push(member); - root = root.object; - } + const { root, members } = electronMemberPath(callee); + if (!members.every((member): member is string => member !== null)) + return undefined; const exported = context.electronBindings?.get(root.start ?? -1); - return exported === undefined - ? undefined - : [...exported, ...members.toReversed()]; + return exported === undefined ? undefined : [...exported, ...members]; }; /** Preserve one literal value or the exact inert expression. */