From 2612e474b6739d3753991ba470c9684c1a20fe1b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:27:46 +0800 Subject: [PATCH] fix(javascript): recover Electron API identity through aliases Reuse semantic module provenance for aliases and keep property path segments intact through Electron matching. Reject shadowed names and nested or dotted lookalikes. Reassigned and conditionally initialized module origins remain ambiguous, with their source origins retained. Add a compiled CLI/stdio MCP regression for windows, preloads, context bridges, IPC operations, pairing, and source ranges. Validate 771 JavaScript domain tests, seven composition tests, the public acceptance workflow, build, and check:fast. Fixes #1656 --- .../electronStaticAnalysisBrowser.ts | 24 +++---- .../javascript/electronStaticAnalysisIpc.ts | 30 +++----- .../electronStaticAnalysisOptions.test.ts | 3 + .../electronStaticAnalysisValues.ts | 19 ++--- ...avascriptFeatureSeed.array_members.test.ts | 2 +- ...vascriptSemanticAnalysis.structure.test.ts | 12 ++-- .../javascript/javascriptSemanticAnalysis.ts | 69 ++++++++++++------- .../javascriptSemanticAnalysisSteps.test.ts | 67 +++++++++++++++++- .../javascript/javascriptSemanticValues.ts | 24 +++++++ ...riptStaticAnalysis.no_finding_caps.test.ts | 3 +- .../javascriptStaticAnalysisState.ts | 4 +- .../javascriptStaticPathResolution.test.ts | 2 +- .../applicationWorkflowCli.test.ts | 56 ++++++++++++++- tests/fixtures/electronApiAliasApplication.ts | 57 +++++++++++++++ 14 files changed, 296 insertions(+), 76 deletions(-) create mode 100644 tests/fixtures/electronApiAliasApplication.ts diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index a44baf89a..7657614ae 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 a560ea184..e30786881 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 e133e4dfe..0fc044696 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 c2bb6e76a..1fb8d366d 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 00bddb649..9cab84239 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 ba0644056..cd3b85932 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 f0b299b1c..c73f22da1 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 506ab7a54..0c5b4b12d 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 d001f8a86..2cc4ece92 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 fb182a742..acfbaa755 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 06a60a0bd..93b27bf1c 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 4d3662ce6..e54122440 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 5df8813ea..b6b402bc4 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 000000000..6399e8d65 --- /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), + ]); +};