diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index e164a237c2b..9cd76e22f7c 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -200,6 +200,141 @@ describe("Electron option captures and initialization", () => { }); }); +it("resolves module-level webPreferences used inside a function", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow } = require("electron"); + const webPreferences = { nodeIntegration: true, contextIsolation: false }; + function createWindow() { + return new BrowserWindow({ width: 800, webPreferences }); + } + createWindow(); + `); + const window = analysis.electron.browser_windows[0]; + expect(window?.web_preferences_status).toBe("object-literal"); + expect(window?.web_preferences).toEqual( + expect.arrayContaining([ + { + name: "nodeIntegration", + value: expect.objectContaining({ status: "literal", value: true }), + }, + { + name: "contextIsolation", + value: expect.objectContaining({ status: "literal", value: false }), + }, + ]), + ); +}); +it("keeps a module-level option unresolved when called before initialization", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow } = require("electron"); + function createWindow() { + return new BrowserWindow({ webPreferences }); + } + createWindow(); + const webPreferences = { nodeIntegration: true }; + `); + expect(analysis.electron.browser_windows[0]).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); +}); +it.each([ + { label: "a direct call", before: "createWindow();", after: "" }, + { + label: "an aliased call", + before: "const early = createWindow; early();", + after: "", + }, + { + label: "a forwarding function", + before: "start();", + after: "function start() { createWindow(); }", + }, +])( + "does not initialize a hoisted window function reached by $label", + ({ before, after }) => { + const source = ` + const { BrowserWindow } = require("electron"); + ${before} + const webPreferences = { nodeIntegration: true }; + function createWindow() { + return new BrowserWindow({ webPreferences }); + } + ${after} + `; + expect(() => + runInNewContext(source, { + require: () => ({ BrowserWindow: class {} }), + }), + ).toThrow(/before initialization/); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + }, +); + +it("resolves a hoisted window function called after module initialization", () => { + const source = ` + const { BrowserWindow } = require("electron"); + function createWindow() { + return new BrowserWindow({ webPreferences }); + } + const webPreferences = { nodeIntegration: true }; + createWindow(); + `; + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); +}); + +it.each([ + "for (const item of [0])", + "for (let index = 0; index < 1; index++)", + "while (true)", +])( + "keeps function initialization coverage separate from %s ordering", + (loop) => { + const source = (early: boolean) => ` + const { BrowserWindow } = require("electron"); + ${early ? "createWindow();" : ""} + const webPreferences = { nodeIntegration: true }; + function createWindow() { + ${loop} { + return new BrowserWindow({ webPreferences }); + } + } + ${early ? "" : "createWindow();"} + `; + const runtime = () => ({ require: () => ({ BrowserWindow: class {} }) }); + expect(() => runInNewContext(source(false), runtime())).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source(false)).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); + expect(() => runInNewContext(source(true), runtime())).toThrow( + /before initialization/, + ); + expect( + analyzeJavaScriptStaticSource(source(true)).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + }, +); + describe("Electron option evidence", () => { it.each([ '({ webPreferences: { preload: "./preload.js", sandbox: true } } as const)', diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 2276d8a1ba2..9210fe87a3f 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -1,4 +1,5 @@ import * as t from "@babel/types"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { delayedInstanceFieldValue, @@ -54,6 +55,11 @@ interface InitializerPositionCache { readonly mutation: WeakMap; readonly reference: WeakMap; readonly read: WeakMap; + readonly enclosingFunction: WeakMap; + readonly moduleFunctionReads: WeakMap< + readonly t.Statement[], + WeakMap + >; readonly evaluationChildren: WeakMap< t.Node, ReadonlyMap | null @@ -78,6 +84,8 @@ const initializerPositionCache = ( mutation: new WeakMap(), reference: new WeakMap(), read: new WeakMap(), + enclosingFunction: new WeakMap(), + moduleFunctionReads: new WeakMap(), evaluationChildren: new WeakMap(), expressionAncestry: undefined, }; @@ -709,6 +717,35 @@ export const semanticMutationInitializers = ( }; }; +/** Function ownership is independent of uncertain ordering within a loop. */ +const bindingEnclosingFunction = ( + read: t.Node, + parents: WeakMap, +): t.Function | undefined => { + const cache = initializerPositionCache(parents).enclosingFunction; + const path: t.Node[] = []; + let current = read; + let result: t.Function | null = null; + while (true) { + const cached = cache.get(current); + if (cached !== undefined) { + result = cached; + break; + } + path.push(current); + const parent = parents.get(current); + if (parent === undefined || delayedInstanceFieldValue(parent, current)) + break; + if (t.isFunction(parent) && !semanticComputedMethodKey(parent, current)) { + if (current === parent.body) result = parent; + break; + } + current = parent; + } + for (const node of path) cache.set(node, result); + return result ?? undefined; +}; + /** Reuse immutable ancestry facts across reads nested in the same expression. */ const bindingReadPosition = ( read: t.Node, @@ -798,6 +835,70 @@ type SemanticBindingRead = { readonly binding: JavaScriptSemanticBindingState; }; +/** A hoisted function needs a module-level use after initialization, not a later declaration. */ +const moduleFunctionFirstRead = ( + declaration: t.FunctionDeclaration, + body: readonly t.Statement[], + parents: WeakMap, +): number | null => { + const cache = initializerPositionCache(parents).moduleFunctionReads; + let reads = cache.get(body); + if (reads === undefined) { + reads = new WeakMap(); + cache.set(body, reads); + const functions = new Map(); + for (const statement of body) { + const exported = + t.isExportNamedDeclaration(statement) || + t.isExportDefaultDeclaration(statement); + const node = exported ? statement.declaration : statement; + if (!t.isFunctionDeclaration(node)) continue; + reads.set(node, exported ? null : Infinity); + if (node.id == null) continue; + const name = node.id.name; + if (functions.has(name)) { + const previous = functions.get(name); + if (previous != null) reads.set(previous, null); + reads.set(node, null); + functions.set(name, null); + } else functions.set(name, node); + } + const indexedReads = reads; + const active = new Set(); + for (let index = 0; index < body.length; index++) { + const statement = body[index]; + if (statement === undefined) continue; + traverseJavaScriptAst(statement, { + enter(node, parent) { + if (t.isFunction(node)) active.add(node); + if ( + !t.isIdentifier(node) || + parent === null || + !t.isReferenced(node, parent) + ) + return; + const fn = functions.get(node.name); + if (fn == null || active.has(fn)) return; + const current = indexedReads.get(fn); + if (current === null || current === undefined) return; + // Indirect activations and live exports have no proven module timing. + indexedReads.set( + fn, + active.size > 0 || t.isExportSpecifier(parent) + ? null + : Math.min(current, index), + ); + }, + exit(node) { + if (t.isFunction(node)) active.delete(node); + }, + }); + } + } + const first = reads.get(declaration); + return first !== undefined && Number.isFinite(first) ? first : null; +}; + export const semanticBindingAtRead = ( binding: JavaScriptSemanticBindingState, read: t.Node, @@ -897,11 +998,53 @@ export const semanticBindingAtRead = ( binding, hasDefiniteInitializer, }; + const moduleConstBinding = + binding.scopeId.startsWith("scope:program:") && + binding.declarationKinds.size === 1 && + binding.declarationKinds.has("const") && + !binding.mutable; + const enclosingFunction = moduleConstBinding + ? bindingEnclosingFunction(read, parents) + : undefined; + const functionStatementPosition = (): StatementPosition | null => { + if (enclosingFunction === undefined) return null; + if (t.isStatement(enclosingFunction)) + return statementPosition(enclosingFunction, parents); + for ( + let owner = parents.get(enclosingFunction); + owner !== undefined; + owner = parents.get(owner) + ) + if (t.isStatement(owner)) return statementPosition(owner, parents); + return null; + }; + const functionPosition = moduleConstBinding + ? functionStatementPosition() + : null; + const firstFunctionRead = + moduleConstBinding && + sourcePosition !== null && + functionPosition !== null && + sourcePosition.body === functionPosition.body && + t.isFunctionDeclaration(enclosingFunction) + ? moduleFunctionFirstRead(enclosingFunction, sourcePosition.body, parents) + : null; + const moduleConstInitializedBeforeActivation = + moduleConstBinding && + sourcePosition !== null && + functionPosition !== null && + sourcePosition.body === functionPosition.body && + (t.isFunctionDeclaration(enclosingFunction) + ? firstFunctionRead !== null && sourcePosition.index < firstFunctionRead + : positionPrecedes(sourcePosition, functionPosition)); const uncertainOrder = repeatingLoop || sourcePosition === null || position === null || sourcePosition.body !== position.body; + if (uncertainOrder && moduleConstInitializedBeforeActivation) + // Initialization coverage does not order a capture in another activation. + return { status: "unresolved", binding, hasDefiniteInitializer: true }; if (uncertainOrder) return { status: "unresolved", binding, hasDefiniteInitializer }; const selected = selectInitializers(binding, position, read, parents); diff --git a/tests/acceptance/applications/electronModuleInitializationCli.test.ts b/tests/acceptance/applications/electronModuleInitializationCli.test.ts new file mode 100644 index 00000000000..8bf380c159a --- /dev/null +++ b/tests/acceptance/applications/electronModuleInitializationCli.test.ts @@ -0,0 +1,88 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves hoisted Electron initialization uncertainty through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-electron-initialization-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "early.cjs" }), + "early.cjs": ` + const { BrowserWindow } = require("electron"); + createWindow(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { + return new BrowserWindow({ webPreferences }); + } + require("./late.cjs"); + require("./early-loop.cjs"); + require("./late-loop.cjs"); + throw new Error("must not execute source"); + `, + "late.cjs": ` + const { BrowserWindow } = require("electron"); + function createWindow() { + return new BrowserWindow({ webPreferences }); + } + const webPreferences = { nodeIntegration: true }; + createWindow(); + throw new Error("must not execute source"); + `, + "early-loop.cjs": ` + const { BrowserWindow } = require("electron"); + createWindow(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { + for (const item of [0]) return new BrowserWindow({ webPreferences }); + } + throw new Error("must not execute source"); + `, + "late-loop.cjs": ` + const { BrowserWindow } = require("electron"); + const webPreferences = { nodeIntegration: true }; + function createWindow() { + for (const item of [0]) return new BrowserWindow({ webPreferences }); + } + createWindow(); + throw new Error("must not execute source"); + `, + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const windows = result.graph.nodes.filter( + ({ kind }) => kind === "browser-window", + ); + expect(windows).toHaveLength(4); + const observations = windows.flatMap(({ observations }) => observations); + const inSource = (source: string) => + observations.filter( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ); + for (const source of ["early.cjs", "early-loop.cjs"]) { + expect(inSource(source)).toHaveLength(1); + expect(inSource(source)[0]?.properties).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + } + for (const source of ["late.cjs", "late-loop.cjs"]) { + expect(inSource(source)).toHaveLength(1); + expect(inSource(source)[0]?.properties).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { + name: "nodeIntegration", + value: { status: "literal", value: true }, + }, + ], + }); + } + }, +);