mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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`,
|
||||
"` } });",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number, string> =>
|
||||
): ReadonlyMap<number, readonly string[]> =>
|
||||
completeSemanticSteps(classifyParsedJavaScriptElectronBindingsSteps(file));
|
||||
|
||||
/** Resolve Electron aliases while yielding during lexical and AST scans. */
|
||||
export function* classifyParsedJavaScriptElectronBindingsSteps(
|
||||
file: ParsedJavaScriptSource,
|
||||
): Generator<void, ReadonlyMap<number, string>> {
|
||||
const facts = new Map<number, string>();
|
||||
): Generator<void, ReadonlyMap<number, readonly string[]>> {
|
||||
const facts = new Map<number, readonly string[]>();
|
||||
const state = createState(file.program);
|
||||
yield* collectDefinitionsSteps(file.program, state);
|
||||
const exportsByBinding = new Map<string, string | null>();
|
||||
const exportsByBinding = new Map<string, readonly string[] | null>();
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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"]'), []],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<number, string>;
|
||||
/** Proven Electron export path segments by root offset. */
|
||||
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
|
||||
}
|
||||
|
||||
/** Candidate import, require, worker, or service-worker reference. */
|
||||
|
||||
@@ -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};`,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+57
@@ -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<void> => {
|
||||
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),
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user