fix(electron): retain nested bridge facts through wrapper resolution

Merge direct same-call nested member facts into symbolic wrapper results using canonical deduplication and ordering. Preserve unknown spread coverage rather than losing known member paths.

Validation: bounded source cases and public CLI/stdio MCP spread/no-spread controls. The existing 125,000-member stress case exceeded the 384 MiB local cap and was stopped without increasing the limit; that capacity case remains unverified locally.

Fixes #3212
This commit is contained in:
morluto committed 2026-10-14 16:54:08 +08:00
1 parent 81f7638203
commit 0351328e54
3 files changed
+82 -1

No files matched your search

@@ -140,6 +140,40 @@ it("preserves an observed empty contextBridge API object", () => {
expect(result.parse_status).toBe("complete");
});
it.each([
[
"explicit nested paths after an unknown spread",
`{ ...extra, a: { b: 1 }, c: 2 }`,
["a", "a.b", "c"],
1,
"dynamic",
],
[
"a direct object literal",
`{ a: { b: 1 }, c: 2 }`,
["a", "a.b", "c"],
0,
"object-literal",
],
["a dynamic API argument", "getApi()", [], 1, "dynamic"],
] as const)(
"preserves directly observed nested bridge members for %s",
(_name, api, members, unknownMembers, apiStatus) => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge } = require("electron");
contextBridge.exposeInMainWorld("api", ${api});
`);
expect(result.electron.context_bridge_apis).toContainEqual(
expect.objectContaining({
api_key: "api",
api_status: apiStatus,
members,
unknown_members: unknownMembers,
}),
);
},
);
describe("wrapped contextBridge exposure methods", () => {
it("retains the original expression for a dynamic isolated world id", () => {
const result = analyzeJavaScriptStaticSource(
@@ -36,6 +36,7 @@ import {
symbolicDefiniteExit,
symbolicLogicalRight,
} from "./javascriptSymbolicControlFlow.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
const callLabel = (node: t.Node): string =>
t.isSequenceExpression(node)
@@ -410,7 +411,12 @@ export const collectElectronWrappedCallPaths = (
api_key_expression: null,
api_status: "dynamic" as const,
resolution_basis: "wrapper-substitution" as const,
members,
// The direct syntax pass recursively retains literal dotted paths. The
// symbolic resolver enumerates only immediate object keys, so its
// replacement must not erase paths already established at this call.
members: [
...new Set([...(existing?.value.members ?? []), ...members]),
].sort(compareUnicodeCodePoints),
unknown_members: resolver.unknownMembers(object),
module_key: null,
location,
@@ -308,6 +308,47 @@ cliTest(
);
for (const surface of ["CLI", "stdio MCP"] as const) {
cliTest(
`retains explicit nested contextBridge members after an unknown spread through ${surface}`,
async ({ cli }) => {
const analyzeBridge = async (api: string) => {
const root = await createTestTempDirectory(
"rea-electron-spread-bridge-members-",
);
await writeFixtureFiles(root, {
"preload.js": `const { contextBridge } = require("electron");
const extra = getExtra();
contextBridge.exposeInMainWorld("api", ${api});`,
});
return analyzeJavaScriptDirectory(surface, cli, root);
};
const withSpread = await analyzeBridge("{ ...extra, a: { b: 1 }, c: 2 }");
const control = await analyzeBridge("{ a: { b: 1 }, c: 2 }");
const bridgeObservation = (application: typeof withSpread) =>
application.graph.nodes
.filter((node) => node.kind === "context-bridge-api")
.flatMap((node) => node.observations)
.find((observation) => observation.properties.api_key === "api");
expect(bridgeObservation(withSpread)).toMatchObject({
properties: {
api_status: "dynamic",
members: ["a", "a.b", "c"],
unknown_members: 1,
},
});
expect(bridgeObservation(control)).toMatchObject({
properties: {
api_status: "object-literal",
members: ["a", "a.b", "c"],
unknown_members: 0,
},
});
expect(withSpread.summary.exposed_api_members).toBe(3);
expect(control.summary.exposed_api_members).toBe(3);
},
);
cliTest(
`preserves handler ambiguity and short-circuit factory paths through ${surface}`,
async ({ cli }) => {