From e4a8e0b728154fe0bf181684ccd0d73df45cf4bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:44:32 +0000 Subject: [PATCH 1/2] fix(javascript): recognize Electron APIs through renamed bindings Electron recognition matched callee spellings, so a bundler rename such as esbuild's `import { ipcMain as ipcMain2 } from "electron"`, or a destructured `const { contextBridge: bridge } = require("electron")`, hid every IPC handler, BrowserWindow, contextBridge and utility process behind it. Resolve each call and construction root lexically to its Electron import or require origin, and classify the callee under that export's name. Shadowed locals keep their own meaning. On Hermes Desktop v0.21.6, whose main process is one esbuild ESM bundle, recognized main IPC handlers rise from 1 to 204, BrowserWindow constructions from 0 to 11, and paired renderer transmissions from 1 to 201. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XPY2GmufMNGQKoEgRUx1pd --- .../electronStaticAnalysisBrowser.ts | 8 +- .../javascript/electronStaticAnalysisIpc.ts | 3 +- .../electronStaticAnalysisValues.ts | 21 ++++- .../javascript/javascriptSemanticAnalysis.ts | 79 +++++++++++++++++++ .../javascript/javascriptStaticAnalysis.ts | 27 +++---- .../javascriptStaticAnalysisState.ts | 2 + .../electronApplicationAnalysis.test.ts | 66 ++++++++++++++++ 7 files changed, 186 insertions(+), 20 deletions(-) diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index f29e7cf8f4..a44baf89a7 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -12,6 +12,7 @@ import type { import { boundedExpression, collectContextBridgeMembers, + electronCalleeName, electronStaticValue, objectProperty, } from "./electronStaticAnalysisValues.js"; @@ -19,7 +20,6 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; import { argumentNode, - calleeName, range, staticPath, staticPathResolutionContext, @@ -42,7 +42,7 @@ const inspectBrowserWindow = ( node: t.NewExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); if (name !== "BrowserWindow" && !name.endsWith(".BrowserWindow")) return; const options = argumentNode(node.arguments[0]); const collected = collectWindowOptions(context.source, options); @@ -165,7 +165,7 @@ const inspectContextBridge = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); const main = name === "contextBridge.exposeInMainWorld" || name.endsWith(".contextBridge.exposeInMainWorld"); @@ -213,7 +213,7 @@ const inspectUtilityProcess = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); if (name !== "utilityProcess.fork" && !name.endsWith(".utilityProcess.fork")) return; const moduleNode = argumentNode(node.arguments[0]); diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index 9efc2670b8..a560ea1844 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -7,6 +7,7 @@ import type { } from "./electronStaticAnalysisTypes.js"; import { boundedExpression, + electronCalleeName, electronStaticValue, handlerKind, } from "./electronStaticAnalysisValues.js"; @@ -36,7 +37,7 @@ const inspectIpcCall = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); const descriptor = ipcDescriptor(name); if (descriptor === undefined) return; const channelNode = argumentNode(node.arguments[0]); diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 2a800e6bbd..c2bb6e76a2 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -6,7 +6,26 @@ import { readExactJavaScriptLiteral, semanticStaticPropertyName, } from "./javascriptAstValues.js"; -import { sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; +import { calleeName, sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; +import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; + +/** Name a callee, spelling a root renamed from Electron as its export. */ +export const electronCalleeName = ( + callee: t.Node, + context: JavaScriptFindingContext, +): string => { + const name = calleeName(callee); + let root = callee; + while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) { + if (!t.isNode(root.object)) return name; + root = root.object; + } + if (!t.isIdentifier(root)) return name; + const exported = context.electronBindings?.get(root.start ?? -1); + return exported === undefined + ? name + : `${exported}${name.slice(root.name.length)}`; +}; /** Preserve one literal value or the exact inert expression. */ export const electronStaticValue = ( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 5a873429e1..b5c1c7f5df 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -29,6 +29,7 @@ import { semanticVariableScope, } from "./javascriptSemanticState.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { collectSemanticMemberMutations } from "./javascriptSemanticMemberMutations.js"; import { collectSemanticReturns, @@ -282,6 +283,84 @@ const resolveGlobalAliasFact = ( return openGlobalFact(name); }; +const ELECTRON_MODULE = /^electron(?:\/(?:common|main|renderer|utility))?$/u; + +/** + * Electron export paths for call and construction roots bound to Electron + * under another name, such as esbuild's `import { ipcMain as ipcMain2 }`. + * Keys are root identifier offsets; roots spelled as their export are omitted. + */ +export const classifyParsedJavaScriptElectronBindings = ( + file: ParsedJavaScriptSource, +): ReadonlyMap => { + const facts = new Map(); + const state = createState(file.program); + collectDefinitions(file.program, state); + const exportsByBinding = new Map(); + traverseJavaScriptAst(file.program, { + enter: (node) => { + if ( + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) + ) + return; + const root = calleeRoot(node.callee); + if (root === undefined) return; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) return; + let exported = exportsByBinding.get(binding.bindingId); + if (exported === undefined) { + exported = electronExport(binding, state); + exportsByBinding.set(binding.bindingId, exported); + } + if (exported !== null && exported !== root.name) + facts.set(root.start ?? -1, exported); + }, + }); + return facts; +}; + +const calleeRoot = (node: t.Node): t.Identifier | undefined => { + while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + if (!t.isNode(node.object)) return undefined; + node = node.object; + } + return t.isIdentifier(node) ? node : undefined; +}; + +// Read import and require origins directly; general value evaluation is +// unnecessary here and costly across large vendor bundles. +const electronExport = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): string | null => { + const origin = bindingOrigin(binding, state); + return origin !== undefined && + ELECTRON_MODULE.test(origin.specifier) && + origin.importedPath.length > 0 + ? origin.importedPath.join(".") + : null; +}; + +const bindingOrigin = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): JavaScriptModuleOrigin | undefined => { + if (binding.directOrigins.length > 0) + return binding.directOrigins.length === 1 + ? binding.directOrigins[0] + : undefined; + const [initializer] = binding.initializers; + if (binding.mutable || binding.initializers.length !== 1) return undefined; + const origin = semanticRequireOrigin(initializer?.node, state); + const projection = initializer?.projection ?? []; + return origin === undefined || + !projection.every((key): key is string => typeof key === "string") + ? undefined + : { ...origin, importedPath: [...origin.importedPath, ...projection] }; +}; + const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { const root: JavaScriptSemanticScopeState = { scopeId: semanticScopeId("program", program), diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index af20eaa926..524af811eb 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -25,9 +25,11 @@ import { finalizeLocatedFindings } from "./javascriptStaticAnalysisFindings.js"; import { createJavaScriptAnalysisAccumulator, type JavaScriptAnalysisAccumulator as AnalysisAccumulator, + type JavaScriptFindingContext, } from "./javascriptStaticAnalysisState.js"; import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js"; import { + classifyParsedJavaScriptElectronBindings, classifyParsedJavaScriptOpenReceivers, type JavaScriptOpenReceiverFact, } from "./javascriptSemanticAnalysis.js"; @@ -57,21 +59,24 @@ export const analyzeParsedJavaScriptStaticSource = ( openReceiverFacts?: ReadonlyMap, ): JavaScriptStaticAnalysis => { const accumulator = createJavaScriptAnalysisAccumulator(source.length); - traverseStaticSource(source, file, accumulator, openReceiverFacts); + traverseStaticSource(file, { + source, + accumulator, + ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), + electronBindings: classifyParsedJavaScriptElectronBindings(file), + }); addSourceMapDirectives(source, file.comments ?? [], accumulator); return finalizeStaticAnalysis(source, file, accumulator); }; const traverseStaticSource = ( - source: string, file: ParsedJavaScriptSource, - accumulator: AnalysisAccumulator, - openReceiverFacts?: ReadonlyMap, + findings: JavaScriptFindingContext, ): void => { traverseJavaScriptAst(file, { enter: (node) => { - accumulator.visitedNodes += 1; - inspectNode(source, node, accumulator, openReceiverFacts); + findings.accumulator.visitedNodes += 1; + inspectNode(node, findings); return undefined; }, }); @@ -156,16 +161,10 @@ const finalizeStaticAnalysis = ( }; const inspectNode = ( - source: string, node: t.Node, - accumulator: AnalysisAccumulator, - openReceiverFacts?: ReadonlyMap, + findings: JavaScriptFindingContext, ): void => { - const findings = { - source, - accumulator, - ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), - }; + const { source, accumulator } = findings; inspectElectronStaticNode(node, findings); if (t.isCallExpression(node)) { inspectBundlerRegistration(source, node, accumulator); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 23de6952bf..47e25bccdb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -65,6 +65,8 @@ export interface JavaScriptFindingContext { | "local-indexed-db" | "local-cache-storage" >; + /** Electron export paths for renamed call roots, by identifier offset. */ + readonly electronBindings?: ReadonlyMap; } /** Candidate import, require, worker, or service-worker reference. */ diff --git a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts index 3484f98a24..4180e1818d 100644 --- a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts @@ -89,6 +89,33 @@ describe("static Electron application analysis", () => { expect(requestedMembers).toEqual(["*"]); }); + it("maps Electron boundaries through bundler-renamed bindings", async () => { + const root = await renamedBindingFixtureDirectory(); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + const handledChannels = graph.nodes.flatMap(({ kind, observations }) => + kind === "ipc-handler" + ? observations.flatMap(({ properties }) => + properties.side === "main" ? [properties.channel] : [], + ) + : [], + ); + + expect(result.electron_summary).toMatchObject({ + browser_windows: 1, + context_bridge_apis: 1, + exposed_api_members: 2, + ipc: { + main_handlers: 2, + paired_renderer_transmissions: 2, + unpaired_literal_renderer_transmissions: 0, + }, + utility_processes: 1, + }); + expect(handledChannels.sort()).toEqual(["rea:read", "rea:write"]); + }); + it("returns a tagged cancellation without executing application code", async () => { const root = await fixtureDirectory(); const controller = new AbortController(); @@ -299,6 +326,45 @@ const fixtureDirectory = async (): Promise => { return root; }; +const renamedBindingFixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-renamed-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ name: "rea-electron-renamed", main: "main.mjs" }), + ), + // Bundlers rename colliding imports, e.g. esbuild's ipcMain2. + writeFile( + join(root, "main.mjs"), + String.raw` +import { BrowserWindow as BrowserWindow2, ipcMain as ipcMain2 } from "electron"; +import { ipcMain as ipcMain3, utilityProcess as utility } from "electron/main"; + +new BrowserWindow2({ webPreferences: { preload: "./preload.cjs" } }); +ipcMain2.handle("rea:read", async () => "value"); +ipcMain3.on("rea:write", () => undefined); +utility.fork("./worker.js"); +function register(ipcMain2) { + ipcMain2.handle("rea:shadowed", async () => "local"); +} +export { register }; +`, + ), + writeFile( + join(root, "preload.cjs"), + String.raw` +const { contextBridge: bridge, ipcRenderer: ipc } = require("electron"); +bridge.exposeInMainWorld("reaApi", { + read: () => ipc.invoke("rea:read"), + write: (value) => ipc.send("rea:write", value), +}); +`, + ), + writeFile(join(root, "worker.js"), "module.exports = {};"), + ]); + return root; +}; + const computedMemberFixtureDirectory = async (): Promise => { const root = await createTestTempDirectory("rea-electron-computed-members-"); await mkdir(join(root, "native")); From 0d2082d36b8bed3cda5daa22adee2163652fc511 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:56:40 +0800 Subject: [PATCH 2/2] fix(javascript): recognize unchanged var and let Electron aliases --- .../javascript/javascriptSemanticAnalysis.ts | 14 ++++- .../electronApplicationAnalysis.test.ts | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index b5c1c7f5df..d490adf659 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -352,9 +352,17 @@ const bindingOrigin = ( ? binding.directOrigins[0] : undefined; const [initializer] = binding.initializers; - if (binding.mutable || binding.initializers.length !== 1) return undefined; - const origin = semanticRequireOrigin(initializer?.node, state); - const projection = initializer?.projection ?? []; + // A var/let declaration permits writes without proving one occurred. + // Require one unconditional initializer and no actual binding writes. + if ( + initializer === undefined || + binding.initializers.length !== 1 || + binding.definitions.some(({ kind }) => kind === "assignment") || + state.conditionalInitializers.has(initializer.node) + ) + return undefined; + const origin = semanticRequireOrigin(initializer.node, state); + const projection = initializer.projection; return origin === undefined || !projection.every((key): key is string => typeof key === "string") ? undefined diff --git a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts index 4180e1818d..dd7bd8809e 100644 --- a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts @@ -116,6 +116,18 @@ describe("static Electron application analysis", () => { expect(handledChannels.sort()).toEqual(["rea:read", "rea:write"]); }); + it("maps unchanged var and let require aliases while excluding reassigned bindings", async () => { + const root = await requireAliasFixtureDirectory(); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + + expect(result.electron_summary.ipc).toMatchObject({ + main_handlers: 2, + paired_renderer_transmissions: 2, + unpaired_literal_renderer_transmissions: 0, + }); + }); + it("returns a tagged cancellation without executing application code", async () => { const root = await fixtureDirectory(); const controller = new AbortController(); @@ -326,6 +338,50 @@ const fixtureDirectory = async (): Promise => { return root; }; +const requireAliasFixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-require-aliases-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-electron-require-aliases", + main: "main.cjs", + }), + ), + writeFile( + join(root, "main.cjs"), + String.raw` +var { ipcMain: main } = require("electron"); +let listener = require("electron/main").ipcMain; +main.handle("rea:read", async () => "value"); +listener.on("rea:write", () => undefined); + +let replaced = require("electron").ipcMain; +replaced = { handle() {} }; +replaced.handle("rea:reassigned", () => undefined); + +var assignedLater; +assignedLater.handle("rea:before-assignment", () => undefined); +assignedLater = require("electron").ipcMain; + +if (globalThis.registerIpc) { + var conditional = require("electron").ipcMain; +} +conditional.handle("rea:conditional", () => undefined); +`, + ), + writeFile( + join(root, "preload.cjs"), + String.raw` +const { ipcRenderer } = require("electron"); +ipcRenderer.invoke("rea:read"); +ipcRenderer.send("rea:write", "value"); +`, + ), + ]); + return root; +}; + const renamedBindingFixtureDirectory = async (): Promise => { const root = await createTestTempDirectory("rea-electron-renamed-"); await Promise.all([