diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 72f4ed4e70..097d93458d 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -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); diff --git a/src/domain/javascript/electronScriptLoaderEvidence.test.ts b/src/domain/javascript/electronScriptLoaderEvidence.test.ts new file mode 100644 index 0000000000..02680254e0 --- /dev/null +++ b/src/domain/javascript/electronScriptLoaderEvidence.test.ts @@ -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) => { + 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); + }, +); diff --git a/src/domain/javascript/electronScriptLoaderEvidence.ts b/src/domain/javascript/electronScriptLoaderEvidence.ts new file mode 100644 index 0000000000..c579ca68ab --- /dev/null +++ b/src/domain/javascript/electronScriptLoaderEvidence.ts @@ -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, + ); diff --git a/src/domain/javascript/javascriptSourceParser.test.ts b/src/domain/javascript/javascriptSourceParser.test.ts index a361d3a800..5f1f62024b 100644 --- a/src/domain/javascript/javascriptSourceParser.test.ts +++ b/src/domain/javascript/javascriptSourceParser.test.ts @@ -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"], diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index 174eec4028..211ab00358 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -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; @@ -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) { diff --git a/tests/acceptance/applications/unboundRequires.test.ts b/tests/acceptance/applications/unboundRequires.test.ts index 70e87ed1c8..1cfcd676be 100644 --- a/tests/acceptance/applications/unboundRequires.test.ts +++ b/tests/acceptance/applications/unboundRequires.test.ts @@ -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",