diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index a44baf89a7..7657614ae0 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -12,7 +12,7 @@ import type { import { boundedExpression, collectContextBridgeMembers, - electronCalleeName, + electronCalleePath, electronStaticValue, objectProperty, } from "./electronStaticAnalysisValues.js"; @@ -42,8 +42,8 @@ const inspectBrowserWindow = ( node: t.NewExpression, context: JavaScriptFindingContext, ): void => { - const name = electronCalleeName(node.callee, context); - if (name !== "BrowserWindow" && !name.endsWith(".BrowserWindow")) return; + const path = electronCalleePath(node.callee, context); + if (path?.length !== 1 || path[0] !== "BrowserWindow") return; const options = argumentNode(node.arguments[0]); const collected = collectWindowOptions(context.source, options); context.accumulator.unknownFindings += collected.unknown; @@ -62,7 +62,7 @@ const inspectBrowserWindow = ( }; addLocatedFinding(context, { collection: context.accumulator.browserWindows, - key: `electron-window\0${name}`, + key: "electron-window\0BrowserWindow", node, value: finding, }); @@ -165,13 +165,11 @@ const inspectContextBridge = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = electronCalleeName(node.callee, context); - const main = - name === "contextBridge.exposeInMainWorld" || - name.endsWith(".contextBridge.exposeInMainWorld"); - const isolated = - name === "contextBridge.exposeInIsolatedWorld" || - name.endsWith(".contextBridge.exposeInIsolatedWorld"); + const path = electronCalleePath(node.callee, context); + const exposureMethod = + path?.length === 2 && path[0] === "contextBridge" ? path[1] : undefined; + const main = exposureMethod === "exposeInMainWorld"; + const isolated = exposureMethod === "exposeInIsolatedWorld"; if (!main && !isolated) return; const keyNode = argumentNode(node.arguments[isolated ? 1 : 0]); const apiNode = argumentNode(node.arguments[isolated ? 2 : 1]); @@ -213,8 +211,8 @@ const inspectUtilityProcess = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = electronCalleeName(node.callee, context); - if (name !== "utilityProcess.fork" && !name.endsWith(".utilityProcess.fork")) + const path = electronCalleePath(node.callee, context); + if (path?.length !== 2 || path[0] !== "utilityProcess" || path[1] !== "fork") return; const moduleNode = argumentNode(node.arguments[0]); const modulePath = diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index a560ea1844..e307868816 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -7,7 +7,7 @@ import type { } from "./electronStaticAnalysisTypes.js"; import { boundedExpression, - electronCalleeName, + electronCalleePath, electronStaticValue, handlerKind, } from "./electronStaticAnalysisValues.js"; @@ -37,8 +37,7 @@ const inspectIpcCall = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = electronCalleeName(node.callee, context); - const descriptor = ipcDescriptor(name); + const descriptor = ipcDescriptor(electronCalleePath(node.callee, context)); if (descriptor === undefined) return; const channelNode = argumentNode(node.arguments[0]); const channelValue = electronStaticValue(context.source, channelNode); @@ -176,9 +175,13 @@ const addValidation = ( }); }; -const ipcDescriptor = (name: string): ElectronIpcDescriptor | undefined => { - const renderer = suffixOperation(name, "ipcRenderer"); - if (renderer !== undefined) { +const ipcDescriptor = ( + path: readonly string[] | undefined, +): ElectronIpcDescriptor | undefined => { + if (path?.length !== 2) return undefined; + const [object, operation] = path; + if (object === "ipcRenderer" && operation !== undefined) { + const renderer = operation; switch (renderer) { case "send": return { side: "renderer", operation: "send", mode: "send" }; @@ -204,7 +207,7 @@ const ipcDescriptor = (name: string): ElectronIpcDescriptor | undefined => { return { side: "renderer", operation: "once", mode: "listen" }; } } - const main = suffixOperation(name, "ipcMain"); + const main = object === "ipcMain" ? operation : undefined; switch (main) { case "on": return { side: "main", operation: "on", mode: "listen" }; @@ -219,19 +222,6 @@ const ipcDescriptor = (name: string): ElectronIpcDescriptor | undefined => { } }; -const suffixOperation = ( - name: string, - object: "ipcRenderer" | "ipcMain", -): string | undefined => { - const marker = `${object}.`; - const index = name.lastIndexOf(marker); - if (index < 0) return undefined; - const prefix = name.slice(0, index); - if (prefix !== "" && !prefix.endsWith(".")) return undefined; - const operation = name.slice(index + marker.length); - return operation.includes(".") ? undefined : operation; -}; - const senderSubject = ( node: t.Node, ): ElectronSenderValidationFinding["subject"] | undefined => { diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index e133e4dfe0..0fc0446966 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -5,6 +5,7 @@ import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; describe("Electron option evidence", () => { it("keeps the final explicit preload and service name with unrelated keys", () => { const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow, utilityProcess } = require("electron"); new BrowserWindow({ webPreferences: { preload: "./kept.js", sandbox: true }, title: "App" }); utilityProcess.fork("./worker.js", [], { serviceName: "kept", cwd: "/app" }); `); @@ -40,6 +41,7 @@ describe("Electron option evidence", () => { it("keeps values unknown when a later computed key can override them", () => { const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow, utilityProcess } = require("electron"); new BrowserWindow({ webPreferences: { preload: "./maybe.js" }, [key]: null }); utilityProcess.fork("./worker.js", [], { serviceName: "maybe", [key]: null }); `); @@ -61,6 +63,7 @@ describe("Electron option evidence", () => { it("keeps recovered templates with invalid escapes dynamic", () => { const source = [ + 'const { BrowserWindow } = require("electron");', "new BrowserWindow({ webPreferences: { sandbox: `", String.raw`\unicode`, "` } });", diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index c2bb6e76a2..1fb8d366dd 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -6,25 +6,26 @@ import { readExactJavaScriptLiteral, semanticStaticPropertyName, } from "./javascriptAstValues.js"; -import { calleeName, sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; +import { 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 = ( +/** Resolve a callee only when its root has a proven Electron module identity. */ +export const electronCalleePath = ( callee: t.Node, context: JavaScriptFindingContext, -): string => { - const name = calleeName(callee); +): readonly string[] | undefined => { + const members: string[] = []; let root = callee; while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) { - if (!t.isNode(root.object)) return name; + const member = semanticStaticPropertyName(root.property, root.computed); + if (member === "" || !t.isNode(root.object)) return undefined; + members.push(member); 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)}`; + ? undefined + : [...exported, ...members.toReversed()]; }; /** Preserve one literal value or the exact inert expression. */ diff --git a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts index 00bddb649a..9cab84239e 100644 --- a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts +++ b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts @@ -425,7 +425,7 @@ const propertyMatch = (node: ApplicationNode, field: string) => ({ const contextBridgeGraph = () => { const analysis = analyzeJavaScriptStaticSource( - "contextBridge.exposeInMainWorld('desktopApi', { openProject() {}, closeProject() {} });", + "const { contextBridge } = require('electron');\ncontextBridge.exposeInMainWorld('desktopApi', { openProject() {}, closeProject() {} });", ); expect(analysis.parse_status).toBe("complete"); const finding = analysis.electron.context_bridge_apis[0]; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index ba06440565..cd3b85932f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -43,9 +43,9 @@ describe("JavaScript semantic analysis: structure 1", () => { specifier: "electron", importedPath: ["ipcMain"], }); - expect(origin(topLevelBinding(ir, "assigned"))).toEqual({ - specifier: "electron", - importedPath: ["ipcRenderer"], + expect(topLevelBinding(ir, "assigned").provenance).toMatchObject({ + status: "ambiguous", + origins: [{ specifier: "electron", importedPath: ["ipcRenderer"] }], }); expect(ir.moduleLinks).toEqual( expect.arrayContaining([ @@ -120,11 +120,15 @@ describe("JavaScript semantic analysis: structure 1", () => { assigned = require("electron").ipcMain; { const require = localLoader; require("./local.js"); } `); - for (const name of ["bus", "forwarded", "assigned"]) + for (const name of ["bus", "forwarded"]) expect(origin(topLevelBinding(ir, name))).toEqual({ specifier: "electron", importedPath: ["ipcMain"], }); + expect(topLevelBinding(ir, "assigned").provenance).toMatchObject({ + status: "ambiguous", + origins: [{ specifier: "electron", importedPath: ["ipcMain"] }], + }); expect( ir.moduleLinks.filter(({ kind }) => kind === "require"), ).toHaveLength(1); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index f0b299b1cb..c73f22da1e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -43,6 +43,7 @@ import { resolveSemanticModuleCallables, } from "./javascriptSemanticReturns.js"; import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js"; +import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName } from "./javascriptAstValues.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; @@ -313,23 +314,23 @@ const resolveGlobalAliasFact = ( 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. + * Proven Electron export path segments for call and construction roots, keyed by + * root offset. Only proven Electron origins are present; callers must not + * infer an Electron export from an absent entry or familiar local name. */ export const classifyParsedJavaScriptElectronBindings = ( file: ParsedJavaScriptSource, -): ReadonlyMap => +): ReadonlyMap => completeSemanticSteps(classifyParsedJavaScriptElectronBindingsSteps(file)); /** Resolve Electron aliases while yielding during lexical and AST scans. */ export function* classifyParsedJavaScriptElectronBindingsSteps( file: ParsedJavaScriptSource, -): Generator> { - const facts = new Map(); +): Generator> { + const facts = new Map(); const state = createState(file.program); yield* collectDefinitionsSteps(file.program, state); - const exportsByBinding = new Map(); + const exportsByBinding = new Map(); yield* traverseJavaScriptAstSteps(file.program, { enter: (node) => { if ( @@ -340,26 +341,31 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( 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 (t.isIdentifier(root)) { + 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) facts.set(root.start ?? -1, exported); + return; } - if (exported !== null && exported !== root.name) - facts.set(root.start ?? -1, exported); + const origin = semanticRequireOrigin(root, state); + if (origin !== undefined && ELECTRON_MODULE.test(origin.specifier)) + facts.set(root.start ?? -1, origin.importedPath); }, }); return facts; } -const calleeRoot = (node: t.Node): t.Identifier | undefined => { +const calleeRoot = (node: t.Node): t.Node | undefined => { while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { if (!t.isNode(node.object)) return undefined; node = node.object; } - return t.isIdentifier(node) ? node : undefined; + return node; }; // Read import and require origins directly; general value evaluation is @@ -367,12 +373,29 @@ const calleeRoot = (node: t.Node): t.Identifier | undefined => { 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(".") +): readonly string[] | null => { + if (binding.definitions.some(({ kind }) => kind === "assignment")) + return null; + const directOrigin = bindingOrigin(binding, state); + const [initializer] = binding.initializers; + if ( + directOrigin === undefined && + (initializer === undefined || + binding.initializers.length !== 1 || + state.conditionalInitializers.has(initializer.node)) + ) + return null; + const provenance = + directOrigin === undefined + ? evaluateSemanticProvenance(binding, state) + : undefined; + const origin = + directOrigin ?? + (provenance?.status === "module" && provenance.origins.length === 1 + ? provenance.origins[0] + : undefined); + return origin !== undefined && ELECTRON_MODULE.test(origin.specifier) + ? origin.importedPath : null; }; diff --git a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts index 506ab7a54c..0c5b4b12dc 100644 --- a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts @@ -96,9 +96,74 @@ new NativeWindow(); expect(electronStep.done).toBe(false); while (electronStep.done !== true) electronStep = electronSteps.next(); expect([...electronStep.value]).toEqual([ - [text.lastIndexOf("NativeWindow()"), "BrowserWindow"], + [text.lastIndexOf("NativeWindow()"), ["BrowserWindow"]], ]); expect(electronStep.value).toEqual( classifyParsedJavaScriptElectronBindings(parsed), ); }); + +it("resolves Electron APIs through proven immutable aliases", () => { + const text = ` +const { + BrowserWindow: ImportedWindow, + contextBridge: ImportedBridge, + ipcMain: ImportedMain, + ipcRenderer: ImportedRenderer, +} = require("electron"); +const Win = ImportedWindow; +const bridge = ImportedBridge; +const ipc = ImportedMain; +const renderer = ImportedRenderer; +new Win({ webPreferences: { preload: "./preload.js" } }); +bridge.exposeInMainWorld("api", {}); +ipc.handle("main:ping", () => "pong"); +renderer.invoke("main:ping"); + +const electron = require("electron"); +const namespace = electron; +const directMember = electron.ipcMain; +directMember.handle("direct-namespace:ping", () => "pong"); +const member = namespace.ipcMain; +member.handle("namespace:ping", () => "pong"); +require("electron").ipcMain.handle("direct-require:ping", () => "pong"); +require("electron").notAnApi.ipcMain.handle("nested-lookalike", () => "local"); +require("electron")["ipcMain.handle"]("dotted-lookalike", () => "local"); + +function shadow(ipc) { + ipc.handle("shadowed:ping", () => "local"); +} +const fakeElectron = require("./fake-electron"); +const fakeIpc = fakeElectron.ipcMain; +fakeIpc.handle("lookalike:ping", () => "local"); +let reassigned = ImportedMain; +reassigned = fakeIpc; +reassigned.handle("reassigned:ping", () => "local"); +let { ipcMain: reassignedImport } = require("electron"); +reassignedImport = fakeIpc; +reassignedImport.handle("reassigned-import:ping", () => "local"); +let transitive = ImportedMain; +transitive = fakeIpc; +const reassignedAlias = transitive; +reassignedAlias.handle("transitive-reassigned:ping", () => "local"); +if (globalThis.register) { + var conditional = require("electron").ipcMain; +} +const conditionalAlias = conditional; +conditionalAlias.handle("conditional-alias:ping", () => "local"); +`; + const parsed = parseJavaScriptSource(text, "aliases.js"); + if (parsed === null) throw new Error("Expected parsed source"); + + expect([...classifyParsedJavaScriptElectronBindings(parsed)]).toEqual([ + [text.indexOf("Win({"), ["BrowserWindow"]], + [text.indexOf("bridge.exposeInMainWorld"), ["contextBridge"]], + [text.indexOf('ipc.handle("main:ping"'), ["ipcMain"]], + [text.indexOf("renderer.invoke"), ["ipcRenderer"]], + [text.indexOf("directMember.handle"), ["ipcMain"]], + [text.indexOf("member.handle"), ["ipcMain"]], + [text.indexOf('require("electron").ipcMain.handle'), []], + [text.indexOf('require("electron").notAnApi.ipcMain.handle'), []], + [text.indexOf('require("electron")["ipcMain.handle"]'), []], + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index d001f8a867..2cc4ece92c 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -567,6 +567,30 @@ const projectValue = ( const provenanceForBinding = ( binding: JavaScriptSemanticBindingState, context: EvaluationContext, +): JavaScriptBindingProvenance => { + const provenance = provenanceForBindingValue(binding, context); + const assigned = binding.definitions.some( + ({ kind }) => kind === "assignment", + ); + const conditionallyInitialized = binding.initializers.some(({ node }) => + context.state.conditionalInitializers.has(node), + ); + if ( + provenance.status !== "module" || + (!assigned && !conditionallyInitialized) + ) + return provenance; + return semanticAmbiguousProvenance( + provenance.origins, + assigned + ? `Binding ${binding.name} may be reassigned.` + : `Binding ${binding.name} is conditionally initialized.`, + ); +}; + +const provenanceForBindingValue = ( + binding: JavaScriptSemanticBindingState, + context: EvaluationContext, ): JavaScriptBindingProvenance => { if (context.bindings.has(binding.bindingId)) return semanticUnresolvedProvenance( diff --git a/src/domain/javascript/javascriptStaticAnalysis.no_finding_caps.test.ts b/src/domain/javascript/javascriptStaticAnalysis.no_finding_caps.test.ts index fb182a7426..acfbaa7555 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.no_finding_caps.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.no_finding_caps.test.ts @@ -15,6 +15,7 @@ describe("JavaScript static analysis findings", () => { it("retains every static reference and Electron property in large literals", () => { const names = Array.from({ length: 10_001 }, (_, index) => `key${index}`); const source = [ + 'const { BrowserWindow, contextBridge } = require("electron");', ...names.map( (name, index) => `import value${index} from "./${name}.js";`, ), @@ -25,7 +26,7 @@ describe("JavaScript static analysis findings", () => { const analysis = analyzeJavaScriptStaticSource(source); expect(analysis.parse_status).toBe("complete"); - expect(analysis.references).toHaveLength(10_002); + expect(analysis.references).toHaveLength(10_003); expect(analysis.electron.browser_windows[0]?.web_preferences).toHaveLength( 10_001, ); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 06a60a0bde..93b27bf1c2 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -68,8 +68,8 @@ export interface JavaScriptFindingContext { | "local-indexed-db" | "local-cache-storage" >; - /** Electron export paths for renamed call roots, by identifier offset. */ - readonly electronBindings?: ReadonlyMap; + /** Proven Electron export path segments by root offset. */ + readonly electronBindings?: ReadonlyMap; } /** Candidate import, require, worker, or service-worker reference. */ diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 4d3662ce65..e54122440f 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -205,7 +205,7 @@ const analyzePathConsumers = (expression: string) => { windowExpression, utilityExpression, analysis: analyzeJavaScriptStaticSource( - `${genericPrefix}${property} };\n${windowExpression};\n${utilityExpression};`, + `${genericPrefix}${property} }; const { BrowserWindow, utilityProcess } = require("electron");\n${windowExpression};\n${utilityExpression};`, ), }; }; diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 5df8813ea5..b6b402bc4c 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -9,18 +9,19 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { afterEach, describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeElectronApiAliasApplication } from "../../fixtures/electronApiAliasApplication.js"; import { JAVASCRIPT_FEATURE_TRACE_EXAMPLE, JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, } from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js"; -import { JAVASCRIPT_EXPORT_SHAPE_COMPARISON_EXAMPLE } from "../../../src/contracts/javascript/javascriptExportShapeComparisonExample.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { javascriptApplicationAnalysisResultSchema, type JavaScriptApplicationAnalysisResult, } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { z } from "zod"; const execute = promisify(execFile); @@ -250,6 +251,59 @@ describe("JavaScript application path CLI", () => { result.normalized_result.semantic_graph, ); }, 20_000); + + it("preserves canonical Electron API aliases through CLI and stdio MCP", async () => { + const root = await createTestTempDirectory("rea-electron-api-alias-"); + temporary.push(root); + await writeElectronApiAliasApplication(root); + + const cliResult = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse( + await runCli(["analyze-javascript-application", root, "--json"]), + ).normalized_result; + expect(cliResult.summary).toMatchObject({ + browser_windows: 1, + explicit_web_preferences: 1, + preload_entrypoints: 1, + context_bridge_apis: 1, + exposed_api_members: 1, + ipc: { + operations: 2, + literal_channels: 1, + main_handlers: 1, + renderer_transmissions: 1, + paired_renderer_transmissions: 1, + unpaired_literal_renderer_transmissions: 0, + }, + }); + const graph = parseJavaScriptApplicationGraph(cliResult.graph); + const window = graph.nodes.find(({ kind }) => kind === "browser-window"); + expect(window?.observations[0]?.evidence.location).toMatchObject({ + available: true, + value: { + kind: "source-range", + source: "main.cjs", + start: { line: 6 }, + }, + }); + const bridge = graph.nodes.find( + ({ kind }) => kind === "context-bridge-api", + ); + expect(bridge?.observations[0]?.evidence.location).toMatchObject({ + available: true, + value: { + kind: "source-range", + source: "preload.cjs", + start: { line: 5 }, + }, + }); + + const mcpResult = await analyzeThroughStdioMcp(root); + expect(mcpResult.summary).toEqual(cliResult.summary); + expect(mcpResult.graph).toEqual(cliResult.graph); + expect(mcpResult.semantic_graph).toEqual(cliResult.semantic_graph); + }, 20_000); }); describe("application workflow CLI parity", () => { diff --git a/tests/fixtures/electronApiAliasApplication.ts b/tests/fixtures/electronApiAliasApplication.ts new file mode 100644 index 0000000000..6399e8d65e --- /dev/null +++ b/tests/fixtures/electronApiAliasApplication.ts @@ -0,0 +1,57 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** A source-only Electron application whose API values flow through aliases. */ +export const electronApiAliasSources = { + main: String.raw` +const { BrowserWindow } = require("electron"); +const Win = BrowserWindow; +const electron = require("electron"); +const ipc = electron.ipcMain; +new Win({ webPreferences: { preload: "./preload.cjs" } }); +ipc.handle("alias:ping", (_event, payload) => payload); +function shadow(BrowserWindow, ipcMain) { + new BrowserWindow({ webPreferences: { preload: "./not-real.cjs" } }); + ipcMain.handle("shadowed:ping", () => null); +} +const fakeIpc = { handle() {} }; +fakeIpc.handle("lookalike:ping", () => null); +const nestedElectronExport = require("electron").notAnApi; +nestedElectronExport.ipcMain.handle("nested-lookalike:ping", () => null); +require("electron")["ipcMain.handle"]("dotted-lookalike:ping", () => null); +let reassigned = ipc; +reassigned = fakeIpc; +reassigned.handle("reassigned:ping", () => null); +`, + preload: String.raw` +const { contextBridge, ipcRenderer } = require("electron"); +const bridge = contextBridge; +const renderer = ipcRenderer; +bridge.exposeInMainWorld("api", { + ping: (payload) => renderer.invoke("alias:ping", payload), +}); +function shadow(contextBridge, ipcRenderer) { + contextBridge.exposeInMainWorld("shadowed", {}); + ipcRenderer.invoke("shadowed:ping"); +} +const fakeBridge = { exposeInMainWorld() {} }; +fakeBridge.exposeInMainWorld("lookalike", {}); +`, +} as const; + +export const writeElectronApiAliasApplication = async ( + root: string, +): Promise => { + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-electron-api-alias-fixture", + version: "1.0.0", + main: "main.cjs", + }), + ), + writeFile(join(root, "main.cjs"), electronApiAliasSources.main), + writeFile(join(root, "preload.cjs"), electronApiAliasSources.preload), + ]); +};