diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 1dd3c99d58..3381815cc5 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -541,6 +541,14 @@ the same artifact. Inline option syntax keeps its observed Evidence. This does not execute code or establish runtime configuration; popup overrides from `setWindowOpenHandler` are not covered by this constant-options resolution. +A hoisted function does not establish initialization timing by declaration order. +For classic scripts, the shared index also accounts for matching literal global +property accesses through `globalThis`, `window`, `self`, and `this`, including +optional and computed literal access. An early read or an indirect activation +keeps captured options unknown even when a later bare call exists. ES module +function declarations are not inferred to be global properties. This remains a +bounded source-order check, not a proof that runtime activation succeeds. + Recognized literal module-table bootstraps also support bounded lexical substitution through module exports, import helper bodies, local wrapper calls, returned closures, object spreads, and callback parameters. Recovered Electron diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index e164a237c2..60734de318 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -1,7 +1,14 @@ import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import * as t from "@babel/types"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { semanticPropertyReadSources } from "./javascriptSemanticIterationSources.js"; +import { + analyzeParsedJavaScriptStaticSource, + analyzeJavaScriptStaticSource, +} from "./javascriptStaticAnalysis.js"; describe("Electron constant option evidence", () => { it("keeps mixed constant, inline and unresolved windows in construction order", () => { @@ -200,6 +207,714 @@ 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 = () => { + const context = { require: () => ({ BrowserWindow: class {} }) }; + return { ...context, window: undefined, self: undefined }; + }; + 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: [], + }); + }, +); + +it("keeps class-owned this separate from a global activation", () => { + const source = `const { BrowserWindow } = require("electron"); + class Other { static createWindow() {} static value = this.createWindow(); } + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({webPreferences}); } + createWindow();`; + expect(() => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }), + ).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); +}); + +it.each([32, 64])( + "shares guarded subtree summaries across %i nested catches", + (size) => { + const source = + "try {".repeat(size) + + "invoke();" + + "} catch ({createWindow: start}) {}".repeat(size); + const parsed = parseJavaScriptSource(source); + expect(parsed).not.toBeNull(); + if (parsed === null) return; + const catches: { node: t.CatchClause; parent: t.TryStatement }[] = []; + const blocks: t.BlockStatement[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node, parent) { + if (t.isBlockStatement(node)) blocks.push(node); + if (t.isCatchClause(node) && t.isTryStatement(parent)) + catches.push({ node, parent }); + }, + }); + let reads = 0; + for (const block of blocks) { + const body = block.body; + Object.defineProperty(block, "body", { + configurable: true, + get() { + reads++; + return body; + }, + }); + } + for (const { node, parent } of catches) + expect(semanticPropertyReadSources(node, parent).length).toBeGreaterThan( + 0, + ); + expect(reads).toBeLessThanOrEqual(size * 8); + }, +); + +it.each([ + "function boot() { this.createWindow(); } boot();", + "function boot() { this.createWindow(); } boot.call(globalThis);", + "function boot() { const run = () => this.createWindow(); run(); } boot();", +])("retains caller-owned this uncertainty: %s", (activation) => { + for (const early of [true, false]) { + const source = `const { BrowserWindow } = require("electron"); + ${early ? activation : ""} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({webPreferences}); } + ${early ? "" : activation} + createWindow();`; + const run = () => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }); + if (early) expect(run).toThrow(/before initialization/); + else expect(run).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + } +}); + +const CLASSIC_GLOBAL_ACTIVATIONS = [ + "function boot() { Object.setPrototypeOf(this.Array.prototype, globalThis); } boot(); [].createWindow();", + "const box = { get value() { throw globalThis; } }; try { const { value } = box; } catch ({ createWindow: start }) { start(); }", + "function fail() { throw globalThis; } try { const box = { [fail()]() {} }; } catch ({ createWindow: start }) { start(); }", + "Object.setPrototypeOf(Number.prototype, globalThis); (0).createWindow();", + "Object.setPrototypeOf(Boolean.prototype, globalThis); true.createWindow();", + "Object.setPrototypeOf(RegExp.prototype, globalThis); /x/.createWindow();", + "Object.setPrototypeOf(BigInt.prototype, globalThis); (0n).createWindow();", + "Object.assign(Object.prototype, globalThis); (0).createWindow();", + "Object.assign(Object.prototype, globalThis); true.createWindow();", + "Object.assign(Object.prototype, globalThis); /x/.createWindow();", + "Object.setPrototypeOf(globalThis.window.Array.prototype, globalThis); [].createWindow();", + "function fail() { throw globalThis; } try { fail``; } catch ({ createWindow: start }) { start(); }", + "const box = { get value() { throw globalThis; } }; try { box.value; } catch ({ createWindow: start }) { start(); }", + "const { root } = { root: globalThis, createWindow() {} }; const early = root.createWindow; early();", + "let root; ({ root } = { root: globalThis, createWindow() {} }); const early = root.createWindow; early();", + "const { nested: { root } } = { nested: { root: globalThis }, createWindow() {} }; root.createWindow();", + "function fail() { throw globalThis; } try { fail(); } catch ({ createWindow: start }) { start(); }", + "function fail() { throw globalThis; } try { if (true) fail(); } catch ({ createWindow: start }) { start(); }", + "const args = [globalThis, 'createWindow']; Reflect.get.apply(Reflect, args)();", + "const args = [globalThis, 'createWindow']; Reflect.get(...args)();", + "Object.assign(Object.prototype, globalThis); ({}).createWindow();", + "Object.setPrototypeOf(String.prototype, globalThis); ''.createWindow();", + "Object.setPrototypeOf(this.Array.prototype, globalThis); [].createWindow();", + "globalThis[`Reflect`].get(globalThis, 'createWindow')();", + "Object.extra = 1; function unused() { [].createWindow; } Reflect.get?.(globalThis, 'createWindow')();", + "const R = Reflect; R.get(globalThis, 'createWindow')();", + "const read = Reflect.get; read(globalThis, 'createWindow')();", + "const read = Reflect.get.bind(Reflect); read(globalThis, 'createWindow')();", + "const root = []; Object.setPrototypeOf(Array.prototype, globalThis); root.createWindow();", + "const root = []; const alias = root; Object.setPrototypeOf(Array.prototype, globalThis); alias.createWindow();", + "const root = function(){}; const alias = root; Object.setPrototypeOf(Function.prototype, globalThis); alias.createWindow();", + "globalThis.createWindow();", + 'globalThis["createWindow"]();', + "const early = globalThis.createWindow; early();", + "globalThis?.createWindow?.();", + "window.createWindow();", + 'self["createWindow"]();', + "this.createWindow();", + "const root = globalThis; root.createWindow();", + "const root = window; const alias = root; alias.createWindow();", + "const root = globalThis.window; root.createWindow();", + "const { root } = { root: globalThis }; root.createWindow();", + "const key = 'createWindow'; globalThis[key]();", + "with ({}) { createWindow(); }", + "const { createWindow: start } = globalThis; start();", + "let start; ({ createWindow: start } = globalThis); start();", + "let start; for ({ createWindow: start } of [globalThis]) start();", + "for (const { createWindow: start } of [globalThis]) start();", + "let start; ({ root: { createWindow: start } } = { root: globalThis }); start();", + "const start = (createWindow ||= null); start();", + "const root = { __proto__: globalThis }; root.createWindow();", + "const root = { ...globalThis }; root.createWindow();", + "(0, eval)('createWindow()');", + "Function('createWindow()')();", + "class C extends (this.Function('createWindow()')(), Object) {}", + "({ [this.eval('createWindow()')]() {} });", + "Function`createWindow()`();", + "Object.setPrototypeOf(Array.prototype, globalThis); [].createWindow();", + "Object.extra=1; function unusedArrayRead(){[].createWindow;} Reflect.get(globalThis, 'createWindow')();", + "Object.extra=1; function unusedArrayRead(){[].createWindow;} try { throw globalThis; } catch ({ createWindow: start }) { start(); }", + "Object.setPrototypeOf(Function.prototype, globalThis); (function(){}).createWindow();", + "new Function('createWindow()')();", + "this['Function']('createWindow()')();", + "const root = {}; const alias = root; Object.setPrototypeOf(root, globalThis); alias.createWindow();", + "const root = {}; const alias = root; Object.assign(root, globalThis); alias.createWindow();", + "const F = Function.bind(null); new F('createWindow()')();", + "Reflect.construct(Function, ['createWindow()'])();", + "const root = {}; Object.setPrototypeOf(root, globalThis); root.createWindow();", + "const root = {}; Object.assign(root, globalThis); root.createWindow();", + "const F = Function; F('createWindow()')();", + "Function.call(null, 'createWindow()')();", + "Function.apply(null, ['createWindow()'])();", + "globalThis.Function('createWindow()')();", + "const F = Function.bind(null); F('createWindow()')();", + "const noop = () => {}; function unused() { noop(); } (0, eval)('createWindow()');", + "const noop = () => {}; (0, eval)(''); function unused() { noop(); } (0, eval)('createWindow()');", +]; + +it.each([ + "", + "const root = globalThis;", + "const unrelated = { createWindow() {} }; function unusedReceiverRead() { unrelated.createWindow; }", + "for (const ignored of [1, 2]) { ({ createWindow() {} }).createWindow; }", +])("keeps class heritage activation after initialization with %s", (extra) => { + const source = ` + const { BrowserWindow } = require("electron"); + ${extra} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + class C extends (this.Function('createWindow()')(), Object) {} + 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([64, 128])( + "bounds own-method key visits for %i repeated reads", + (size) => { + const source = `const { BrowserWindow } = require('electron'); + const box = { createWindow() {}, ${Array.from({ length: size }, (_, i) => `p${i}: 0`).join(",")} }; + ${"box.createWindow; box.unrelated;".repeat(size)} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow();`; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("fixture did not parse"); + const statement = parsed.program.body[1]; + if (statement?.type !== "VariableDeclaration") + throw new Error("fixture changed"); + const object = statement.declarations[0]?.init; + if (object?.type !== "ObjectExpression") throw new Error("fixture changed"); + let keyVisits = 0; + for (const property of object.properties) { + if (property.type === "SpreadElement") continue; + const key = property.key; + Object.defineProperty(property, "key", { + configurable: true, + get: () => { + keyVisits += 1; + return key; + }, + }); + } + expect( + analyzeParsedJavaScriptStaticSource(source, parsed).electron + .browser_windows[0], + ).toMatchObject({ web_preferences_status: "object-literal" }); + expect(keyVisits).toBeLessThanOrEqual(32 * (size + 1)); + }, +); + +it.each([64, 128])( + "bounds projected property key visits for %i distinct reads", + (size) => { + const names = Array.from({ length: size }, (_, i) => `p${i}`); + const source = `const { BrowserWindow } = require('electron'); + const { ${names.map((name) => `${name}: { unrelated: ${name} }`).join(",")} } = + { ${names.map((name) => `${name}: {}`).join(",")} }; + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow();`; + const parsed = parseJavaScriptSource(source); + const statement = parsed?.program.body[1]; + if (parsed === null || statement?.type !== "VariableDeclaration") + throw new Error("fixture changed"); + const object = statement.declarations[0]?.init; + if (object?.type !== "ObjectExpression") throw new Error("fixture changed"); + let keyVisits = 0; + for (const property of object.properties) { + if (property.type === "SpreadElement") continue; + const key = property.key; + Object.defineProperty(property, "key", { + configurable: true, + get: () => { + keyVisits += 1; + return key; + }, + }); + } + expect( + analyzeParsedJavaScriptStaticSource(source, parsed).electron + .browser_windows[0], + ).toMatchObject({ web_preferences_status: "object-literal" }); + expect(keyVisits).toBeLessThanOrEqual(32 * size); + }, +); + +it("retains possible global code from a caller-owned receiver", () => { + const source = ` + const { BrowserWindow } = require("electron"); + function boot() { this.Function("createWindow()")(); } + boot(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow(); + `; + expect(() => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }), + ).toThrow(/before initialization/); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ web_preferences_status: "dynamic", web_preferences: [] }); +}); + +describe("Classic-script global function activation", () => { + it.each([ + "const { nested: { value } } = { nested: { value: 1 } };", + "let value; ({ nested: { value } } = { nested: { value: 1 } });", + "export type { createWindow };", + "export { type createWindow };", + ])("preserves settings across runtime-independent syntax: %s", (prefix) => { + const source = `${prefix === "export { type createWindow };" ? "import { BrowserWindow } from 'electron';" : "const { BrowserWindow } = require('electron');"} + ${prefix} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow();`; + const parsed = parseJavaScriptSource(source, "fixture.ts"); + expect(parsed).not.toBeNull(); + if (parsed === null) throw new Error("fixture did not parse"); + expect( + analyzeParsedJavaScriptStaticSource(source, parsed).electron + .browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); + }); + + it("retains possible classic activation after an erased type import", () => { + const source = `import type { T } from './types'; + const { BrowserWindow } = require('electron'); globalThis.createWindow(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow();`; + expect(() => + runInNewContext(source.replace("import type { T } from './types';", ""), { + require: () => ({ BrowserWindow: class {} }), + }), + ).toThrow(/before initialization/); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ web_preferences_status: "dynamic", web_preferences: [] }); + }); + it("keeps a shadowed Function constructor independent of global activation", () => { + const source = `const { BrowserWindow } = require('electron'); + const Function = () => () => {}; Function('createWindow()')(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow();`; + expect(() => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }), + ).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); + }); + it.each(CLASSIC_GLOBAL_ACTIVATIONS)( + "keeps early global access unresolved: %s", + (activation) => { + const body = (early: boolean) => ` + const { BrowserWindow } = require("electron"); + ${early ? activation : ""} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + ${early ? "" : activation} + createWindow(); + `; + const runtime = () => ({ require: () => ({ BrowserWindow: class {} }) }); + expect(() => + runInNewContext( + "globalThis.window = globalThis; globalThis.self = globalThis;" + + body(true), + runtime(), + ), + ).toThrow(/before initialization/); + expect(() => + runInNewContext( + "globalThis.window = globalThis; globalThis.self = globalThis;" + + body(false), + runtime(), + ), + ).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(body(true)).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + expect( + analyzeJavaScriptStaticSource(body(false)).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { + name: "nodeIntegration", + value: { status: "literal", value: true }, + }, + ], + }); + }, + ); + + it.each([ + "@dec(this.createWindow()) class Decorated {}", + "class Decorated { @dec(this.createWindow()) method() {} }", + ])( + "tracks decorator expressions in the surrounding script: %s", + (decorated) => { + const source = (early: boolean) => ` + const { BrowserWindow } = require("electron"); + function dec() {} + ${early ? decorated : ""} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + ${early ? "" : decorated} + createWindow(); + `; + expect( + analyzeJavaScriptStaticSource(source(true)).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + expect( + analyzeJavaScriptStaticSource(source(false)).electron + .browser_windows[0], + ).toMatchObject({ web_preferences_status: "object-literal" }); + }, + ); + + it("keeps callbacks in hoisted default parameters uncertain", () => { + const source = ` + const { BrowserWindow } = require("electron"); + createWindow(); + const webPreferences = { nodeIntegration: true }; + function createWindow(make = () => new BrowserWindow({ webPreferences })) { return make(); } + `; + expect(() => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }), + ).toThrow(/before initialization/); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ web_preferences_status: "dynamic", web_preferences: [] }); + }); +}); + +describe("Classic-script global receiver identity", () => { + it("keeps a long flat global alias chain stack safe", () => { + const aliases = ["const root0 = globalThis;"]; + for (let index = 1; index < 6000; index++) + aliases.push( + "const root" + + index + + " = root" + + (index - 1) + + "; root" + + index + + ".document;", + ); + const source = ` + const { BrowserWindow } = require("electron"); + ${aliases.join("\n")} + root5999.createWindow(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow(); + `; + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ web_preferences_status: "dynamic", web_preferences: [] }); + }); + + it("does not confuse an unrelated parameter with the module function", () => { + const source = ` + const { BrowserWindow } = require("electron"); + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + function helper(createWindow) { createWindow(); } + createWindow(); + `; + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); + }); + + it("does not treat an ES module function as a global property", () => { + const source = ` + import { BrowserWindow } from "electron"; + globalThis.createWindow(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + 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([ + "function unused() { other.createWindow; }", + "for (const value of [1,2]) { other.createWindow; }", + "const alias = other; function unused() { alias.createWindow; }", + ])( + "preserves no-effect receiver identity across deferred reads: %s", + (read) => { + const source = `const { BrowserWindow } = require("electron"); + const other = { createWindow() {} }; ${read} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({webPreferences}); } + createWindow();`; + expect(() => + runInNewContext(source, { + require: () => ({ BrowserWindow: class {} }), + }), + ).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { + name: "nodeIntegration", + value: { status: "literal", value: true }, + }, + ], + }); + }, + ); + + it.each([ + "String.prototype.extra = 1; [].createWindow;", + "const Reflect = { get() { return () => {}; } }; Reflect.get(globalThis, 'createWindow')();", + "Reflect.get({createWindow(){}}, 'createWindow')();", + "try { throw {createWindow(){}}; } catch ({createWindow:start}) { start(); }", + "Object.extra = 1; function unused() { [].createWindow; }", + "Array.extra = 1; function unused() { [].createWindow; }", + "Function.extra = 1; function unused() { (function(){}).createWindow; }", + "[].createWindow; Object.setPrototypeOf(Array.prototype, globalThis);", + "Function.prototype.extra = 1; [].createWindow;", + ])("keeps unrelated or later prototype effects isolated: %s", (effect) => { + const source = `const { BrowserWindow } = require("electron"); ${effect} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({webPreferences}); } + createWindow();`; + expect(() => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }), + ).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); + }); + + it("keeps unrelated same-named member calls independent", () => { + const source = ` + const { BrowserWindow } = require("electron"); + const other = { createWindow() {} }; + let root = other; + root = globalThis; + root.document; + other.createWindow(); + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow(); + `; + expect(() => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }), + ).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); + }); +}); + describe("Electron option evidence", () => { it.each([ '({ webPreferences: { preload: "./preload.js", sandbox: true } } as const)', @@ -473,3 +1188,60 @@ describe("Electron empty option keys", () => { }, ); }); + +it.each([ + "function boot({ createWindow: start }) { start(); } boot(globalThis);", + "function boot({ createWindow: start } = {}) { start(); } boot(globalThis);", +])( + "retains unresolved caller-supplied parameter property reads: %s", + (activation) => { + for (const early of [true, false]) { + const source = ` + const { BrowserWindow } = require("electron"); + ${early ? activation : ""} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + ${early ? "" : activation} + createWindow(); + `; + const run = () => + runInNewContext(source, { + require: () => ({ BrowserWindow: class {} }), + }); + if (early) expect(run).toThrow(/before initialization/); + else expect(run).not.toThrow(); + // No argument-source or interprocedural activation proof is established. + // A default value does not exclude a caller-supplied ambient receiver. + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + } + }, +); + +it.each([ + "const Reflect = { get() { return () => {}; } }; const read = Reflect.get; read(globalThis, 'createWindow')();", + "const Reflect = { get() { return () => {}; } }; Reflect.get?.(globalThis, 'createWindow')();", +])("keeps shadowed reflected-call spellings isolated: %s", (activation) => { + const source = ` + const { BrowserWindow } = require("electron"); + ${activation} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + createWindow(); + `; + expect(() => + runInNewContext(source, { require: () => ({ BrowserWindow: class {} }) }), + ).not.toThrow(); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows[0], + ).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); +}); diff --git a/src/domain/javascript/electronStaticOptionObjects.ts b/src/domain/javascript/electronStaticOptionObjects.ts index 1f2a2baf1e..9c0e7dc219 100644 --- a/src/domain/javascript/electronStaticOptionObjects.ts +++ b/src/domain/javascript/electronStaticOptionObjects.ts @@ -32,7 +32,7 @@ const optionObjectCandidate = ( !binding.declarationKinds.has("const") || initializer === undefined || initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, node, index.bindings.parentsByNode) + !semanticBindingAtRead(binding, node, index.bindings) .hasDefiniteInitializer ) return undefined; diff --git a/src/domain/javascript/electronStaticRenderer.ts b/src/domain/javascript/electronStaticRenderer.ts index 578dff96b6..d68630c045 100644 --- a/src/domain/javascript/electronStaticRenderer.ts +++ b/src/domain/javascript/electronStaticRenderer.ts @@ -75,7 +75,7 @@ export const resolveElectronBrowserWindowReceiver = ( if ( initializer === undefined || initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, index.bindings.parentsByNode) + !semanticBindingAtRead(binding, receiver, index.bindings) .hasDefiniteInitializer ) return undefined; diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 861f7651ab..f7d4e5be44 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -89,7 +89,7 @@ export const collectJavaScriptExports = ( const read = semanticBindingAtRead( withoutUnusedFactoryDefaults(binding, roles), reference, - bindings.parentsByNode, + bindings, ); if (read.status !== "ordered") return "unresolved"; return read.binding.initializers.length === 0 && diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index d65dfdb83f..c3c53a3cad 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -1,8 +1,28 @@ import * as t from "@babel/types"; + import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; import { semanticArrayIndex } from "./javascriptSemanticPropertyPaths.js"; +/** Computed definition keys use the surrounding evaluation environment. */ +export const javaScriptComputedDefinitionKey = ( + parent: t.Node | undefined, + child: t.Node, +): boolean => + (t.isObjectMethod(parent) || + t.isClassMethod(parent) || + t.isClassProperty(parent) || + t.isClassAccessorProperty(parent)) && + parent.computed && + parent.key === child; + +/** Class decorators and computed definition keys use the enclosing environment. */ +export const javaScriptDefinitionExpression = ( + parent: t.Node | undefined, + child: t.Node, +): boolean => + t.isDecorator(child) || javaScriptComputedDefinitionKey(parent, child); + /** Type-only class fields neither initialize own slots nor shadow inherited members. */ export const isRuntimeJavaScriptClassProperty = ( node: t.Node, @@ -444,13 +464,43 @@ const readStaticObjectProperty = ( return prototypeMaySupply ? { status: "dynamic" } : { status: "missing" }; }; -/** Parse ordered members once for related property queries within one boundary. */ +/** Index effective keys once for related property queries within one boundary. */ export const staticObjectPropertyReader = ( object: t.Node | undefined, isNullish?: (node: t.Node) => boolean, ): ((name: string) => StaticObjectProperty) => { const source = staticObjectPropertySource(object, isNullish); - return (name) => readStaticObjectProperty(source, name); + if (typeof source === "string") return () => ({ status: source }); + const properties = new Map(); + let fallback: "missing" | "dynamic" = "missing"; + let hasIndexedSpread = false; + for (let index = source.length - 1; index >= 0; index--) { + const member = source[index]!; + if (member.kind === "prototype-setter") { + if (member.prototype === "unknown") fallback = "dynamic"; + } else if ( + member.kind === "primitive-indices" || + member.kind === "array-indices" + ) { + hasIndexedSpread = true; + } else if (member.kind === "unknown" || member.key === null) { + fallback = "dynamic"; + break; + } else if (!properties.has(member.key)) { + const { property, key } = member; + const value = objectDataPropertyValue(property); + properties.set( + key, + value === undefined + ? { status: "dynamic" } + : { status: "explicit", property, value }, + ); + } + } + return (name) => + hasIndexedSpread && semanticArrayIndex(name) !== null + ? readStaticObjectProperty(source, name) + : (properties.get(name) ?? { status: fallback }); }; /** Find the effective explicit property, preserving unresolved overrides. */ @@ -461,6 +511,28 @@ export const staticObjectProperty = ( ): StaticObjectProperty => readStaticObjectProperty(staticObjectPropertySource(object, isNullish), name); +/** Prove a literal's effective own member is a method, independent of its prototype. */ +export const staticObjectOwnMethodReader = ( + object: t.Node, +): ((name: string) => boolean) => { + if (!t.isObjectExpression(object)) return () => false; + const methods = new Map(); + const members = staticObjectMembers(object, 0); + for (let index = members.length - 1; index >= 0; index--) { + const member = members[index]!; + if (member.kind === "prototype-setter") continue; + if (member.kind === "unknown") break; + if (member.kind !== "property") continue; + if (member.key === null) break; + if (!methods.has(member.key)) + methods.set( + member.key, + t.isObjectMethod(member.property) && member.property.kind === "method", + ); + } + return (name) => methods.get(name) === true; +}; + export type StaticObjectPropertyNames = | { readonly status: "missing" | "dynamic" } | { readonly status: "partial"; readonly names: readonly string[] } diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index bed7e9da70..0e7db5e856 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -175,11 +175,7 @@ const canonicalWebpackInteropBindings = ( const binding = resolveSemanticBindingState(state, node, node.name); if (binding === undefined) continue; if (loaderBindingIds.has(binding.bindingId)) return true; - const selected = semanticBindingAtRead( - binding, - node, - state.parentsByNode, - ).binding; + const selected = semanticBindingAtRead(binding, node, state).binding; for (const source of [ ...selected.initializers, ...selected.referenceInitializers, diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index 082fe5006d..22034b3508 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -147,16 +147,27 @@ export const resolveJavaScriptGenericInvocation = ( * Binding a function alone is not a write; only its eventual call is examined. */ export const resolveJavaScriptIntrinsicInvocation = ( - node: t.CallExpression | t.OptionalCallExpression, + node: + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression + | t.TaggedTemplateExpression, intrinsicPath: (node: t.Node) => readonly string[] | undefined, boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined, intrinsicReadChanged: (node: t.Node, path: readonly string[]) => boolean, nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration, ): JavaScriptIntrinsicInvocation | undefined => { const visited = new Set(); - let callee: t.Node = node.callee; + let callee: t.Node = t.isTaggedTemplateExpression(node) + ? node.tag + : node.callee; let arguments_: JavaScriptIntrinsicInvocation["arguments"] = - expandJavaScriptInvocationArguments(node.arguments, nativeArrayIteration); + t.isTaggedTemplateExpression(node) + ? undefined + : expandJavaScriptInvocationArguments( + node.arguments, + nativeArrayIteration, + ); let receiver: t.Node | undefined; let explicitReceiver = false; for (;;) { @@ -240,6 +251,15 @@ export const resolveJavaScriptIntrinsicInvocation = ( arguments_ = selected.context.arguments; continue; } + if (path.join(".") === "intrinsic.Reflect.construct") { + const callable = arguments_?.[0]; + if (!t.isExpression(callable)) return undefined; + callee = callable; + receiver = undefined; + explicitReceiver = true; + arguments_ = arrayArguments(arguments_?.[1], nativeArrayIteration); + continue; + } if (path.join(".") === "intrinsic.Reflect.apply") { const callable = arguments_?.[0]; if (!t.isExpression(callable)) return undefined; diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 29957ae9e9..795e780b98 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -626,7 +626,11 @@ export const createSemanticModuleInterop = ( callOrigin, intrinsicPath, intrinsicInvocation: ( - node: t.CallExpression | t.OptionalCallExpression, + node: + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression + | t.TaggedTemplateExpression, callablePath = intrinsicPath, ) => resolveJavaScriptIntrinsicInvocation( diff --git a/src/domain/javascript/javascriptNetworkRequests.ts b/src/domain/javascript/javascriptNetworkRequests.ts index efc897f714..a2b4cfc8ec 100644 --- a/src/domain/javascript/javascriptNetworkRequests.ts +++ b/src/domain/javascript/javascriptNetworkRequests.ts @@ -89,8 +89,7 @@ const isXhrOpenRequest = ( if ( initializer === undefined || initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, bindings.parentsByNode) - .hasDefiniteInitializer + !semanticBindingAtRead(binding, receiver, bindings).hasDefiniteInitializer ) return false; receiver = unwrapJavaScriptExpression(initializer.node).node; diff --git a/src/domain/javascript/javascriptRouteScan.ts b/src/domain/javascript/javascriptRouteScan.ts index e3e1f38992..137b772fe9 100644 --- a/src/domain/javascript/javascriptRouteScan.ts +++ b/src/domain/javascript/javascriptRouteScan.ts @@ -23,10 +23,8 @@ import { type JavaScriptBindingIndex, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { - semanticCopiedReferencePath, - semanticReferenceSourcesInactive, -} from "./javascriptSemanticReferenceSources.js"; +import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; +import { semanticCopiedReferencePath } from "./javascriptSemanticCopiedReferencePaths.js"; import { semanticAliasSelectionChoices, type SemanticAliasSelectionPath, @@ -452,7 +450,7 @@ export class JavaScriptRouteScan { const binding = semanticBindingAtRead( declared, node, - this.index.bindings.parentsByNode, + this.index.bindings, ).binding; for (const initializers of [ binding.initializers, @@ -1020,11 +1018,7 @@ export class JavaScriptRouteScan { seen.add(node); const binding = index.bindingForReferenceAt(node); if (binding === undefined) return; - const read = semanticBindingAtRead( - binding, - node, - index.bindings.parentsByNode, - ); + const read = semanticBindingAtRead(binding, node, index.bindings); if (!read.hasDefiniteInitializer) return; const initializer = semanticStableInitializer( read.binding, diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 688ff5c4a8..2c7e3f15dd 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -1,3 +1,4 @@ +import { javaScriptThisOwner } from "./javascriptSourceParser.js"; import * as t from "@babel/types"; import { @@ -38,10 +39,10 @@ import { } from "./javascriptSemanticGlobalReference.js"; import { readSemanticRequireLoader } from "./javascriptSemanticRequireOriginSlot.js"; import { - semanticCopiedReferencePath, semanticReferenceIterationUnknown, semanticReferenceSourcesInactive, } from "./javascriptSemanticReferenceSources.js"; +import { semanticCopiedReferencePath } from "./javascriptSemanticCopiedReferencePaths.js"; export { isUnmodifiedGlobalReference, @@ -144,6 +145,7 @@ type AmbientIdentityUnknownReason = interface AmbientResolution { readonly paths: readonly AmbientPath[]; + readonly callerPaths: readonly AmbientPath[]; readonly ownership: "local" | "ambient" | "unknown"; readonly unknownReasons: readonly AmbientIdentityUnknownReason[]; } @@ -179,6 +181,7 @@ export const createAmbientPathResolver = ( const prior = possibleWrites ? undefined : cached.get(node); if (prior !== undefined) return prior; const result = new Map(); + const callerPaths = new Map(); let hasLocalIdentity = false; const unknownReasons = new Set(); // The caller's write site selects initializers for the written target. @@ -254,6 +257,7 @@ export const createAmbientPathResolver = ( if (!possibleWrites) { const unresolved: AmbientResolution = { paths: [], + callerPaths: [...callerPaths.values()], ownership: "unknown", unknownReasons: ["widened-projection"], }; @@ -319,6 +323,22 @@ export const createAmbientPathResolver = ( resolvedIdentity = true; continue; } + if (t.isThisExpression(root)) { + const owner = javaScriptThisOwner( + root, + state.program, + state.parentsByNode, + ); + if (owner === "global") { + retain(["global", ...(suffix ?? [])]); + resolvedIdentity = true; + continue; + } + if (owner === "caller" && suffix !== undefined) { + const path = canonicalAmbientPath(["global", ...suffix]); + callerPaths.set(JSON.stringify(path), path); + } + } if (t.isIdentifier(root)) { // The wrapper module object is distinct from globalThis.module. Keep // its identity through the same alias/projection owner used for writes. @@ -345,7 +365,7 @@ export const createAmbientPathResolver = ( const readAt = current.readsAtSelf ? root : capture; const atRead = possibleWrites && readAt !== undefined - ? semanticBindingAtRead(binding, readAt, state.parentsByNode) + ? semanticBindingAtRead(binding, readAt, state) : undefined; const selected = atRead?.binding ?? binding; if (possibleWrites && atRead?.hasDefiniteInitializer === false) @@ -552,6 +572,7 @@ export const createAmbientPathResolver = ( unknownReasons.add("mixed-identities"); const resolution: AmbientResolution = { paths, + callerPaths: [...callerPaths.values()], ownership: unknownReasons.size > 0 ? "unknown" diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index ac0dd13d7c..c3ba1098fa 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1,3 +1,4 @@ +import { semanticPatternPropertyReadSources } from "./javascriptSemanticIterationSources.js"; import { createSemanticOpaqueGlobalObserver, indexSemanticOpaqueEffects, @@ -9,7 +10,7 @@ import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticReceiverFac import * as t from "@babel/types"; import { - semanticComputedMethodKey, + semanticMethodDefinitionExpression, semanticBranchMayBeSkipped, semanticPriorStatementMayExit, semanticIterationCardinality, @@ -48,6 +49,8 @@ import type { } from "./javascriptSemanticState.js"; import { currentSemanticScope, + semanticPrototypeOwners, + type SemanticPrototypeOwner, resolveSemanticBindingState, semanticResolutionBlocked, resolveSemanticBindingFromScope, @@ -228,6 +231,7 @@ function* indexSemanticMemberMutationFactsSteps( (site, reference, path) => referenceEffectsMayHaveChanged(state, site, reference, path), ); + state.memberMutationFactsComplete = true; indexedMemberMutationStates.add(state); invalidateSemanticRequireDelegateWrites(state); } @@ -1041,9 +1045,10 @@ const createState = ( }; return { arrayIterationUnknown: false, + memberMutationFactsComplete: false, program, opaqueEffects: { - mayReferenceGlobalEval: false, + mayReferenceGlobalCode: false, sites: new WeakSet(), sloppySites: new WeakSet(), byScope: new Map(), @@ -1063,6 +1068,8 @@ const createState = ( bindingWrites: new Map(), prototypeRequireMutationObserved: false, prototypeEnumerationMayAffect: false, + prototypeMutationSites: [], + implicitPropertyReads: new WeakMap(), callables: [], callableNodesById: new Map(), moduleLinks: [], @@ -1081,8 +1088,9 @@ const createState = ( }; }; -const globalEvalRoots = new Set([ +const globalCodeRoots = new Set([ "eval", + "Function", "globalThis", "global", "window", @@ -1120,6 +1128,8 @@ function* collectDefinitionsSteps( scope: JavaScriptSemanticScopeState; }[] = []; let needsPrototypeFacts = false; + const implicitReadCalls: (t.CallExpression | t.OptionalCallExpression)[] = []; + let mayReferenceReflect = false; const functionBodies: { readonly body: t.BlockStatement; readonly scope: JavaScriptSemanticScopeState; @@ -1138,15 +1148,17 @@ function* collectDefinitionsSteps( if ( t.isWithStatement(node) || t.isForInStatement(node) || - t.isForOfStatement(node) + t.isForOfStatement(node) || + t.isArrayExpression(node) || + t.isFunction(node) ) needsPrototypeFacts = true; let parentScope = currentSemanticScope(stack); if ( - semanticComputedMethodKey(parent ?? undefined, node) && + semanticMethodDefinitionExpression(parent ?? undefined, node) && parentScope.parentScopeId !== null ) { - // A computed method key is evaluated before entering the method's + // A method definition expression is evaluated before entering the method's // parameter and body environments. const keyScope = state.scopesById.get(parentScope.parentScopeId); if (keyScope !== undefined) { @@ -1211,12 +1223,26 @@ function* collectDefinitionsSteps( state, }); bindInnerDeclaration(node, parent, scope, state); + if ( + t.isIdentifier(node, { name: "Reflect" }) || + t.isStringLiteral(node, { value: "Reflect" }) || + (t.isTemplateLiteral(node) && + semanticStaticPropertyKey(node, true) === "Reflect") + ) + mayReferenceReflect = true; + // Aliases and optional/call/apply/bind forms share the intrinsic owner; + // callee spelling alone cannot identify the property-read operation. + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) + implicitReadCalls.push(node); if (t.isThrowStatement(node)) thrownValues.push({ node, ancestors: readAncestors() }); - // Every supported global eval origin starts with one of these decoded + // Every supported global-code origin starts with one of these decoded // identifiers, including computed properties and destructured aliases. - if (t.isIdentifier(node) && globalEvalRoots.has(node.name)) - state.opaqueEffects.mayReferenceGlobalEval = true; + if ( + t.isThisExpression(node) || + (t.isIdentifier(node) && globalCodeRoots.has(node.name)) + ) + state.opaqueEffects.mayReferenceGlobalCode = true; if ( t.isCallExpression(node) && t.isIdentifier(unwrapJavaScriptExpression(node.callee).node, { @@ -1328,6 +1354,15 @@ function* collectDefinitionsSteps( parent.handler != null && child === parent.block ) { + if (parent.handler.param != null) + state.implicitPropertyReads.set( + node, + semanticPatternPropertyReadSources( + parent.handler.param, + { node: node.argument, projection: [] }, + node, + ), + ); const scope = state.scopeByNode.get(parent.handler); if (scope !== undefined && parent.handler.param != null) bindPattern({ @@ -1398,36 +1433,85 @@ function* collectDefinitionsSteps( if (t.isIdentifier(identifier)) copyParameterToBody({ identifier, body, scope, state }); } + const readInterop = mayReferenceReflect + ? createSemanticModuleInterop(state) + : undefined; + for (const node of readInterop === undefined ? [] : implicitReadCalls) { + const invocation = readInterop?.intrinsicInvocation(node); + if ( + invocation?.kind !== "known" || + invocation.path.join(".") !== "intrinsic.Reflect.get" + ) + continue; + const [receiver, key] = invocation.arguments ?? []; + const receiverUnknown = + invocation.arguments === undefined || t.isSpreadElement(receiver); + if (!receiverUnknown && !t.isExpression(receiver)) continue; + const selected = + !receiverUnknown && (t.isStringLiteral(key) || t.isNumericLiteral(key)) + ? key.value + : null; + state.implicitPropertyReads.set(node, [ + { + node: receiverUnknown ? node : (receiver ?? node), + projection: receiverUnknown ? [null, selected] : [selected], + mutation: node, + lookup: "prototype", + }, + ]); + } indexSemanticOpaqueEffects(opaqueCandidates, state); if (state.opaqueEffects.byScope.size > 0) clearSemanticValueCaches(state); } -type PrototypeOwner = "Object" | "Array" | "String"; - -const prototypeOwners = ["Object", "Array", "String"] as const; - /** Preserve uncertainty when source can change or expose a built-in prototype. */ function* indexPrototypeUncertaintySteps( program: t.Program, state: JavaScriptSemanticAnalysisState, ): Generator { - const taint = (owner: PrototypeOwner): void => { + let effectSite: t.Node = program; + const seen = new Map>(); + const possiblePrototypeReferences = createAmbientPathResolver(state, false); + const taint = (owner: SemanticPrototypeOwner, inherited = true): void => { + let sites = seen.get(owner); + if (sites === undefined) { + sites = new WeakSet(); + seen.set(owner, sites); + } + if (inherited && !sites.has(effectSite)) { + sites.add(effectSite); + state.prototypeMutationSites.push({ owner, node: effectSite }); + } if (owner === "Object") state.prototypeRequireMutationObserved = undefined; state.prototypeEnumerationMayAffect = undefined; }; - const globalOwner = (node: t.Node): PrototypeOwner | undefined => { + const globalOwner = (node: t.Node): SemanticPrototypeOwner | undefined => { const value = unwrapJavaScriptExpression(node).node; - for (const owner of prototypeOwners) { - if (isUnmodifiedGlobalReference(value, state, owner)) return owner; - if ( - t.isIdentifier(value, { name: owner }) && - resolveSemanticBindingState(state, value, owner) === undefined - ) - return owner; + const member = + t.isMemberExpression(value) || t.isOptionalMemberExpression(value); + const key = t.isIdentifier(value) + ? value.name + : member + ? semanticStaticPropertyKey(value.property, value.computed) + : null; + const owner = semanticPrototypeOwners.find( + (candidate) => candidate === key, + ); + if (owner === undefined) return undefined; + if (isUnmodifiedGlobalReference(value, state, owner)) return owner; + if (member) { + const { paths, callerPaths } = possiblePrototypeReferences(value); + const selectsOwner = (path: readonly (string | null)[]) => + path.length === 2 && path[0] === "global" && path[1] === owner; + return paths.some(selectsOwner) || callerPaths.some(selectsOwner) + ? owner + : undefined; } - return undefined; + return resolveSemanticBindingState(state, value, owner) === undefined + ? owner + : undefined; }; - const prototypeOwner = (node: t.Node): PrototypeOwner | undefined => { + const prototypeOwner = (node: t.Node): SemanticPrototypeOwner | undefined => { const value = unwrapJavaScriptExpression(node).node; return (t.isMemberExpression(value) || t.isOptionalMemberExpression(value)) && @@ -1451,6 +1535,7 @@ function* indexPrototypeUncertaintySteps( ]); yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node, parent) => { + effectSite = node; const write = semanticWriteSiteTarget(node); if (write !== null) { for (const target of semanticWriteTargets(write)) { @@ -1459,9 +1544,15 @@ function* indexPrototypeUncertaintySteps( t.isMemberExpression(value) || t.isOptionalMemberExpression(value) ) { - const owner = - prototypeOwner(value.object) ?? globalOwner(value.object); - if (owner !== undefined) taint(owner); + const prototype = prototypeOwner(value.object); + const owner = prototype ?? globalOwner(value.object); + if (owner !== undefined) + taint( + owner, + prototype !== undefined || + semanticStaticPropertyKey(value.property, value.computed) === + "prototype", + ); } else { const owner = globalOwner(value); if (owner !== undefined) taint(owner); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 4c5d56fa4b..e88832eaa4 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -235,7 +235,7 @@ const emitterIdentity = ( if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); if (binding !== undefined) { - const read = semanticBindingAtRead(binding, node, state.parentsByNode); + const read = semanticBindingAtRead(binding, node, state); const origins = [ ...read.binding.initializers, ...read.binding.referenceInitializers, @@ -383,7 +383,7 @@ const collectTimer = (input: TimerCollectionInput): void => { const snapshot = semanticBindingAtRead( handle, node.arguments[0], - state.parentsByNode, + state, ); // A const handle has the same value inside a later closure even // when the source positions belong to different function bodies. diff --git a/src/domain/javascript/javascriptSemanticCallResolution.test.ts b/src/domain/javascript/javascriptSemanticCallResolution.test.ts index e96889419a..fd42045f8f 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.test.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.test.ts @@ -4,6 +4,7 @@ import { expect, it, vi } from "vitest"; import { analyzeJavaScriptSemantics, indexJavaScriptBindingsSteps, + indexJavaScriptBindingsWithReceiversSteps, } from "./javascriptSemanticAnalysis.js"; import { resolveLocalCallables, @@ -186,6 +187,73 @@ it("bounds shared partial callback alias expansion by AST size", () => { expect(large.referenceReads).toBeLessThan(3 * small.referenceReads); }); +it("reuses the immutable enclosing-function position across distinct captured reads", () => { + const { state } = indexed( + `const make=()=>1; ${"0;".repeat(64)} function use(){${"make();".repeat(64)}} use();`, + ); + const binding = [...state.bindingsById.values()].find( + (b) => b.name === "make", + ); + const fn = state.program.body.find( + (n) => t.isFunctionDeclaration(n) && n.id?.name === "use", + ); + if (binding === undefined || fn === undefined) + throw Error("Missing fixture binding"); + const lookup = vi.spyOn(state.program.body, "indexOf"); + let reads = 0; + try { + traverseJavaScriptAst(fn, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "make" }) + ) { + reads++; + expect( + semanticBindingAtRead(binding, node.callee, state) + .hasDefiniteInitializer, + ).toBe(true); + } + }, + }); + expect(reads).toBe(64); + expect( + lookup.mock.calls.filter(([node]) => node === fn).length, + ).toBeLessThanOrEqual(1); + } finally { + lookup.mockRestore(); + } +}); + +it.each([true, false])( + "orders a module direct-eval activation with early=%s", + (early) => { + const { state } = indexed( + `export {}; ${early ? "eval('open()');" : ""} const make = () => 1; function open() { return make(); } ${early ? "" : "eval('open()');"} open();`, + ); + const binding = [...state.bindingsById.values()].find( + (binding) => binding.name === "make", + ); + if (binding === undefined) throw new Error("Missing make binding"); + let reads = 0; + traverseJavaScriptAst(state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "make" }) + ) { + reads++; + expect( + semanticBindingAtRead(binding, node.callee, state) + .hasDefiniteInitializer, + ).toBe(!early); + } + }, + }); + expect(reads).toBe(1); + }, +); + it.each([ ["if(flag){cb=fallback;cb();}", true], ["if(flag)cb=fallback;cb();", false], @@ -207,7 +275,7 @@ it.each([ ) { reads++; expect( - semanticBindingAtRead(binding, node.callee, state.parentsByNode) + semanticBindingAtRead(binding, node.callee, state) .hasDefiniteInitializer, ).toBe(expected); } @@ -412,7 +480,7 @@ it.each(["||=", "??="])( ) { reads++; expect( - semanticBindingAtRead(binding, node.callee, state.parentsByNode) + semanticBindingAtRead(binding, node.callee, state) .hasDefiniteInitializer, ).toBe(true); } @@ -540,3 +608,87 @@ it("resolves calls in computed method keys before subsequent pattern writes", () calleeCallableIds: [original?.callableId], }); }); + +it.each([64, 128])( + "indexes receiver effects once for %s reads before distinct writes", + (count) => { + const source = `const object = {}; ${"object.x;".repeat(count)} ${Array.from({ length: count }, (_, i) => `object.p${i}=1;`).join("")} const make=()=>1; function use(){make();} use();`; + const file = parseJavaScriptSource(source, "fixture.js"); + if (file === null) throw Error("Invalid fixture"); + const steps = indexJavaScriptBindingsWithReceiversSteps(file.program); + let step = steps.next(); + while (!step.done) step = steps.next(); + const index = step.value; + for (const _ of index.indexMemberMutationFactsSteps()) { + void _; + } + const state = index.bindings; + const object = [...state.bindingsById.values()].find( + (b) => b.name === "object", + ); + const make = [...state.bindingsById.values()].find( + (b) => b.name === "make", + ); + if (object === undefined || make === undefined) + throw Error("Missing fixture bindings"); + let visits = 0; + Object.defineProperty(object, "memberWrites", { + value: new Proxy(object.memberWrites, { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) visits++; + return Reflect.get(target, key, receiver); + }, + }), + }); + let checked = 0; + traverseJavaScriptAst(file.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "make" }) + ) { + expect( + semanticBindingAtRead(make, node.callee, state) + .hasDefiniteInitializer, + ).toBe(true); + checked++; + } + }, + }); + expect(checked).toBe(1); + expect(object.memberWrites.length).toBe(count); + expect(visits).toBeLessThanOrEqual(count * 2); + }, +); + +it("indexes one statement body for distinct captured functions", () => { + const { state } = indexed( + `const make=()=>1; ${Array.from({ length: 128 }, (_, i) => `function use${i}(){make();} use${i}();`).join(" ")}`, + ); + const binding = [...state.bindingsById.values()].find( + (b) => b.name === "make", + ); + if (binding === undefined) throw Error("Missing fixture binding"); + const lookup = vi.spyOn(state.program.body, "indexOf"); + let reads = 0; + try { + traverseJavaScriptAst(state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "make" }) + ) { + reads++; + expect( + semanticBindingAtRead(binding, node.callee, state) + .hasDefiniteInitializer, + ).toBe(true); + } + }, + }); + expect(reads).toBe(128); + expect(lookup.mock.calls.length).toBeLessThanOrEqual(1); + } finally { + lookup.mockRestore(); + } +}); diff --git a/src/domain/javascript/javascriptSemanticCallResolution.ts b/src/domain/javascript/javascriptSemanticCallResolution.ts index 0dde354078..57d94066eb 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.ts @@ -104,11 +104,7 @@ const resolveIdentifier = ( complete: false, reason: `Unresolved call target ${node.name}.`, }; - const read = semanticBindingAtRead( - binding, - node, - context.state.parentsByNode, - ); + const read = semanticBindingAtRead(binding, node, context.state); // A full read re-enters the same candidate set wherever the chain returns // to this binding. Close that cycle at the binding; position-narrowed reads // keep their per-initializer cycle handling. diff --git a/src/domain/javascript/javascriptSemanticCallableStack.ts b/src/domain/javascript/javascriptSemanticCallableStack.ts index f7c84d2040..9d75fa31b5 100644 --- a/src/domain/javascript/javascriptSemanticCallableStack.ts +++ b/src/domain/javascript/javascriptSemanticCallableStack.ts @@ -1,6 +1,6 @@ import * as t from "@babel/types"; -import { semanticComputedMethodKey } from "./javascriptSemanticEvaluationOrder.js"; +import { semanticMethodDefinitionExpression } from "./javascriptSemanticEvaluationOrder.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; /** @@ -49,7 +49,7 @@ export const createSemanticCallableStack = ( enter: (node, parent) => { if ( parent !== null && - semanticComputedMethodKey(parent, node) && + semanticMethodDefinitionExpression(parent, node) && frames.at(-1)?.callableId === semanticCallableIdForNode(parent) ) { const method = pop(); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 3b3ad852bd..690329962d 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -340,11 +340,7 @@ const resolveChildCandidates = ( // An alias captures its source at its initializer read, not at the later // interaction. The shared selector retains all sources across uncertain // control flow, loops and writes in other activations. - const snapshot = semanticBindingAtRead( - binding, - current, - state.parentsByNode, - ); + const snapshot = semanticBindingAtRead(binding, current, state); const selected = snapshot.binding; const initializers = [ ...selected.initializers, diff --git a/src/domain/javascript/javascriptSemanticCopiedReferencePaths.ts b/src/domain/javascript/javascriptSemanticCopiedReferencePaths.ts new file mode 100644 index 0000000000..7210211139 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticCopiedReferencePaths.ts @@ -0,0 +1,57 @@ +import { + semanticArrayIndex, + type JavaScriptSemanticPropertyPath as PropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; + +interface CopiedReferenceSource { + readonly projection: PropertyPath; + readonly copyKind?: "object-rest" | "array-rest"; + readonly copyProjectionOffset?: number; + readonly copyExcludedKeys?: readonly string[]; + readonly copyStartIndex?: number; +} + +/** Map copied child selections once; the fresh copy itself keeps its own identity. */ +export const semanticCopiedReferencePath = ( + initializer: CopiedReferenceSource, + path: PropertyPath, + effect: "write" | "escape" | "identity", +): PropertyPath | null => { + const offset = initializer.copyProjectionOffset; + if (offset === undefined) return [...initializer.projection, ...path]; + const copiedPath = [...initializer.projection.slice(offset + 1), ...path]; + if (effect === "identity" && copiedPath.length === 0) return null; + if (effect === "write" && copiedPath.length < 2) return null; + let [key, ...remaining] = copiedPath; + if (initializer.copyKind === "array-rest") { + const startIndex = initializer.copyStartIndex ?? 0; + if (key === undefined || key === null) + key = { excludedKeys: [], startIndex }; + else if (typeof key === "object") + key = { + excludedKeys: key.excludedKeys.flatMap((name) => { + const index = semanticArrayIndex(name); + return index === null ? [] : [String(index + startIndex)]; + }), + startIndex: (key.startIndex ?? 0) + startIndex, + }; + else { + const index = semanticArrayIndex(key); + if (index === null) return null; + key = index + startIndex; + } + } else { + const excludedKeys = initializer.copyExcludedKeys ?? []; + if (key === undefined || key === null) key = { excludedKeys }; + else if (typeof key === "object") + key = { + ...key, + excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort( + compareUnicodeCodePoints, + ), + }; + else if (excludedKeys.includes(String(key))) return null; + } + return [...initializer.projection.slice(0, offset), key, ...remaining]; +}; diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 4b8385f53a..384cf05a2e 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -278,11 +278,7 @@ const soleWriteReachesRead = ( request: t.Node | undefined, ): boolean => { if (binding === undefined || request === undefined) return false; - const snapshot = semanticBindingAtRead( - binding, - read, - context.state.parentsByNode, - ); + const snapshot = semanticBindingAtRead(binding, read, context.state); const initializers = snapshot.binding.initializers.filter( ({ node }) => semanticEffectTiming(read, node, context.state) !== "after", ); diff --git a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts index 97bc10d8e9..bab836bb13 100644 --- a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts +++ b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import { + javaScriptDefinitionExpression, readExactJavaScriptLiteral, semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -15,14 +16,15 @@ export type JavaScriptSemanticIterationCardinality = | "nonempty" | "unknown"; -/** Whether a method's computed key executes in the enclosing environment. */ -export const semanticComputedMethodKey = ( +/** Whether a method child executes in the enclosing environment. */ +export const semanticMethodDefinitionExpression = ( parent: t.Node | undefined, child: t.Node, ): boolean => - (t.isObjectMethod(parent) || t.isClassMethod(parent)) && - parent.computed && - parent.key === child; + (t.isObjectMethod(parent) || + t.isClassMethod(parent) || + t.isClassPrivateMethod(parent)) && + javaScriptDefinitionExpression(parent, child); /** Exact source-only cardinality for literal loop heads. */ export const semanticIterationCardinality = ( diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.ts index 423bf91dd3..395a5af1d6 100644 --- a/src/domain/javascript/javascriptSemanticExpressionShapes.ts +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.ts @@ -96,7 +96,7 @@ export const createSemanticExpressionShapeResolver = ( const initializer = semanticStableInitializer(binding, state); if (initializer === undefined || initializer.projection.length > 0) return "unknown"; - const read = semanticBindingAtRead(binding, node, state.parentsByNode); + const read = semanticBindingAtRead(binding, node, state); if (read.status !== "ordered" || !read.hasDefiniteInitializer) return "unknown"; node = initializer.node; diff --git a/src/domain/javascript/javascriptSemanticGetterResultWalker.ts b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts index 7c2fe5f831..3144b0d1ab 100644 --- a/src/domain/javascript/javascriptSemanticGetterResultWalker.ts +++ b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts @@ -208,7 +208,7 @@ export class GetterResultWalker { const { binding: origins } = semanticBindingAtRead( binding, expression, - state.parentsByNode, + state, ); const aliases: AliasVisit = { identity, diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index 70e8581ffa..0c7bc77dc4 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -11,7 +11,10 @@ import { type JavaScriptSemanticPropertyPath, } from "./javascriptSemanticPropertyPaths.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; -import { semanticCopiedReferencePath } from "./javascriptSemanticReferenceSources.js"; + +import { semanticCopiedReferencePath } from "./javascriptSemanticCopiedReferencePaths.js"; +import { semanticMethodDefinitionExpression } from "./javascriptSemanticEvaluationOrder.js"; +import { childNodes } from "./javascriptSemanticTraversal.js"; import type { IterationSourceValue, SemanticIterationSource, @@ -274,6 +277,50 @@ const emptySourceFacts: SemanticSourceFacts = { iterations: [], propertyReads: [], }; +// Immutable AST subtree summaries are reused by enclosing and nested catches. +// Property reads can invoke getters; tags and coercions can execute user code. +const guardedExecutions = new WeakMap(); +const guardedBodyMayCall = (body: t.BlockStatement): boolean => { + const pending: { node: t.Node; parent: t.Node | null; finish: boolean }[] = [ + { node: body, parent: null, finish: false }, + ]; + while (pending.length > 0) { + const frame = pending.pop(); + if (frame === undefined || guardedExecutions.has(frame.node)) continue; + const { node, parent } = frame; + const children = [...childNodes(node)].filter( + (child) => + !t.isFunction(node) || semanticMethodDefinitionExpression(node, child), + ); + if (!frame.finish) { + pending.push({ node, parent, finish: true }); + for (const child of children) + pending.push({ node: child, parent: node, finish: false }); + } else { + const direct = + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) || + t.isTaggedTemplateExpression(node) || + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) || + t.isBinaryExpression(node) || + t.isUnaryExpression(node) || + t.isUpdateExpression(node) || + t.isSpreadElement(node) || + t.isForOfStatement(node) || + t.isAwaitExpression(node) || + ((t.isVariableDeclarator(node) || t.isAssignmentExpression(node)) && + semanticSourceFacts(node, parent).propertyReads.length > 0); + guardedExecutions.set( + node, + direct || + children.some((child) => guardedExecutions.get(child) === true), + ); + } + } + return guardedExecutions.get(body) === true; +}; const sourceFacts = new WeakMap< t.Node, { readonly parent: t.Node | null; readonly facts: SemanticSourceFacts } @@ -291,6 +338,7 @@ const semanticSourceFacts = ( t.isForOfStatement(node) || t.isSpreadElement(node) || t.isYieldExpression(node) || + t.isCatchClause(node) || t.isFunction(node) ) ) @@ -330,9 +378,19 @@ const semanticSourceFacts = ( node.argument != null ) add(sources, { node: node.argument, projection: [] }, node, {}); + else if ( + t.isCatchClause(node) && + node.param != null && + t.isTryStatement(parent) && + guardedBodyMayCall(parent.block) + ) + visit(context, node.param, { node: node.param, projection: [null] }); else if (t.isFunction(node)) for (const parameter of runtimeFunctionParameters(node)) - visit(context, parameter, undefined); + // A caller-supplied value is unknown, not an absent property-read source. + // Retain the owning pattern as an unresolved source, including alongside + // default-value projections, instead of dropping its property lookups. + visit(context, parameter, { node: parameter, projection: [] }); const facts = { iterations: sources, propertyReads }; sourceFacts.set(node, { parent, facts }); return facts; @@ -344,8 +402,28 @@ export const semanticIterationSources = ( ): readonly SemanticIterationSource[] => semanticSourceFacts(node, parent).iterations; +/** Pattern reads from an explicitly retained source, including thrown values. */ +export const semanticPatternPropertyReadSources = ( + pattern: t.Node, + source: IterationSourceValue, + mutation: t.Node, +): readonly SemanticPropertyReadSource[] => { + const propertyReads: SemanticPropertyReadSource[] = []; + visit({ sources: [], propertyReads, mutation }, pattern, source); + return propertyReads; +}; + export const semanticPropertyReadSources = ( node: t.Node, parent: t.Node | null, -): readonly SemanticPropertyReadSource[] => - semanticSourceFacts(node, parent).propertyReads; + supplemental?: { + readonly implicitPropertyReads: WeakMap< + t.Node, + readonly SemanticPropertyReadSource[] + >; + }, +): readonly SemanticPropertyReadSource[] => { + const ordinary = semanticSourceFacts(node, parent).propertyReads; + const implicit = supplemental?.implicitPropertyReads.get(node); + return implicit === undefined ? ordinary : [...ordinary, ...implicit]; +}; diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts index 9cf0a2a4c1..ce559fe3ba 100644 --- a/src/domain/javascript/javascriptSemanticMapOperations.ts +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -54,8 +54,7 @@ export const isIntrinsicMapLookup = ( if ( initializer === undefined || initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, bindings.parentsByNode) - .hasDefiniteInitializer + !semanticBindingAtRead(binding, receiver, bindings).hasDefiniteInitializer ) return false; receiver = unwrapJavaScriptExpression(initializer.node).node; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 21737b1976..a59c798602 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -69,11 +69,11 @@ import { import { semanticCoercionOperands } from "./javascriptSemanticLanguageInvocations.js"; import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; import { - semanticCopiedReferencePath, semanticKnownArrayIteration, semanticReferenceSourcesInactive, semanticSelectedReferenceSlot, } from "./javascriptSemanticReferenceSources.js"; +import { semanticCopiedReferencePath } from "./javascriptSemanticCopiedReferencePaths.js"; import { semanticArrayIndex, joinSemanticPropertyPaths, @@ -162,11 +162,7 @@ const localAccessorMethods = ( if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); if (binding === undefined) continue; - const selected = semanticBindingAtRead( - binding, - node, - state.parentsByNode, - ).binding; + const selected = semanticBindingAtRead(binding, node, state).binding; for (const source of [ ...selected.initializers, ...selected.referenceInitializers, @@ -1652,7 +1648,7 @@ export function* collectSemanticMemberMutationsSteps( else if (node.init != null) markEscaped(node.init, exit); } } - for (const source of semanticPropertyReadSources(node, parent)) { + for (const source of semanticPropertyReadSources(node, parent, state)) { if (semanticReferenceSourcesInactive(source, state)) continue; for (const effect of getterEffects( source.node, diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 6a062f3681..bce356fd46 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -1,20 +1,35 @@ import * as t from "@babel/types"; +import { + isErasedJavaScriptValueChild, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { isTypeOnlyExportDeclaration } from "./javascriptModuleDeclaration.js"; +import { + javaScriptRuntimeContext, + javaScriptThisOwner, +} from "./javascriptSourceParser.js"; +import { semanticPropertyReadSources } from "./javascriptSemanticIterationSources.js"; import { delayedInstanceFieldValue, - semanticComputedMethodKey, + semanticMethodDefinitionExpression, semanticBranchMayBeSkipped, semanticIterationCardinality, } from "./javascriptSemanticEvaluationOrder.js"; import { + staticObjectPropertyReader, + staticObjectOwnMethodReader, semanticStaticPropertyKey, + semanticWriteSiteReadsFirst, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, semanticScopeId, + semanticPrototypeOwners, + type SemanticPrototypeOwner, type JavaScriptSemanticBindingState, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticEffectTiming, @@ -55,6 +70,22 @@ interface InitializerPositionCache { readonly mutation: WeakMap; readonly reference: WeakMap; readonly read: WeakMap; + readonly enclosingFunction: WeakMap; + readonly functionPosition: WeakMap; + readonly statementIndices: WeakMap< + readonly t.Statement[], + ReadonlyMap + >; + readonly moduleFunctionReads: WeakMap< + readonly t.Statement[], + { + readonly reads: WeakMap; + readonly memberEffectsComplete: boolean; + readonly opaquePositions: WeakMap; + opaqueSitesIndexed: number; + firstOpaqueRead: number | null; + } + >; readonly evaluationChildren: WeakMap< t.Node, ReadonlyMap | null @@ -80,6 +111,10 @@ const initializerPositionCache = ( mutation: new WeakMap(), reference: new WeakMap(), read: new WeakMap(), + enclosingFunction: new WeakMap(), + functionPosition: new WeakMap(), + statementIndices: new WeakMap(), + moduleFunctionReads: new WeakMap(), evaluationChildren: new WeakMap(), expressionAncestry: undefined, }; @@ -101,7 +136,13 @@ const statementPosition = ( : statement; const parent = parents.get(enclosing); if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null; - const index = parent.body.indexOf(enclosing); + const cache = initializerPositionCache(parents).statementIndices; + let positions = cache.get(parent.body); + if (positions === undefined) { + positions = new Map(parent.body.map((entry, index) => [entry, index])); + cache.set(parent.body, positions); + } + const index = positions.get(enclosing) ?? -1; return index < 0 ? null : { @@ -135,7 +176,8 @@ const computeTopLevelStatementPosition = ( while (node !== undefined) { if ( (t.isFunction(node) && - (child === undefined || !semanticComputedMethodKey(node, child))) || + (child === undefined || + !semanticMethodDefinitionExpression(node, child))) || t.isLoop(node) ) return null; @@ -731,6 +773,38 @@ 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) && + !semanticMethodDefinitionExpression(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, @@ -754,7 +828,8 @@ const bindingReadPosition = ( } if ( parent === undefined || - (t.isFunction(parent) && !semanticComputedMethodKey(parent, current)) || + (t.isFunction(parent) && + !semanticMethodDefinitionExpression(parent, current)) || delayedInstanceFieldValue(parent, current) ) { result = { position: null, repeatingLoop: undefined }; @@ -820,11 +895,608 @@ 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, + state?: JavaScriptSemanticAnalysisState, +): number | null => { + const cache = initializerPositionCache(parents).moduleFunctionReads; + let retained = cache.get(body); + const memberEffectsComplete = state?.memberMutationFactsComplete === true; + if (retained?.memberEffectsComplete !== memberEffectsComplete) + retained = undefined; + if (retained === undefined) { + const reads = new WeakMap(); + retained = { + reads, + memberEffectsComplete, + opaquePositions: new WeakMap(), + opaqueSitesIndexed: 0, + firstOpaqueRead: Infinity, + }; + cache.set(body, retained); + const functions = new Map(); + for (const statement of body) { + const exported = + t.isExportNamedDeclaration(statement) || + t.isExportDefaultDeclaration(statement); + if ( + t.isExportNamedDeclaration(statement) && + isTypeOnlyExportDeclaration(statement) + ) + continue; + 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 firstStatement = body[0]; + const program = + firstStatement === undefined ? undefined : parents.get(firstStatement); + const classicScript = + t.isProgram(program) && javaScriptRuntimeContext(program) === "unknown"; + const globalReceivers = new WeakMap(); + const propertyReaders = new WeakMap< + t.Node, + ReturnType + >(); + const ownMethodReaders = new WeakMap boolean>(); + const ownMethodAtSource = (node: t.Node, name: string): boolean => { + let read = ownMethodReaders.get(node); + if (read === undefined) { + read = staticObjectOwnMethodReader(node); + ownMethodReaders.set(node, read); + } + return read(name); + }; + const receiverPrototypeOwners = new WeakMap< + t.Node, + ReadonlySet + >(); + + const bindingReceivers = new WeakMap< + JavaScriptSemanticBindingState, + t.Node + >(); + const globalReceiver = (input: t.Node): boolean | null => { + const pending: { node: t.Node; finish: boolean }[] = [ + { node: input, finish: false }, + ]; + const visiting = new Set(); + const origins = new WeakMap< + t.Node, + { + dependencies: t.Node[]; + unknown: boolean; + global: boolean; + owners: Set; + } + >(); + while (pending.length > 0) { + const frame = pending.pop(); + if (frame === undefined) continue; + const current = frame.node; + if (frame.finish) { + const fact = origins.get(current); + if (fact === undefined) continue; + let global = fact.global; + let unknown = fact.unknown; + for (const dependency of fact.dependencies) { + for (const owner of receiverPrototypeOwners.get(dependency) ?? []) + fact.owners.add(owner); + const answer = globalReceivers.get(dependency); + if (answer === true) global = true; + if (answer === null || answer === undefined) unknown = true; + } + receiverPrototypeOwners.set(current, fact.owners); + globalReceivers.set(current, global ? true : unknown ? null : false); + visiting.delete(current); + continue; + } + if (globalReceivers.has(current)) continue; + if (visiting.has(current)) { + // A back edge has no finite identity proof; retain uncertainty. + globalReceivers.set(current, null); + continue; + } + visiting.add(current); + const fact = { + dependencies: [] as t.Node[], + unknown: false, + global: false, + owners: new Set(), + }; + const node = unwrapJavaScriptExpression(current).node; + if (t.isArrayExpression(node)) fact.owners.add("Array"); + else if (t.isFunction(node)) fact.owners.add("Function"); + else if (t.isObjectExpression(node)) fact.owners.add("Object"); + else if (t.isStringLiteral(node) || t.isTemplateLiteral(node)) + fact.owners.add("String"); + else if (t.isNumericLiteral(node)) fact.owners.add("Number"); + else if (t.isBooleanLiteral(node)) fact.owners.add("Boolean"); + else if (t.isRegExpLiteral(node)) fact.owners.add("RegExp"); + else if (t.isBigIntLiteral(node)) fact.owners.add("BigInt"); + if (node !== current) fact.dependencies.push(node); + else if (t.isThisExpression(node)) { + const owner = t.isProgram(program) + ? javaScriptThisOwner(node, program, parents) + : "caller"; + fact.global = owner === "global"; + fact.unknown = owner === "caller"; + } else if (t.isIdentifier(node)) { + const binding = + state === undefined + ? undefined + : resolveSemanticBindingState(state, node, node.name); + if (binding === undefined) { + if (["globalThis", "window", "self"].includes(node.name)) + fact.global = true; + else fact.unknown = true; + } else { + const representative = bindingReceivers.get(binding); + if (representative !== undefined && representative !== current) { + fact.dependencies.push(representative); + } else { + bindingReceivers.set(binding, current); + if ( + binding.initializers.length === 0 || + binding.referenceInitializers.length > 0 + ) + fact.unknown = true; + for (const initializer of binding.initializers) { + if (initializer.projection.length > 0) fact.unknown = true; + else fact.dependencies.push(initializer.node); + } + } + } + } else if (t.isObjectExpression(node)) { + fact.unknown = node.properties.some( + (property) => + t.isSpreadElement(property) || + (t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === + "__proto__"), + ); + } else if ( + !t.isArrayExpression(node) && + !t.isLiteral(node) && + !t.isFunction(node) + ) + fact.unknown = true; + origins.set(current, fact); + pending.push({ node: current, finish: true }); + for (const dependency of fact.dependencies) + pending.push({ node: dependency, finish: false }); + } + return globalReceivers.get(input) ?? null; + }; + interface ReceiverBound { + index: number; + node?: t.Node; + ambiguous: boolean; + } + const receiverBounds = new WeakMap< + JavaScriptSemanticBindingState, + ReceiverBound | null + >(); + const receiverBound = ( + root: JavaScriptSemanticBindingState, + ): ReceiverBound | null => { + const pending = [{ binding: root, finish: false }]; + const activeBindings = new Set(); + const dependencies = new WeakMap< + JavaScriptSemanticBindingState, + JavaScriptSemanticBindingState[] + >(); + const merge = ( + left: ReceiverBound, + right: ReceiverBound, + ): ReceiverBound => { + if (left.index < right.index) return left; + if (right.index < left.index) return right; + return { + ...left, + ambiguous: + left.ambiguous || right.ambiguous || left.node !== right.node, + }; + }; + while (pending.length > 0) { + const frame = pending.pop()!; + const binding = frame.binding; + if (frame.finish) { + let bound: ReceiverBound | null = { + index: Infinity, + ambiguous: false, + }; + for (const effects of [binding.memberWrites, binding.escapedPaths]) { + for (const effect of effects) { + const position = + effect.node === undefined + ? null + : directMutationPosition(effect.node, parents); + if ( + effect.node === undefined || + position === null || + position.body !== body + ) { + bound = null; + break; + } + bound = merge(bound, { + index: position.index, + node: effect.node, + ambiguous: false, + }); + } + if (bound === null) break; + } + for (const dependency of dependencies.get(binding) ?? []) { + const inherited = receiverBounds.get(dependency); + if ( + bound === null || + inherited === null || + inherited === undefined + ) { + bound = null; + break; + } + bound = merge(bound, inherited); + } + receiverBounds.set(binding, bound); + activeBindings.delete(binding); + continue; + } + if (receiverBounds.has(binding)) continue; + if (activeBindings.has(binding)) { + receiverBounds.set(binding, null); + continue; + } + activeBindings.add(binding); + const selected: JavaScriptSemanticBindingState[] = []; + for (const initializer of binding.initializers) { + const node = unwrapJavaScriptExpression(initializer.node).node; + if (!t.isIdentifier(node) || state === undefined) continue; + const dependency = resolveSemanticBindingState( + state, + node, + node.name, + ); + if (dependency !== undefined) selected.push(dependency); + } + dependencies.set(binding, selected); + pending.push({ binding, finish: true }); + for (const dependency of selected) + pending.push({ binding: dependency, finish: false }); + } + return receiverBounds.get(root) ?? null; + }; + // A single owner-specific bound serves every literal read in this body. + const prototypeBounds = new Map< + SemanticPrototypeOwner, + ReceiverBound | null + >(); + for (const owner of semanticPrototypeOwners) { + let bound: ReceiverBound | null = { index: Infinity, ambiguous: false }; + for (const effect of state?.prototypeMutationSites ?? []) { + if (effect.owner !== owner && effect.owner !== "Object") continue; + const position = directMutationPosition(effect.node, parents); + if (position === null || position.body !== body) { + bound = null; + break; + } + if (position.index < bound.index) + bound = { + index: position.index, + node: effect.node, + ambiguous: false, + }; + else if (position.index === bound.index && effect.node !== bound.node) + bound = { ...bound, ambiguous: true }; + } + prototypeBounds.set(owner, bound); + } + const receiverAtRead = (input: t.Node, read: t.Node): boolean | null => { + // Allocation provenance follows the same initializer DAG as receiver identity. + const identity = globalReceiver(input); + for (const owner of receiverPrototypeOwners.get(input) ?? []) { + const bound = prototypeBounds.get(owner); + if (bound === null) return null; + if (bound !== undefined && bound.index !== Infinity) { + const position = bindingReadPosition(read, parents); + if ( + position.position === null || + position.repeatingLoop || + position.position.body !== body + ) + return null; + if (position.position.index > bound.index) return null; + if ( + position.position.index === bound.index && + (bound.ambiguous || + bound.node === undefined || + state === undefined || + semanticEffectTiming(read, bound.node, state) !== "after") + ) + return null; + } + } + if (identity !== false || state === undefined || !memberEffectsComplete) + return identity; + const root = unwrapJavaScriptExpression(input).node; + if (!t.isIdentifier(root)) return identity; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) return null; + const bound = receiverBound(binding); + if (bound === null) return null; + if (bound.index === Infinity) return identity; + const position = bindingReadPosition(read, parents); + if ( + position.position === null || + position.repeatingLoop || + position.position.body !== body + ) + return null; + if (position.position.index < bound.index) return identity; + if (position.position.index > bound.index || bound.ambiguous) return null; + return bound.node !== undefined && + (semanticReadPrecedesEffectWithinActivation( + read, + bound.node, + parents, + ) || + semanticEffectTiming(read, bound.node, state) === "after") + ? identity + : null; + }; + const active = new Set(); + const retainNamedRead = ( + name: string, + parent: t.Node | null, + index: number, + ): void => { + const fn = functions.get(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), + ); + }; + const opaquePositions = retained.opaquePositions; + const definitionKeys = new WeakMap(); + let unknownPropertyRead: number | null = Infinity; + for (let index = 0; index < body.length; index++) { + const statement = body[index]; + if (statement === undefined) continue; + traverseJavaScriptAst(statement, { + skip: (node) => + state?.erasedSyntaxNodes.has(node) === true || + isErasedJavaScriptValueChild(node, parents.get(node) ?? null), + enter(node, parent) { + if ( + semanticMethodDefinitionExpression(parent ?? undefined, node) && + parent !== null && + t.isFunction(parent) + ) { + active.delete(parent); + definitionKeys.set(node, parent); + } + if (t.isFunction(node)) active.add(node); + // Direct eval sees lexical module/CommonJS bindings too. Its + // scope-owned sites are complete before ambient indexing starts. + if ( + t.isCallExpression(node) && + state?.opaqueEffects.sites.has(node) + ) { + if (unknownPropertyRead !== null) + unknownPropertyRead = + active.size > 0 ? null : Math.min(unknownPropertyRead, index); + } + if ( + classicScript && + (t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) || + t.isTaggedTemplateExpression(node)) + ) { + opaquePositions.set(node, active.size > 0 ? null : index); + } + if (classicScript) { + for (const source of semanticPropertyReadSources( + node, + parent, + state, + )) { + const projected = + source.iterationSources !== undefined || + source.projection.length > 1; + if (!projected && receiverAtRead(source.node, node) === false) + continue; + // Select the receiver through the shared property-reference owner. + // Unknown/iterated sources retain possible activation. + if (projected && source.iterationSources === undefined) { + let receiver: t.Node | undefined = source.node; + for (const key of source.projection.slice(0, -1)) { + if (typeof key !== "string" && typeof key !== "number") { + receiver = undefined; + break; + } + if (receiver === undefined) break; + let read = propertyReaders.get(receiver); + if (read === undefined) { + read = staticObjectPropertyReader(receiver); + propertyReaders.set(receiver, read); + } + const selected = read(String(key)); + receiver = + selected.status === "explicit" ? selected.value : undefined; + if (receiver === undefined) break; + } + if ( + receiver !== undefined && + receiverAtRead(receiver, node) === false + ) + continue; + } + const key = source.projection[0]; + if (!projected && typeof key === "string") + retainNamedRead(key, parent, index); + else if (unknownPropertyRead !== null) + unknownPropertyRead = + active.size > 0 ? null : Math.min(unknownPropertyRead, index); + } + } + let name: string | null = null; + if ( + t.isIdentifier(node) && + parent !== null && + (t.isReferenced(node, parent) || + (t.isAssignmentExpression(parent) && + parent.left === node && + semanticWriteSiteReadsFirst(parent))) + ) { + const candidate = functions.get(node.name); + const binding = + state === undefined + ? undefined + : resolveSemanticBindingState(state, node, node.name); + if ( + candidate != null && + state !== undefined && + semanticResolutionBlocked(state, node, node.name) + ) { + const current = indexedReads.get(candidate); + if (current !== null && current !== undefined) + indexedReads.set( + candidate, + active.size > 0 ? null : Math.min(current, index), + ); + return; + } + if ( + state === undefined || + (candidate != null && + binding?.initializers.some( + ({ node: value }) => value === candidate, + )) + ) + name = node.name; + } else if ( + classicScript && + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + ) { + const receiver = unwrapJavaScriptExpression(node.object).node; + const selected = semanticStaticPropertyKey( + node.property, + node.computed, + ); + const ownMethod = + selected !== null && + functions.has(selected) && + (ownMethodAtSource(receiver, selected) || + (state !== undefined && + t.isIdentifier(receiver) && + (() => { + const binding = resolveSemanticBindingState( + state, + receiver, + receiver.name, + ); + if ( + binding === undefined || + receiverBound(binding)?.index !== Infinity + ) + return false; + return ( + binding.initializers.length === 1 && + binding.initializers[0]!.projection.length === 0 && + ownMethodAtSource( + unwrapJavaScriptExpression( + binding.initializers[0]!.node, + ).node, + selected, + ) + ); + })())); + // Classic scripts expose hoisted functions through the global object. + // Property names are not referenced identifiers; keep their read timing too. + if (!ownMethod && receiverAtRead(receiver, node) !== false) { + name = selected; + if (name === null) { + if (unknownPropertyRead !== null) + unknownPropertyRead = + active.size > 0 + ? null + : Math.min(unknownPropertyRead, index); + return; + } + } + } + if (name !== null) retainNamedRead(name, parent, index); + }, + exit(node) { + if (t.isFunction(node)) active.delete(node); + const method = definitionKeys.get(node); + if (method !== undefined) active.add(method); + }, + }); + } + // Unknown global keys share one timing fact across the module functions. + for (const fn of functions.values()) { + if (fn == null) continue; + const current = indexedReads.get(fn); + if (current !== null && current !== undefined) + indexedReads.set( + fn, + unknownPropertyRead === null + ? null + : Math.min(current, unknownPropertyRead), + ); + } + } + // Callee resolution can query timing during the ambient walk. Source reads + // stay fixed; consume each newly observed opaque call once without rescanning. + const sites = state?.opaqueEffects.globalSites ?? []; + while (retained.opaqueSitesIndexed < sites.length) { + const site = sites[retained.opaqueSitesIndexed++]; + const position = + site === undefined ? undefined : retained.opaquePositions.get(site); + if (position === undefined || retained.firstOpaqueRead === null) continue; + retained.firstOpaqueRead = + position === null ? null : Math.min(retained.firstOpaqueRead, position); + } + const read = retained.reads.get(declaration); + const first = + read === undefined || read === null || retained.firstOpaqueRead === null + ? null + : Math.min(read, retained.firstOpaqueRead); + return first !== undefined && Number.isFinite(first) ? first : null; +}; + export const semanticBindingAtRead = ( binding: JavaScriptSemanticBindingState, read: t.Node, - parents: WeakMap, + context: WeakMap | JavaScriptSemanticAnalysisState, ): SemanticBindingRead => { + const state = "parentsByNode" in context ? context : undefined; + const parents = + state === undefined + ? (context as WeakMap) + : state.parentsByNode; if ( binding.initializers.length === 0 && binding.referenceInitializers.length === 0 @@ -919,11 +1591,66 @@ 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; + let functionPosition: StatementPosition | null = null; + if (enclosingFunction !== undefined) { + const positions = initializerPositionCache(parents).functionPosition; + const cached = positions.get(enclosingFunction); + if (cached !== undefined) functionPosition = cached; + else { + const compute = (): StatementPosition | null => { + if (t.isStatement(enclosingFunction)) + return statementPosition(enclosingFunction, parents); + for ( + let owner = parents.get(enclosingFunction); + owner !== undefined; + owner = parents.get(owner) + ) { + if (t.isFunction(owner)) return null; + if (t.isStatement(owner)) return statementPosition(owner, parents); + } + return null; + }; + functionPosition = compute(); + positions.set(enclosingFunction, functionPosition); + } + } + const firstFunctionRead = + moduleConstBinding && + sourcePosition !== null && + functionPosition !== null && + sourcePosition.body === functionPosition.body && + t.isFunctionDeclaration(enclosingFunction) + ? moduleFunctionFirstRead( + enclosingFunction, + sourcePosition.body, + parents, + state, + ) + : 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); @@ -1271,7 +1998,7 @@ export const semanticBindingHasPriorValueWrite = ( ): boolean => { if (binding.definitions.some(({ kind }) => kind === "function")) return true; const writes = state.bindingWrites.get(binding.bindingId) ?? []; - const read = semanticBindingAtRead(binding, site, state.parentsByNode); + const read = semanticBindingAtRead(binding, site, state); const retained = read.status === "ordered" ? new Set([ diff --git a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts index c213557f4e..b4905f7bc2 100644 --- a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts +++ b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts @@ -53,44 +53,68 @@ export const createSemanticOpaqueGlobalObserver = ( ) => ( node: t.Node, site: t.Node | undefined, - ) => { paths: readonly (readonly (string | null)[])[] }; + ) => { + paths: readonly (readonly (string | null)[])[]; + callerPaths: readonly (readonly (string | null)[])[]; + }; createSemanticModuleInterop: ( state: JavaScriptSemanticAnalysisState, options?: { intrinsicPath: (node: t.Node) => readonly string[] | undefined; }, ) => { + intrinsicPath: (node: t.Node) => readonly string[] | undefined; intrinsicInvocation: ( - node: t.CallExpression | t.OptionalCallExpression, + node: + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression + | t.TaggedTemplateExpression, ) => { readonly path: readonly string[] } | undefined; }; }, ): ((node: t.Node) => void) | undefined => { - if (!state.opaqueEffects.mayReferenceGlobalEval) return undefined; + if (!state.opaqueEffects.mayReferenceGlobalCode) return undefined; const ambientPaths = dependencies.createAmbientPathResolver(state, true); let invocationSite: t.Node | undefined; const interop = dependencies.createSemanticModuleInterop(state, { intrinsicPath: (node) => { - const paths = ambientPaths(node, invocationSite).paths; - const path = paths.find((path) => path[1] === "eval") ?? paths[0]; + const { paths, callerPaths } = ambientPaths(node, invocationSite); + const path = + paths.find((path) => path[1] === "eval" || path[1] === "Function") ?? + callerPaths.find( + (path) => path[1] === "eval" || path[1] === "Function", + ) ?? + paths[0]; if (path?.[0] !== "global" || path.some((key) => key === null)) return undefined; // Keep the global callable owner for generic call/apply/bind resolution. - return path[1] === "eval" + return path[1] === "eval" || path[1] === "Function" ? ["intrinsic", ...(path as readonly string[])] : ["intrinsic", ...(path.slice(1) as readonly string[])]; }, }); return (node) => { - if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; + if ( + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) && + !t.isTaggedTemplateExpression(node) + ) + return; invocationSite = node; - const invocation = interop.intrinsicInvocation(node); - if (invocation?.path.join(".") === "intrinsic.global.eval") + const path = interop.intrinsicInvocation(node)?.path; + // Constructed code may escape and activate a classic-script global. Keep + // its creation as a conservative timing bound, not proof of execution. + if ( + path?.join(".") === "intrinsic.global.eval" || + path?.join(".") === "intrinsic.global.Function" + ) state.opaqueEffects.globalSites.push(node); }; }; -/** Indirect eval can change globals, but cannot rebind its caller's locals. */ +/** Global code can affect globals, but cannot rebind its caller's locals. */ export const semanticOpaqueGlobalMayHaveChanged = ( read: t.Node, state: JavaScriptSemanticAnalysisState, diff --git a/src/domain/javascript/javascriptSemanticProjectedSources.ts b/src/domain/javascript/javascriptSemanticProjectedSources.ts index 152271cfb4..963d0d8903 100644 --- a/src/domain/javascript/javascriptSemanticProjectedSources.ts +++ b/src/domain/javascript/javascriptSemanticProjectedSources.ts @@ -18,10 +18,10 @@ import { semanticTopLevelEffectFollowsCapture, } from "./javascriptSemanticMutationInitializers.js"; import { - semanticCopiedReferencePath, semanticReferenceIterationUnknown, semanticReferenceSourcesInactive, } from "./javascriptSemanticReferenceSources.js"; +import { semanticCopiedReferencePath } from "./javascriptSemanticCopiedReferencePaths.js"; import { semanticStoredSourceSelection } from "./javascriptSemanticIterationSources.js"; import { semanticEffectPathCandidates, @@ -227,7 +227,7 @@ const projectedSourceFactsAtRead = ( readonly retainsOrigin: boolean; } | undefined => { - const read = semanticBindingAtRead(binding, reference, state.parentsByNode); + const read = semanticBindingAtRead(binding, reference, state); const captured = [ ...read.binding.initializers, ...read.binding.referenceInitializers, diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 3ad7cc3af0..ae201131ab 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -47,7 +47,7 @@ import { type CommonJsExportSite, } from "./javascriptCommonJsExportReceivers.js"; import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; -import { semanticComputedMethodKey } from "./javascriptSemanticEvaluationOrder.js"; +import { semanticMethodDefinitionExpression } from "./javascriptSemanticEvaluationOrder.js"; interface CollectCallableInput { readonly node: t.Node; @@ -77,7 +77,7 @@ export const collectSemanticCallable = (input: CollectCallableInput): void => { computedKeyLocation: "key" in node && t.isNode(node.key) && - semanticComputedMethodKey(node, node.key) + semanticMethodDefinitionExpression(node, node.key) ? range(node.key) : null, returnSites: [], diff --git a/src/domain/javascript/javascriptSemanticPromises.ts b/src/domain/javascript/javascriptSemanticPromises.ts index 08515e4e43..80aae5e75d 100644 --- a/src/domain/javascript/javascriptSemanticPromises.ts +++ b/src/domain/javascript/javascriptSemanticPromises.ts @@ -488,7 +488,7 @@ const resolvePromiseExpression = ( const binding = resolveSemanticBindingState(state, node, node.name); if (binding === undefined || seenBindings.has(binding.bindingId)) return unresolvedPromise(); - const read = semanticBindingAtRead(binding, node, state.parentsByNode); + const read = semanticBindingAtRead(binding, node, state); if ( read.status !== "ordered" || !read.hasDefiniteInitializer || diff --git a/src/domain/javascript/javascriptSemanticReferenceSources.ts b/src/domain/javascript/javascriptSemanticReferenceSources.ts index 58b1307c00..3a97d3a157 100644 --- a/src/domain/javascript/javascriptSemanticReferenceSources.ts +++ b/src/domain/javascript/javascriptSemanticReferenceSources.ts @@ -5,7 +5,6 @@ import { semanticPropertyPathKeyMatches, type JavaScriptSemanticPropertyPath as PropertyPath, } from "./javascriptSemanticPropertyPaths.js"; -import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { evaluateSemanticExpression, semanticValueDefinitelyDefined, @@ -147,55 +146,3 @@ export const semanticSelectedReferenceSlot = ( path, ); }; - -interface CopiedReferenceSource { - readonly projection: PropertyPath; - readonly copyKind?: "object-rest" | "array-rest"; - readonly copyProjectionOffset?: number; - readonly copyExcludedKeys?: readonly string[]; - readonly copyStartIndex?: number; -} - -/** Map copied child selections once; the fresh copy itself keeps its own identity. */ -export const semanticCopiedReferencePath = ( - initializer: CopiedReferenceSource, - path: PropertyPath, - effect: "write" | "escape" | "identity", -): PropertyPath | null => { - const offset = initializer.copyProjectionOffset; - if (offset === undefined) return [...initializer.projection, ...path]; - const copiedPath = [...initializer.projection.slice(offset + 1), ...path]; - if (effect === "identity" && copiedPath.length === 0) return null; - if (effect === "write" && copiedPath.length < 2) return null; - let [key, ...remaining] = copiedPath; - if (initializer.copyKind === "array-rest") { - const startIndex = initializer.copyStartIndex ?? 0; - if (key === undefined || key === null) - key = { excludedKeys: [], startIndex }; - else if (typeof key === "object") - key = { - excludedKeys: key.excludedKeys.flatMap((name) => { - const index = semanticArrayIndex(name); - return index === null ? [] : [String(index + startIndex)]; - }), - startIndex: (key.startIndex ?? 0) + startIndex, - }; - else { - const index = semanticArrayIndex(key); - if (index === null) return null; - key = index + startIndex; - } - } else { - const excludedKeys = initializer.copyExcludedKeys ?? []; - if (key === undefined || key === null) key = { excludedKeys }; - else if (typeof key === "object") - key = { - ...key, - excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort( - compareUnicodeCodePoints, - ), - }; - else if (excludedKeys.includes(String(key))) return null; - } - return [...initializer.projection.slice(0, offset), key, ...remaining]; -}; diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 1e924ab607..59475e18d6 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -493,11 +493,7 @@ const selectedRequireSourcesAt = ( } if (facts === undefined) { unknown = true; - const retained = semanticBindingAtRead( - binding, - node, - state.parentsByNode, - ).binding; + const retained = semanticBindingAtRead(binding, node, state).binding; pending.push( ...inheritLoaderSourceContext( current, @@ -1265,7 +1261,7 @@ const hasCommonJsModuleSource = ( continue; } if (binding === undefined) continue; - const read = semanticBindingAtRead(binding, value, state.parentsByNode); + const read = semanticBindingAtRead(binding, value, state); const retainsMainModule = mainModulePath && commonJsWrapperBindingDeclaration(value, "require", state) !== diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index cd9a1717c7..4a3e93cb00 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -390,11 +390,7 @@ export const createSemanticReturnedReferences = ( result.precision = "widened"; if (sourceGenericInvocation) result.unresolvedInvocation = true; } - const selected = semanticBindingAtRead( - binding, - node, - state.parentsByNode, - ).binding; + const selected = semanticBindingAtRead(binding, node, state).binding; for (const initializer of [ ...selected.initializers, ...selected.referenceInitializers, diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index a34b7be780..31e2305701 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -166,7 +166,7 @@ const callableIdsForBinding = ( const selected = read === undefined ? binding - : semanticBindingAtRead(binding, read, state.parentsByNode).binding; + : semanticBindingAtRead(binding, read, state).binding; const initializer = selected.initializers[0]; if ( initializer === undefined || diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 946ac31977..fc524fc4bd 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,3 +1,4 @@ +import type { SemanticPropertyReadSource } from "./javascriptSemanticIterationSources.js"; import * as t from "@babel/types"; import { semanticStaticPropertyKey, @@ -119,17 +120,37 @@ export interface JavaScriptSemanticScopeState { /** One source owns sites and the minimal scope tree reaching them; O(AST) storage. */ export interface JavaScriptOpaqueScopeEffects { /** Cheap source evidence collected with declarations, before alias queries. */ - mayReferenceGlobalEval: boolean; + mayReferenceGlobalCode: boolean; readonly sites: WeakSet; readonly sloppySites: WeakSet; readonly byScope: Map; readonly children: Map>; - readonly globalSites: (t.CallExpression | t.OptionalCallExpression)[]; + readonly globalSites: ( + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression + | t.TaggedTemplateExpression + )[]; } +/** Intrinsic prototypes tracked from mutation sites to inherited reads. */ +export const semanticPrototypeOwners = [ + "Object", + "Array", + "String", + "Function", + "Number", + "Boolean", + "RegExp", + "BigInt", +] as const; +export type SemanticPrototypeOwner = (typeof semanticPrototypeOwners)[number]; + /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { arrayIterationUnknown: boolean; + /** Receiver isolation requires the completed member-effect inventory. */ + memberMutationFactsComplete: boolean; readonly program: t.Program; readonly opaqueEffects: JavaScriptOpaqueScopeEffects; /** Effective loader for static imports in this source artifact. */ @@ -154,6 +175,14 @@ export interface JavaScriptSemanticAnalysisState { prototypeRequireMutationObserved: boolean | undefined; /** Source evidence that Object.prototype may add inherited enumerable keys. */ prototypeEnumerationMayAffect: boolean | undefined; + readonly implicitPropertyReads: WeakMap< + t.Node, + readonly SemanticPropertyReadSource[] + >; + readonly prototypeMutationSites: { + readonly owner: SemanticPrototypeOwner; + readonly node: t.Node; + }[]; /** Effects on a cached require exports object, independent of local projections. */ readonly requiredModuleEffects: Map< string, diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 11b48f9663..bc2e07e9f1 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -682,11 +682,7 @@ const evaluateExpressionValue = ( const binding = resolveSemanticBindingState(context.state, node, node.name); if (binding === undefined) return { status: "unknown", reason: `Unbound identifier ${node.name}.` }; - const read = semanticBindingAtRead( - binding, - node, - context.state.parentsByNode, - ); + const read = semanticBindingAtRead(binding, node, context.state); if ( read.status === "unresolved" && binding.mutable && @@ -1377,7 +1373,7 @@ const provenanceForBindingAtRead = ( const { binding: snapshot } = semanticBindingAtRead( binding, read, - context.state.parentsByNode, + context.state, ); if (snapshot === binding) return provenanceForBinding(binding, context); if (context.bindings.has(binding.bindingId)) diff --git a/src/domain/javascript/javascriptSourceParser.test.ts b/src/domain/javascript/javascriptSourceParser.test.ts index 5f1f62024b..64ab0f3534 100644 --- a/src/domain/javascript/javascriptSourceParser.test.ts +++ b/src/domain/javascript/javascriptSourceParser.test.ts @@ -7,9 +7,43 @@ import { parserPluginsForPath, javaScriptRuntimeContext, javaScriptProgramStrictness, + javaScriptTopLevelThisMayBeGlobal, } from "./javascriptSourceParser.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +it.each([ + ["class C extends (this.marker, Object) {}", true], + ["class C { [this.marker]() {} }", true], + ["class C { [this.marker] = 1 }", true], + ["({[this.marker]() {}})", true], + ["class C { value = this.marker }", false], + ["class C { static value = this.marker }", false], + ["class C { static { this.marker } }", false], + ["class C { method() { this.marker } }", false], + ["function f() { return class C extends (this.marker, Object) {} }", false], + ["class C { value = class D { [this.marker]() {} } }", false], + ["(() => class C { [this.marker]() {} })()", true], +] as const)( + "owns definition-time this separately from class bodies: %s", + (source, expected) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected parsed fixture"); + const parents = new WeakMap(); + let selected: t.ThisExpression | undefined; + traverseJavaScriptAst(parsed.program, { + enter(node, parent) { + if (parent !== null) parents.set(node, parent); + if (t.isThisExpression(node)) selected = node; + }, + }); + if (selected === undefined) throw new Error("Missing this fixture"); + expect( + javaScriptTopLevelThisMayBeGlobal(selected, parsed.program, parents), + ).toBe(expected); + }, +); + it.each([ ['import type {T} from "example";', "fixture.cts", "commonjs"], ['import type {T} from "example";', "fixture.mts", "module"], diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index 82bff08cb8..9fc1d9a1b9 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -1,5 +1,6 @@ import { parse, type ParserOptions, type ParserPlugin } from "@babel/parser"; import * as t from "@babel/types"; +import { javaScriptDefinitionExpression } from "./javascriptAstValues.js"; import { hasOnlyInlineTypeExportSpecifiers, hasOnlyInlineTypeImportSpecifiers, @@ -282,3 +283,51 @@ export const parseJavaScriptSource = ( return null; } }; + +/** A script's top-level lexical this may be its ambient global; wrappers and modules isolate it. */ +export const javaScriptThisOwner = ( + node: t.ThisExpression, + program: t.Program, + parents: WeakMap, +): "global" | "caller" | "isolated" => { + let child: t.Node = node; + let classDefinitionExpression = false; + let current: t.Node | undefined = parents.get(node); + while (current !== undefined && current !== program) { + const definitionExpression = javaScriptDefinitionExpression(current, child); + if (definitionExpression && !t.isObjectMethod(current)) + classDefinitionExpression = true; + if ( + (t.isClassProperty(current) || + t.isClassPrivateProperty(current) || + t.isClassAccessorProperty(current)) && + current.value === child + ) + return "isolated"; + if (t.isStaticBlock(current)) return "isolated"; + if ( + t.isFunction(current) && + !t.isArrowFunctionExpression(current) && + !definitionExpression + ) + return "caller"; + if ( + t.isClass(current) && + current.superClass !== child && + !classDefinitionExpression + ) + return "isolated"; + if (t.isClass(current)) classDefinitionExpression = false; + child = current; + current = parents.get(current); + } + return current === program && javaScriptRuntimeContext(program) === "unknown" + ? "global" + : "isolated"; +}; + +export const javaScriptTopLevelThisMayBeGlobal = ( + node: t.ThisExpression, + program: t.Program, + parents: WeakMap, +): boolean => javaScriptThisOwner(node, program, parents) === "global"; diff --git a/tests/acceptance/applications/electronModuleInitializationCli.test.ts b/tests/acceptance/applications/electronModuleInitializationCli.test.ts new file mode 100644 index 0000000000..e4944f4a75 --- /dev/null +++ b/tests/acceptance/applications/electronModuleInitializationCli.test.ts @@ -0,0 +1,347 @@ +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 }, + }, + ], + }); + } + }, +); + +cliTest.for([ + ["CLI", false], + ["stdio MCP", false], + ["CLI", true], + ["stdio MCP", true], + ["CLI", "cached-eval"], + ["stdio MCP", "cached-eval"], + ["CLI", "constructed-function"], + ["stdio MCP", "constructed-function"], + ["CLI", "erased-type"], + ["stdio MCP", "erased-type"], + ["CLI", "module-eval"], + ["stdio MCP", "module-eval"], + ["CLI", "bound-constructor"], + ["stdio MCP", "bound-constructor"], + ["CLI", "reflect-constructor"], + ["stdio MCP", "reflect-constructor"], + ["CLI", "prototype-effect"], + ["stdio MCP", "prototype-effect"], + ["CLI", "assign-effect"], + ["stdio MCP", "assign-effect"], + ["CLI", "this-constructor"], + ["stdio MCP", "this-constructor"], + ["CLI", "alias-prototype"], + ["stdio MCP", "alias-prototype"], + ["CLI", "alias-assign"], + ["stdio MCP", "alias-assign"], + ["CLI", "assignment-pattern"], + ["stdio MCP", "assignment-pattern"], + ["CLI", "iteration-pattern"], + ["stdio MCP", "iteration-pattern"], + ["CLI", "reflect-read"], + ["stdio MCP", "reflect-read"], + ["CLI", "catch-read"], + ["stdio MCP", "catch-read"], + ["CLI", "heritage"], + ["stdio MCP", "heritage"], + ["CLI", "computed-key"], + ["stdio MCP", "computed-key"], + ["CLI", "tagged-function"], + ["stdio MCP", "tagged-function"], + ["CLI", "array-prototype"], + ["stdio MCP", "array-prototype"], +] as const)( + "preserves classic global initialization uncertainty through %s (%s)", + async ([surface, alias], { cli }) => { + const root = await createTestTempDirectory( + "rea-electron-global-initialization-", + ); + const activation = + alias === "reflect-read" + ? "Reflect.get(globalThis, 'createWindow')();" + : alias === "catch-read" + ? "try { throw globalThis; } catch ({createWindow:start}) { start(); }" + : alias === "heritage" + ? "class C extends (this.Function('createWindow()')(), Object) {}" + : alias === "computed-key" + ? "({ [this.eval('createWindow()')]() {} });" + : alias === "tagged-function" + ? "Function`createWindow()`();" + : alias === "array-prototype" + ? "Object.setPrototypeOf(Array.prototype, globalThis); [].createWindow();" + : alias === "this-constructor" + ? "this['Function']('createWindow()')();" + : alias === "alias-prototype" + ? "const changed = {}; const indirect = changed; Object.setPrototypeOf(changed, globalThis); indirect.createWindow();" + : alias === "alias-assign" + ? "const changed = {}; const indirect = changed; Object.assign(changed, globalThis); indirect.createWindow();" + : alias === "bound-constructor" + ? "const F = Function.bind(null); new F('createWindow()')();" + : alias === "reflect-constructor" + ? "Reflect.construct(Function, ['createWindow()'])();" + : alias === "prototype-effect" + ? "const changed = {}; Object.setPrototypeOf(changed, globalThis); changed.createWindow();" + : alias === "assign-effect" + ? "const changed = {}; Object.assign(changed, globalThis); changed.createWindow();" + : alias === "assignment-pattern" + ? "let start; ({createWindow:start}=globalThis); start();" + : alias === "iteration-pattern" + ? "for(const {createWindow:start} of [globalThis]) start();" + : alias === "module-eval" + ? "eval('createWindow()');" + : alias === "erased-type" + ? "globalThis.createWindow();" + : alias === "constructed-function" + ? "new Function('createWindow()')();" + : alias === "cached-eval" + ? "const noop = () => {}; function unused() { noop(); } (0, eval)('createWindow()');" + : alias + ? "root.createWindow();" + : "globalThis.createWindow();"; + const source = (early: boolean) => ` + ${alias === "erased-type" ? "import type { T } from './types';" : alias === "module-eval" ? "export {};" : ""} + ${alias === "module-eval" ? 'import { BrowserWindow } from "electron";' : 'const { BrowserWindow } = require("electron");'} + ${alias ? "const root = globalThis;" : ""} + ${alias === "reflect-read" || alias === "catch-read" ? "Object.extra = 1; function unusedArrayRead() { [].createWindow; }" : ""} + const unrelated = { createWindow() {} }; + function unusedReceiverRead() { unrelated.createWindow; } + for (const ignored of [1, 2]) { unrelated.createWindow; } + ${early ? activation : ""} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + ${early || alias === "module-eval" ? "" : activation} + createWindow(); + throw new Error("must not execute source"); + `; + const extension = + alias === "erased-type" ? "ts" : alias === "module-eval" ? "mjs" : "js"; + const earlyName = `early.${extension}`; + const lateName = `late.${extension}`; + await writeFixtureFiles(root, { + [earlyName]: source(true), + [lateName]: source(false), + "index.html": ``, + }); + 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(2); + const inSource = (name: string) => + windows + .flatMap(({ observations }) => observations) + .filter( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === name, + ); + expect(inSource(earlyName)).toHaveLength(1); + expect(inSource(earlyName)[0]?.properties).toMatchObject({ + web_preferences_status: "dynamic", + web_preferences: [], + }); + expect(inSource(lateName)).toHaveLength(1); + expect(inSource(lateName)[0]?.properties).toMatchObject({ + web_preferences_status: "object-literal", + web_preferences: [ + { name: "nodeIntegration", value: { status: "literal", value: true } }, + ], + }); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves aliased prototype, reflected and parameter activation through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory( + "rea-electron-prototype-parameters-", + ); + const activations = { + callerThis: "function boot() { this.createWindow(); } boot();", + boundThis: + "function boot() { this.createWindow(); } boot.call(globalThis);", + numericPrototype: + "Object.assign(Object.prototype, globalThis); (0).createWindow();", + booleanPrototype: + "Object.assign(Object.prototype, globalThis); true.createWindow();", + regexpPrototype: + "Object.assign(Object.prototype, globalThis); /x/.createWindow();", + nestedGlobalPrototype: + "Object.setPrototypeOf(globalThis.window.Array.prototype, globalThis); [].createWindow();", + taggedCatch: + "function fail() { throw globalThis; } try { fail``; } catch ({createWindow:start}) { start(); }", + getterCatch: + "const box = { get value() { throw globalThis; } }; try { box.value; } catch ({createWindow:start}) { start(); }", + reflectedApply: + "const args = [globalThis, 'createWindow']; Reflect.get.apply(Reflect, args)();", + projectedReceiver: + "const { root } = { root: globalThis, createWindow() {} }; const early = root.createWindow; early();", + assignedReceiver: + "let root; ({ root } = { root: globalThis, createWindow() {} }); const early = root.createWindow; early();", + nestedReceiver: + "const { nested: { root } } = { nested: { root: globalThis }, createWindow() {} }; root.createWindow();", + catchPropagated: + "function fail() { throw globalThis; } try { fail(); } catch ({ createWindow: start }) { start(); }", + catchNested: + "function fail() { throw globalThis; } try { if (true) fail(); } catch ({ createWindow: start }) { start(); }", + objectPrototype: + "Object.assign(Object.prototype, globalThis); ({}).createWindow();", + stringPrototype: + "Object.setPrototypeOf(String.prototype, globalThis); ''.createWindow();", + lexicalThisPrototype: + "Object.setPrototypeOf(this.Array.prototype, globalThis); [].createWindow();", + reflectedTemplate: + "globalThis[`Reflect`].get(globalThis, 'createWindow')();", + reflectedOptional: "Reflect.get?.(globalThis, 'createWindow')();", + reflectedNamespace: + "const R = Reflect; R.get(globalThis, 'createWindow')();", + reflectedMethod: + "const read = Reflect.get; read(globalThis, 'createWindow')();", + reflectedBound: + "const read = Reflect.get.bind(Reflect); read(globalThis, 'createWindow')();", + array: + "const receiver = []; const alias = receiver; Object.setPrototypeOf(Array.prototype, globalThis); alias.createWindow();", + function: + "const receiver = function(){}; const alias = receiver; Object.setPrototypeOf(Function.prototype, globalThis); alias.createWindow();", + parameter: + "function boot({ createWindow: start } = {}) { start(); } boot(globalThis);", + }; + const files: Record = {}; + for (const [kind, activation] of Object.entries(activations)) { + for (const early of [true, false]) { + files[`${kind}-${early ? "early" : "late"}.js`] = ` + const { BrowserWindow } = require("electron"); + ${early ? activation : ""} + const webPreferences = { nodeIntegration: true }; + function createWindow() { return new BrowserWindow({ webPreferences }); } + ${early ? "" : activation} + createWindow(); + throw new Error("must not execute source"); + `; + } + } + files["index.html"] = Object.keys(files) + .map((name) => ``) + .join(""); + await writeFixtureFiles(root, files); + 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(50); + for (const source of Object.keys(files).filter((name) => + name.endsWith(".js"), + )) { + const observations = windows + .flatMap(({ observations }) => observations) + .filter( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ); + expect(observations).toHaveLength(1); + const definite = + source.endsWith("-late.js") && + !["parameter-", "callerThis-", "boundThis-"].some((prefix) => + source.startsWith(prefix), + ); + expect(observations[0]?.properties).toMatchObject( + definite + ? { + web_preferences_status: "object-literal", + web_preferences: [ + { + name: "nodeIntegration", + value: { status: "literal", value: true }, + }, + ], + } + : { web_preferences_status: "dynamic", web_preferences: [] }, + ); + } + }, +);