From 29cc1b8746608a9173def10801ad080953d5cf6b Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:25:32 +0800 Subject: [PATCH] fix(javascript): exclude overwritten Electron API identities (#1664) * fix(javascript): exclude overwritten Electron API identities * test(javascript): bind Electron in file-base path fixture * test(electron): bind BrowserWindow in preload override fixtures * test(mcp): allow cold catalog validation in runtime probe * test(electron): bind APIs in option reconstruction fixtures --- docs/javascript-artifact-reconstruction.md | 8 + .../JavaScriptArtifactGraphBuilder.ts | 6 + .../JavaScriptArtifactPathResolution.test.ts | 1 + src/domain/javascript/javascriptAstValues.ts | 25 ++ .../javascriptElectronMemberWrites.ts | 61 +++++ .../javascript/javascriptSemanticAnalysis.ts | 56 ++-- .../javascriptSemanticMemberMutations.ts | 19 +- .../javascriptSemanticPropertyPaths.ts | 40 +-- .../javascript/javascriptStaticAnalysis.ts | 10 + .../applicationWorkflowCli.test.ts | 250 ++++++++++++++++++ .../electronPreloadOverridesCli.test.ts | 5 +- tests/acceptance/applications/runtime.test.ts | 5 +- .../electronOptionKeys.test.ts | 10 +- 13 files changed, 434 insertions(+), 62 deletions(-) create mode 100644 src/domain/javascript/javascriptElectronMemberWrites.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index bd3b3a8f5..6446432d9 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -270,6 +270,14 @@ 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. Reassigned bindings, +dynamic selections, and visibly overwritten namespace members are unresolved. +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 +not evaluate arbitrary call side effects or runtime registration. Missing findings +do not establish that an application has no Electron boundary usage. + IPC pairing is an inference, not an observation. A renderer `invoke` pairs only with one unique `ipcMain.handle`/`handleOnce` candidate on the same exact literal channel; a renderer send pairs only with one unique `ipcMain.on`/`once` diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index b10b92deb..d2227c7b4 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,3 +1,4 @@ +import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { createJavaScriptApplicationGraph, @@ -255,6 +256,11 @@ const graphLimitations = ( "CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.", "Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code.", "Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.", + ...(context.analysis.files.some(({ javascript }) => + javascript?.limitations.includes(ELECTRON_IDENTITY_LIMITATION), + ) + ? [ELECTRON_IDENTITY_LIMITATION] + : []), ...(electronFindings === 0 ? [] : [ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 6eef8ced7..c83534ed9 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -1015,6 +1015,7 @@ describe("contextual JavaScript module identity", () => { it("retains CommonJS and ESM file-base identity during inert extraction", () => { const analysis = analyzeJavaScriptStaticSource( ` + import { BrowserWindow } from "electron"; new BrowserWindow({ webPreferences: { preload: path.resolve("ignored", __dirname, "preload.cjs") }}); diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 5479c8d1f..f89eabb05 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -86,6 +86,31 @@ export const semanticObjectPatternKeys = ( return key === null ? [] : [key]; }); +/** Visit member write targets in direct and destructuring assignment forms. */ +export function* semanticMemberWriteTargets( + root: t.Node, +): 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)) + yield 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.toReversed()) + if (element !== null) pending.push(element); + } else if (t.isObjectPattern(node)) { + for (const property of node.properties.toReversed()) + pending.push( + t.isRestElement(property) ? property.argument : property.value, + ); + } + } +} + /** * Display an exact JavaScript string as nonempty label text. Graph labels and * artifact-local keys are nonempty, so the legal empty value is shown as `""`; diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts new file mode 100644 index 000000000..598133791 --- /dev/null +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -0,0 +1,61 @@ +import * as t from "@babel/types"; + +import { + semanticStaticPropertyKey, + semanticMemberWriteTargets, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; +import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; + +/** Match Electron's supported module entry points. */ +export const ELECTRON_MODULE = + /^electron(?:\/(?:common|main|renderer|utility))?$/u; + +/** Static identity recovery cannot establish the absence of Electron usage. */ +export const ELECTRON_IDENTITY_LIMITATION = + "Electron API identity follows proven lexical module aliases. 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."; + +/** Preserve exact property segments instead of flattening literal dotted keys. */ +export const electronMemberPath = ( + node: t.Node, +): { + readonly root: t.Node; + readonly members: readonly (string | null)[]; +} => { + let root = unwrapJavaScriptExpression(node).node; + const members: (string | null)[] = []; + while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) { + members.push(semanticStaticPropertyKey(root.property, root.computed)); + root = unwrapJavaScriptExpression(root.object).node; + } + return { root, members: members.toReversed() }; +}; + +/** Index visible writes through proven Electron aliases, without executing them. */ +export function* collectElectronMemberWritesSteps( + program: t.Program, + origin: (root: t.Node) => readonly string[] | undefined, +): Generator { + const writes = new SemanticPropertyPathCoverage(); + yield* traverseJavaScriptAstSteps(program, { + enter: (node) => { + const target = t.isAssignmentExpression(node) + ? node.left + : t.isUpdateExpression(node) + ? node.argument + : t.isUnaryExpression(node, { operator: "delete" }) + ? node.argument + : t.isForOfStatement(node) || t.isForInStatement(node) + ? 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]); + } + }, + }); + return writes; +} diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index c73f22da1..82f7e6244 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -44,6 +44,11 @@ import { } from "./javascriptSemanticReturns.js"; import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js"; import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js"; +import { + collectElectronMemberWritesSteps, + electronMemberPath, + ELECTRON_MODULE, +} from "./javascriptElectronMemberWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName } from "./javascriptAstValues.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; @@ -311,8 +316,6 @@ const resolveGlobalAliasFact = ( return openGlobalFact(name); }; -const ELECTRON_MODULE = /^electron(?:\/(?:common|main|renderer|utility))?$/u; - /** * Proven Electron export path segments for call and construction roots, keyed by * root offset. Only proven Electron origins are present; callers must not @@ -331,6 +334,27 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( const state = createState(file.program); yield* collectDefinitionsSteps(file.program, state); const exportsByBinding = new Map(); + const rootExport = (root: t.Node): readonly string[] | undefined => { + if (t.isIdentifier(root)) { + if (semanticResolutionBlocked(state, root, root.name)) return undefined; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) return undefined; + let exported = exportsByBinding.get(binding.bindingId); + if (exported === undefined) { + exported = electronExport(binding, state); + exportsByBinding.set(binding.bindingId, exported); + } + return exported ?? undefined; + } + const origin = semanticRequireOrigin(root, state); + return origin !== undefined && ELECTRON_MODULE.test(origin.specifier) + ? origin.importedPath + : undefined; + }; + const writes = yield* collectElectronMemberWritesSteps( + file.program, + rootExport, + ); yield* traverseJavaScriptAstSteps(file.program, { enter: (node) => { if ( @@ -339,35 +363,15 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( !t.isNewExpression(node) ) return; - const root = calleeRoot(node.callee); - if (root === undefined) return; - 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; - } - const origin = semanticRequireOrigin(root, state); - if (origin !== undefined && ELECTRON_MODULE.test(origin.specifier)) - facts.set(root.start ?? -1, origin.importedPath); + const { root, members } = electronMemberPath(node.callee); + const exported = rootExport(root); + if (exported !== undefined && !writes.covers([...exported, ...members])) + facts.set(root.start ?? -1, exported); }, }); return facts; } -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 node; -}; - // Read import and require origins directly; general value evaluation is // unnecessary here and costly across large vendor bundles. const electronExport = ( diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 245184e67..980ff197c 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -28,6 +28,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticStaticPropertyKey, + semanticMemberWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -356,26 +357,14 @@ export function* collectSemanticMemberMutationsSteps( markEscaped(expression.object, mutation); }; const markTarget = (node: t.Node, mutation: t.Node): void => { - if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + for (const target of semanticMemberWriteTargets(node)) markValue( - node.object, - [semanticStaticPropertyKey(node.property, node.computed)], + target.object, + [semanticStaticPropertyKey(target.property, target.computed)], new Set(), "write", mutation, ); - else if (t.isRestElement(node)) markTarget(node.argument, mutation); - else if (t.isAssignmentPattern(node)) markTarget(node.left, mutation); - else if (t.isArrayPattern(node)) { - for (const element of node.elements) - if (element !== null) markTarget(element, mutation); - } else if (t.isObjectPattern(node)) { - for (const property of node.properties) - markTarget( - t.isRestElement(property) ? property.argument : property.value, - mutation, - ); - } }; yield* traverseJavaScriptAstSteps(program, { enter: (node, parent) => { diff --git a/src/domain/javascript/javascriptSemanticPropertyPaths.ts b/src/domain/javascript/javascriptSemanticPropertyPaths.ts index 95cbeef97..6d1fe6148 100644 --- a/src/domain/javascript/javascriptSemanticPropertyPaths.ts +++ b/src/domain/javascript/javascriptSemanticPropertyPaths.ts @@ -60,23 +60,7 @@ export class SemanticPropertyPathCoverage { /** Retain a path only when no previously retained prefix covers it. */ retain(path: JavaScriptSemanticPropertyPath): boolean { - const pending = [{ node: this.#root, offset: 0 }]; - while (pending.length > 0) { - const current = pending.pop(); - if (current === undefined) break; - if (current.node.terminal) return false; - const key = path[current.offset]; - if (key === undefined) continue; - if (key !== null && typeof key !== "object") { - const exact = current.node.exact.get(String(key)); - if (exact !== undefined) - pending.push({ node: exact, offset: current.offset + 1 }); - } - for (const selected of current.node.selectors.values()) { - if (keyCovers(selected.key, key)) - pending.push({ node: selected.node, offset: current.offset + 1 }); - } - } + if (this.covers(path)) return false; let node = this.#root; for (const key of path) { if (key !== null && typeof key !== "object") { @@ -102,6 +86,28 @@ export class SemanticPropertyPathCoverage { node.selectors.clear(); return true; } + + /** Query retained prefixes without changing the coverage index. */ + covers(path: JavaScriptSemanticPropertyPath): boolean { + const pending = [{ node: this.#root, offset: 0 }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + if (current.node.terminal) return true; + const key = path[current.offset]; + if (key === undefined) continue; + if (key !== null && typeof key !== "object") { + const exact = current.node.exact.get(String(key)); + if (exact !== undefined) + pending.push({ node: exact, offset: current.offset + 1 }); + } + for (const selected of current.node.selectors.values()) { + if (keyCovers(selected.key, key)) + pending.push({ node: selected.node, offset: current.offset + 1 }); + } + } + return false; + } } const keyCovers = ( diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 0354e5355..8d306af25 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -1,4 +1,8 @@ import * as t from "@babel/types"; +import { + ELECTRON_IDENTITY_LIMITATION, + ELECTRON_MODULE, +} from "./javascriptElectronMemberWrites.js"; import { completeSemanticSteps, @@ -114,6 +118,12 @@ const finalizeStaticAnalysis = ( : [ "One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.", ]), + ...(accumulator.references.some( + ({ value }) => + value.specifier !== null && ELECTRON_MODULE.test(value.specifier), + ) + ? [ELECTRON_IDENTITY_LIMITATION] + : []), "JavaScript syntax was parsed as data and was never evaluated.", ]; return { diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index b6b402bc4..30c5b81f3 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -24,6 +24,8 @@ import { import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { z } from "zod"; +import { ELECTRON_IDENTITY_LIMITATION } from "../../../src/domain/javascript/javascriptElectronMemberWrites.js"; + const execute = promisify(execFile); const requireFixture = createRequire(import.meta.url); const temporary: string[] = []; @@ -201,6 +203,254 @@ const analyzeThroughStdioMcp = async ( } }; +describe("Electron alias identity CLI and MCP", () => { + it.each([ + { + name: "CommonJS binding chains", + esm: false, + main: 'const { BrowserWindow, ipcMain } = require("electron"); const First = BrowserWindow; const Win = First; const ipc = ipcMain;', + preload: + 'const { contextBridge, ipcRenderer } = require("electron"); const First = contextBridge; const bridge = First; const ipc = ipcRenderer;', + }, + { + name: "static namespace members", + esm: false, + main: 'const electron = require("electron"); const copied = electron; copied.unrelated = {}; const Win = copied["BrowserWindow"]; const ipc = copied.ipcMain;', + preload: + 'const electron = require("electron"); const copied = electron; const bridge = copied.contextBridge; const ipc = copied["ipcRenderer"];', + }, + { + name: "namespace destructuring", + esm: false, + main: 'const electron = require("electron"); const { BrowserWindow: Win, ipcMain: ipc } = electron;', + preload: + 'const electron = require("electron"); const { contextBridge: bridge, ipcRenderer: ipc } = electron;', + }, + { + name: "unwritten let and var bindings", + esm: false, + main: 'const { BrowserWindow, ipcMain } = require("electron"); let Win = BrowserWindow; var ipc = ipcMain;', + preload: + 'const { contextBridge, ipcRenderer } = require("electron"); var bridge = contextBridge; let ipc = ipcRenderer;', + }, + { + name: "ESM binding aliases", + esm: true, + main: 'import { BrowserWindow, ipcMain } from "electron"; const Win = BrowserWindow; const ipc = ipcMain;', + preload: + 'import { contextBridge, ipcRenderer } from "electron"; const bridge = contextBridge; const ipc = ipcRenderer;', + }, + ])( + "recovers Electron boundaries through $name in CLI and MCP (#1656)", + async ({ main, preload, esm }) => { + const root = await createTestTempDirectory("rea-electron-alias-cli-"); + temporary.push(root); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "alias-repro", + version: "1.0.0", + main: "main.js", + type: esm ? "module" : "commonjs", + }), + ), + writeFile( + join(root, "main.js"), + `${main}\n${esm ? 'import { fileURLToPath } from "node:url";' : 'const path = require("node:path");'}\nnew Win({ webPreferences: { preload: ${esm ? 'fileURLToPath(new URL("./preload.js", import.meta.url))' : 'path.join(__dirname, "preload.js")'} } });\nipc.handle("ch:ping", async () => "pong");\nfunction shadow(Win, ipc) { new Win(); ipc.handle("ch:decoy", () => null); }\n`, + ), + writeFile( + join(root, "preload.js"), + `${preload}\nbridge.exposeInMainWorld("api", { ping: () => ipc.invoke("ch:ping") });\nfunction shadow(bridge, ipc) { bridge.exposeInMainWorld("decoy", {}); ipc.invoke("ch:decoy"); }\n`, + ), + ]); + const cli = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse( + await runCli(["analyze-javascript-application", root, "--json"]), + ).normalized_result; + const mcp = await analyzeThroughStdioMcp(root); + expect(cli.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, + }, + }); + expect(mcp.summary).toEqual(cli.summary); + for (const result of [cli, mcp]) { + expect(result.limitations).toContain(ELECTRON_IDENTITY_LIMITATION); + expect(result.graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "ipc-channel", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + channel: "ch:ping", + operation: "handle", + }), + evidence: expect.objectContaining({ + authority: "ast-static-analysis", + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + kind: "source-range", + source: "main.js", + }), + }), + }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ + channel: "ch:ping", + operation: "invoke", + }), + evidence: expect.objectContaining({ + authority: "ast-static-analysis", + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + kind: "source-range", + source: "preload.js", + }), + }), + }), + }), + ]), + }), + ]), + ); + expect(result.graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ relation: "handles" }), + expect.objectContaining({ relation: "invokes" }), + expect.objectContaining({ relation: "loads" }), + expect.objectContaining({ relation: "exposes" }), + ]), + ); + } + }, + ); +}); + +describe("Electron unavailable identity CLI and MCP", () => { + it.each([ + { + name: "reassigned aliases", + main: 'const { BrowserWindow, ipcMain } = require("electron"); let Win = BrowserWindow; let ipc = ipcMain; Win = class {}; ipc = { handle() {} };', + preload: + 'const { contextBridge, ipcRenderer } = require("electron"); let bridge = contextBridge; let ipc = ipcRenderer; bridge = { exposeInMainWorld() {} }; ipc = { invoke() {} };', + }, + { + name: "conditional initializers", + main: 'const { BrowserWindow, ipcMain } = require("electron"); if (enabled) { var Win = BrowserWindow; var ipc = ipcMain; }', + preload: + 'const { contextBridge, ipcRenderer } = require("electron"); if (enabled) { var bridge = contextBridge; var ipc = ipcRenderer; }', + }, + { + name: "dynamic property selections", + main: 'const electron = require("electron"); const Win = electron[windowKey]; const ipc = electron[ipcKey];', + preload: + 'const electron = require("electron"); const bridge = electron[bridgeKey]; const ipc = electron[ipcKey];', + }, + { + name: "overwritten namespace exports", + main: 'const electron = require("electron"); const copied = electron; copied.BrowserWindow = class {}; electron.ipcMain = { handle() {} }; const Win = electron.BrowserWindow; const ipc = copied.ipcMain;', + preload: + 'const electron = require("electron"); electron.contextBridge = { exposeInMainWorld() {} }; electron.ipcRenderer = { invoke() {} }; const bridge = electron.contextBridge; const ipc = electron.ipcRenderer;', + }, + { + name: "overwritten API methods", + main: 'const electron = require("electron"); const ipc = electron.ipcMain; ipc.handle = () => null; const Win = electron[windowKey];', + preload: + 'const electron = require("electron"); const bridge = electron.contextBridge; delete bridge.exposeInMainWorld; const ipc = electron.ipcRenderer; ipc.invoke++;', + }, + { + name: "direct require namespace writes", + main: 'require("electron").BrowserWindow = class {}; require("electron").ipcMain = {handle() {}}; const {BrowserWindow: Win, ipcMain: ipc} = require("electron");', + preload: + 'require("electron").contextBridge = {}; require("electron").ipcRenderer = {}; const {contextBridge: bridge, ipcRenderer: ipc} = require("electron");', + }, + { + name: "unknown namespace write keys", + main: 'const electron = require("electron"); electron[unknownKey] = {}; const {BrowserWindow: Win, ipcMain: ipc} = electron;', + preload: + 'const electron = require("electron"); electron[unknownKey] = {}; const {contextBridge: bridge, ipcRenderer: ipc} = electron;', + }, + { + name: "destructuring assignment targets", + main: 'const electron = require("electron"); ({ BrowserWindow: electron.BrowserWindow, ipcMain: electron.ipcMain } = replacement); const {BrowserWindow: Win, ipcMain: ipc} = electron;', + preload: + 'const electron = require("electron"); [electron.contextBridge, electron.ipcRenderer] = replacements; const {contextBridge: bridge, ipcRenderer: ipc} = electron;', + }, + { + name: "loop assignment targets", + main: 'const electron = require("electron"); for ({ BrowserWindow: electron.BrowserWindow, ipcMain: electron.ipcMain } of replacements) {} const {BrowserWindow: Win, ipcMain: ipc} = electron;', + preload: + 'const electron = require("electron"); for (electron.contextBridge in replacements) {} for (electron.ipcRenderer of replacements) {} const {contextBridge: bridge, ipcRenderer: ipc} = electron;', + }, + { + name: "literal keys containing dots", + main: 'const electron = require("electron"); const Win = electron["fake.BrowserWindow"]; const ipc = electron["fake.ipcMain"]; electron["ipcMain.handle"]("ch:literal", () => null);', + preload: + 'const electron = require("electron"); const bridge = electron["fake.contextBridge"]; const ipc = electron["fake.ipcRenderer"];', + }, + ])( + "keeps Electron identity unresolved for $name in CLI and MCP (#1656)", + async ({ main, preload }) => { + const root = await createTestTempDirectory("rea-electron-alias-unknown-"); + temporary.push(root); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "alias-unknown", + version: "1.0.0", + main: "main.js", + }), + ), + writeFile( + join(root, "main.js"), + `${main}\nnew Win(); ipc.handle("ch:ping", () => "pong");\n`, + ), + writeFile( + join(root, "preload.js"), + `${preload}\nbridge.exposeInMainWorld("api", { ping: () => ipc.invoke("ch:ping") });\n`, + ), + ]); + const cli = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse( + await runCli(["analyze-javascript-application", root, "--json"]), + ).normalized_result; + const mcp = await analyzeThroughStdioMcp(root); + for (const result of [cli, mcp]) { + expect(result.summary).toMatchObject({ + browser_windows: 0, + context_bridge_apis: 0, + ipc: { operations: 0 }, + }); + expect( + result.graph.nodes.some(({ kind }) => kind === "ipc-channel"), + ).toBe(false); + expect(result.limitations).toContain(ELECTRON_IDENTITY_LIMITATION); + } + }, + ); +}); + describe("JavaScript application path CLI", () => { it("accepts a relative local application path and preserves canonical Evidence identity", async () => { const root = await createTestTempDirectory("rea-relative-application-cli-"); diff --git a/tests/acceptance/applications/electronPreloadOverridesCli.test.ts b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts index 7997a7d8c..9b0a3df2f 100644 --- a/tests/acceptance/applications/electronPreloadOverridesCli.test.ts +++ b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts @@ -67,7 +67,10 @@ cliTest.for(["CLI", "stdio MCP"] as const)( `new BrowserWindow({webPreferences:{${preferences}}});`, ); await Promise.all([ - writeFile(join(root, "main.js"), `${sourceLines.join("\n")}\n`), + writeFile( + join(root, "main.js"), + `${sourceLines.join("\n")}\nimport { BrowserWindow } from "electron";\n`, + ), writeFile(join(root, "first.js"), 'throw new Error("inert preload");\n'), writeFile(join(root, "last.js"), 'throw new Error("inert preload");\n'), ]); diff --git a/tests/acceptance/applications/runtime.test.ts b/tests/acceptance/applications/runtime.test.ts index bfa75b721..3c454258f 100644 --- a/tests/acceptance/applications/runtime.test.ts +++ b/tests/acceptance/applications/runtime.test.ts @@ -218,7 +218,10 @@ describe("production stdio runtime", () => { record.level >= 50, ); expect(fatal).toEqual([]); - }, 15_000); + // Full catalog discovery and first-call SDK schema compilation take about + // 8–9 seconds on one local CPU; the 15-second budget repeatedly expired in + // parallel CI shards. Keep a finite budget for the complete workflow. + }, 30_000); it.runIf(process.platform === "darwin")( "serves a database-kind initial target without a fatal record on macOS", diff --git a/tests/composition/javascript-applications/electronOptionKeys.test.ts b/tests/composition/javascript-applications/electronOptionKeys.test.ts index 16c1ce2cd..54cf4c11b 100644 --- a/tests/composition/javascript-applications/electronOptionKeys.test.ts +++ b/tests/composition/javascript-applications/electronOptionKeys.test.ts @@ -164,8 +164,14 @@ const windowPreloadNodes = (result: Reconstruction) => const reconstructSource = async (source: string): Promise => { const root = await createTestTempDirectory("rea-electron-option-keys-"); await Promise.all([ - writeFile(join(root, "package.json"), JSON.stringify({ main: "main.js" })), - writeFile(join(root, "main.js"), source), + writeFile( + join(root, "package.json"), + JSON.stringify({ main: "main.js", type: "module" }), + ), + writeFile( + join(root, "main.js"), + `${source}\nimport { BrowserWindow, utilityProcess } from "electron";\n`, + ), writeFile(join(root, "preload.js"), "void 0;"), writeFile(join(root, "worker.js"), "void 0;"), ]);