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
This commit is contained in:
parent
ad41afa2b5
commit
29cc1b8746
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? []
|
||||
: [
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}});
|
||||
|
|
|
|||
|
|
@ -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<t.MemberExpression | t.OptionalMemberExpression> {
|
||||
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 `""`;
|
||||
|
|
|
|||
|
|
@ -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<void, SemanticPropertyPathCoverage> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<string, readonly string[] | null>();
|
||||
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 = (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -164,8 +164,14 @@ const windowPreloadNodes = (result: Reconstruction) =>
|
|||
const reconstructSource = async (source: string): Promise<Reconstruction> => {
|
||||
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;"),
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue