fix(javascript): admit Electron script loaders only on renderer evidence

The previous commit dropped a module package type whenever a source
required an Electron module. A Node-loaded ES module that requires
Electron, or one whose Electron call goes through a local `require`,
then regained the false require edges the package scope removes.

The parser applies the package type unconditionally again. The worker
re-reads a package-module script that requires an Electron module once
outside the package scope. The source keeps that context only when the
static pass there directly observes a contextBridge exposure or a
renderer IPC operation, which only Electron's renderer-side loaders
provide.
This commit is contained in:
RRFZ committed 2026-10-29 19:05:37 -04:00
1 parent 5ad7f52a03
commit b54845a6b8
6 files changed
+165 -77

No files matched your search

@@ -1,6 +1,11 @@
import {
hasElectronRendererEvidence,
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";
@@ -31,7 +36,23 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async (
return null;
}
await callbacks.phase("static");
const facts = await completeJavaScriptAnalysisSteps(
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;
}
}
}
facts ??= await completeJavaScriptAnalysisSteps(
analyzeParsedJavaScriptStaticSourceSteps(file.text.value, parsed),
);
const resultBudgetBytes = await callbacks.checkpoint(facts);
@@ -0,0 +1,74 @@
import { expect, it } from "vitest";
import {
hasElectronRendererEvidence,
mayRunUnderElectronScriptLoader,
} from "./electronScriptLoaderEvidence.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const parse = (...input: Parameters<typeof parseJavaScriptSource>) => {
const parsed = parseJavaScriptSource(...input);
if (parsed === null) throw new Error("Expected parsed source");
return parsed;
};
it.each([
[
'const { ipcRenderer } = require("electron");',
"preload.js",
"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],
] as const)(
"selects package-module scripts that require Electron: %s (%s, %s)",
(source, path, packageType, expected) => {
expect(
mayRunUnderElectronScriptLoader(parse(source, path, packageType).program),
).toBe(expected);
},
);
it.each([
[
"a destructured contextBridge exposure",
'const { contextBridge } = require("electron");\ncontextBridge.exposeInMainWorld("api", {});',
true,
],
[
"a direct ipcRenderer send",
'require("electron").ipcRenderer.send("ready");',
true,
],
[
"a renderer IPC listener",
'const { ipcRenderer } = require("electron");\nipcRenderer.on("update", () => {});',
true,
],
[
"main-process IPC only",
'const { app, ipcMain } = require("electron");\nipcMain.handle("ping", () => app.getName());',
false,
],
["no Electron operation", 'require("electron");', false],
[
"a locally bound require",
'function load(require) {\n require("electron").contextBridge.exposeInMainWorld("api", {});\n}',
false,
],
] as const)(
"reads renderer-side Electron evidence from %s",
(_name, source, expected) => {
const parsed = parse(source, "preload.js");
const { electron } = analyzeParsedJavaScriptStaticSource(source, parsed);
expect(hasElectronRendererEvidence(electron)).toBe(expected);
},
);
@@ -0,0 +1,54 @@
import * as t from "@babel/types";
import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js";
import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { javaScriptRuntimeContext } from "./javascriptSourceParser.js";
/**
* Whether a script owes its module context only to a `"type": "module"`
* package scope and requires an Electron module. Electron's preload loader
* ignores that package type and gives a .js preload `require`; ESM preloads
* 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.
*/
export const mayRunUnderElectronScriptLoader = (
program: t.Program,
): boolean => {
if (
javaScriptRuntimeContext(program) !== "module" ||
program.sourceType !== "script"
)
return false;
let found = false;
traverseJavaScriptAst(program, {
skip: () => found,
enter: (node) => {
found ||=
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "require" }) &&
t.isStringLiteral(node.arguments[0]) &&
ELECTRON_MODULE.test(node.arguments[0].value);
},
});
return found;
};
/**
* `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.
*/
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,
);
@@ -49,38 +49,6 @@ it.each([
},
);
it.each([
[
'const { ipcRenderer } = require("electron");',
"preload.js",
"module",
"unknown",
],
[
'require("electron/renderer").ipcRenderer.send("ready");',
"preload.js",
"module",
"unknown",
],
['void (() => require("electron"));', "preload.ts", "module", "unknown"],
['const dependency = require("./dep.cjs");', "main.js", "module", "module"],
['const store = require("electron-store");', "main.js", "module", "module"],
['import "./setup.js"; require("electron");', "main.js", "module", "module"],
[
'const { ipcRenderer } = require("electron");',
"preload.js",
"commonjs",
"commonjs",
],
] as const)(
"applies an observed package type unless Electron's script loaders apply: %s (%s, %s)",
(source, path, packageType, context) => {
const parsed = parseJavaScriptSource(source, path, packageType);
if (parsed === null) throw new Error("Expected parsed source");
expect(javaScriptRuntimeContext(parsed.program)).toBe(context);
},
);
it.each([
["const args=[]; export = args;", "fixture.ts", "sloppy"],
['"use strict"; const args=[]; export = args;', "fixture.ts", "strict"],
@@ -8,8 +8,6 @@ import {
isErasedJavaScriptDeclaration,
semanticImportEqualsSpecifier,
} from "./javascriptModuleDeclaration.js";
import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
/** Babel AST produced by REA's inert JavaScript parser boundary. */
export type ParsedJavaScriptSource = ReturnType<typeof parse>;
@@ -72,43 +70,13 @@ const hasRuntimeModuleDeclaration = (program: t.Program): boolean =>
);
});
/**
* Electron's preload loader ignores a package's `"type": "module"` and gives
* the script `require`; ESM preloads need the .mjs suffix ("ES Modules (ESM)
* in Electron"). A renderer with Node integration also provides `require`.
* The main process loads such a .js source as an ES module, where `require`
* is undefined. A call that requires an Electron module therefore indicates
* one of Electron's script loaders, which the package type does not select.
*/
const requiresElectronModule = (program: t.Program): boolean => {
let found = false;
traverseJavaScriptAst(program, {
skip: () => found,
enter: (node) => {
found ||=
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "require" }) &&
t.isStringLiteral(node.arguments[0]) &&
ELECTRON_MODULE.test(node.arguments[0].value);
},
});
return found;
};
const parseWithRuntimeContext = (
source: string,
sourcePath: string | undefined,
options: ParserOptions,
observedPackageContext?: JavaScriptPackageType,
electronScriptLoaders = false,
packageContext?: JavaScriptPackageType,
): ParsedJavaScriptSource => {
const file = parse(source, options);
const packageContext =
electronScriptLoaders &&
observedPackageContext === "module" &&
requiresElectronModule(file.program)
? undefined
: observedPackageContext;
runtimeContextByProgram.set(
file.program,
/\.c[jt]s$/iu.test(sourcePath ?? "")
@@ -287,9 +255,7 @@ export const parseRecoveredJavaScript = (
/**
* Parse inert JavaScript or TypeScript facts without attaching comments to AST
* nodes. An observed package type selects the runtime context of .js/.ts
* sources as it does for reference imports, except that a module type does
* not apply to a source that requires an Electron module; syntax recovery is
* unchanged.
* sources as it does for reference imports; syntax recovery is unchanged.
*/
export const parseJavaScriptSource = (
source: string,
@@ -320,7 +286,6 @@ export const parseJavaScriptSource = (
allowNewTargetOutsideFunction: commonjs,
},
packageContext,
true,
);
// Unambiguous mode retains module-only diagnostics after choosing a script.
// The .cjs extension establishes wrapper mode, so obtain its own diagnostics.
@@ -335,7 +300,6 @@ export const parseJavaScriptSource = (
sourceType: "commonjs",
},
packageContext,
true,
)
: file;
} catch (cause: unknown) {
@@ -365,10 +365,14 @@ cliTest(
const source = (dependency: string) =>
`try { console.log(require(${JSON.stringify(dependency)})); } catch (error) { console.log(error.name); }\n`;
// The nearest package.json owns each .js file's scope (Node packages.md,
// "Determining module system").
// "Determining module system"). Requiring Electron, directly or through a
// local loader, does not give a Node-loaded module a require.
await writeFixtureFiles(root, {
"package.json": JSON.stringify({ type: "module" }),
"main.js": source("./dep.cjs"),
"electron-main.js":
'try { require("electron"); console.log(require("./dep.cjs")); } catch (error) { console.log(error.name); }\n',
"shadowed.js": `function load(require) { return require("electron").contextBridge; }\n${source("./dep.cjs")}`,
"dep.cjs": "module.exports = 1;\n",
"legacy/package.json": JSON.stringify({ type: "commonjs" }),
"legacy/main.js": source("../dep.cjs"),
@@ -377,6 +381,8 @@ cliTest(
});
for (const [path, expected] of [
["main.js", "ReferenceError"],
["electron-main.js", "ReferenceError"],
["shadowed.js", "ReferenceError"],
["legacy/main.js", "1"],
["untyped/main.js", "1"],
] as const) {
@@ -396,11 +402,12 @@ cliTest(
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
expect(sourceRequireFacts(graph, "main.js")).toEqual({
module_format: "unknown",
import_relationships: 0,
require_targets: [],
});
for (const path of ["main.js", "electron-main.js", "shadowed.js"])
expect(sourceRequireFacts(graph, path), path).toEqual({
module_format: "unknown",
import_relationships: 0,
require_targets: [],
});
for (const path of ["legacy/main.js", "untyped/main.js"])
expect(sourceRequireFacts(graph, path), path).toEqual({
module_format: "commonjs",