fix(javascript): check Electron renderer evidence without a second static pass

b54845a6b ran the full static pass on an unscoped parse of every
package-module script that requires Electron, then discarded it when no
renderer operation turned up, so such main-process sources paid for two
full passes.

A candidate must now also name contextBridge or ipcRenderer, which any
renderer evidence needs. The evidence check runs only the static pass's
binding and Electron identity preamble with its exposure and IPC
descriptors. The full static pass runs once, on the chosen parse.
This commit is contained in:
RRFZ committed 2026-10-29 20:45:01 -04:00
1 parent b54845a6b8
commit 4a86bca9b0
4 files changed
+132 -46

No files matched your search

@@ -1,11 +1,10 @@
import {
hasElectronRendererEvidence,
electronRendererEvidenceSteps,
mayRunUnderElectronScriptLoader,
} from "../domain/javascript/electronScriptLoaderEvidence.js";
import { moduleResolutionModeForJavaScriptReference } from "../domain/javascript/javascriptModuleResolutionMode.js";
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
import { analyzeParsedJavaScriptStaticSourceSteps } from "../domain/javascript/javascriptStaticAnalysis.js";
import type { JavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisTypes.js";
import { analyzeParsedJavaScriptSemanticsSteps } from "../domain/javascript/javascriptSemanticAnalysis.js";
import { failedJavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisHelpers.js";
import { projectJavaScriptModuleSemantics } from "../domain/javascript/javascriptModuleSemanticIr.js";
@@ -36,23 +35,20 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async (
return null;
}
await callbacks.phase("static");
let facts: JavaScriptStaticAnalysis | undefined;
if (mayRunUnderElectronScriptLoader(parsed.program)) {
// Renderer-side evidence is read outside the package scope, where an
// unbound require keeps its Electron identity; with that evidence the
// whole source keeps that context, so its static and semantic facts agree.
const script = parseJavaScriptSource(file.text.value, file.path);
if (script !== null) {
const scriptFacts = await completeJavaScriptAnalysisSteps(
analyzeParsedJavaScriptStaticSourceSteps(file.text.value, script),
);
if (hasElectronRendererEvidence(scriptFacts.electron)) {
parsed = script;
facts = scriptFacts;
}
}
if (
script !== null &&
(await completeJavaScriptAnalysisSteps(
electronRendererEvidenceSteps(script.program),
))
)
parsed = script;
}
facts ??= await completeJavaScriptAnalysisSteps(
const facts = await completeJavaScriptAnalysisSteps(
analyzeParsedJavaScriptStaticSourceSteps(file.text.value, parsed),
);
const resultBudgetBytes = await callbacks.checkpoint(facts);
@@ -1,9 +1,10 @@
import { expect, it } from "vitest";
import {
hasElectronRendererEvidence,
electronRendererEvidenceSteps,
mayRunUnderElectronScriptLoader,
} from "./electronScriptLoaderEvidence.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
@@ -20,14 +21,37 @@ it.each([
"module",
true,
],
['require("electron/renderer");', "preload.ts", "module", true],
['void (() => require("electron"));', "preload.js", "module", true],
['const dependency = require("./dep.cjs");', "main.js", "module", false],
['const store = require("electron-store");', "main.js", "module", false],
['import "./setup.js"; require("electron");', "main.js", "module", false],
['require("electron");', "preload.mjs", "module", false],
['require("electron");', "preload.js", "commonjs", false],
['require("electron");', "preload.js", undefined, false],
['require("electron/renderer").contextBridge;', "preload.ts", "module", true],
[
'void (() => require("electron")["ipcRenderer"]);',
"preload.js",
"module",
true,
],
["require(`electron`)[`ipcRenderer`];", "preload.js", "module", true],
['require("electron");', "main.js", "module", false],
[
'const { app, BrowserWindow } = require("electron");',
"main.js",
"module",
false,
],
['const ipcRenderer = require("./dep.cjs");', "main.js", "module", false],
[
'const store = require("electron-store"); store.ipcRenderer;',
"main.js",
"module",
false,
],
[
'import "./setup.js"; require("electron").ipcRenderer;',
"main.js",
"module",
false,
],
['require("electron").ipcRenderer;', "preload.mjs", "module", false],
['require("electron").ipcRenderer;', "preload.js", "commonjs", false],
['require("electron").ipcRenderer;', "preload.js", undefined, false],
] as const)(
"selects package-module scripts that require Electron: %s (%s, %s)",
(source, path, packageType, expected) => {
@@ -48,6 +72,11 @@ it.each([
'require("electron").ipcRenderer.send("ready");',
true,
],
[
"a template-literal specifier",
'require(`electron`).ipcRenderer.send("ready");',
true,
],
[
"a renderer IPC listener",
'const { ipcRenderer } = require("electron");\nipcRenderer.on("update", () => {});',
@@ -68,7 +97,19 @@ it.each([
"reads renderer-side Electron evidence from %s",
(_name, source, expected) => {
const parsed = parse(source, "preload.js");
expect(
completeSemanticSteps(electronRendererEvidenceSteps(parsed.program)),
).toBe(expected);
// The focused check agrees with the full static pass's direct findings.
const { electron } = analyzeParsedJavaScriptStaticSource(source, parsed);
expect(hasElectronRendererEvidence(electron)).toBe(expected);
expect(
electron.context_bridge_apis.some(
({ resolution_basis }) => resolution_basis === undefined,
) ||
electron.ipc.some(
({ side, resolution_basis }) =>
side === "renderer" && resolution_basis === undefined,
),
).toBe(expected);
},
);
@@ -1,9 +1,20 @@
import * as t from "@babel/types";
import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js";
import { ipcDescriptor } from "./electronStaticAnalysisIpc.js";
import { electronCalleePath } from "./electronStaticAnalysisValues.js";
import { contextBridgeExposureArguments } from "./javascriptContextBridgeExposure.js";
import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
indexJavaScriptBindingsWithReceiversSteps,
indexJavaScriptElectronIdentitySteps,
} from "./javascriptSemanticAnalysis.js";
import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js";
import {
traverseJavaScriptAst,
traverseJavaScriptValueAstSteps,
} from "./javascriptSemanticTraversal.js";
import { javaScriptRuntimeContext } from "./javascriptSourceParser.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
/**
* Whether a script owes its module context only to a `"type": "module"`
@@ -12,7 +23,10 @@ import { javaScriptRuntimeContext } from "./javascriptSourceParser.js";
* need the .mjs suffix ("ES Modules (ESM) in Electron"). The call alone is not
* evidence of that loader: Node's ESM loader, which the main process uses,
* has no `require`, so the same call there throws. Module syntax would make
* the source a module under every loader, so only scripts qualify.
* the source a module under every loader, so only scripts qualify. The
* source must also name `contextBridge` or `ipcRenderer`: Electron identity
* reaches a member only through an identifier, string, or template key, so a
* source naming neither cannot yield {@link electronRendererEvidenceSteps}.
*/
export const mayRunUnderElectronScriptLoader = (
program: t.Program,
@@ -22,33 +36,68 @@ export const mayRunUnderElectronScriptLoader = (
program.sourceType !== "script"
)
return false;
let found = false;
let requiresElectron = false;
let namesRendererModule = false;
traverseJavaScriptAst(program, {
skip: () => found,
skip: () => requiresElectron && namesRendererModule,
enter: (node) => {
found ||=
requiresElectron ||=
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "require" }) &&
t.isStringLiteral(node.arguments[0]) &&
ELECTRON_MODULE.test(node.arguments[0].value);
ELECTRON_MODULE.test(stringValue(node.arguments[0]) ?? "");
namesRendererModule ||=
(t.isIdentifier(node) && rendererModules.has(node.name)) ||
(t.isStringLiteral(node) && rendererModules.has(node.value)) ||
(t.isTemplateElement(node) &&
rendererModules.has(node.value.cooked ?? ""));
},
});
return found;
return requiresElectron && namesRendererModule;
};
const rendererModules: ReadonlySet<string> = new Set([
"contextBridge",
"ipcRenderer",
]);
/**
* `contextBridge` and `ipcRenderer` are renderer-process Electron modules; the
* main process cannot obtain them. A directly observed exposure or renderer
* IPC operation therefore places its source under a renderer-side loader.
* Wrapper substitutions are inferred, so they do not count.
* Whether a source directly calls a `contextBridge` exposure or a renderer IPC
* operation. Both are renderer-process Electron modules that the main process
* cannot obtain, so such a call places the source under a renderer-side
* loader. The check runs only the static pass's binding and Electron identity
* preamble and its exposure and IPC descriptors, without the rest of that
* pass; wrapper substitutions are inferred, so they are not expanded.
*/
export const hasElectronRendererEvidence = (
electron: ElectronStaticFindings,
): boolean =>
electron.context_bridge_apis.some(
({ resolution_basis }) => resolution_basis === undefined,
) ||
electron.ipc.some(
({ side, resolution_basis }) =>
side === "renderer" && resolution_basis === undefined,
export function* electronRendererEvidenceSteps(
program: t.Program,
): Generator<void, boolean> {
const index = yield* indexJavaScriptBindingsWithReceiversSteps(program);
yield* index.indexAmbientWritesSteps();
if (hasPossibleCommonJsDelegateWrite(index.bindings))
yield* index.indexMemberMutationFactsSteps();
const identity = yield* indexJavaScriptElectronIdentitySteps(
program,
index.bindings,
);
const context = { electronBindings: identity.bindings };
let found = false;
yield* traverseJavaScriptValueAstSteps(
program,
index.bindings.erasedSyntaxNodes,
{
skip: () => found,
enter: (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
const path = electronCalleePath(node.callee, context);
found ||=
(path?.length === 2 &&
path[0] === "contextBridge" &&
contextBridgeExposureArguments(path[1] ?? "", node.arguments) !==
undefined) ||
ipcDescriptor(path)?.side === "renderer";
},
},
);
return found;
}
@@ -13,7 +13,7 @@ import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.j
/** Resolve a callee only when its root has a proven Electron module identity. */
export const electronCalleePath = (
callee: t.Node,
context: JavaScriptFindingContext,
context: Pick<JavaScriptFindingContext, "electronBindings">,
): readonly string[] | undefined => {
const { root, members } = electronMemberPath(callee);
if (!members.every((member): member is string => member !== null))