From 041a5064703dc2a00b80e4c8e0fc5852aee558a7 Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Mon, 2 Nov 2026 19:40:06 +0800 Subject: [PATCH 01/28] fix(javascript): emit no event operation for a missing listener argument `EventEmitter` validates argument 1 with `checkListener` before it registers or removes anything. A call that does not pass a listener at all therefore gets `undefined` there and throws: emitter.on() -> THREW ERR_INVALID_ARG_TYPE emitter.on("x") -> THREW ERR_INVALID_ARG_TYPE emitter.once("x") -> THREW ERR_INVALID_ARG_TYPE emitter.addListener("x") -> THREW ERR_INVALID_ARG_TYPE emitter.prependListener("x") -> THREW ERR_INVALID_ARG_TYPE emitter.prependOnceListener("x") -> THREW ERR_INVALID_ARG_TYPE emitter.removeListener("x") -> THREW ERR_INVALID_ARG_TYPE emitter.off("x") / emitter.off() -> THREW ERR_INVALID_ARG_TYPE The analysis still reported a `register` or `remove` operation for those calls, because the listener guard only ran when argument 1 existed. The position is empty, so no function can arrive in it. A spread argument still reaches the position through the call, so `e.on(...pairs)` keeps its operation, as does the already-shifted `e.on(...args, 1)` form. Measured on Node 22.23.3; nine regressions cover the listener methods and the two no-argument forms, and all nine fail without the guard. --- ...icAnalysis.rejected_event_listener.test.ts | 42 +++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 14 +++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 3e217f94d04..c8ed218c99c 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -67,6 +67,48 @@ it.each([ ]); }); +/** + * A listener method called without a listener argument passes `undefined` to + * Node's `checkListener`, which throws before anything is registered or + * removed. The argument position is empty, so no function can arrive there. + * + * Measured on Node 22.23.3: + * + * emitter.on() -> THREW ERR_INVALID_ARG_TYPE + * emitter.on("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.once("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.addListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.prependListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.prependOnceListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.removeListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.off("x") / emitter.off() -> THREW ERR_INVALID_ARG_TYPE + * + * A spread still reaches the position through the call, so it is untouched. + */ +it.each([ + ["a missing listener", `e.on("x")`], + ["a missing once listener", `e.once("x")`], + ["a missing addListener listener", `e.addListener("x")`], + ["a missing prependListener listener", `e.prependListener("x")`], + ["a missing prependOnceListener listener", `e.prependOnceListener("x")`], + ["a missing removeListener listener", `e.removeListener("x")`], + ["a missing off listener", `e.off("x")`], + ["no arguments at all", `e.on()`], + ["no off arguments at all", `e.off()`], +] as const)("emits no event operation for %s", (_name, call) => { + expect(eventsFor(call)).toEqual([]); +}); + +it("keeps the event operation for a spread call with no fixed listener", () => { + expect(eventsFor(`e.on(...pairs)`)).toEqual([ + { + kind: expect.any(String) as unknown as string, + method: "on", + eventName: null, + }, + ]); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index ea9e990bd18..ac5bea8a142 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -177,9 +177,17 @@ const collectEvent = ( if (method === undefined) return; const kind = eventKind(method); const eventArgument = node.arguments[0]; - const listenerArgument = EVENT_LISTENER_METHODS.includes(method) - ? node.arguments[1] - : undefined; + const listenerMethod = EVENT_LISTENER_METHODS.includes(method); + const listenerArgument = listenerMethod ? node.arguments[1] : undefined; + // A listener method called without a listener argument passes `undefined` to + // Node's `checkListener`, which throws before it registers or removes + // anything. Only a spread argument can still reach that position. + if ( + listenerMethod && + listenerArgument === undefined && + !node.arguments.some((argument) => t.isSpreadElement(argument)) + ) + return; // A leading spread shifts the runtime argument positions, so argument 1 is not // provably the listener: `e.on(...["x", fn], 1)` registers `fn` and ignores the // trailing `1`. From f1d30fe2e653523c50220863dd90dc127feb0eb6 Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Tue, 3 Nov 2026 10:32:15 +0800 Subject: [PATCH 02/28] fix(javascript): hold only a proven Node emitter to the listener contract A listener method called without a listener argument throws in Node, but EventEmitter-style APIs outside Node define one-argument forms: `socket.off("x")` removes every listener for the event, and a subclass may default the listener position. Rejecting the operation by method name alone dropped those real operations from the semantic graph. Require the receiver to be a value this module constructs from `events.EventEmitter`, resolved through imports and requires. Any other receiver keeps its operation as an unresolved candidate. --- ...icAnalysis.rejected_event_listener.test.ts | 75 +++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 59 ++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index c8ed218c99c..190acadd17b 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -27,6 +27,11 @@ const eventsFor = (call: string) => eventName, })); +const eventsForSource = (source: string) => + analyzeJavaScriptSemantics(source).eventOperations.map( + ({ kind, method, eventName }) => ({ kind, method, eventName }), + ); + it.each([ ["a numeric listener", `e.on("x", 1)`], ["a string listener", `e.on("x", "fn")`], @@ -109,6 +114,76 @@ it("keeps the event operation for a spread call with no fixed listener", () => { ]); }); +it.each([ + [ + "a named import", + `import { EventEmitter } from "node:events";`, + "EventEmitter", + ], + ["a default import", `import EE from "node:events";`, "EE"], + [ + "a destructured require", + `const { EventEmitter } = require("events");`, + "EventEmitter", + ], + [ + "a member require", + `const events = require("node:events");`, + "events.EventEmitter", + ], +] as const)( + "drops a missing listener on a receiver built from %s", + (_name, header, constructor) => { + expect( + eventsForSource(`${header} +const e = new ${constructor}(); +e.on("x");`), + ).toEqual([]); + }, +); + +it("keeps a missing listener when the receiver was reassigned", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e = other; +e.on("x");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "x" }); +}); + +/** + * `checkListener` is Node's own contract. An EventEmitter-style receiver that + * this module does not construct from `events.EventEmitter` may define a + * one-argument form: Socket.IO's `socket.off("x")` removes every listener for + * the event, so the operation must survive as an unresolved candidate. + */ +it("keeps a one-argument removal on a receiver with no proven Node emitter", () => { + expect( + eventsForSource(`function g(socket) { socket.off("x"); }`), + ).toContainEqual({ kind: "remove", method: "off", eventName: "x" }); +}); + +/** + * A subclass may default the listener position, which makes `bus.on("ready")` + * a real registration even though the module never proves Node's rejection. + * Measured on Node 22.23.3: this class stores `"ready"` and does not throw. + */ +it("keeps the registration of a subclass that defaults its listener", () => { + const events = eventsForSource( + `import { EventEmitter } from "node:events"; +class Bus extends EventEmitter { + on(name, fn = () => {}) { return super.on(name, fn); } +} +const bus = new Bus(); +bus.on("ready");`, + ); + expect(events).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index ac5bea8a142..9f8d1c2f246 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -15,6 +15,7 @@ import { import { resolveSemanticBindingState, semanticResolutionBlocked, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; @@ -23,6 +24,7 @@ import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { + builtinDataEffectCallablePath, builtinDataEffectMethod, dataEffectArgumentBindingId, dataEffectLiteralString, @@ -161,6 +163,57 @@ const rejectsEventListener = (listener: t.Node): boolean => { ); }; +/** + * Node's `events` module, whose `EventEmitter` validates every listener + * argument before it registers or removes anything. + */ +const NODE_EVENTS_SPECIFIERS = ["events", "node:events"] as const; + +/** + * Report whether a constructor reference resolves to Node's `EventEmitter`. + * Named and default imports, destructured requires, and member requires all + * denote that class. + */ +const isNodeEventEmitterConstructor = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const value = unwrapJavaScriptExpression(node).node; + if (!t.isNewExpression(value)) return false; + const selected = builtinDataEffectCallablePath( + value.callee, + state, + NODE_EVENTS_SPECIFIERS, + ); + // An empty selected path is the module's default export, which `events` + // defines as `EventEmitter`. + return ( + selected !== null && + (selected.length === 0 || selected.at(-1) === "EventEmitter") + ); +}; + +/** + * Report whether the receiver is a value this module constructs from Node's + * `EventEmitter`. Only that class is bound by `checkListener`; other + * EventEmitter-style APIs define one-argument forms, such as Socket.IO's + * `socket.off("x")`, which removes every listener for the event. A receiver + * that is not proven this way keeps its operation as an unresolved candidate. + */ +const nodeEventEmitterReceiver = ( + node: t.Expression | t.Super, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (!t.isIdentifier(node)) return false; + const binding = resolveSemanticBindingState(state, node, node.name); + const initializer = semanticStableInitializer(binding, state); + return ( + initializer !== undefined && + initializer.projection.length === 0 && + isNodeEventEmitterConstructor(initializer.node, state) + ); +}; + const collectEvent = ( node: t.CallExpression | t.OptionalCallExpression, ownerCallableId: string | null, @@ -181,11 +234,13 @@ const collectEvent = ( const listenerArgument = listenerMethod ? node.arguments[1] : undefined; // A listener method called without a listener argument passes `undefined` to // Node's `checkListener`, which throws before it registers or removes - // anything. Only a spread argument can still reach that position. + // anything. Only a spread argument can still reach that position, and only a + // receiver proven to be Node's `EventEmitter` is held to that contract. if ( listenerMethod && listenerArgument === undefined && - !node.arguments.some((argument) => t.isSpreadElement(argument)) + !node.arguments.some((argument) => t.isSpreadElement(argument)) && + nodeEventEmitterReceiver(member.object, state) ) return; // A leading spread shifts the runtime argument positions, so argument 1 is not From 4d362501481c89660422909279f631bc5fcc776c Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 3 Nov 2026 15:17:48 +0800 Subject: [PATCH 03/28] fix(javascript): require the native listener method itself, not just its class Proving the receiver was constructed from `events.EventEmitter` says nothing about which definition answers the call. Two writes replace the method this contract belongs to and were still treated as native: const e = new EventEmitter(); e.on = (name, fn = () => {}) => EventEmitter.prototype.on.call(e, name, fn); EventEmitter.prototype.on = function (name, fn = () => {}) { return this; }; Both register `"ready"` for a one-argument `e.on("ready")`, but the operation was dropped as an invalid listener. The proof now also requires that no member write selects the method on the receiver, and none replaces it on the constructor's shared prototype. A write to an unrelated member keeps the proof, so `e.tag = 1` still rejects the missing listener. Tests: three cases in javascriptSemanticAnalysis.rejected_event_listener.test.ts, two of which fail without this guard. Full JavaScript domain: 167 files, 4582 tests pass; tsc and oxfmt clean. --- ...icAnalysis.rejected_event_listener.test.ts | 40 ++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 47 ++++++++++++++++--- .../javascriptSemanticDataEffectHelpers.ts | 2 +- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 190acadd17b..24f1405142e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -184,6 +184,46 @@ bus.on("ready");`, }); }); +/** + * A member write replaces the method the native contract belongs to. Measured + * on Node 22.23.3: `e.on = (name, fn = () => {}) => ...` stores `"ready"`, so + * `checkListener` never runs and the one-argument call is a real registration. + */ +it("keeps the registration of a receiver whose method was replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on = (name, fn = () => {}) => EventEmitter.prototype.on.call(e, name, fn); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Every instance shares the constructor's prototype, so replacing the method + * there replaces it for this receiver too. + */ +it("keeps the registration of a receiver whose prototype was replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +EventEmitter.prototype.on = function (name, fn = () => {}) { return this; }; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * The proof is keyed on the selected method, so a write to an unrelated member + * must not weaken it: `tag` never answers `on`. + */ +it("still rejects a missing listener beside an unrelated member write", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.tag = 1; +e.on("ready");`), + ).toEqual([]); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 9f8d1c2f246..1be9b43e5fd 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -19,11 +19,15 @@ import { type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { + affectsBuiltinPath, builtinDataEffectCallablePath, builtinDataEffectMethod, dataEffectArgumentBindingId, @@ -202,15 +206,46 @@ const isNodeEventEmitterConstructor = ( */ const nodeEventEmitterReceiver = ( node: t.Expression | t.Super, + method: string, state: JavaScriptSemanticAnalysisState, ): boolean => { if (!t.isIdentifier(node)) return false; const binding = resolveSemanticBindingState(state, node, node.name); + if (binding === undefined) return false; const initializer = semanticStableInitializer(binding, state); - return ( - initializer !== undefined && - initializer.projection.length === 0 && - isNodeEventEmitterConstructor(initializer.node, state) + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !isNodeEventEmitterConstructor(initializer.node, state) + ) + return false; + // Constructing the class proves where the receiver came from, not which + // definition answers the call. A member write replaces the selected method, + // so it no longer keeps the native contract. An unrelated write such as + // `emitter.tag = 1` leaves the proof intact. + if ( + binding.memberWrites.some( + ({ path, node: write }) => + affectsBuiltinPath(path, [method]) && + !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), + ) + ) + return false; + // Every instance shares the constructor's prototype, so a write there + // replaces the method for this receiver as well. + const constructed = (initializer.node as t.NewExpression).callee; + const structural = unwrapJavaScriptExpression(constructed).node; + if (!t.isIdentifier(structural)) return true; + const constructorBinding = resolveSemanticBindingState( + state, + structural, + structural.name, + ); + if (constructorBinding === undefined) return false; + return !constructorBinding.memberWrites.some( + ({ path, node: write }) => + affectsBuiltinPath(path, ["prototype", method]) && + !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), ); }; @@ -240,7 +275,7 @@ const collectEvent = ( listenerMethod && listenerArgument === undefined && !node.arguments.some((argument) => t.isSpreadElement(argument)) && - nodeEventEmitterReceiver(member.object, state) + nodeEventEmitterReceiver(member.object, method, state) ) return; // A leading spread shifts the runtime argument positions, so argument 1 is not diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 2b4965eb134..9179ccc370a 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -580,7 +580,7 @@ const builtinNamespaceOrigin = ( : { ...origin, specifier, importedPath }; }; -const affectsBuiltinPath = ( +export const affectsBuiltinPath = ( path: JavaScriptSemanticPropertyPath, selected: readonly string[], ): boolean => From 9617f1a590c357127ad379bb7edcf7f0dab915e1 Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Tue, 3 Nov 2026 15:45:36 +0800 Subject: [PATCH 04/28] fix(javascript): keep the listener operation when the method or the receiver may change Constructing Node's EventEmitter proves where the receiver came from, not which definition answers the call. Three shapes still lost the operation: - a namespace import or a member require builds the class through a member expression, where the prototype write is recorded on the module binding rather than on a constructor binding; - writing the prototype after a named import already worked only because the constructor happened to be an identifier; - passing the instance to another call lets that call replace the method before the operation runs. The proof now also scans every binding for a prototype. write, so the constructor form no longer matters, and withdraws when the receiver itself escapes. Reading emitter.on to make the call passes the function value, and the collector records that call as an escape of the receiver, so the operation under analysis is excluded from the escape check. --- ...icAnalysis.rejected_event_listener.test.ts | 58 ++++++++++ .../javascriptSemanticAsyncEffects.ts | 104 +++++++++++------- 2 files changed, 125 insertions(+), 37 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 24f1405142e..20d11a3dcd8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -224,6 +224,64 @@ e.on("ready");`), ).toEqual([]); }); +/** + * A namespace import constructs the class through a member expression, so the + * constructor has no binding of its own and the prototype write is recorded + * against the module instead. Measured on Node 22.22.2: rewriting `on` to + * delegate to the `addListener` captured before the rewrite stores `"ready"` + * for a one-argument call. Delegating to `this.addListener` instead overflows + * the stack, because Node defines `addListener` as the same function as `on`. + */ +it("keeps the registration of a receiver built from a namespace member", () => { + expect( + eventsForSource(`import * as events from "node:events"; +const original = events.EventEmitter.prototype.addListener; +const e = new events.EventEmitter(); +events.EventEmitter.prototype.on = function (name, fn = () => {}) { return original.call(this, name, fn); }; +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** A member require reaches the same class through the same member expression. */ +it("keeps the registration of a receiver built from a member require", () => { + expect( + eventsForSource(`const events = require("node:events"); +const original = events.EventEmitter.prototype.addListener; +const e = new events.EventEmitter(); +events.EventEmitter.prototype.on = function (name, fn = () => {}) { return original.call(this, name, fn); }; +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Handing the instance to another call lets that call replace the method. + * Measured on Node 22.22.2: `configure(e)` replaces `on` with a form that + * defaults the listener, so `e.on("ready")` registers. + */ +it("keeps the registration of a receiver that was passed to a call", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +function configure(target) { + target.on = (name, fn = () => {}) => target.addListener(name, fn); +} +configure(e); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * The added checks must not withdraw the proof from an untouched namespace + * receiver, or the guard would never apply to the member-expression forms. + */ +it("still rejects a missing listener on an untouched namespace receiver", () => { + expect( + eventsForSource(`import * as events from "node:events"; +const e = new events.EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 1be9b43e5fd..3c7961a02d4 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -17,7 +17,6 @@ import { semanticResolutionBlocked, semanticStableInitializer, type JavaScriptSemanticAnalysisState, - type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { semanticBindingAtRead, @@ -174,55 +173,99 @@ const rejectsEventListener = (listener: t.Node): boolean => { const NODE_EVENTS_SPECIFIERS = ["events", "node:events"] as const; /** - * Report whether a constructor reference resolves to Node's `EventEmitter`. - * Named and default imports, destructured requires, and member requires all - * denote that class. + * Report whether a value is `new .EventEmitter()`. Named and default + * imports, destructured requires, and member requires all denote that class. + * The construction is returned so the caller reads `callee` from the unwrapped + * `NewExpression` rather than from a wrapper around it. */ -const isNodeEventEmitterConstructor = ( +const nodeEventEmitterConstruction = ( node: t.Node, state: JavaScriptSemanticAnalysisState, -): boolean => { +): t.NewExpression | null => { const value = unwrapJavaScriptExpression(node).node; - if (!t.isNewExpression(value)) return false; + if (!t.isNewExpression(value)) return null; const selected = builtinDataEffectCallablePath( value.callee, state, NODE_EVENTS_SPECIFIERS, ); + if (selected === null) return null; // An empty selected path is the module's default export, which `events` // defines as `EventEmitter`. - return ( - selected !== null && - (selected.length === 0 || selected.at(-1) === "EventEmitter") - ); + return selected.length === 0 || selected.at(-1) === "EventEmitter" + ? value + : null; +}; + +/** + * Report whether any binding in this module writes a `prototype.` slot. + * A receiver cannot be held to the native contract once the method that answers + * the call may have been replaced on a shared prototype. The write is recorded + * on whichever binding owns the object, so a scan covers every spelling of it: + * the class itself, a namespace import, a member require, or an alias of any of + * them. A file that reassigns `prototype.` for an unrelated class loses + * the proof as well, which keeps the guard narrow instead of guessing which + * definition answers the call. + */ +const writesPrototypeMethod = ( + method: string, + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + for (const binding of state.bindingsById.values()) + if ( + binding.memberWrites.some( + ({ path, node: write }) => + path.at(-1) === method && + path.at(-2) === "prototype" && + !semanticTopLevelEffectFollowsCapture( + read, + write, + state.parentsByNode, + ), + ) + ) + return true; + return false; }; /** * Report whether the receiver is a value this module constructs from Node's - * `EventEmitter`. Only that class is bound by `checkListener`; other - * EventEmitter-style APIs define one-argument forms, such as Socket.IO's - * `socket.off("x")`, which removes every listener for the event. A receiver - * that is not proven this way keeps its operation as an unresolved candidate. + * `EventEmitter` and still calls the native listener method. Only that class is + * bound by `checkListener`; other EventEmitter-style APIs define one-argument + * forms, such as Socket.IO's `socket.off("x")`, which removes every listener + * for the event. Any receiver whose selected method may have been replaced + * keeps its operation as an unresolved candidate. */ const nodeEventEmitterReceiver = ( node: t.Expression | t.Super, method: string, state: JavaScriptSemanticAnalysisState, + call: t.Node, ): boolean => { if (!t.isIdentifier(node)) return false; const binding = resolveSemanticBindingState(state, node, node.name); if (binding === undefined) return false; const initializer = semanticStableInitializer(binding, state); + if (initializer === undefined || initializer.projection.length !== 0) + return false; + if (nodeEventEmitterConstruction(initializer.node, state) === null) + return false; + // Constructing the class proves where the receiver came from, not which + // definition answers the call. Passing the instance to another call lets that + // call replace the method first, so the proof no longer holds. Reading + // `emitter.on` in order to call it passes the function value rather than the + // instance, and the collector records the operation's own call as one of + // these escapes, so that call is not evidence against the receiver. if ( - initializer === undefined || - initializer.projection.length !== 0 || - !isNodeEventEmitterConstructor(initializer.node, state) + binding.escapedPaths.some( + (escape) => escape.path.length === 0 && escape.node !== call, + ) ) return false; - // Constructing the class proves where the receiver came from, not which - // definition answers the call. A member write replaces the selected method, - // so it no longer keeps the native contract. An unrelated write such as - // `emitter.tag = 1` leaves the proof intact. + // A member write replaces the selected method, so it no longer keeps the + // native contract. An unrelated write such as `emitter.tag = 1` leaves the + // proof intact. if ( binding.memberWrites.some( ({ path, node: write }) => @@ -233,20 +276,7 @@ const nodeEventEmitterReceiver = ( return false; // Every instance shares the constructor's prototype, so a write there // replaces the method for this receiver as well. - const constructed = (initializer.node as t.NewExpression).callee; - const structural = unwrapJavaScriptExpression(constructed).node; - if (!t.isIdentifier(structural)) return true; - const constructorBinding = resolveSemanticBindingState( - state, - structural, - structural.name, - ); - if (constructorBinding === undefined) return false; - return !constructorBinding.memberWrites.some( - ({ path, node: write }) => - affectsBuiltinPath(path, ["prototype", method]) && - !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), - ); + return !writesPrototypeMethod(method, node, state); }; const collectEvent = ( @@ -275,7 +305,7 @@ const collectEvent = ( listenerMethod && listenerArgument === undefined && !node.arguments.some((argument) => t.isSpreadElement(argument)) && - nodeEventEmitterReceiver(member.object, method, state) + nodeEventEmitterReceiver(member.object, method, state, node) ) return; // A leading spread shifts the runtime argument positions, so argument 1 is not From cb36de817d16781b8de56889894057461cc1dede Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Tue, 3 Nov 2026 21:57:51 +0800 Subject: [PATCH 05/28] fix(javascript): read prototype writes by slot, not by exact key `prototype.` was compared key by key, so two spellings of the same replacement were read as native: - `EventEmitter.prototype[key] = ...` records an unknown key, which covers the method this call selects; - `require("node:events").EventEmitter.prototype.on = ...` has no binding at all and is recorded against the module. Both leave `e.on("ready")` storing a listener, so the operation has to stay. The receiver escape check also counted calls that run after the registration. A call that runs later cannot change whether the registration threw, so only earlier escapes weaken the proof now. Measured on Node 22.22.2: each replacement form stores one listener for a one-argument `e.on("ready")`, and a later `configure(e)` leaves the earlier `e.on("x")` throwing `ERR_INVALID_ARG_TYPE`. --- ...icAnalysis.rejected_event_listener.test.ts | 50 +++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 62 +++++++++++++++---- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 20d11a3dcd8..f7f0e362d80 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -282,6 +282,56 @@ e.on("ready");`), ).toEqual([]); }); +/** + * A call that runs after the registration cannot change whether that + * registration threw. Measured on Node 22.22.2: `e.on("x")` throws + * `ERR_INVALID_ARG_TYPE` before `configure(e)` or `e.emit("done")` run, so a + * later escape is not evidence about this call. + */ +it("still rejects a missing listener registered before the receiver escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("ready"); +function configure(target) { + target.on = (name, fn = () => {}) => target.addListener(name, fn); +} +configure(e); +e.emit("done");`), + ).not.toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * `prototype[key]` records an unknown property name, which still covers the + * method this call selects. Measured on Node 22.22.2: after that write + * `e.on("ready")` stores one listener. + */ +it("keeps the registration of a receiver whose method is replaced by key", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const original = EventEmitter.prototype.addListener; +const key = "on"; +EventEmitter.prototype[key] = function (name, fn = () => {}) { return original.call(this, name, fn); }; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * A write through an inline require belongs to the module rather than to a + * binding. Measured on Node 22.22.2: it replaces `on` for every instance, so + * `e.on("ready")` stores one listener. + */ +it("keeps the registration of a receiver after an inline require write", () => { + expect( + eventsForSource(`const original = require("node:events").EventEmitter.prototype.addListener; +require("node:events").EventEmitter.prototype.on = function (name, fn = () => {}) { return original.call(this, name, fn); }; +const { EventEmitter } = require("node:events"); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 3c7961a02d4..94dec329f3c 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -24,6 +24,7 @@ import { } from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { affectsBuiltinPath, @@ -198,31 +199,57 @@ const nodeEventEmitterConstruction = ( }; /** - * Report whether any binding in this module writes a `prototype.` slot. + * Report whether a recorded path can select, or replace, `prototype.`. + * The path is recorded against whichever object owns the write, so it carries + * the owner's own prefix (`EventEmitter.prototype.on`, `events.EventEmitter + * .prototype.on`) and only its tail names the slot. An unknown property key + * such as `prototype[key]` covers the method as well, so the tail is compared + * with the shared builtin-path rule instead of by exact keys. + */ +const affectsPrototypeMethod = ( + path: JavaScriptSemanticPropertyPath, + method: string, +): boolean => { + for (let index = 0; index < path.length; index += 1) + if (affectsBuiltinPath(path.slice(index), ["prototype", method])) + return true; + return false; +}; + +/** + * Report whether any object in this module writes a `prototype.` slot. * A receiver cannot be held to the native contract once the method that answers * the call may have been replaced on a shared prototype. The write is recorded * on whichever binding owns the object, so a scan covers every spelling of it: * the class itself, a namespace import, a member require, or an alias of any of - * them. A file that reassigns `prototype.` for an unrelated class loses - * the proof as well, which keeps the guard narrow instead of guessing which - * definition answers the call. + * them. A write through an inline `require(...)` has no binding at all and is + * recorded against the module, so those records are scanned too. A file that + * reassigns `prototype.` for an unrelated class loses the proof as + * well, which keeps the guard narrow instead of guessing which definition + * answers the call. */ const writesPrototypeMethod = ( method: string, read: t.Node, state: JavaScriptSemanticAnalysisState, ): boolean => { + const precedes = (write: t.Node | undefined): boolean => + !semanticTopLevelEffectFollowsCapture(read, write, state.parentsByNode); for (const binding of state.bindingsById.values()) if ( binding.memberWrites.some( ({ path, node: write }) => - path.at(-1) === method && - path.at(-2) === "prototype" && - !semanticTopLevelEffectFollowsCapture( - read, - write, - state.parentsByNode, - ), + affectsPrototypeMethod(path, method) && precedes(write), + ) + ) + return true; + for (const specifier of NODE_EVENTS_SPECIFIERS) + if ( + (state.requiredModuleEffects.get(specifier) ?? []).some( + ({ kind, path, node: write }) => + kind === "write" && + affectsPrototypeMethod(path, method) && + precedes(write), ) ) return true; @@ -256,10 +283,19 @@ const nodeEventEmitterReceiver = ( // call replace the method first, so the proof no longer holds. Reading // `emitter.on` in order to call it passes the function value rather than the // instance, and the collector records the operation's own call as one of - // these escapes, so that call is not evidence against the receiver. + // these escapes, so that call is not evidence against the receiver. A call + // that runs after this one cannot change whether it threw, so it is not + // evidence either. if ( binding.escapedPaths.some( - (escape) => escape.path.length === 0 && escape.node !== call, + (escape) => + escape.path.length === 0 && + escape.node !== call && + !semanticTopLevelEffectFollowsCapture( + call, + escape.node, + state.parentsByNode, + ), ) ) return false; From e9e604a6e30522df3d9a58bfdc4b71d21b3aaf8e Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Tue, 3 Nov 2026 22:23:40 +0800 Subject: [PATCH 06/28] fix(javascript): withdraw the listener proof when the prototype itself moves The prototype answers the call, but only writes to it were checked. Two ways of replacing it were read as native: - `configure(EventEmitter.prototype)` hands the object out, and the callee can install a defaulting wrapper on it; - `e.__proto__ = ...` replaces the method the receiver resolves through its own prototype chain. Measured on Node 22.22.2: after either form, `e.on("ready")` does not throw. Both are ordering-aware, so a call that runs after the registration leaves the guard intact. Passing a method value out (`configure(proto.on)`) still keeps the proof, because it cannot change the prototype. --- ...icAnalysis.rejected_event_listener.test.ts | 33 ++++++++++++ .../javascriptSemanticAsyncEffects.ts | 54 +++++++++++++++++-- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index f7f0e362d80..e885d801e48 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -332,6 +332,39 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +/** + * Handing the prototype object itself to a call lets that call install a + * method. Measured on Node 22.22.2: after `configure(EventEmitter.prototype)` + * installs a defaulting wrapper, `e.on("ready")` stores one listener. + */ +it("keeps the registration after the prototype object escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +function configure(proto) { + const saved = proto.on; + proto.on = function (name, fn = () => {}) { return saved.call(this, name, fn); }; +} +configure(EventEmitter.prototype); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Replacing the receiver's own prototype changes which method answers the call. + * Measured on Node 22.22.2: with `e.__proto__` set to an object that defaults + * the listener, `e.on("ready")` does not throw. + */ +it("keeps the registration after the receiver prototype is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +const saved = EventEmitter.prototype.on; +e.__proto__ = { on(name, fn = () => {}) { return saved.call(this, name, fn); } }; +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 94dec329f3c..9b12fb68d1f 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -256,6 +256,47 @@ const writesPrototypeMethod = ( return false; }; +/** + * Report whether an escape hands out the prototype object itself. Reading a + * method and passing the function value out (`proto.on`) cannot change the + * prototype, but passing the object (`configure(proto)`) lets the call install + * a replacement method on it. + */ +const escapesPrototypeObject = ( + path: JavaScriptSemanticPropertyPath, +): boolean => + path.length > 0 && (path.at(-1) === "prototype" || path.at(-1) === null); + +/** + * Report whether the shared prototype may have gained a method through a call. + * The prototype escapes exactly like an instance does, and the callee can + * install a defaulting wrapper on it, so the class no longer answers with the + * method it defines. + */ +const prototypeObjectEscaped = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const precedes = (escape: t.Node | undefined): boolean => + !semanticTopLevelEffectFollowsCapture(read, escape, state.parentsByNode); + for (const binding of state.bindingsById.values()) + if ( + binding.escapedPaths.some( + ({ path, node }) => escapesPrototypeObject(path) && precedes(node), + ) + ) + return true; + for (const specifier of NODE_EVENTS_SPECIFIERS) + if ( + (state.requiredModuleEffects.get(specifier) ?? []).some( + ({ kind, path, node }) => + kind === "escape" && escapesPrototypeObject(path) && precedes(node), + ) + ) + return true; + return false; +}; + /** * Report whether the receiver is a value this module constructs from Node's * `EventEmitter` and still calls the native listener method. Only that class is @@ -301,18 +342,23 @@ const nodeEventEmitterReceiver = ( return false; // A member write replaces the selected method, so it no longer keeps the // native contract. An unrelated write such as `emitter.tag = 1` leaves the - // proof intact. + // proof intact. Replacing the receiver's own prototype selects a different + // method as well, and is recorded as a `__proto__` write. if ( binding.memberWrites.some( ({ path, node: write }) => - affectsBuiltinPath(path, [method]) && + (affectsBuiltinPath(path, [method]) || path.at(0) === "__proto__") && !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), ) ) return false; // Every instance shares the constructor's prototype, so a write there - // replaces the method for this receiver as well. - return !writesPrototypeMethod(method, node, state); + // replaces the method for this receiver as well, and so does handing that + // prototype to a call that may install the method itself. + return ( + !writesPrototypeMethod(method, node, state) && + !prototypeObjectEscaped(node, state) + ); }; const collectEvent = ( From 0bd6c112a293891ea6498df47fc8a33d117e6204 Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Tue, 3 Nov 2026 22:45:06 +0800 Subject: [PATCH 07/28] fix(javascript): require the events class surface to be untouched Three more routes let the call reach a method this analysis did not describe, and each one kept the guard: - `configure(EventEmitter)` hands the class out, and the callee installs `Ctor.prototype.on`; - replacing `EventEmitter.init` before construction installs an own defaulting method on every instance, because Node invokes that hook while constructing; - `import A from "events"` and `import B from "node:events"` name the same mutable module, so `A.EventEmitter = ...` answers a `new B.EventEmitter()`. Adding one predicate per route leaves the next one open, so the proof now requires the class surface to be untouched: no escape of the class or its namespace, and no member write on it, whether the file reaches it directly or through the module records. `__proto__` escapes of the prototype carry the same weight as `prototype` escapes. Measured on Node 22.22.2: all three forms leave `e.on("ready")` not throwing. --- ...icAnalysis.rejected_event_listener.test.ts | 56 +++++++++++++ .../javascriptSemanticAsyncEffects.ts | 78 ++++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index e885d801e48..f27120e6bf9 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -365,6 +365,62 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +/** + * Handing the class itself to a call opens the same route as handing out its + * prototype. Measured on Node 22.22.2: after `configure(EventEmitter)` installs + * a defaulting wrapper, `e.on("ready")` does not throw. + */ +it("keeps the registration after the class object escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +function configure(Ctor) { + Ctor.prototype.on = function (name, fn = () => {}) { + return EventEmitter.prototype.addListener.call(this, name, fn); + }; +} +const e = new EventEmitter(); +configure(EventEmitter); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Node runs `EventEmitter.init` while constructing, so a replaced hook can give + * every new instance its own method. Measured on Node 22.22.2: `e.on("ready")` + * does not throw after `init` is replaced with a wrapper that installs one. + */ +it("keeps the registration after the init hook is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const savedInit = EventEmitter.init; +EventEmitter.init = function (opts) { + savedInit.call(this, opts); + this.on = function (name, fn = () => {}) { + return EventEmitter.prototype.addListener.call(this, name, fn); + }; +}; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Two specifiers name the same mutable module, so a replacement installed + * through one import answers the other. Measured on Node 22.22.2: `e.on("ready")` + * does not throw after the class is replaced through the first import. + */ +it("keeps the registration after the class is replaced through another import", () => { + expect( + eventsForSource(`import first from "events"; +import second from "node:events"; +first.EventEmitter = class extends first.EventEmitter { + on(name, fn = () => {}) { return super.on(name, fn); } +}; +const e = new second.EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 9b12fb68d1f..e46f3c70358 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -17,6 +17,7 @@ import { semanticResolutionBlocked, semanticStableInitializer, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { semanticBindingAtRead, @@ -198,6 +199,70 @@ const nodeEventEmitterConstruction = ( : null; }; +/** + * Resolve the identifier a construction reads the class through: the class + * itself for `new EventEmitter()`, and the module namespace for + * `new events.EventEmitter()`. + */ +const constructionRootBinding = ( + callee: t.Expression | t.Super, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState | undefined => { + let node: t.Node = unwrapJavaScriptExpression(callee).node; + while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + node = unwrapJavaScriptExpression(node.object).node; + return t.isIdentifier(node) + ? resolveSemanticBindingState(state, node, node.name) + : undefined; +}; + +/** + * Report whether construction leaves the class's own surface intact. The method + * that answers a call has to come from the class as Node defines it, so any + * route that lets other code reshape that class removes the proof: handing the + * class or its namespace to a call, writing a member of it (including the + * `init` hook Node invokes while constructing, which can install an own method + * on the instance), or replacing the exported class through another import of + * the same module. Enumerating the individual channels instead would leave the + * next one open, so the proof now requires the surface to be untouched. + */ +const eventsClassSurfaceTouched = ( + construction: t.NewExpression, + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const precedes = (node: t.Node | undefined): boolean => + !semanticTopLevelEffectFollowsCapture(read, node, state.parentsByNode); + const root = constructionRootBinding(construction.callee, state); + if ( + root !== undefined && + root.escapedPaths.some(({ node }) => precedes(node)) + ) + return true; + const targetsClass = (path: JavaScriptSemanticPropertyPath): boolean => + path.some((key) => key === "EventEmitter" || key === "init"); + for (const binding of state.bindingsById.values()) { + const isRoot = root !== undefined && binding.bindingId === root.bindingId; + if ( + binding.memberWrites.some( + ({ path, node }) => + precedes(node) && + (path.length > 0 ? isRoot || targetsClass(path) : false), + ) + ) + return true; + } + for (const specifier of NODE_EVENTS_SPECIFIERS) + if ( + (state.requiredModuleEffects.get(specifier) ?? []).some( + ({ kind, path, node }) => + kind === "write" && targetsClass(path) && precedes(node), + ) + ) + return true; + return false; +}; + /** * Report whether a recorded path can select, or replace, `prototype.`. * The path is recorded against whichever object owns the write, so it carries @@ -265,7 +330,10 @@ const writesPrototypeMethod = ( const escapesPrototypeObject = ( path: JavaScriptSemanticPropertyPath, ): boolean => - path.length > 0 && (path.at(-1) === "prototype" || path.at(-1) === null); + path.length > 0 && + (path.at(-1) === "prototype" || + path.at(-1) === "__proto__" || + path.at(-1) === null); /** * Report whether the shared prototype may have gained a method through a call. @@ -317,8 +385,12 @@ const nodeEventEmitterReceiver = ( const initializer = semanticStableInitializer(binding, state); if (initializer === undefined || initializer.projection.length !== 0) return false; - if (nodeEventEmitterConstruction(initializer.node, state) === null) - return false; + const construction = nodeEventEmitterConstruction(initializer.node, state); + if (construction === null) return false; + // Reaching the class is not the same as reaching the method: the class object + // and the `init` hook Node runs while constructing can both be reshaped, so a + // construction whose surface was touched keeps its operation. + if (eventsClassSurfaceTouched(construction, node, state)) return false; // Constructing the class proves where the receiver came from, not which // definition answers the call. Passing the instance to another call lets that // call replace the method first, so the proof no longer holds. Reading From c964e9892fc0aea63b55122ab6a7d1203d4879fb Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Wed, 4 Nov 2026 05:10:56 +0800 Subject: [PATCH 08/28] fix(javascript): scope the class-surface proof to the events class A member write only removes the proof when the value can name the events module, so an unrelated `config.init = true` no longer withdraws it, and class-level writes are compared against the construction they shape rather than against the call. A direct `eval` in the activation withdraws the proof as well, because evaluated code can replace the method without leaving a write record. --- ...icAnalysis.rejected_event_listener.test.ts | 43 ++++++++++++ .../javascriptSemanticAsyncEffects.ts | 70 +++++++++++++++---- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index f27120e6bf9..235d6e4daf2 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -421,6 +421,49 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +/** + * An `init` property on an unrelated object cannot reshape the class. Measured + * on Node 22.22.2: `e.on("x")` still throws after `config.init = true`. + */ +it("still rejects a missing listener beside an unrelated init write", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const config = {}; +config.init = true; +const e = new EventEmitter(); +e.on("x");`), + ).toEqual([]); +}); + +/** + * Only a hook installed before the construction can shape the instance, so a + * later class-level write leaves this receiver on the native method. Measured + * on Node 22.22.2: `e.on("x")` still throws after `EventEmitter.init` is + * replaced on an already-built receiver. + */ +it("still rejects a missing listener when the init hook changes later", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +EventEmitter.init = function () {}; +e.on("x");`), + ).toEqual([]); +}); + +/** + * A direct `eval` can rewrite the receiver with code no write record describes. + * Measured on Node 22.22.2: `e.on("ready")` registers one listener after the + * evaluated replacement installs a defaulting method. + */ +it("keeps the registration when eval may replace the method", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +eval("e.on = (name, fn = () => {}) => e.addListener(name, fn)"); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index e46f3c70358..2ddedbe5420 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -25,6 +25,7 @@ import { } from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { semanticBindingMayHaveOpaqueEffect } from "./javascriptSemanticOpaqueEffects.js"; import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { @@ -216,6 +217,30 @@ const constructionRootBinding = ( : undefined; }; +/** + * Report whether a binding can name the events module or its class, which is + * what makes a member write on it reach the class this construction used. An + * imported or required binding carries the module origin; a local alias such as + * `const EE = EventEmitter` is followed through its initializer. + */ +const reachesEventsClassSurface = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): boolean => + binding.directOrigins.some( + ({ specifier, importedPath }) => + importedPath.length <= 1 && + NODE_EVENTS_SPECIFIERS.some( + (candidate) => + candidate.replace(/^node:/u, "") === specifier.replace(/^node:/u, ""), + ), + ) || + binding.initializers.some( + ({ node }) => + builtinDataEffectCallablePath(node, state, NODE_EVENTS_SPECIFIERS) !== + null, + ); + /** * Report whether construction leaves the class's own surface intact. The method * that answers a call has to come from the class as Node defines it, so any @@ -224,33 +249,46 @@ const constructionRootBinding = ( * `init` hook Node invokes while constructing, which can install an own method * on the instance), or replacing the exported class through another import of * the same module. Enumerating the individual channels instead would leave the - * next one open, so the proof now requires the surface to be untouched. + * next one open, so the proof requires the surface to be untouched. + * + * Only changes that land before the construction can shape the instance, so a + * class-level write that runs afterwards does not count; the shared prototype + * is the exception and is checked against the call instead. The scan is limited + * to values that can name this surface, because an unrelated object that + * happens to carry an `init` property cannot change the class. */ const eventsClassSurfaceTouched = ( construction: t.NewExpression, - read: t.Node, state: JavaScriptSemanticAnalysisState, ): boolean => { const precedes = (node: t.Node | undefined): boolean => - !semanticTopLevelEffectFollowsCapture(read, node, state.parentsByNode); + !semanticTopLevelEffectFollowsCapture( + construction, + node, + state.parentsByNode, + ); const root = constructionRootBinding(construction.callee, state); if ( root !== undefined && root.escapedPaths.some(({ node }) => precedes(node)) ) return true; + // An unknown property key can name any slot, so it covers this surface too. const targetsClass = (path: JavaScriptSemanticPropertyPath): boolean => - path.some((key) => key === "EventEmitter" || key === "init"); + path.some( + (key) => + key === null || + key === "EventEmitter" || + key === "init" || + key === "prototype", + ); for (const binding of state.bindingsById.values()) { + const writesSurface = binding.memberWrites.some( + ({ path, node }) => precedes(node) && targetsClass(path), + ); + if (!writesSurface) continue; const isRoot = root !== undefined && binding.bindingId === root.bindingId; - if ( - binding.memberWrites.some( - ({ path, node }) => - precedes(node) && - (path.length > 0 ? isRoot || targetsClass(path) : false), - ) - ) - return true; + if (isRoot || reachesEventsClassSurface(binding, state)) return true; } for (const specifier of NODE_EVENTS_SPECIFIERS) if ( @@ -390,7 +428,13 @@ const nodeEventEmitterReceiver = ( // Reaching the class is not the same as reaching the method: the class object // and the `init` hook Node runs while constructing can both be reshaped, so a // construction whose surface was touched keeps its operation. - if (eventsClassSurfaceTouched(construction, node, state)) return false; + if (eventsClassSurfaceTouched(construction, state)) return false; + // A direct `eval` in this activation can rewrite the receiver or the shared + // prototype through code no write record describes, so a file that uses one + // keeps its operation. Evaluated code that runs later cannot change whether + // this call threw, so only earlier sites count. + if (semanticBindingMayHaveOpaqueEffect(binding, state, call, "reference")) + return false; // Constructing the class proves where the receiver came from, not which // definition answers the call. Passing the instance to another call lets that // call replace the method first, so the proof no longer holds. Reading From c9427ea7bdafe0725d94c9a07f91351180f851da Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Thu, 5 Nov 2026 12:00:07 +0800 Subject: [PATCH 09/28] fix(javascript): keep listener candidates after shared mutations --- ...icAnalysis.rejected_event_listener.test.ts | 311 ++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 101 +++--- .../javascriptSemanticOpaqueEffects.ts | 21 ++ 3 files changed, 388 insertions(+), 45 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 235d6e4daf2..4021bfa4acf 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -464,6 +464,317 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +/** The imported helper hides its writes, so only the shared escape proves uncertainty. */ +it.each([ + [ + "the class", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + "EventEmitter", + ], + [ + "the namespace", + 'import * as events from "node:events";', + "events.EventEmitter", + "events", + ], + [ + "a class alias", + 'import { EventEmitter } from "node:events"; const EE = EventEmitter;', + "EE", + "EE", + ], + [ + "another import", + 'import first from "events"; import second from "node:events";', + "second.EventEmitter", + "first", + ], + [ + "a required module", + 'const events = require("node:events");', + "events.EventEmitter", + "events", + ], + [ + "an inline require", + "", + 'require("node:events").EventEmitter', + 'require("events")', + ], +] as const)( + "keeps the registration when %s escapes after construction", + (_name, header, constructor, escaped) => { + expect( + eventsForSource(`import { configure } from "./configure.js"; +${header} +const e = new (${constructor})(); +configure(${escaped}); +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it.each([ + [ + "the class", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + "EventEmitter", + ], + [ + "the namespace", + 'import * as events from "node:events";', + "events.EventEmitter", + "events", + ], + [ + "another import", + 'import first from "events"; import second from "node:events";', + "second.EventEmitter", + "first", + ], +] as const)( + "still rejects a missing listener before %s escapes", + (_name, header, constructor, escaped) => { + expect( + eventsForSource(`import { configure } from "./configure.js"; +${header} +const e = new (${constructor})(); +e.on("ready"); +configure(${escaped});`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it("still rejects a missing listener when only the prototype method value escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +configure(EventEmitter.prototype.on); +const e = new EventEmitter(); +e.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener when only a saved method value escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const saved = EventEmitter.prototype.on; +configure(saved); +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +it.each([ + "configure(EventEmitter)", + 'eval("EventEmitter.prototype.on = replacement")', +])( + "still rejects when %s runs after method selection in an argument", + (effect) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +e.on((${effect}, "ready"));`), + ).toEqual([]); + }, +); + +it.each(["before", "after"] as const)( + "keeps the registration when the prototype constructor escapes %s construction", + (timing) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +${timing === "before" ? "configure(EventEmitter.prototype.constructor);" : ""} +const e = new EventEmitter(); +${timing === "after" ? "configure(EventEmitter.prototype.constructor);" : ""} +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it("keeps the registration after an alias of the prototype constructor escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const Ctor = EventEmitter.prototype.constructor; +const e = new EventEmitter(); +configure(Ctor); +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("keeps the registration after a computed prototype constructor escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const key = "prototype"; +const e = new EventEmitter(); +configure(EventEmitter[key].constructor); +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +/** Shared prototype changes may outlive the activation that made them. */ +it("keeps a registration when a class escape may run in an earlier invocation", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +function run(change) { + const e = new EventEmitter(); + if (!change) e.on("ready"); + configure(EventEmitter); +} +run(true); +run(false);`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each([ + [ + "a named import", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + ], + ["a local class alias", 'import { EventEmitter } from "node:events";', "EE"], + [ + "a required module", + 'const events = require("node:events");', + "events.EventEmitter", + ], + ["an inline require", "", 'require("node:events").EventEmitter'], +] as const)( + "keeps a nested receiver when module eval may change %s", + (_name, header, constructor) => { + expect( + eventsForSource(`${header} +const original = (${constructor === "EE" ? "EventEmitter" : constructor}).prototype.on; +eval(${JSON.stringify(`(${constructor === "EE" ? "EventEmitter" : constructor}).prototype.on = function(name, fn = () => {}) { return original.call(this, name, fn); }`)}); +function run() { + ${constructor === "EE" ? "const EE = EventEmitter;" : ""} + const e = new (${constructor})(); + e.on("ready"); +} +run();`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it("keeps a registration when outer eval may run in an earlier invocation", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const original = EventEmitter.prototype.on; +function run(change) { + if (change) eval("EventEmitter.prototype.on = function(name, fn = () => {}) { return original.call(this, name, fn); }"); + function listen() { + const e = new EventEmitter(); + e.on("ready"); + } + if (!change) listen(); +} +run(true); +run(false);`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener before a top-level eval runs", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("ready"); +eval("EventEmitter.prototype.on = replacement");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener beside a shadowed eval call", () => { + expect( + eventsForSource(`const { EventEmitter } = require("node:events"); +function run(eval) { + const e = new EventEmitter(); + eval("ordinary callback"); + e.on("ready"); +}`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("keeps a registration when indirect eval may reacquire the shared module", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const indirect = eval; +indirect("process.getBuiltinModule('events').EventEmitter.prototype.on = replacement"); +function run() { + const e = new EventEmitter(); + e.on("ready"); +}`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener before a top-level indirect eval", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const indirect = eval; +const e = new EventEmitter(); +e.on("ready"); +indirect("process.getBuiltinModule('events').EventEmitter.prototype.on = replacement");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 03bf171f21f..f6c0ee20125 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -26,8 +26,8 @@ import { import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { - semanticBindingMayHaveOpaqueEffect, semanticOpaqueNameMayHaveChanged, + semanticSharedModuleMayHaveOpaqueEffect, } from "./javascriptSemanticOpaqueEffects.js"; import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; @@ -226,56 +226,61 @@ const constructionRootBinding = ( * imported or required binding carries the module origin; a local alias such as * `const EE = EventEmitter` is followed through its initializer. */ +const eventsClassPath = (path: JavaScriptSemanticPropertyPath): boolean => { + for (let index = 0; index < path.length; index += 1) { + const key = path[index]; + // An unknown key may select the class again; do not prove absence from it. + if (key === null) return true; + if (key === "EventEmitter" || key === "default") continue; + // The standard back-reference returns from the prototype to this class. + if ( + key === "prototype" && + index + 1 < path.length && + (path[index + 1] === "constructor" || path[index + 1] === null) + ) { + index += 1; + continue; + } + return false; + } + return true; +}; + const reachesEventsClassSurface = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, ): boolean => binding.directOrigins.some( ({ specifier, importedPath }) => - importedPath.length <= 1 && + eventsClassPath(importedPath) && NODE_EVENTS_SPECIFIERS.some( (candidate) => candidate.replace(/^node:/u, "") === specifier.replace(/^node:/u, ""), ), ) || - binding.initializers.some( - ({ node }) => - builtinDataEffectCallablePath(node, state, NODE_EVENTS_SPECIFIERS) !== - null, - ); + binding.initializers.some(({ node }) => { + const path = builtinDataEffectCallablePath( + node, + state, + NODE_EVENTS_SPECIFIERS, + ); + return path !== null && eventsClassPath(path); + }); /** - * Report whether construction leaves the class's own surface intact. The method - * that answers a call has to come from the class as Node defines it, so any - * route that lets other code reshape that class removes the proof: handing the - * class or its namespace to a call, writing a member of it (including the - * `init` hook Node invokes while constructing, which can install an own method - * on the instance), or replacing the exported class through another import of - * the same module. Enumerating the individual channels instead would leave the - * next one open, so the proof requires the surface to be untouched. - * - * Only changes that land before the construction can shape the instance, so a - * class-level write that runs afterwards does not count; the shared prototype - * is the exception and is checked against the call instead. The scan is limited - * to values that can name this surface, because an unrelated object that - * happens to carry an `init` property cannot change the class. + * Construction-only writes (including Node's init hook) matter before allocation. + * Escaping the class or namespace can still replace the shared prototype after + * allocation, so those effects remain relevant until the listener call. + * Passing a method value cannot replace its containing class or prototype. */ const eventsClassSurfaceTouched = ( construction: t.NewExpression, + read: t.Node, state: JavaScriptSemanticAnalysisState, ): boolean => { - const precedes = (node: t.Node | undefined): boolean => - !semanticTopLevelEffectFollowsCapture( - construction, - node, - state.parentsByNode, - ); + const precedes = (read: t.Node, effect: t.Node | undefined): boolean => + !semanticTopLevelEffectFollowsCapture(read, effect, state.parentsByNode); const root = constructionRootBinding(construction.callee, state); - if ( - root !== undefined && - root.escapedPaths.some(({ node }) => precedes(node)) - ) - return true; // An unknown property key can name any slot, so it covers this surface too. const targetsClass = (path: JavaScriptSemanticPropertyPath): boolean => path.some( @@ -286,18 +291,25 @@ const eventsClassSurfaceTouched = ( key === "prototype", ); for (const binding of state.bindingsById.values()) { - const writesSurface = binding.memberWrites.some( - ({ path, node }) => precedes(node) && targetsClass(path), - ); - if (!writesSurface) continue; const isRoot = root !== undefined && binding.bindingId === root.bindingId; - if (isRoot || reachesEventsClassSurface(binding, state)) return true; + if (!isRoot && !reachesEventsClassSurface(binding, state)) continue; + if ( + binding.escapedPaths.some( + ({ path, node }) => eventsClassPath(path) && precedes(read, node), + ) || + binding.memberWrites.some( + ({ path, node }) => targetsClass(path) && precedes(construction, node), + ) + ) + return true; } for (const specifier of NODE_EVENTS_SPECIFIERS) if ( (state.requiredModuleEffects.get(specifier) ?? []).some( ({ kind, path, node }) => - kind === "write" && targetsClass(path) && precedes(node), + kind === "escape" + ? eventsClassPath(path) && precedes(read, node) + : targetsClass(path) && precedes(construction, node), ) ) return true; @@ -431,13 +443,12 @@ const nodeEventEmitterReceiver = ( // Reaching the class is not the same as reaching the method: the class object // and the `init` hook Node runs while constructing can both be reshaped, so a // construction whose surface was touched keeps its operation. - if (eventsClassSurfaceTouched(construction, state)) return false; - // A direct `eval` in this activation can rewrite the receiver or the shared - // prototype through code no write record describes, so a file that uses one - // keeps its operation. Evaluated code that runs later cannot change whether - // this call threw, so only earlier sites count. - if (semanticBindingMayHaveOpaqueEffect(binding, state, call, "reference")) - return false; + if (eventsClassSurfaceTouched(construction, node, state)) return false; + // The receiver is activation-local, but the events module and its prototype + // are shared: opaque code in an outer activation can replace their methods + // even when this receiver does not exist yet. Use the opaque-effect owner's + // shared-identity query rather than interpreting eval text here. + if (semanticSharedModuleMayHaveOpaqueEffect(node, state)) return false; // Constructing the class proves where the receiver came from, not which // definition answers the call. Passing the instance to another call lets that // call replace the method first, so the proof no longer holds. Reading diff --git a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts index c213557f4ef..b1317be5e73 100644 --- a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts +++ b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts @@ -99,6 +99,27 @@ export const semanticOpaqueGlobalMayHaveChanged = ( (effect) => semanticEffectTiming(read, effect, state) !== "after", ); +/** + * A shared module object may be reached again in another activation, including + * through an inline require with no binding. Opaque code can mutate that object + * without an explicit write record. Only provably later effects are excluded; + * fresh-local capture rules do not apply to the shared identity. + */ +export const semanticSharedModuleMayHaveOpaqueEffect = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (semanticOpaqueGlobalMayHaveChanged(read, state)) return true; + for (const sites of state.opaqueEffects.byScope.values()) + if ( + sites.some( + (effect) => semanticEffectTiming(read, effect, state) !== "after", + ) + ) + return true; + return false; +}; + /** Rebinding requires lexical visibility. Object references can remain reachable * through aliases even where the original name is shadowed or out of block scope. * Without a disjointness proof, consider their enclosing activation and closures. From f038fdc6b506ee5145d072aa2a196b3775ce03f9 Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Thu, 5 Nov 2026 12:20:50 +0800 Subject: [PATCH 10/28] fix(javascript): retain constructed receiver class references --- ...icAnalysis.rejected_event_listener.test.ts | 127 ++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 6 +- .../javascriptSemanticMemberMutations.ts | 22 ++- 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 4021bfa4acf..cd8b41e1054 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -775,6 +775,133 @@ indirect("process.getBuiltinModule('events').EventEmitter.prototype.on = replace }); }); +it.each([ + "configure(e.constructor);", + "const Ctor = e.constructor; configure(Ctor);", + 'const key = "constructor"; configure(e[key]);', + "configure(e.__proto__.constructor);", +])("keeps a registration when the receiver exposes its class: %s", (effect) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +${effect} +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each([ + "configure(e.constructor);", + "const Ctor = e.constructor; configure(Ctor);", +])("still rejects before the receiver exposes its class: %s", (effect) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +e.on("ready"); +${effect}`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects when only a method reached through the receiver class escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +configure(e.constructor.prototype.on); +e.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("keeps another receiver's registration when a whole instance exposes the shared class", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +configure(first); +second.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects before another instance exposes the shared class", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +second.on("ready"); +configure(first);`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects when only another receiver's method value escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +configure(first.on); +second.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects after an instance constructor slot is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.constructor = Other; +e.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each(["const copy = { ...first };", "const { tag, ...copy } = first;"])( + "still rejects when a shallow copy excludes inherited class references: %s", + (copy) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +${copy} +configure(copy); +second.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index f6c0ee20125..45f46c11c02 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -277,8 +277,12 @@ const eventsClassSurfaceTouched = ( construction: t.NewExpression, read: t.Node, state: JavaScriptSemanticAnalysisState, + invocation: t.Node, ): boolean => { + // This invocation runs after selecting its method. Its own receiver escape + // cannot replace that selected native method retroactively. const precedes = (read: t.Node, effect: t.Node | undefined): boolean => + effect !== invocation && !semanticTopLevelEffectFollowsCapture(read, effect, state.parentsByNode); const root = constructionRootBinding(construction.callee, state); // An unknown property key can name any slot, so it covers this surface too. @@ -443,7 +447,7 @@ const nodeEventEmitterReceiver = ( // Reaching the class is not the same as reaching the method: the class object // and the `init` hook Node runs while constructing can both be reshaped, so a // construction whose surface was touched keeps its operation. - if (eventsClassSurfaceTouched(construction, node, state)) return false; + if (eventsClassSurfaceTouched(construction, node, state, call)) return false; // The receiver is activation-local, but the events module and its prototype // are shared: opaque code in an outer activation can replace their methods // even when this receiver does not exist yet. Use the opaque-effect owner's diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 21737b1976c..757a0ff3e70 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -1966,13 +1966,33 @@ const constructedReferenceValues = ( const classes = t.isClass(node) ? [node] : semanticClassCandidates(node.callee, state); - return classes.flatMap((owner) => + const references: ReferencedValue[] = classes.flatMap((owner) => semanticClassPropertyReferences( { owner, kind: t.isClass(node) ? "static" : "instance" }, [key, ...remaining], state, ), ); + // A constructed receiver may expose its constructor or prototype even when + // the class is imported and has no local AST. Retain these as inherited + // may-references, not exact value provenance, with the original effect site. + // Replacing the instance slot itself was excluded above. + if ( + t.isNewExpression(node) && + // Copied-slot selections contain own enumerable values, never inherited + // constructor/prototype links. A null key is an ordinary unknown read. + (key === null || typeof key !== "object") + ) { + if (semanticPropertyPathKeyMatches(key, "constructor")) + references.push({ node: node.callee, path: remaining, inherited: true }); + if (semanticPropertyPathKeyMatches(key, "__proto__")) + references.push({ + node: node.callee, + path: ["prototype", ...remaining], + inherited: true, + }); + } + return references; }; const branchReferenceValues = ( From 47e3b9bfa582488a5305102a14c2598a99fd8821 Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Thu, 5 Nov 2026 13:13:02 +0800 Subject: [PATCH 11/28] fix(javascript): time ambient prototype effects at XHR reads --- .../javascript/javascriptNetworkRequests.ts | 26 +++---- .../javascriptSemanticAmbientWrites.test.ts | 74 ++++++++++++++++++- .../javascriptSemanticAmbientWrites.ts | 27 ++++++- src/domain/webBundleAnalyzer.test.ts | 48 ++++++++++++ 4 files changed, 158 insertions(+), 17 deletions(-) diff --git a/src/domain/javascript/javascriptNetworkRequests.ts b/src/domain/javascript/javascriptNetworkRequests.ts index efc897f7143..9e63f2f3f30 100644 --- a/src/domain/javascript/javascriptNetworkRequests.ts +++ b/src/domain/javascript/javascriptNetworkRequests.ts @@ -12,7 +12,10 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import * as t from "@babel/types"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; export const isFetchCall = ( node: t.Node, @@ -51,20 +54,13 @@ const isXhrOpenRequest = ( return false; if ( ["XMLHttpRequest", "Object"].some((owner) => - ["open", "__proto__"].some( - (member) => - bindings.ambientWrites.covers([ - "global", - owner, - "prototype", - member, - ]) || - bindings.ambientEscapes.covers([ - "global", - owner, - "prototype", - member, - ]), + ["open", "__proto__"].some((member) => + semanticAmbientPathMayHaveChangedAt( + callee, + bindings, + ["global", owner, "prototype", member], + node, + ), ), ) ) diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index ca6a1cbbd26..60cf50fd306 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -10,7 +10,10 @@ import { completeSemanticSteps, traverseJavaScriptAst, } from "./javascriptSemanticTraversal.js"; -import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js"; +import { + createGlobalReferenceReader, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; it("keeps wrapper module aliases distinct from global-object module properties", () => { @@ -337,3 +340,72 @@ it.each([ expect(statuses).toEqual(expected); }, ); + +it.each([ + [ + "an intact method", + "const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');", + false, + ], + [ + "a current function-local invocation", + "function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');}", + false, + ], + [ + "an earlier constructor escape", + "const xhr=new XMLHttpRequest();mutate(xhr.constructor);xhr.open('GET','/xhr');", + true, + ], + [ + "a later constructor escape", + "const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);", + false, + ], + [ + "an escaped method value", + "const xhr=new XMLHttpRequest();mutate(XMLHttpRequest.prototype.open);xhr.open('GET','/xhr');", + false, + ], + [ + "a selected method before argument effects", + "const xhr=new XMLHttpRequest();xhr.open('GET',(mutate(xhr.constructor),'/xhr'));", + false, + ], + [ + "a later effect in a possibly repeated activation", + "function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);}", + true, + ], + [ + "an unlocated ambient escape", + "function request(){window.fetch('/other');}fetch=replacement;const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);", + true, + ], +])("retains timed ambient path effects for %s", (_label, source, changed) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected ambient-effect fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: boolean[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property, { name: "open" }) + ) + results.push( + semanticAmbientPathMayHaveChangedAt( + node.callee, + index.bindings, + ["global", "XMLHttpRequest", "prototype", "open"], + node, + ), + ); + }, + }); + expect(results).toEqual([changed]); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 688ff5c4a8b..cee26a39278 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -61,6 +61,31 @@ const canonicalAmbientPath = ( type AmbientPath = readonly (string | null)[]; +/** Query retained ambient effects at a selected read, preserving unknown timing. */ +export const semanticAmbientPathMayHaveChangedAt = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly string[], + invocation?: t.Node, +): boolean => { + if (!state.ambientWrites.covers(path) && !state.ambientEscapes.covers(path)) + return false; + const mayAffect = ( + effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number], + ): boolean => + // A receiver is exposed by the invocation after its method was selected. + (invocation === undefined || effect.node !== invocation) && + effect.path.length <= path.length && + effect.path.every((key, index) => + semanticPropertyPathKeyMatches(key, path[index]!), + ) && + semanticEffectTiming(read, effect.node, state) !== "after"; + return ( + state.ambientWriteEffects.some(mayAffect) || + state.ambientEscapeEffects.some(mayAffect) + ); +}; + const retainedAmbientEffects = new WeakMap< JavaScriptSemanticAnalysisState, Map> @@ -835,7 +860,7 @@ export function* indexJavaScriptAmbientWritesSteps( state.ambientWrites.covers(["global", method]), ) ) - state.ambientEscapes.retain(["global", null]); + retainSemanticAmbientEffect(state, "escape", ["global"], undefined); } export type GlobalReferenceRead = diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index c1df025881a..475bad9bbab 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -2201,3 +2201,51 @@ it.each([ ); }, ); + +it.each([ + [ + "an unchanged function-local receiver", + "function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');}", + true, + ], + [ + "an earlier constructor escape", + "const xhr=new XMLHttpRequest();mutate(xhr.constructor);xhr.open('GET','/xhr');", + false, + ], + [ + "an earlier cross-instance escape", + "const other=new XMLHttpRequest();const xhr=new XMLHttpRequest();mutate(other);xhr.open('GET','/xhr');", + false, + ], + [ + "a later constructor escape", + "const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);", + true, + ], + [ + "a later cross-instance escape", + "const other=new XMLHttpRequest();const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(other);", + true, + ], + [ + "an escaped method value", + "const xhr=new XMLHttpRequest();mutate(XMLHttpRequest.prototype.open);xhr.open('GET','/xhr');", + true, + ], + [ + "a shared escape in a possibly repeated activation", + "const other=new XMLHttpRequest();function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(other);}", + false, + ], +])("orders XHR method selection against %s", (_label, source, observed) => { + const result = analyzeWebBundleFixture(inspection(source)); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual( + observed ? ["/xhr"] : [], + ); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value), + ).toEqual(observed ? [] : ["/xhr"]); +}); From ad13261e32dfe38287af6eb9c5e81b7d4817efb8 Mon Sep 17 00:00:00 2001 From: Gong Liu <1360187968@qq.com> Date: Thu, 5 Nov 2026 13:28:03 +0800 Subject: [PATCH 12/28] perf(javascript): share ambient path effect matching --- .../javascriptSemanticAmbientWrites.test.ts | 124 ++++++++++++++++++ .../javascriptSemanticAmbientWrites.ts | 61 +++++++-- 2 files changed, 172 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index 60cf50fd306..b51d6b7c98e 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -12,6 +12,7 @@ import { } from "./javascriptSemanticTraversal.js"; import { createGlobalReferenceReader, + retainSemanticAmbientEffect, semanticAmbientPathMayHaveChangedAt, } from "./javascriptSemanticAmbientWrites.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; @@ -409,3 +410,126 @@ it.each([ }); expect(results).toEqual([changed]); }); + +it("shares matching ambient records across reads instead of scanning unrelated effects again", () => { + const work = (count: number): number => { + const source = + Array.from( + { length: count }, + (_value, index) => + "new XMLHttpRequest().open('GET','/xhr');globalThis.unrelated" + + index + + "=replacement;", + ).join("") + "XMLHttpRequest.prototype.open=replacement;"; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected ambient work fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + let reads = 0; + for (const effect of [ + ...index.bindings.ambientWriteEffects, + ...index.bindings.ambientEscapeEffects, + ]) { + const path = effect.path; + Object.defineProperty(effect, "path", { + configurable: true, + get: () => { + reads += 1; + return path; + }, + }); + } + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property, { name: "open" }) + ) + semanticAmbientPathMayHaveChangedAt( + node.callee, + index.bindings, + ["global", "XMLHttpRequest", "prototype", "open"], + node, + ); + }, + }); + return reads; + }; + expect(work(64)).toBeLessThan(3 * work(32)); +}); + +it.each(["write", "escape"] as const)( + "refreshes cached ambient records when %s facts grow without caching read answers", + (kind) => { + const parsed = parseJavaScriptSource( + "before();inspect();after();inspect();", + ); + if (parsed === null) + throw new Error("Expected growing ambient-effect fixture"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + const calls: t.CallExpression[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) calls.push(node); + }, + }); + const [before, first, after, second] = calls; + if (!before || !first || !after || !second) + throw new Error("Expected four call sites"); + const path = ["global", "XMLHttpRequest", "prototype", "open"]; + retainSemanticAmbientEffect(state, "write", path, after); + expect(semanticAmbientPathMayHaveChangedAt(first.callee, state, path)).toBe( + false, + ); + expect( + semanticAmbientPathMayHaveChangedAt(second.callee, state, path), + ).toBe(true); + retainSemanticAmbientEffect( + state, + kind, + kind === "write" ? path : ["global", "XMLHttpRequest"], + before, + ); + expect(semanticAmbientPathMayHaveChangedAt(first.callee, state, path)).toBe( + true, + ); + }, +); + +it("keeps invocation exclusions outside the shared ambient record cache", () => { + const parsed = parseJavaScriptSource("function run(){invoke();}"); + if (parsed === null) throw new Error("Expected invocation-effect fixture"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + let invocation: t.CallExpression | undefined; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) invocation = node; + }, + }); + if (invocation === undefined) throw new Error("Expected invocation"); + const path = ["global", "XMLHttpRequest", "prototype", "open"]; + retainSemanticAmbientEffect( + state, + "escape", + ["global", "XMLHttpRequest"], + invocation, + ); + expect( + semanticAmbientPathMayHaveChangedAt( + invocation.callee, + state, + path, + invocation, + ), + ).toBe(false); + expect( + semanticAmbientPathMayHaveChangedAt(invocation.callee, state, path), + ).toBe(true); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index cee26a39278..726901c8ab0 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -61,6 +61,20 @@ const canonicalAmbientPath = ( type AmbientPath = readonly (string | null)[]; +interface AmbientPathEffectCache { + readonly writes: number; + readonly escapes: number; + readonly byPath: Map< + string, + JavaScriptSemanticAnalysisState["ambientWriteEffects"] + >; +} + +const ambientPathEffectCaches = new WeakMap< + JavaScriptSemanticAnalysisState, + AmbientPathEffectCache +>(); + /** Query retained ambient effects at a selected read, preserving unknown timing. */ export const semanticAmbientPathMayHaveChangedAt = ( read: t.Node, @@ -70,19 +84,40 @@ export const semanticAmbientPathMayHaveChangedAt = ( ): boolean => { if (!state.ambientWrites.covers(path) && !state.ambientEscapes.covers(path)) return false; - const mayAffect = ( - effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number], - ): boolean => - // A receiver is exposed by the invocation after its method was selected. - (invocation === undefined || effect.node !== invocation) && - effect.path.length <= path.length && - effect.path.every((key, index) => - semanticPropertyPathKeyMatches(key, path[index]!), - ) && - semanticEffectTiming(read, effect.node, state) !== "after"; - return ( - state.ambientWriteEffects.some(mayAffect) || - state.ambientEscapeEffects.some(mayAffect) + let cache = ambientPathEffectCaches.get(state); + if ( + cache?.writes !== state.ambientWriteEffects.length || + cache?.escapes !== state.ambientEscapeEffects.length + ) { + cache = { + writes: state.ambientWriteEffects.length, + escapes: state.ambientEscapeEffects.length, + byPath: new Map(), + }; + ambientPathEffectCaches.set(state, cache); + } + const key = JSON.stringify(path); + let effects = cache.byPath.get(key); + if (effects === undefined) { + const matches = ( + effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number], + ): boolean => + effect.path.length <= path.length && + effect.path.every((key, index) => + semanticPropertyPathKeyMatches(key, path[index]!), + ); + effects = [ + ...state.ambientWriteEffects.filter(matches), + ...state.ambientEscapeEffects.filter(matches), + ]; + cache.byPath.set(key, effects); + } + // Only matching records are shared. Their timing belongs to each read. + return effects.some( + (effect) => + // A receiver is exposed by the invocation after its method was selected. + (invocation === undefined || effect.node !== invocation) && + semanticEffectTiming(read, effect.node, state) !== "after", ); }; From 98316a1ba0d3f85b9a663201e346187d3eb82725 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 15:21:49 +0800 Subject: [PATCH 13/28] perf(javascript): index shared EventEmitter effects --- .../javascriptSemanticAsyncEffects.test.ts | 95 ++++++++ .../javascriptSemanticAsyncEffects.ts | 214 +++++++++++------- 2 files changed, 221 insertions(+), 88 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAsyncEffects.test.ts diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.test.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.test.ts new file mode 100644 index 00000000000..1628afbac7f --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "vitest"; + +import { + analyzeJavaScriptSemantics, + indexJavaScriptBindingsWithReceiversSteps, +} from "./javascriptSemanticAnalysis.js"; +import { collectJavaScriptSemanticAsyncEffects } from "./javascriptSemanticAsyncEffects.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const indexed = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected event-effect fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return { program: parsed.program, state: index.bindings }; +}; + +it("shares binding scans across missing-listener calls", () => { + const work = (count: number): number => { + // Imports are hoisted, but unrelated declarations may precede them in source. + const source = `${Array.from({ length: count }, (_, i) => `const unrelated${String(i)} = ${String(i)};`).join("\n")} +import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +${Array.from({ length: count }, (_, i) => `e.on("event${String(i)}");`).join("\n")}`; + const { program, state } = indexed(source); + const bindings = state.bindingsById.values.bind(state.bindingsById); + let reads = 0; + Object.defineProperty(state.bindingsById, "values", { + value: function* () { + for (const binding of bindings()) { + reads += 1; + yield binding; + } + }, + }); + const { eventOperations } = collectJavaScriptSemanticAsyncEffects( + program, + state, + [], + ); + // Preserve the earlier-invocation escape uncertainty for subsequent calls. + expect(eventOperations.map(({ eventName }) => eventName)).toEqual( + Array.from({ length: count - 1 }, (_, i) => `event${String(i + 1)}`), + ); + return reads; + }; + expect(work(64)).toBeLessThan(3 * work(32)); +}); + +it("checks shared prototype timing separately at each call", () => { + const result = + analyzeJavaScriptSemantics(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("before"); +EventEmitter.prototype.on = replacement; +e.on("after");`); + expect(result.eventOperations.map(({ eventName }) => eventName)).toEqual([ + "after", + ]); +}); + +it("checks initialization effects against each receiver construction", () => { + const result = + analyzeJavaScriptSemantics(`import { EventEmitter } from "node:events"; +const before = new EventEmitter(); +EventEmitter.init = replacement; +const after = new EventEmitter(); +before.on("native"); +after.on("changed");`); + expect(result.eventOperations.map(({ eventName }) => eventName)).toEqual([ + "changed", + ]); +}); + +it("rebuilds shared effects for a later collection on the same state", () => { + const { program, state } = + indexed(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("ready");`); + expect( + collectJavaScriptSemanticAsyncEffects(program, state, []).eventOperations, + ).toEqual([]); + const emitter = [...state.bindingsById.values()].find( + ({ name }) => name === "EventEmitter", + ); + if (emitter === undefined) throw new Error("Expected EventEmitter binding"); + emitter.memberWrites.push({ path: ["prototype", "on"], node: undefined }); + expect( + collectJavaScriptSemanticAsyncEffects(program, state, []).eventOperations, + ).toMatchObject([{ method: "on", eventName: "ready" }]); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 6095d476acc..028e3ff2173 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -130,6 +130,11 @@ export const collectJavaScriptSemanticAsyncEffects = ( callables: readonly JavaScriptSemanticCallable[], ): AsyncEffectAnalysis => { const events: JavaScriptSemanticEventOperation[] = []; + // Mutation facts are complete before this collection. Share only matching + // records within this pass; every read still decides its own timing. + let emitterEffects: EventEmitterSharedEffects | undefined; + const sharedEmitterEffects = (): EventEmitterSharedEffects => + (emitterEffects ??= indexEventEmitterSharedEffects(state)); const timers: TimerCandidate[] = []; const callableIds = new Set(callables.map(({ callableId }) => callableId)); const callableStack = createSemanticCallableIdStack(callableIds); @@ -139,7 +144,13 @@ export const collectJavaScriptSemanticAsyncEffects = ( callableStack.enter(node, parent); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const ownerCallableId = callableStack.callables.at(-1) ?? null; - collectEvent(node, ownerCallableId, state, events); + collectEvent( + node, + ownerCallableId, + state, + events, + sharedEmitterEffects, + ); collectTimer({ node, ownerCallableId, @@ -286,57 +297,111 @@ const reachesEventsClassSurface = ( return path !== null && eventsClassPath(path); }); +type EventEmitterEffect = t.Node | undefined; + +interface EventEmitterClassEffects { + readonly writes: readonly EventEmitterEffect[]; + readonly escapes: readonly EventEmitterEffect[]; +} + +interface EventEmitterSharedEffects extends EventEmitterClassEffects { + readonly otherRoots: ReadonlyMap; + readonly prototypeWrites: ReadonlyMap; + readonly prototypeEscapes: readonly EventEmitterEffect[]; +} + +/** Unknown property keys may select any part of the construction surface. */ +const targetsEventsClass = (path: JavaScriptSemanticPropertyPath): boolean => + path.some( + (key) => + key === null || + key === "EventEmitter" || + key === "init" || + key === "prototype", + ); + +/** Index shared matching records once, without caching a call's validity. */ +const indexEventEmitterSharedEffects = ( + state: JavaScriptSemanticAnalysisState, +): EventEmitterSharedEffects => { + const writes: EventEmitterEffect[] = []; + const escapes: EventEmitterEffect[] = []; + const otherRoots = new Map(); + const prototypeWrites = new Map(); + const prototypeEscapes: EventEmitterEffect[] = []; + const retainPrototypeWrite = ( + path: JavaScriptSemanticPropertyPath, + node: EventEmitterEffect, + ): void => { + for (const method of EVENT_LISTENER_METHODS) { + if (!affectsPrototypeMethod(path, method)) continue; + const matching = prototypeWrites.get(method) ?? []; + matching.push(node); + prototypeWrites.set(method, matching); + } + }; + for (const binding of state.bindingsById.values()) { + const classWrites = binding.memberWrites + .filter(({ path }) => targetsEventsClass(path)) + .map(({ node }) => node); + const classEscapes = binding.escapedPaths + .filter(({ path }) => eventsClassPath(path)) + .map(({ node }) => node); + if (classWrites.length > 0 || classEscapes.length > 0) { + if (reachesEventsClassSurface(binding, state)) { + for (const node of classWrites) writes.push(node); + for (const node of classEscapes) escapes.push(node); + } else { + // A proven construction's root remains relevant even when the general + // class-origin predicate cannot establish that binding's surface. + otherRoots.set(binding.bindingId, { + writes: classWrites, + escapes: classEscapes, + }); + } + } + for (const { path, node } of binding.memberWrites) + retainPrototypeWrite(path, node); + for (const { path, node } of binding.escapedPaths) + if (escapesPrototypeObject(path)) prototypeEscapes.push(node); + } + for (const specifier of NODE_EVENTS_SPECIFIERS) + for (const { kind, path, node } of state.requiredModuleEffects.get( + specifier, + ) ?? []) { + if (kind === "escape") { + if (eventsClassPath(path)) escapes.push(node); + if (escapesPrototypeObject(path)) prototypeEscapes.push(node); + } else { + if (targetsEventsClass(path)) writes.push(node); + retainPrototypeWrite(path, node); + } + } + return { writes, escapes, otherRoots, prototypeWrites, prototypeEscapes }; +}; + /** - * Construction-only writes (including Node's init hook) matter before allocation. - * Escaping the class or namespace can still replace the shared prototype after - * allocation, so those effects remain relevant until the listener call. - * Passing a method value cannot replace its containing class or prototype. + * Construction-only writes matter before allocation. Class and namespace + * escapes can replace the shared prototype until the listener call. */ const eventsClassSurfaceTouched = ( + effects: EventEmitterSharedEffects, construction: t.NewExpression, read: t.Node, state: JavaScriptSemanticAnalysisState, invocation: t.Node, ): boolean => { - // This invocation runs after selecting its method. Its own receiver escape - // cannot replace that selected native method retroactively. - const precedes = (read: t.Node, effect: t.Node | undefined): boolean => + const precedes = (read: t.Node, effect: EventEmitterEffect): boolean => effect !== invocation && !semanticTopLevelEffectFollowsCapture(read, effect, state.parentsByNode); + const touched = (selected: EventEmitterClassEffects): boolean => + selected.escapes.some((node) => precedes(read, node)) || + selected.writes.some((node) => precedes(construction, node)); + if (touched(effects)) return true; const root = constructionRootBinding(construction.callee, state); - // An unknown property key can name any slot, so it covers this surface too. - const targetsClass = (path: JavaScriptSemanticPropertyPath): boolean => - path.some( - (key) => - key === null || - key === "EventEmitter" || - key === "init" || - key === "prototype", - ); - for (const binding of state.bindingsById.values()) { - const isRoot = root !== undefined && binding.bindingId === root.bindingId; - if (!isRoot && !reachesEventsClassSurface(binding, state)) continue; - if ( - binding.escapedPaths.some( - ({ path, node }) => eventsClassPath(path) && precedes(read, node), - ) || - binding.memberWrites.some( - ({ path, node }) => targetsClass(path) && precedes(construction, node), - ) - ) - return true; - } - for (const specifier of NODE_EVENTS_SPECIFIERS) - if ( - (state.requiredModuleEffects.get(specifier) ?? []).some( - ({ kind, path, node }) => - kind === "escape" - ? eventsClassPath(path) && precedes(read, node) - : targetsClass(path) && precedes(construction, node), - ) - ) - return true; - return false; + const rootEffects = + root === undefined ? undefined : effects.otherRoots.get(root.bindingId); + return rootEffects !== undefined && touched(rootEffects); }; /** @@ -370,32 +435,15 @@ const affectsPrototypeMethod = ( * answers the call. */ const writesPrototypeMethod = ( + effects: EventEmitterSharedEffects, method: string, read: t.Node, state: JavaScriptSemanticAnalysisState, -): boolean => { - const precedes = (write: t.Node | undefined): boolean => - !semanticTopLevelEffectFollowsCapture(read, write, state.parentsByNode); - for (const binding of state.bindingsById.values()) - if ( - binding.memberWrites.some( - ({ path, node: write }) => - affectsPrototypeMethod(path, method) && precedes(write), - ) - ) - return true; - for (const specifier of NODE_EVENTS_SPECIFIERS) - if ( - (state.requiredModuleEffects.get(specifier) ?? []).some( - ({ kind, path, node: write }) => - kind === "write" && - affectsPrototypeMethod(path, method) && - precedes(write), - ) - ) - return true; - return false; -}; +): boolean => + (effects.prototypeWrites.get(method) ?? []).some( + (write) => + !semanticTopLevelEffectFollowsCapture(read, write, state.parentsByNode), + ); /** * Report whether an escape hands out the prototype object itself. Reading a @@ -418,28 +466,14 @@ const escapesPrototypeObject = ( * method it defines. */ const prototypeObjectEscaped = ( + effects: EventEmitterSharedEffects, read: t.Node, state: JavaScriptSemanticAnalysisState, -): boolean => { - const precedes = (escape: t.Node | undefined): boolean => - !semanticTopLevelEffectFollowsCapture(read, escape, state.parentsByNode); - for (const binding of state.bindingsById.values()) - if ( - binding.escapedPaths.some( - ({ path, node }) => escapesPrototypeObject(path) && precedes(node), - ) - ) - return true; - for (const specifier of NODE_EVENTS_SPECIFIERS) - if ( - (state.requiredModuleEffects.get(specifier) ?? []).some( - ({ kind, path, node }) => - kind === "escape" && escapesPrototypeObject(path) && precedes(node), - ) - ) - return true; - return false; -}; +): boolean => + effects.prototypeEscapes.some( + (escape) => + !semanticTopLevelEffectFollowsCapture(read, escape, state.parentsByNode), + ); /** * Report whether the receiver is a value this module constructs from Node's @@ -454,6 +488,7 @@ const nodeEventEmitterReceiver = ( method: string, state: JavaScriptSemanticAnalysisState, call: t.Node, + sharedEffects: () => EventEmitterSharedEffects, ): boolean => { if (!t.isIdentifier(node)) return false; const binding = resolveSemanticBindingState(state, node, node.name); @@ -466,7 +501,9 @@ const nodeEventEmitterReceiver = ( // Reaching the class is not the same as reaching the method: the class object // and the `init` hook Node runs while constructing can both be reshaped, so a // construction whose surface was touched keeps its operation. - if (eventsClassSurfaceTouched(construction, node, state, call)) return false; + const effects = sharedEffects(); + if (eventsClassSurfaceTouched(effects, construction, node, state, call)) + return false; // The receiver is activation-local, but the events module and its prototype // are shared: opaque code in an outer activation can replace their methods // even when this receiver does not exist yet. Use the opaque-effect owner's @@ -509,8 +546,8 @@ const nodeEventEmitterReceiver = ( // replaces the method for this receiver as well, and so does handing that // prototype to a call that may install the method itself. return ( - !writesPrototypeMethod(method, node, state) && - !prototypeObjectEscaped(node, state) + !writesPrototypeMethod(effects, method, node, state) && + !prototypeObjectEscaped(effects, node, state) ); }; @@ -519,6 +556,7 @@ const collectEvent = ( ownerCallableId: string | null, state: JavaScriptSemanticAnalysisState, output: JavaScriptSemanticEventOperation[], + sharedEffects: () => EventEmitterSharedEffects, ): void => { const member = dataEffectMemberCallee(node); if (member === null) return; @@ -540,7 +578,7 @@ const collectEvent = ( listenerMethod && listenerArgument === undefined && !node.arguments.some((argument) => t.isSpreadElement(argument)) && - nodeEventEmitterReceiver(member.object, method, state, node) + nodeEventEmitterReceiver(member.object, method, state, node, sharedEffects) ) return; // A leading spread shifts the runtime argument positions, so argument 1 is not From c719fb1a1a13b9b49b9caad35b6c4f363d322e65 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 15:34:51 +0800 Subject: [PATCH 14/28] fix(javascript): track EventEmitter initializer escapes --- ...icAnalysis.rejected_event_listener.test.ts | 78 +++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 37 +++++++-- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 9c6606317e9..6e07d10d43e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -439,6 +439,84 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +/** + * Node invokes `EventEmitter.init.call(this, opts)`. The initializer function is + * mutable: an external configurator can replace its `.call` slot with a wrapper + * that installs an own defaulting listener method. Measured on Node 24.22.1: + * `e.on("ready")` then registers one listener without throwing. + */ +it.each([ + [ + "a named import", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + "EventEmitter.init", + ], + [ + "a namespace", + 'import * as events from "node:events";', + "events.EventEmitter", + "events.EventEmitter.init", + ], + [ + "an initializer alias", + 'import { EventEmitter } from "node:events"; const init = EventEmitter.init;', + "EventEmitter", + "init", + ], + [ + "a required module", + 'const events = require("node:events");', + "events.EventEmitter", + "events.EventEmitter.init", + true, + ], + [ + "an inline require", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + 'require("events").EventEmitter.init', + true, + ], +] as const)( + "checks initializer escape timing through %s", + (_name, header, constructor, initializer, commonJs: boolean = false) => { + const events = commonJs ? eventsForCommonJsSource : eventsForSource; + const source = (before: boolean) => `${header} +import { configure } from "./configure.js"; +${before ? `configure(${initializer});` : ""} +const e = new ${constructor}(); +${before ? "" : `configure(${initializer});`} +e.on("ready");`; + expect(events(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(events(source(false))).toEqual([]); + }, +); + +it("still rejects a missing listener when only the initializer call value escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +configure(EventEmitter.init.call); +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +it("keeps the registration after an initializer alias call slot is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const init = EventEmitter.init; +init.call = replacement; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + /** * Two specifiers name the same mutable module, so a replacement installed * through one import answers the other. Measured on Node 22.22.2: `e.on("ready")` diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 028e3ff2173..d0381579b6b 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -276,6 +276,10 @@ const eventsClassPath = (path: JavaScriptSemanticPropertyPath): boolean => { return true; }; +/** The initializer object owns the `.call` slot Node reads during allocation. */ +const eventsInitializerPath = (path: JavaScriptSemanticPropertyPath): boolean => + path.at(-1) === "init" && eventsClassPath(path.slice(0, -1)); + const reachesEventsClassSurface = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, @@ -301,6 +305,7 @@ type EventEmitterEffect = t.Node | undefined; interface EventEmitterClassEffects { readonly writes: readonly EventEmitterEffect[]; + readonly initializerEscapes: readonly EventEmitterEffect[]; readonly escapes: readonly EventEmitterEffect[]; } @@ -325,6 +330,7 @@ const indexEventEmitterSharedEffects = ( state: JavaScriptSemanticAnalysisState, ): EventEmitterSharedEffects => { const writes: EventEmitterEffect[] = []; + const initializerEscapes: EventEmitterEffect[] = []; const escapes: EventEmitterEffect[] = []; const otherRoots = new Map(); const prototypeWrites = new Map(); @@ -347,15 +353,25 @@ const indexEventEmitterSharedEffects = ( const classEscapes = binding.escapedPaths .filter(({ path }) => eventsClassPath(path)) .map(({ node }) => node); - if (classWrites.length > 0 || classEscapes.length > 0) { + const classInitializerEscapes = binding.escapedPaths + .filter(({ path }) => eventsInitializerPath(path)) + .map(({ node }) => node); + if ( + classWrites.length > 0 || + classEscapes.length > 0 || + classInitializerEscapes.length > 0 + ) { if (reachesEventsClassSurface(binding, state)) { for (const node of classWrites) writes.push(node); for (const node of classEscapes) escapes.push(node); + for (const node of classInitializerEscapes) + initializerEscapes.push(node); } else { // A proven construction's root remains relevant even when the general // class-origin predicate cannot establish that binding's surface. otherRoots.set(binding.bindingId, { writes: classWrites, + initializerEscapes: classInitializerEscapes, escapes: classEscapes, }); } @@ -371,18 +387,28 @@ const indexEventEmitterSharedEffects = ( ) ?? []) { if (kind === "escape") { if (eventsClassPath(path)) escapes.push(node); + if (eventsInitializerPath(path)) initializerEscapes.push(node); if (escapesPrototypeObject(path)) prototypeEscapes.push(node); } else { if (targetsEventsClass(path)) writes.push(node); retainPrototypeWrite(path, node); } } - return { writes, escapes, otherRoots, prototypeWrites, prototypeEscapes }; + return { + writes, + initializerEscapes, + escapes, + otherRoots, + prototypeWrites, + prototypeEscapes, + }; }; /** - * Construction-only writes matter before allocation. Class and namespace - * escapes can replace the shared prototype until the listener call. + * Construction-only writes and initializer escapes matter before allocation. + * An escaped initializer can gain an own `.call` wrapper without replacement. + * Class and namespace escapes can replace the shared prototype until the + * listener call. */ const eventsClassSurfaceTouched = ( effects: EventEmitterSharedEffects, @@ -396,7 +422,8 @@ const eventsClassSurfaceTouched = ( !semanticTopLevelEffectFollowsCapture(read, effect, state.parentsByNode); const touched = (selected: EventEmitterClassEffects): boolean => selected.escapes.some((node) => precedes(read, node)) || - selected.writes.some((node) => precedes(construction, node)); + selected.writes.some((node) => precedes(construction, node)) || + selected.initializerEscapes.some((node) => precedes(construction, node)); if (touched(effects)) return true; const root = constructionRootBinding(construction.callee, state); const rootEffects = From 88d5732207e4c729ef3bad51fb5f1f5f9cbec61c Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 15:50:03 +0800 Subject: [PATCH 15/28] fix(javascript): honor initializer binding provenance --- ...icAnalysis.rejected_event_listener.test.ts | 96 ++++++++++++++++++- .../javascriptSemanticAsyncEffects.ts | 27 ++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 6e07d10d43e..b5fcf54fa99 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -478,6 +478,31 @@ it.each([ 'require("events").EventEmitter.init', true, ], + [ + "a named initializer import", + 'import { EventEmitter, init } from "node:events";', + "EventEmitter", + "init", + ], + [ + "a named initializer alias", + 'import { EventEmitter, init } from "node:events"; const initialize = init;', + "EventEmitter", + "initialize", + ], + [ + "a namespace initializer", + 'import * as events from "node:events";', + "events.EventEmitter", + "events.init", + ], + [ + "a member required initializer", + 'const EventEmitter = require("node:events"); const init = require("events").init;', + "EventEmitter", + "init", + true, + ], ] as const)( "checks initializer escape timing through %s", (_name, header, constructor, initializer, commonJs: boolean = false) => { @@ -497,11 +522,78 @@ e.on("ready");`; }, ); -it("still rejects a missing listener when only the initializer call value escapes", () => { +it("keeps the registration after a destructured required initializer escapes", () => { + expect( + eventsForCommonJsSource(`const { EventEmitter, init } = require("node:events"); +import { configure } from "./configure.js"; +configure(init); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * The inherited call slot is mutable, and init.prototype.constructor leads back + * to init itself. Existing prototype/escape facts must retain both surfaces. + */ +it.each([ + "EventEmitter.init.__proto__", + "Reflect.getPrototypeOf(EventEmitter.init)", + "EventEmitter.init.prototype", + "init.__proto__", + "Reflect.getPrototypeOf(init)", + "init.prototype", +])( + "keeps the registration when the initializer is reachable through %s", + (surface) => { + expect( + eventsForSource(`import { EventEmitter, init } from "node:events"; +import { configure } from "./configure.js"; +configure(${surface}); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + }, +); + +it.each(["EventEmitter.init", "init"])( + "still rejects a missing listener when only %s.call escapes", + (initializer) => { + expect( + eventsForSource(`import { EventEmitter, init } from "node:events"; +import { configure } from "./configure.js"; +configure(${initializer}.call); +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); + }, +); + +it.each(["init.call = replacement;", "init.__proto__ = replacement;"])( + "checks named initializer mutation timing for %s", + (mutation) => { + const source = ( + before: boolean, + ) => `import { EventEmitter, init } from "node:events"; +${before ? mutation : ""} +const e = new EventEmitter(); +${before ? "" : mutation} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("still rejects a missing listener beside an unrelated imported initializer escape", () => { expect( eventsForSource(`import { EventEmitter } from "node:events"; +import { init } from "./unrelated.js"; import { configure } from "./configure.js"; -configure(EventEmitter.init.call); +configure(init); const e = new EventEmitter(); e.on("ready");`), ).toEqual([]); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index d0381579b6b..7b844580426 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -31,6 +31,7 @@ import { } from "./javascriptSemanticOpaqueEffects.js"; import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; +import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js"; import { affectsBuiltinPath, builtinDataEffectCallablePath, @@ -376,6 +377,32 @@ const indexEventEmitterSharedEffects = ( }); } } + // A named initializer import owns its own records, rather than paths on a + // class binding. Consume the provenance owner's retained origins, including + // candidate origins: uncertainty can withdraw this proof, never create it. + const initializerWrites = binding.memberWrites.filter( + ({ path }) => + affectsBuiltinPath(path, ["call"]) || path.at(0) === "__proto__", + ); + const initializerObjectEscapes = binding.escapedPaths.filter( + ({ path }) => path.length === 0, + ); + if ( + (initializerWrites.length > 0 || initializerObjectEscapes.length > 0) && + evaluateSemanticProvenance(binding, state).origins.some( + ({ specifier, importedPath }) => + eventsInitializerPath(importedPath) && + NODE_EVENTS_SPECIFIERS.some( + (candidate) => + candidate.replace(/^node:/u, "") === + specifier.replace(/^node:/u, ""), + ), + ) + ) { + for (const { node } of initializerWrites) writes.push(node); + for (const { node } of initializerObjectEscapes) + initializerEscapes.push(node); + } for (const { path, node } of binding.memberWrites) retainPrototypeWrite(path, node); for (const { path, node } of binding.escapedPaths) From 514a306f47e0c15819ed959eae712657957da04f Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 19:02:58 +0800 Subject: [PATCH 16/28] fix(javascript): preserve timed native receiver proofs --- ...icAnalysis.rejected_event_listener.test.ts | 28 +++++++++++ .../javascriptSemanticAsyncEffects.ts | 5 +- .../javascriptSemanticMapOperations.ts | 34 ++++++++----- src/domain/webBundleAnalyzer.test.ts | 48 +++++++++++++++++++ 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index b5fcf54fa99..feddf0726e2 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -219,6 +219,34 @@ bus.on("ready");`, }); }); +it.each([ + ["extra.EventEmitter", "extra", "{ EventEmitter: Bus }"], + ["extra.deep.EventEmitter", "extra", "{ deep: { EventEmitter: Bus } }"], + ["default.EventEmitter", "default", "{ EventEmitter: Bus }"], +] as const)( + "keeps custom nested events exports as candidates: %s", + (selected, changed, replacement) => { + for (const listener of ["", ", () => {}"]) { + expect( + eventsForSource(`import A from "events"; +import B from "node:events"; +import { Bus } from "./bus.js"; +A.${changed} = ${replacement}; +const e = new B.${selected}(); +e.on("ready"${listener});`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + } + }, +); + +it("still rejects a canonical class through a namespace default export", () => { + expect( + eventsForSource(`import * as events from "node:events"; +const e = new events.default.EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + /** * A member write replaces the method the native contract belongs to. Measured * on Node 22.23.3: `e.on = (name, fn = () => {}) => ...` stores `"ready"`, so diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 7b844580426..8a61b57ab72 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -229,7 +229,10 @@ const nodeEventEmitterConstruction = ( if (selected === null) return null; // An empty selected path is the module's default export, which `events` // defines as `EventEmitter`. - return selected.length === 0 || selected.at(-1) === "EventEmitter" + // A suffix alone cannot prove the class: user-defined nested exports may + // end in EventEmitter while selecting an unrelated implementation. + return selected.length === 0 || + (selected.length === 1 && selected[0] === "EventEmitter") ? value : null; }; diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts index ce559fe3ba9..2d1670fe6ec 100644 --- a/src/domain/javascript/javascriptSemanticMapOperations.ts +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -4,7 +4,10 @@ import { semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { @@ -25,15 +28,20 @@ export const isIntrinsicMapLookup = ( return false; const method = semanticStaticPropertyName(callee.property, callee.computed); if (method !== "get" && method !== "delete") return false; - const prototypeMemberMayHaveChanged = (member: string): boolean => - ["Map", "Object"].some((owner) => { - const path = ["global", owner, "prototype", member]; - return ( - bindings.ambientWrites.covers(path) || - bindings.ambientEscapes.covers(path) - ); - }); - if (prototypeMemberMayHaveChanged(method)) return false; + const prototypeMemberMayHaveChanged = ( + member: string, + read: t.Node, + invocation: t.Node, + ): boolean => + ["Map", "Object"].some((owner) => + semanticAmbientPathMayHaveChangedAt( + read, + bindings, + ["global", owner, "prototype", member], + invocation, + ), + ); + if (prototypeMemberMayHaveChanged(method, callee, node)) return false; let receiver = unwrapJavaScriptExpression(callee.object).node; const seen = new Set(); while (t.isIdentifier(receiver)) { @@ -66,7 +74,11 @@ export const isIntrinsicMapLookup = ( return false; // Iterable construction reads and invokes the instance's set hook. A changed // adder can install its own get/delete before the constructed value is read. - if (receiver.arguments.length > 0 && prototypeMemberMayHaveChanged("set")) + // The adder is selected after arguments evaluate, not at the callee read. + if ( + receiver.arguments.length > 0 && + prototypeMemberMayHaveChanged("set", receiver, receiver) + ) return false; if (semanticOpaqueGlobalMayHaveChanged(callee, bindings)) return false; return index === undefined && seen.size > 0 ? undefined : true; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index caa8c183df3..efd2f715d16 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -114,6 +114,54 @@ it.each([ "Object.getPrototypeOf = () => Map.prototype; Object.getPrototypeOf({}).get = fetch; new Map().get('/accounts');", true, ], + [ + "const records = new Map(); records.get('/accounts'); const other = new Map(); consume(other);", + false, + ], + [ + "const records = new Map(); records.delete('/accounts'); const other = new Map(); consume(other);", + false, + ], + [ + "const records = new Map(); const other = new Map(); consume(other); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); consume(other); records.delete('/accounts');", + true, + ], + [ + "const records = new Map(); records.get('/accounts'); Map.prototype.get = fetch;", + false, + ], + [ + "const records = new Map(); Map.prototype.get = fetch; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); records.get('/accounts'); consume(Map.prototype);", + false, + ], + [ + "const records = new Map(); consume(Map.prototype); records.get('/accounts');", + true, + ], + [ + "const records = new Map([['x', 1]]); Map.prototype.set = replacement; records.get('/accounts');", + false, + ], + [ + "Map.prototype.set = replacement; const records = new Map([['x', 1]]); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); function later() { consume(Map.prototype); } records.get('/accounts');", + true, + ], + [ + "const records = new Map((Map.prototype.set = function () { this.get = fetch; return this; }, [['x', 1]])); records.get('/accounts');", + true, + ], ["client.get('/accounts');", true], ["eval('Map.prototype.get = fetch'); new Map().get('/accounts');", true], ] as const)( From 9a7b483d9af2436615fe515d8aed723ee7cf59bf Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 19:37:10 +0800 Subject: [PATCH 17/28] fix(javascript): retain builtin acquisition mutation effects --- ...icAnalysis.rejected_event_listener.test.ts | 74 +++++++++++++++++++ ...scriptSemanticBuiltinModuleEffects.test.ts | 74 +++++++++++++++++++ .../javascriptSemanticMemberMutations.ts | 14 ++-- .../javascriptSemanticRequireOrigin.ts | 57 ++++++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index feddf0726e2..1fd03116af0 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -333,6 +333,80 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +it.each([ + ["inline", "", "process.getBuiltinModule('events').EventEmitter"], + [ + "qualified", + "", + "globalThis.process.getBuiltinModule('node:events').EventEmitter", + ], + [ + "bound alias", + "const events = process.getBuiltinModule('events'); const alias = events;", + "alias.EventEmitter", + ], + [ + "imported process", + "import process from 'node:process';", + "process.getBuiltinModule('events').EventEmitter", + ], + [ + "imported getter", + "import { getBuiltinModule } from 'node:process';", + "getBuiltinModule('events').EventEmitter", + ], + [ + "extracted imported getter", + "import * as process from 'node:process'; const get = process.getBuiltinModule;", + "get('events').EventEmitter", + ], +] as const)( + "orders shared prototype changes through %s builtin acquisition", + (_name, setup, constructor) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +${setup} +const e = new EventEmitter(); +${before ? `${constructor}.prototype.on = replacement;` : ""} +e.on("ready"); +${before ? "" : `${constructor}.prototype.on = replacement;`}`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("retains a prototype exposed through builtin acquisition", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +configure(process.getBuiltinModule('events').EventEmitter.prototype); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it("keeps builtin acquisition effects whose callback timing is unknown", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +function patch() { process.getBuiltinModule('events').EventEmitter.prototype.on = replacement; } +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it("does not grant native constructor proof from effect-only builtin acquisition", () => { + expect( + eventsForSource(`const events = process.getBuiltinModule('events'); +const e = new events.EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + /** * The added checks must not withdraw the proof from an untouched namespace * receiver, or the guard would never apply to the member-expression forms. diff --git a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts new file mode 100644 index 00000000000..7e5127521d2 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { + semanticBuiltinModuleEffectOrigin, + semanticRequireOriginCandidate, +} from "./javascriptSemanticRequireOrigin.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; + +const indexed = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) + throw new Error("Expected builtin-module effect fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return index.bindings; +}; + +it.each([ + "process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "globalThis.process['getBuiltinModule']('node:events').EventEmitter.prototype.on = replacement;", + "(process?.getBuiltinModule?.('events')).EventEmitter.prototype.on = replacement;", + "const events = process.getBuiltinModule('events'); events.EventEmitter.prototype.on = replacement;", + "const events = process.getBuiltinModule('events'); const alias = events; alias.EventEmitter.prototype.on = replacement;", + "import process from 'node:process'; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "import { getBuiltinModule } from 'node:process'; getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "import * as process from 'node:process'; const get = process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + "import { getBuiltinModule as get } from 'node:process'; const alias = get; alias('events').EventEmitter.prototype.on = replacement;", +])( + "retains builtin acquisition writes as candidate module effects: %s", + (source) => { + const state = indexed(source); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it("does not promote the effect-only acquisition into require provenance", () => { + const state = indexed("const events = process.getBuiltinModule('events');"); + const binding = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + ); + const expression = binding?.initializers[0]?.node; + expect(semanticBuiltinModuleEffectOrigin(expression, state)).toMatchObject({ + specifier: "events", + importedPath: [], + loaderAuthority: "candidate", + }); + expect(semanticRequireOriginCandidate(expression, state)).toBeUndefined(); +}); + +it.each([ + "function run(process) { process.getBuiltinModule('events').EventEmitter.prototype.on = replacement; }", + "process = other; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "const process = other; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "other.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "process.getBuiltinModule(specifier).EventEmitter.prototype.on = replacement;", +])( + "does not invent module identity outside supported acquisition facts: %s", + (source) => { + expect(indexed(source).requiredModuleEffects.get("events") ?? []).toEqual( + [], + ); + }, +); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index ff279687df2..4d03d11764e 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -11,6 +11,7 @@ import { } from "./javascriptSemanticTypedArrays.js"; import { + semanticBuiltinModuleEffectOrigin, semanticGlobalProcessOrigin, semanticGlobalProcessPathOrigin, semanticOriginalGlobalProcessAt, @@ -873,14 +874,16 @@ export function* collectSemanticMemberMutationsSteps( ) continue; const expression = unwrapJavaScriptExpression(current.node).node; - const required = semanticRequireOriginCandidate(expression, state, true); - if (required !== undefined) { + const moduleEffectOrigin = + semanticRequireOriginCandidate(expression, state, true)?.origin ?? + semanticBuiltinModuleEffectOrigin(expression, state); + if (moduleEffectOrigin !== undefined) { recordRequiredEffect( - required.origin.specifier, - [...required.origin.importedPath, ...current.path], + moduleEffectOrigin.specifier, + [...moduleEffectOrigin.importedPath, ...current.path], effect, mutation, - required.origin.loaderAuthority, + moduleEffectOrigin.loaderAuthority, ); continue; } @@ -2138,6 +2141,7 @@ const observableMutationPaths = ( const hasParsedRequireOrigin = binding.initializers.some( ({ node }) => semanticRequireOriginCandidate(node, state) !== undefined || + semanticBuiltinModuleEffectOrigin(node, state) !== undefined || hasSemanticCreateRequireFactoryOrigin(node, state), ); if ( diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 6190a4a46c5..df60cdf0b60 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -8,6 +8,7 @@ import { } from "./javascriptSemanticProjectedSources.js"; import * as t from "@babel/types"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; +import { evaluateSemanticExpressionProvenance } from "./javascriptSemanticValues.js"; import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js"; import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; @@ -1666,6 +1667,62 @@ export const semanticGlobalProcessPathOrigin = ( }; }; +/** + * Retain possible shared-module effects from Node's inline acquisition API. + * This reader is mutation-only: it must not grant loader or constructor proof. + */ +export const semanticBuiltinModuleEffectOrigin = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptModuleOrigin | undefined => { + const chain = semanticMemberChain(node); + const call = chain?.root; + if ( + chain === undefined || + (!t.isCallExpression(call) && !t.isOptionalCallExpression(call)) + ) + return undefined; + const specifier = stringValue(call.arguments[0]); + if (specifier === undefined) return undefined; + const callee = unwrapJavaScriptExpression(call.callee).node; + let globalProcessGetter = false; + if ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyKey(callee.property, callee.computed) === + "getBuiltinModule" + ) { + const receiver = unwrapJavaScriptExpression(callee.object).node; + const processOrigin = + semanticGlobalProcessOrigin(receiver, state) ?? + semanticGlobalProcessPathOrigin(receiver, state); + globalProcessGetter = processOrigin?.importedPath.length === 0; + } + const importedProcessGetter = + !globalProcessGetter && + evaluateSemanticExpressionProvenance(callee, state).origins.some( + (origin) => { + if (origin.specifier.replace(/^node:/u, "") !== "process") return false; + const path = origin.importedPath; + // Only an ESM default denotes the namespace, not a literal require property. + return ( + (path.length === 1 && path[0] === "getBuiltinModule") || + (origin.resolutionMode !== "require" && + path.length === 2 && + path[0] === "default" && + path[1] === "getBuiltinModule") + ); + }, + ); + return globalProcessGetter || importedProcessGetter + ? { + specifier, + importedPath: chain.members, + resolutionMode: "require", + loaderAuthority: "candidate", + } + : undefined; +}; + /** Check one unknown-host wrapper candidate at its source-ordered read. */ const isConditionalRequireAt = ( root: t.Identifier, From 3c3684c8d58781057d01fda38ece34199c890f1c Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 20:08:30 +0800 Subject: [PATCH 18/28] fix(javascript): retain awaited module acquisition effects --- ...icAnalysis.rejected_event_listener.test.ts | 119 ++++++++++++++++++ ...scriptSemanticBuiltinModuleEffects.test.ts | 112 ++++++++++++++++- .../javascriptSemanticMemberMutations.ts | 28 ++++- .../javascriptSemanticRequireOrigin.ts | 30 +++-- 4 files changed, 276 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 1fd03116af0..1aeb3a0de4e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -407,6 +407,125 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +it.each([ + ["inline named", "", "(await import('node:events')).EventEmitter"], + ["inline default", "", "(await import('events')).default"], + [ + "conditional namespace", + "", + "(await (flag ? import('events') : import('node:events'))).EventEmitter", + ], + [ + "sequence namespace", + "", + "(await (sideEffect(), import('events'))).EventEmitter", + ], + [ + "assignment namespace", + "let namespacePromise;", + "(await (namespacePromise = import('events'))).EventEmitter", + ], + ["logical namespace", "", "(await (other || import('events'))).EventEmitter"], + [ + "bound namespace", + "const events = await import('events');", + "events.EventEmitter", + ], + [ + "destructured named", + "const { EventEmitter: EE } = await import('node:events');", + "EE", + ], + [ + "bound alias", + "const events = await import('events'); const alias = events;", + "alias.EventEmitter", + ], +] as const)( + "orders shared prototype changes through %s dynamic import", + (_name, setup, constructor) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +${setup} +const e = new EventEmitter(); +${before ? `${constructor}.prototype.on = replacement;` : ""} +e.on("ready"); +${before ? "" : `${constructor}.prototype.on = replacement;`}`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("retains a prototype exposed through awaited import", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +configure((await import('events')).EventEmitter.prototype); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it.each([ + "flag ? import('events') : import('node:events')", + "sideEffect(), import('events')", + "promise = import('events')", + "other || import('events')", +])("orders escapes through awaited branch namespaces: %s", (acquisition) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +let promise; +const e = new EventEmitter(); +${before ? `configure((await (${acquisition})).EventEmitter.prototype);` : ""} +e.on("ready"); +${before ? "" : `configure((await (${acquisition})).EventEmitter.prototype);`}`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); +}); + +it("keeps dynamic import effects whose callback timing is unknown", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +async function patch() { (await import('events')).EventEmitter.prototype.on = replacement; } +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +// These callback/Promise routes retain uncertainty without proving a module origin. +it.each([ + "await import('events').then(ns => { ns.EventEmitter.prototype.on = replacement; });", + "await import('events').then(({ default: EE }) => { EE.prototype.on = replacement; });", + "const promise = import('events'); (await promise).EventEmitter.prototype.on = replacement;", + "const [ns] = await Promise.all([import('events')]); ns.EventEmitter.prototype.on = replacement;", +])("preserves uncertain dynamic acquisition effects: %s", (mutation) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +${mutation} +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it("does not grant native constructor proof from effect-only dynamic import", () => { + expect( + eventsForSource(`const events = await import('events'); +const e = new events.EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + /** * The added checks must not withdraw the proof from an untouched namespace * receiver, or the guard would never apply to the member-expression forms. diff --git a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts index 7e5127521d2..56cae37fee2 100644 --- a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts +++ b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; import { - semanticBuiltinModuleEffectOrigin, + semanticModuleAcquisitionEffectOrigin, semanticRequireOriginCandidate, } from "./javascriptSemanticRequireOrigin.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; @@ -50,7 +50,9 @@ it("does not promote the effect-only acquisition into require provenance", () => ({ name }) => name === "events", ); const expression = binding?.initializers[0]?.node; - expect(semanticBuiltinModuleEffectOrigin(expression, state)).toMatchObject({ + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toMatchObject({ specifier: "events", importedPath: [], loaderAuthority: "candidate", @@ -72,3 +74,109 @@ it.each([ ); }, ); + +it.each([ + "(await import('events')).EventEmitter.prototype.on = replacement;", + "const events = await import('node:events'); events.EventEmitter.prototype.on = replacement;", + "const { EventEmitter: EE } = await import('events'); EE.prototype.on = replacement;", + "const events = await import('events'); const alias = events; alias.EventEmitter.prototype.on = replacement;", +])( + "retains awaited import writes as candidate module effects: %s", + (source) => { + expect(indexed(source).requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it("retains a dynamic default export without promoting its origin to require", () => { + const state = indexed( + "const events = await import('events'); events.default.prototype.on = replacement;", + ); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["default", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + const binding = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + ); + const expression = binding?.initializers[0]?.node; + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toMatchObject({ + specifier: "events", + resolutionMode: "import", + loaderAuthority: "candidate", + }); + expect(semanticRequireOriginCandidate(expression, state)).toBeUndefined(); +}); + +it.each([ + "import('events').EventEmitter.prototype.on = replacement;", + "(await import(specifier)).EventEmitter.prototype.on = replacement;", + "(await other('events')).EventEmitter.prototype.on = replacement;", +])("does not invent an awaited namespace from %s", (source) => { + expect(indexed(source).requiredModuleEffects.get("events") ?? []).toEqual([]); +}); + +it.each([ + "(await (flag ? import('events') : import('node:events'))).EventEmitter.prototype.on = replacement;", + "(await (sideEffect(), import('events'))).EventEmitter.prototype.on = replacement;", + "(await (promise = import('events'))).EventEmitter.prototype.on = replacement;", + "(await (other || import('events'))).EventEmitter.prototype.on = replacement;", +])("retains awaited branch value acquisition effects: %s", (source) => { + expect( + indexed( + `import { EventEmitter } from 'events'; ${source}`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); +}); + +it.each([ + "(flag ? import('events') : import('node:events')).EventEmitter.prototype.on = replacement;", + "(await (import('events'), other)).EventEmitter.prototype.on = replacement;", + "(await import('events').selected).EventEmitter.prototype.on = replacement;", + "(await { pending: import('events') }).pending.EventEmitter.prototype.on = replacement;", + "(await [import('events')])[0].EventEmitter.prototype.on = replacement;", + "(await { pending: flag ? import('events') : import('node:events') }).pending.EventEmitter.prototype.on = replacement;", +])( + "does not apply await to unrelated values or Promise children: %s", + (source) => { + expect( + indexed( + `import { EventEmitter } from 'events'; ${source}`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); + +it.each([ + "flag ? import('events') : import('node:events')", + "sideEffect(), import('events')", + "promise = import('events')", + "other || import('events')", +])("retains escaped awaited branch namespaces: %s", (acquisition) => { + const state = indexed( + `import { EventEmitter } from 'events'; configure((await (${acquisition})).EventEmitter.prototype);`, + ); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "escape", + path: ["EventEmitter", "prototype"], + loaderAuthority: "candidate", + }), + ); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 4d03d11764e..f0bbdb3cdcd 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -11,7 +11,7 @@ import { } from "./javascriptSemanticTypedArrays.js"; import { - semanticBuiltinModuleEffectOrigin, + semanticModuleAcquisitionEffectOrigin, semanticGlobalProcessOrigin, semanticGlobalProcessPathOrigin, semanticOriginalGlobalProcessAt, @@ -741,6 +741,8 @@ export function* collectSemanticMemberMutationsSteps( readonly bindings: ReadonlySet; readonly originAt: t.Node | undefined; readonly definiteReceiver: boolean; + /** Only value-preserving branches may carry an await to an acquisition. */ + readonly awaitedModuleAcquisition?: boolean; readonly exactKey?: string; readonly joined?: { readonly key: string; @@ -876,7 +878,11 @@ export function* collectSemanticMemberMutationsSteps( const expression = unwrapJavaScriptExpression(current.node).node; const moduleEffectOrigin = semanticRequireOriginCandidate(expression, state, true)?.origin ?? - semanticBuiltinModuleEffectOrigin(expression, state); + semanticModuleAcquisitionEffectOrigin( + expression, + state, + current.awaitedModuleAcquisition, + ); if (moduleEffectOrigin !== undefined) { recordRequiredEffect( moduleEffectOrigin.specifier, @@ -1317,8 +1323,17 @@ export function* collectSemanticMemberMutationsSteps( ]), }); } else { + // Escape flattening must not discard the await before its value branch + // reaches a direct acquisition. Other escapes keep the shared cache. + const awaitedModuleAcquisition = + t.isAwaitExpression(expression) || + (current.awaitedModuleAcquisition === true && + (t.isConditionalExpression(expression) || + t.isLogicalExpression(expression) || + t.isSequenceExpression(expression) || + t.isAssignmentExpression(expression))); const references = - effect === "escape" + effect === "escape" && !awaitedModuleAcquisition ? escapedReferenceLeaves( expression, current.path, @@ -1373,6 +1388,11 @@ export function* collectSemanticMemberMutationsSteps( else enqueue({ ...value, + // This await consumes the branch value, not a property, alias, + // container child, or callback result. Those paths drop it. + ...(awaitedModuleAcquisition + ? { awaitedModuleAcquisition: true } + : {}), bindings: current.bindings, definiteReceiver: current.definiteReceiver && @@ -2141,7 +2161,7 @@ const observableMutationPaths = ( const hasParsedRequireOrigin = binding.initializers.some( ({ node }) => semanticRequireOriginCandidate(node, state) !== undefined || - semanticBuiltinModuleEffectOrigin(node, state) !== undefined || + semanticModuleAcquisitionEffectOrigin(node, state) !== undefined || hasSemanticCreateRequireFactoryOrigin(node, state), ); if ( diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index df60cdf0b60..46b63e983b3 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -1668,19 +1668,35 @@ export const semanticGlobalProcessPathOrigin = ( }; /** - * Retain possible shared-module effects from Node's inline acquisition API. + * Retain possible shared-module effects from inline module acquisitions. * This reader is mutation-only: it must not grant loader or constructor proof. */ -export const semanticBuiltinModuleEffectOrigin = ( +export const semanticModuleAcquisitionEffectOrigin = ( node: t.Node | null | undefined, state: JavaScriptSemanticAnalysisState, + awaitedDirectValue = false, ): JavaScriptModuleOrigin | undefined => { const chain = semanticMemberChain(node); - const call = chain?.root; - if ( - chain === undefined || - (!t.isCallExpression(call) && !t.isOptionalCallExpression(call)) - ) + if (chain === undefined) return undefined; + const call = chain.root; + const acquisition = t.isAwaitExpression(call) + ? unwrapJavaScriptExpression(call.argument).node + : awaitedDirectValue && chain.members.length === 0 + ? call + : undefined; + if (t.isImportExpression(acquisition)) { + const specifier = stringValue(acquisition.source); + return specifier === undefined + ? undefined + : { + specifier, + importedPath: chain.members, + resolutionMode: "import", + loaderAuthority: "candidate", + }; + } + // An unawaited import is a Promise, not the acquired module namespace. + if (!t.isCallExpression(call) && !t.isOptionalCallExpression(call)) return undefined; const specifier = stringValue(call.arguments[0]); if (specifier === undefined) return undefined; From 65d74b03ac52e2ce1fb5c4029862fdb293e567bb Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 20:32:09 +0800 Subject: [PATCH 19/28] fix(javascript): keep intrinsic Map calls from escaping --- ...icAnalysis.rejected_event_listener.test.ts | 21 ++++++ .../javascriptSemanticMapOperations.ts | 32 ++++++++- .../javascriptSemanticMemberMutations.ts | 15 ++-- src/domain/webBundleAnalyzer.test.ts | 72 +++++++++++++++++++ 4 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 1aeb3a0de4e..b1c6b5dd229 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -518,6 +518,27 @@ e.on("ready");`), ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); }); +// These Promise aliases remain conservative without a shared-module origin fact. +it.each([ + "const pending = import('node:events'); const ns = await pending;", + "const pending = import('node:events'); const alias = pending; const ns = await alias;", + "const { pending } = { pending: import('node:events') }; const ns = await pending;", + "const [pending] = [import('node:events')]; const ns = await pending;", + "const pending = flag ? import('events') : import('node:events'); const ns = await pending;", +])( + "preserves unknown initializer mutations through Promise aliases: %s", + (setup) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { initialize } from "./initialize.js"; +${setup} +ns.EventEmitter.init = initialize; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + }, +); + it("does not grant native constructor proof from effect-only dynamic import", () => { expect( eventsForSource(`const events = await import('events'); diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts index 2d1670fe6ec..ed797729d80 100644 --- a/src/domain/javascript/javascriptSemanticMapOperations.ts +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -18,16 +18,28 @@ import { } from "./javascriptSemanticState.js"; /** Prove an unchanged Map operation; undefined requests shared member-mutation facts. */ -export const isIntrinsicMapLookup = ( +const isIntrinsicMapOperation = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, bindings: JavaScriptSemanticAnalysisState, + purpose: "lookup" | "receiver", index?: Pick, ): boolean | undefined => { const callee = unwrapJavaScriptExpression(node.callee).node; if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false; const method = semanticStaticPropertyName(callee.property, callee.computed); - if (method !== "get" && method !== "delete") return false; + if ( + method !== "get" && + method !== "delete" && + (purpose !== "receiver" || + (method !== "has" && + method !== "clear" && + // set returns this. Until that result identity is retained by its + // owner, only a discarded result proves no receiver escapes through it. + (method !== "set" || + !t.isExpressionStatement(bindings.parentsByNode.get(node))))) + ) + return false; const prototypeMemberMayHaveChanged = ( member: string, read: t.Node, @@ -83,3 +95,19 @@ export const isIntrinsicMapLookup = ( if (semanticOpaqueGlobalMayHaveChanged(callee, bindings)) return false; return index === undefined && seen.size > 0 ? undefined : true; }; + +/** Endpoint filtering applies only to the route-like get/delete methods. */ +export const isIntrinsicMapLookup = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => + isIntrinsicMapOperation(node, bindings, "lookup", index); + +/** Other callback-free native methods also leave the shared class untouched. */ +export const isIntrinsicNonEscapingMapCall = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => + isIntrinsicMapOperation(node, bindings, "receiver", index); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index f0bbdb3cdcd..6fe4797a02e 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -4,7 +4,7 @@ import { createAmbientPathResolver, retainSemanticAmbientEffect, } from "./javascriptSemanticAmbientWrites.js"; -import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; +import { isIntrinsicNonEscapingMapCall } from "./javascriptSemanticMapOperations.js"; import { isIntrinsicTypedArrayConstruction, isTypedArrayNumericIndex, @@ -1720,9 +1720,9 @@ export function* collectSemanticMemberMutationsSteps( else markEscaped(argument, node); // Constructing a member does not pass its container as `this`. if (!t.isNewExpression(node)) { - const mapLookup = isIntrinsicMapLookup(node, state); - if (mapLookup === undefined) deferredMapReceivers.add(node); - else if (!mapLookup) markReceiver(node.callee, node); + const intrinsicMapCall = isIntrinsicNonEscapingMapCall(node, state); + if (intrinsicMapCall === undefined) deferredMapReceivers.add(node); + else if (!intrinsicMapCall) markReceiver(node.callee, node); } } else if (t.isTaggedTemplateExpression(node)) { for (const source of returnedReferences(node.tag).references) @@ -1841,15 +1841,16 @@ export function* collectSemanticMemberMutationsSteps( reference, ); } - // Native Map lookup methods do not expose their receiver to user code. + // Proven native Map operations do not expose their receiver to user code. // Settle genuine effects first, then replay every unproven receiver escape. // Keep proven sites until convergence: later propagated effects can revoke them. let receiverIndex = 0; for (const node of deferredMapReceivers) { if (receiverIndex++ % 64 === 0) yield; if ( - isIntrinsicMapLookup(node, state, { referenceMayHaveChangedAt }) === - true + isIntrinsicNonEscapingMapCall(node, state, { + referenceMayHaveChangedAt, + }) === true ) continue; deferredMapReceivers.delete(node); diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index efd2f715d16..fcc11d2b174 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -162,6 +162,78 @@ it.each([ "const records = new Map((Map.prototype.set = function () { this.get = fetch; return this; }, [['x', 1]])); records.get('/accounts');", true, ], + [ + "const records = new Map(); new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); other.set('x', 1); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); new Map().has('x'); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); consume(other.has('x')); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); new Map().clear(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); consume(other.clear()); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.set = patch; new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); Map.prototype.has = patch; new Map().has('x'); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); Map.prototype.clear = patch; new Map().clear(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.set = patch; other.set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(Map.prototype); new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); eval('Map.prototype.set = patch'); new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); const result = other.set('x', 1); consume(result); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set('x', 1).constructor.prototype.get = fetch; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map([['x', 1]]).forEach((value, key, map) => { map.constructor.prototype.get = fetch; }); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set(records, 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set(...expose(records)); records.get('/accounts');", + true, + ], ["client.get('/accounts');", true], ["eval('Map.prototype.get = fetch'); new Map().get('/accounts');", true], ] as const)( From b6cc7621de2ad8f1b2c119afaa7e4234c87f5ec6 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 21:09:36 +0800 Subject: [PATCH 20/28] fix(javascript): preserve pristine XHR and getter alias effects --- .../javascript/javascriptNetworkRequests.ts | 43 ++++++++- .../javascriptSemanticAmbientWrites.test.ts | 12 ++- ...icAnalysis.rejected_event_listener.test.ts | 30 ++++++ ...scriptSemanticBuiltinModuleEffects.test.ts | 13 +++ .../javascriptSemanticMemberMutations.ts | 32 ++++--- .../javascriptSemanticRequireOrigin.ts | 37 +++++++- src/domain/webBundleAnalyzer.test.ts | 92 +++++++++++++++++++ 7 files changed, 239 insertions(+), 20 deletions(-) diff --git a/src/domain/javascript/javascriptNetworkRequests.ts b/src/domain/javascript/javascriptNetworkRequests.ts index 82240367837..9b375934349 100644 --- a/src/domain/javascript/javascriptNetworkRequests.ts +++ b/src/domain/javascript/javascriptNetworkRequests.ts @@ -5,6 +5,8 @@ import { } from "./javascriptAstValues.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import { semanticPrimitiveCandidates } from "./javascriptSemanticPrimitives.js"; import { resolveSemanticBindingState, semanticStableInitializer, @@ -43,6 +45,7 @@ const isXhrOpenRequest = ( node: t.Node, bindings: JavaScriptSemanticAnalysisState, index?: Pick, + purpose: "request" | "receiver" = "request", ): boolean | undefined => { if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return false; @@ -52,11 +55,32 @@ const isXhrOpenRequest = ( semanticStaticPropertyName(callee.property, callee.computed) !== "open" ) return false; + // Native open can dispatch readystatechange. Its receiver is nonescaping + // only when no handler or prior opaque operation can have configured it. + // Primitive arguments also avoid user code during WebIDL string conversion. if ( - ["XMLHttpRequest", "Object"].some((owner) => - ["open", "__proto__"].some((member) => + purpose === "receiver" && + node.arguments.some( + (argument) => + t.isSpreadElement(argument) || + semanticPrimitiveCandidates( + evaluateSemanticExpression(argument, bindings), + ) === null, + ) + ) + return false; + const prototypeOwners = + purpose === "receiver" + ? ["XMLHttpRequest", "XMLHttpRequestEventTarget", "EventTarget", "Object"] + : ["XMLHttpRequest", "Object"]; + if ( + prototypeOwners.some((owner) => + (purpose === "receiver" + ? ["open", "__proto__", "onreadystatechange"] + : ["open", "__proto__"] + ).some((member) => semanticAmbientPathMayHaveChangedAt( - callee, + purpose === "receiver" ? node : callee, bindings, ["global", owner, "prototype", member], node, @@ -70,8 +94,10 @@ const isXhrOpenRequest = ( while (t.isIdentifier(receiver)) { if ( index !== undefined && - (index.referenceMayHaveChangedAt(node, receiver, ["open"]) || - index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) + (purpose === "receiver" + ? index.referenceMayHaveChangedAt(node, receiver, []) + : index.referenceMayHaveChangedAt(node, receiver, ["open"]) || + index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) ) return false; const binding = resolveSemanticBindingState( @@ -99,6 +125,13 @@ const isXhrOpenRequest = ( return index === undefined && seen.size > 0 ? undefined : true; }; +/** A pristine native open is safe for the mutation producer, not every XHR call. */ +export const isIntrinsicNonEscapingXhrOpen = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => isXhrOpenRequest(node, bindings, index, "receiver"); + /** Resolve once through the shared mutation index when lexical proof needs it. */ export const createXhrOpenRequestQuery = (index: JavaScriptBindingIndex): ((node: t.Node) => boolean) => diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index b51d6b7c98e..19426673fec 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -427,6 +427,14 @@ it("shares matching ambient records across reads instead of scanning unrelated e indexJavaScriptBindingsWithReceiversSteps(parsed.program), ); completeSemanticSteps(index.indexMemberMutationFactsSteps()); + // Producer proofs can already warm this path. Grow facts once so the + // measured reads include one cold lookup followed by shared warm lookups. + retainSemanticAmbientEffect( + index.bindings, + "write", + ["global", "addedAfterIndex"], + parsed.program, + ); let reads = 0; for (const effect of [ ...index.bindings.ambientWriteEffects, @@ -458,7 +466,9 @@ it("shares matching ambient records across reads instead of scanning unrelated e }); return reads; }; - expect(work(64)).toBeLessThan(3 * work(32)); + const smaller = work(32); + expect(smaller).toBeGreaterThan(0); + expect(work(64)).toBeLessThan(3 * smaller); }); it.each(["write", "escape"] as const)( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index b1c6b5dd229..8a4a7b92c42 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -345,6 +345,36 @@ it.each([ "const events = process.getBuiltinModule('events'); const alias = events;", "alias.EventEmitter", ], + [ + "destructured global getter", + "const { getBuiltinModule: get } = process;", + "get('events').EventEmitter", + ], + [ + "extracted global getter", + "const get = process.getBuiltinModule;", + "get('events').EventEmitter", + ], + [ + "qualified global getter", + "const get = globalThis.process.getBuiltinModule;", + "get('events').EventEmitter", + ], + [ + "global getter alias", + "const get0 = process.getBuiltinModule; const get = get0;", + "get('events').EventEmitter", + ], + [ + "process object alias", + "const host = process;", + "host.getBuiltinModule('events').EventEmitter", + ], + [ + "stored global getter", + "const holder = { get: process.getBuiltinModule };", + "holder.get('events').EventEmitter", + ], [ "imported process", "import process from 'node:process';", diff --git a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts index 56cae37fee2..1681f907bac 100644 --- a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts +++ b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts @@ -26,6 +26,14 @@ it.each([ "(process?.getBuiltinModule?.('events')).EventEmitter.prototype.on = replacement;", "const events = process.getBuiltinModule('events'); events.EventEmitter.prototype.on = replacement;", "const events = process.getBuiltinModule('events'); const alias = events; alias.EventEmitter.prototype.on = replacement;", + "const { getBuiltinModule: get } = process; get('events').EventEmitter.prototype.on = replacement;", + "const get = process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + "const get = globalThis.process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + "const get0 = process.getBuiltinModule; const get = get0; get('events').EventEmitter.prototype.on = replacement;", + "const host = process; host.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "const holder = { get: process.getBuiltinModule }; holder.get('events').EventEmitter.prototype.on = replacement;", + "const get = process.getBuiltinModule; process.getBuiltinModule = other; get('events').EventEmitter.prototype.on = replacement;", + "import process from 'node:process'; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", "import { getBuiltinModule } from 'node:process'; getBuiltinModule('events').EventEmitter.prototype.on = replacement;", "import * as process from 'node:process'; const get = process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", @@ -65,6 +73,11 @@ it.each([ "process = other; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", "const process = other; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", "other.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "const process = other; const { getBuiltinModule: get } = process; get('events').EventEmitter.prototype.on = replacement;", + "function run(process) { const get = process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement; }", + "let get = process.getBuiltinModule; get = other; get('events').EventEmitter.prototype.on = replacement;", + "const get = other.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + "process.getBuiltinModule(specifier).EventEmitter.prototype.on = replacement;", ])( "does not invent module identity outside supported acquisition facts: %s", diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 6fe4797a02e..d44e46ddac7 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -5,6 +5,7 @@ import { retainSemanticAmbientEffect, } from "./javascriptSemanticAmbientWrites.js"; import { isIntrinsicNonEscapingMapCall } from "./javascriptSemanticMapOperations.js"; +import { isIntrinsicNonEscapingXhrOpen } from "./javascriptNetworkRequests.js"; import { isIntrinsicTypedArrayConstruction, isTypedArrayNumericIndex, @@ -355,7 +356,16 @@ export function* collectSemanticMemberMutationsSteps( state: JavaScriptSemanticAnalysisState, referenceMayHaveChangedAt: JavaScriptBindingIndex["referenceMayHaveChangedAt"], ): Generator { - const deferredMapReceivers = new Set< + const intrinsicReceiverCall = ( + node: t.CallExpression | t.OptionalCallExpression, + index?: Pick, + ): boolean | undefined => { + const map = isIntrinsicNonEscapingMapCall(node, state, index); + return map === false + ? isIntrinsicNonEscapingXhrOpen(node, state, index) + : map; + }; + const deferredIntrinsicReceivers = new Set< t.CallExpression | t.OptionalCallExpression >(); // The binding pass already retained callable nodes. Ordinary data-only code @@ -1720,9 +1730,9 @@ export function* collectSemanticMemberMutationsSteps( else markEscaped(argument, node); // Constructing a member does not pass its container as `this`. if (!t.isNewExpression(node)) { - const intrinsicMapCall = isIntrinsicNonEscapingMapCall(node, state); - if (intrinsicMapCall === undefined) deferredMapReceivers.add(node); - else if (!intrinsicMapCall) markReceiver(node.callee, node); + const intrinsicCall = intrinsicReceiverCall(node); + if (intrinsicCall === undefined) deferredIntrinsicReceivers.add(node); + else if (!intrinsicCall) markReceiver(node.callee, node); } } else if (t.isTaggedTemplateExpression(node)) { for (const source of returnedReferences(node.tag).references) @@ -1738,7 +1748,7 @@ export function* collectSemanticMemberMutationsSteps( while ( pendingReferences.length > 0 || iterableReferences.length > 0 || - deferredMapReceivers.size > 0 + deferredIntrinsicReceivers.size > 0 ) { const effectsBefore = recordedEffectCount; const iterablesBefore = iterableReferences.length; @@ -1841,19 +1851,15 @@ export function* collectSemanticMemberMutationsSteps( reference, ); } - // Proven native Map operations do not expose their receiver to user code. + // Proven native Map and pristine XHR calls do not expose their receivers. // Settle genuine effects first, then replay every unproven receiver escape. // Keep proven sites until convergence: later propagated effects can revoke them. let receiverIndex = 0; - for (const node of deferredMapReceivers) { + for (const node of deferredIntrinsicReceivers) { if (receiverIndex++ % 64 === 0) yield; - if ( - isIntrinsicNonEscapingMapCall(node, state, { - referenceMayHaveChangedAt, - }) === true - ) + if (intrinsicReceiverCall(node, { referenceMayHaveChangedAt }) === true) continue; - deferredMapReceivers.delete(node); + deferredIntrinsicReceivers.delete(node); markReceiver(node.callee, node); } if ( diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 46b63e983b3..b1bacf4a3b0 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -61,6 +61,7 @@ import { type JavaScriptRequireOrigin, } from "./javascriptSemanticRequireOriginSlot.js"; import { + createAmbientPathResolver, isUnmodifiedGlobalReference, isUnmodifiedImportMetaUrl, } from "./javascriptSemanticAmbientWrites.js"; @@ -1667,6 +1668,38 @@ export const semanticGlobalProcessPathOrigin = ( }; }; +const moduleAcquisitionAmbientReaders = new WeakMap< + JavaScriptSemanticAnalysisState, + ReturnType +>(); + +/** Candidate acquisition effects consume aliases from the shared ambient owner. */ +const hasGlobalBuiltinGetterAlias = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const root = semanticMemberChain(callee)?.root; + if ( + !t.isIdentifier(root) || + resolveSemanticBindingState(state, root, root.name) === undefined + ) + return false; + let read = moduleAcquisitionAmbientReaders.get(state); + if (read === undefined) { + // Possible-effect mode selects aliases at each read and does not cache a + // result across mutation-index updates. It grants no native authority. + read = createAmbientPathResolver(state, true); + moduleAcquisitionAmbientReaders.set(state, read); + } + return read(callee, callee).paths.some( + (path) => + path.length === 3 && + path[0] === "global" && + path[1] === "process" && + path[2] === "getBuiltinModule", + ); +}; + /** * Retain possible shared-module effects from inline module acquisitions. * This reader is mutation-only: it must not grant loader or constructor proof. @@ -1729,7 +1762,9 @@ export const semanticModuleAcquisitionEffectOrigin = ( ); }, ); - return globalProcessGetter || importedProcessGetter + return globalProcessGetter || + importedProcessGetter || + hasGlobalBuiltinGetterAlias(callee, state) ? { specifier, importedPath: chain.members, diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index fcc11d2b174..3579e852a0e 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -2381,3 +2381,95 @@ it.each([ .map(({ value }) => value), ).toEqual(observed ? [] : ["/xhr"]); }); + +it.each([ + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', '/a');", + false, + ], + [ + "const b = new XMLHttpRequest(); new XMLHttpRequest().open('GET', '/a');", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); const alias = a; alias.open('GET', '/a');", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); const method = 'GET'; a.open(method, '/a', true); a.open('GET', '/again');", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', '/a'); a.onreadystatechange = handler;", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.onreadystatechange = handler; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.addEventListener('readystatechange', handler); a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.send(); a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', { toString() { a.onreadystatechange = handler; return '/a'; } });", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', unknownUrl);", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open(...args);", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open = custom; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); XMLHttpRequest.prototype.open = custom; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); XMLHttpRequest.prototype.onreadystatechange = handler; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); configure(XMLHttpRequest.prototype); a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); eval('a.onreadystatechange = handler'); a.open('GET', '/a');", + true, + ], +] as const)( + "keeps pristine XHR calls separate from potentially configured receivers: %s", + (setup, candidate) => { + const source = `${setup} b.open('GET', '/b');`; + const result = analyzeWebBundleFixture(inspection(source)); + expect( + result.observations.endpoints + .filter(({ value }) => value === "/b") + .map(({ value }) => value), + ).toEqual(candidate ? [] : ["/b"]); + expect( + result.inferences + .filter( + ({ kind, value }) => kind === "endpoint_candidate" && value === "/b", + ) + .map(({ value }) => value), + ).toEqual(candidate ? ["/b"] : []); + const endpoint = analyzeJavaScriptStaticSource(source).endpoints.find( + ({ value }) => value === "/b", + ); + expect(endpoint?.interpretation ?? null).toBe( + candidate ? "candidate" : null, + ); + expect(endpoint).toBeDefined(); + }, +); From 676c01da82f02803315697a065ce271edd45121a Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 00:39:53 +0800 Subject: [PATCH 21/28] fix(javascript): share mutable events default effects --- ...scriptSemanticBuiltinModuleEffects.test.ts | 142 +++++++++++++++++- .../javascriptSemanticRequireOrigin.ts | 1 + 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts index 1681f907bac..cb4f603fda9 100644 --- a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts +++ b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts @@ -5,15 +5,25 @@ import { semanticModuleAcquisitionEffectOrigin, semanticRequireOriginCandidate, } from "./javascriptSemanticRequireOrigin.js"; +import { builtinDataEffectCallablePath } from "./javascriptSemanticDataEffectHelpers.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; -import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; -const indexed = (source: string) => { - const parsed = parseJavaScriptSource(source); +const indexed = (source: string, commonJs = false) => { + const parsed = parseJavaScriptSource( + source, + commonJs ? "fixture.cts" : undefined, + ); if (parsed === null) throw new Error("Expected builtin-module effect fixture"); const index = completeSemanticSteps( - indexJavaScriptBindingsWithReceiversSteps(parsed.program), + indexJavaScriptBindingsWithReceiversSteps( + parsed.program, + commonJs ? "commonjs-wrapper" : undefined, + ), ); completeSemanticSteps(index.indexAmbientWritesSteps()); completeSemanticSteps(index.indexMemberMutationFactsSteps()); @@ -193,3 +203,127 @@ it.each([ }), ); }); + +const eventConstructorPaths = (state: ReturnType) => { + const paths: (readonly string[] | null)[] = []; + traverseJavaScriptAst(state.program, { + enter(node) { + if (node.type === "NewExpression") + paths.push( + builtinDataEffectCallablePath(node.callee, state, [ + "events", + "node:events", + ]), + ); + }, + }); + return paths; +}; + +// CommonJS TypeScript fixtures retain inert imports with explicit wrapper authority. + +it.each([ + [ + "default events to node:events", + `import A from "events"; import B from "node:events";`, + ], + [ + "default node:events to events", + `import A from "node:events"; import B from "events";`, + ], + [ + "default to require", + `import A from "events"; const B = require("node:events");`, + ], + [ + "require to default", + `const A = require("node:events"); import B from "events";`, + ], + [ + "default node:events to require", + `import A from "node:events"; const B = require("events");`, + ], + [ + "require events to default", + `const A = require("events"); import B from "node:events";`, + ], +])("owns shared constructor-slot writes from %s", (_name, header) => { + const state = indexed( + `${header} +const before = new B.EventEmitter(); +const Ctor = B.EventEmitter; +const object = B; +A.EventEmitter = replacement; +const saved = new Ctor(); +const after = new B.EventEmitter(); +const throughSavedObject = new object.EventEmitter();`, + header.includes("require("), + ); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter"], + }), + ); + expect(eventConstructorPaths(state)).toEqual([ + ["EventEmitter"], + ["EventEmitter"], + null, + null, + ]); +}); + +it("keeps named and namespace export snapshots separate from mutable default slots", () => { + const state = indexed(`import A from "events"; +import B, { EventEmitter as Ctor } from "node:events"; +import * as ns from "node:events"; +A.EventEmitter = replacement; +const named = new Ctor(); +const namespace = new ns.EventEmitter(); +const mutable = new B.EventEmitter();`); + expect(eventConstructorPaths(state)).toEqual([ + ["EventEmitter"], + ["EventEmitter"], + null, + ]); +}); + +it("does not share replacement of an immutable namespace export slot", () => { + const state = + indexed(`import * as ns from "events"; import B from "node:events"; +ns.EventEmitter = replacement; +const e = new B.EventEmitter();`); + expect(state.requiredModuleEffects.get("events") ?? []).toEqual([]); + expect(eventConstructorPaths(state)).toEqual([["EventEmitter"]]); +}); + +it.each([ + ["unrelated property", "A.extra = replacement;", ["EventEmitter"]], + ["unknown property", "A[key] = replacement;", null], + ["containing object escape", "configure(A);", null], + ["class value escape", "configure(A.EventEmitter);", ["EventEmitter"]], +] as const)( + "uses shared path coverage for an events %s", + (_name, mutation, path) => { + const state = indexed(`import A from "events"; import B from "node:events"; +${mutation} +const e = new B.EventEmitter();`); + expect(eventConstructorPaths(state)).toEqual([path]); + }, +); + +it.each([ + "process.getBuiltinModule('events')", + "(await import('events'))", + "(await import('node:events'))", +])( + "does not promote %s into a proven constructor after sharing default effects", + (acquisition) => { + const state = indexed(`import A from "events"; +const acquired = ${acquisition}; +const before = new acquired.EventEmitter(); +A.EventEmitter = replacement; +const after = new acquired.EventEmitter();`); + expect(eventConstructorPaths(state)).toEqual([null, null]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 9e16643354e..9220d7b109c 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -1939,6 +1939,7 @@ const MUTABLE_BUILTIN_DEFAULTS = new Set([ "http", "https", "child_process", + "events", "timers", "process", "path", From 25cc26bd4e43a9405d7c25073780e451f4b4724f Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 01:00:51 +0800 Subject: [PATCH 22/28] fix(javascript): track inherited initializer call effects --- ...icAnalysis.rejected_event_listener.test.ts | 56 +++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 17 +++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 8a4a7b92c42..1422ee9c2c9 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -1430,3 +1430,59 @@ it.each([ { kind: "register", method, eventName: null }, ]); }); + +it.each([ + ["direct write", "Function.prototype.call = wrapper;"], + ["qualified write", "globalThis.Function.prototype.call = wrapper;"], + [ + "prototype alias", + "const proto = Function.prototype; proto.call = wrapper;", + ], + ["prototype escape", "configure(Function.prototype);"], + ["unknown property", "Function.prototype[key] = wrapper;"], +])( + "orders the initializer's inherited call dependency: %s", + (_name, mutation) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +import { wrapper, configure } from "./patch.js"; +${before ? mutation : ""} +const e = new EventEmitter(); +${before ? "" : mutation} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("keeps unknown timing on the initializer's inherited call dependency", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { wrapper } from "./patch.js"; +function patch() { Function.prototype.call = wrapper; } +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it.each([ + "configure(Function.prototype.call);", + "Function.prototype.tag = wrapper;", + "function patch(Function) { Function.prototype.call = wrapper; }", +])( + "preserves native proof without an inherited call-slot change: %s", + (mutation) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { wrapper, configure } from "./patch.js"; +${mutation} +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 8a61b57ab72..cc97da50991 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -24,7 +24,10 @@ import { semanticTopLevelEffectFollowsCapture, } from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; import { semanticOpaqueNameMayHaveChanged, semanticSharedModuleMayHaveOpaqueEffect, @@ -555,6 +558,18 @@ const nodeEventEmitterReceiver = ( return false; const construction = nodeEventEmitterConstruction(initializer.node, state); if (construction === null) return false; + // Node allocates through EventEmitter.init.call(this, opts). The inherited + // call slot is shared by functions, so its ambient effects belong to the + // allocation even when no events binding was written or exposed. + if ( + semanticAmbientPathMayHaveChangedAt(construction, state, [ + "global", + "Function", + "prototype", + "call", + ]) + ) + return false; // Reaching the class is not the same as reaching the method: the class object // and the `init` hook Node runs while constructing can both be reshaped, so a // construction whose surface was touched keeps its operation. From 95cd68cf5efe50159ffe09231f864086a45d4e04 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 01:25:46 +0800 Subject: [PATCH 23/28] fix(javascript): preserve iterator and initializer effect boundaries --- ...icAnalysis.rejected_event_listener.test.ts | 70 +++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 43 ++++++++++-- .../javascriptSemanticMapOperations.ts | 9 ++- src/domain/webBundleAnalyzer.test.ts | 60 ++++++++++++++++ 4 files changed, 172 insertions(+), 10 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 1422ee9c2c9..e56f0a6066b 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -1486,3 +1486,73 @@ e.on("ready");`), ).toEqual([]); }, ); + +it.each([ + "configure(EventEmitter.init.prototype.constructor);", + "configure(init.prototype.constructor);", + "configure(EventEmitter.init.prototype.constructor.prototype.constructor);", + "const initialize = init.prototype.constructor; configure(initialize);", + "init.prototype.constructor.call = wrapper;", + "init.prototype.constructor.prototype.constructor.call = wrapper;", +])("orders initializer back-reference effects: %s", (mutation) => { + const source = ( + before: boolean, + ) => `import { EventEmitter, init } from "node:events"; +import { configure, wrapper } from "./patch.js"; +${before ? mutation : ""} +const e = new EventEmitter(); +${before ? "" : mutation} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); +}); + +it.each([ + "configure(EventEmitter.init.prototype.constructor.call);", + "configure(init.prototype.constructor.call);", + "init.prototype.constructor.tag = wrapper;", + "init.prototype.constructor = wrapper;", + "init.prototype.constructor.prototype.constructor = wrapper;", + "delete init.prototype.constructor;", + "delete init.prototype.constructor.prototype.constructor;", +])("preserves unrelated initializer back-reference values: %s", (mutation) => { + expect( + eventsForSource(`import { EventEmitter, init } from "node:events"; +import { configure, wrapper } from "./patch.js"; +${mutation} +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +it.each([ + ["namespace", 'import * as ns from "events";', "ns.init"], + ["default", 'import ns from "events";', "ns.init"], + ["builtin getter", "", 'process.getBuiltinModule("events").init'], + ["awaited namespace", "", '(await import("events")).init'], + ["awaited default", "", '(await import("events")).default.init'], +])( + "orders existing %s initializer back-reference facts", + (_name, header, initializer) => { + const effect = `configure(${initializer}.prototype.constructor);`; + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +${header} +${before ? effect : ""} +const e = new EventEmitter(); +${before ? "" : effect} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index cc97da50991..d0a5d836e13 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -283,9 +283,33 @@ const eventsClassPath = (path: JavaScriptSemanticPropertyPath): boolean => { return true; }; +/** A function's default prototype.constructor pair can reach the function again. */ +const constructorBackReferenceEnd = ( + path: JavaScriptSemanticPropertyPath, + start = 0, + limit = path.length, +): number => { + let end = start; + while ( + end + 1 < limit && + path[end] === "prototype" && + path[end + 1] === "constructor" + ) + end += 2; + return end; +}; + /** The initializer object owns the `.call` slot Node reads during allocation. */ -const eventsInitializerPath = (path: JavaScriptSemanticPropertyPath): boolean => - path.at(-1) === "init" && eventsClassPath(path.slice(0, -1)); +const eventsInitializerPath = ( + path: JavaScriptSemanticPropertyPath, +): boolean => { + const initializer = path.indexOf("init"); + return ( + initializer >= 0 && + eventsClassPath(path.slice(0, initializer)) && + constructorBackReferenceEnd(path, initializer + 1) === path.length + ); +}; const reachesEventsClassSurface = ( binding: JavaScriptSemanticBindingState, @@ -386,12 +410,17 @@ const indexEventEmitterSharedEffects = ( // A named initializer import owns its own records, rather than paths on a // class binding. Consume the provenance owner's retained origins, including // candidate origins: uncertainty can withdraw this proof, never create it. - const initializerWrites = binding.memberWrites.filter( - ({ path }) => - affectsBuiltinPath(path, ["call"]) || path.at(0) === "__proto__", - ); + const initializerWrites = binding.memberWrites.filter(({ path }) => { + // A write replaces its last slot, not the value previously stored there. + const selected = path.slice( + constructorBackReferenceEnd(path, 0, path.length - 1), + ); + return ( + affectsBuiltinPath(selected, ["call"]) || selected.at(0) === "__proto__" + ); + }); const initializerObjectEscapes = binding.escapedPaths.filter( - ({ path }) => path.length === 0, + ({ path }) => constructorBackReferenceEnd(path) === path.length, ); if ( (initializerWrites.length > 0 || initializerObjectEscapes.length > 0) && diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts index ed797729d80..d51a5030e30 100644 --- a/src/domain/javascript/javascriptSemanticMapOperations.ts +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -34,9 +34,12 @@ const isIntrinsicMapOperation = ( (purpose !== "receiver" || (method !== "has" && method !== "clear" && - // set returns this. Until that result identity is retained by its - // owner, only a discarded result proves no receiver escapes through it. - (method !== "set" || + // set returns this, and iterators retain entries. Until their result + // identities are retained, only discarded results prove no escape. + ((method !== "set" && + method !== "keys" && + method !== "values" && + method !== "entries") || !t.isExpressionStatement(bindings.parentsByNode.get(node))))) ) return false; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 3579e852a0e..911948265c0 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -234,6 +234,66 @@ it.each([ "const records = new Map(); new Map().set(...expose(records)); records.get('/accounts');", true, ], + [ + "const records = new Map(); new Map().keys(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); other.keys(); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.keys = patch; new Map().keys(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.keys = patch; other.keys(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().keys()); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().values(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); other.values(); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.values = patch; new Map().values(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.values = patch; other.values(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().values()); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().entries(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); other.entries(); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.entries = patch; new Map().entries(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.entries = patch; other.entries(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().entries()); records.get('/accounts');", + true, + ], ["client.get('/accounts');", true], ["eval('Map.prototype.get = fetch'); new Map().get('/accounts');", true], ] as const)( From e118fc9a1cdafc711ee293f0c65fc08aa4761cf2 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 02:07:26 +0800 Subject: [PATCH 24/28] fix(javascript): retain pristine Array receiver certainty --- .../javascriptSemanticAmbientWrites.test.ts | 47 +++++++ .../javascriptSemanticAmbientWrites.ts | 45 +++++- .../javascriptSemanticArrayOperations.test.ts | 128 ++++++++++++++++++ .../javascriptSemanticArrayOperations.ts | 97 +++++++++++++ .../javascriptSemanticMemberMutations.ts | 11 +- 5 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticArrayOperations.test.ts create mode 100644 src/domain/javascript/javascriptSemanticArrayOperations.ts diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index 19426673fec..e3ee999ab84 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -14,6 +14,7 @@ import { createGlobalReferenceReader, retainSemanticAmbientEffect, semanticAmbientPathMayHaveChangedAt, + semanticAmbientObjectMayHaveChangedAt, } from "./javascriptSemanticAmbientWrites.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; @@ -543,3 +544,49 @@ it("keeps invocation exclusions outside the shared ambient record cache", () => semanticAmbientPathMayHaveChangedAt(invocation.callee, state, path), ).toBe(true); }); + +it.each([ + ["Array.prototype[0] = replacement; inspect();", true], + ["Array.prototype[key] = replacement; inspect();", true], + ["Array.prototype[0].value = replacement; inspect();", true], + ["configure(Array.prototype[0]); inspect();", true], + ["configure(Array.prototype); inspect();", true], + ["configure(Array); inspect();", true], + ["inspect(); Array.prototype[0] = replacement;", false], + ["function patch() { Array.prototype[0] = replacement; } inspect();", true], + ["Map.prototype[0] = replacement; inspect();", false], +])("queries timed ambient subtree overlap: %s", (source, changed) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected ambient subtree fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: boolean[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "inspect" }) + ) { + // A path query must not cache a subtree query's matching records or vice versa. + semanticAmbientPathMayHaveChangedAt( + node, + index.bindings, + ["global", "Array", "prototype"], + node, + ); + results.push( + semanticAmbientObjectMayHaveChangedAt( + node, + index.bindings, + ["global", "Array", "prototype"], + node, + ), + ); + } + }, + }); + expect(results).toEqual([changed]); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 7521d89c932..89af9bf2fd4 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -85,14 +85,18 @@ const ambientPathEffectCaches = new WeakMap< AmbientPathEffectCache >(); -/** Query retained ambient effects at a selected read, preserving unknown timing. */ -export const semanticAmbientPathMayHaveChangedAt = ( +const semanticAmbientEffectsMayHaveChangedAt = ( read: t.Node, state: JavaScriptSemanticAnalysisState, path: readonly string[], + scope: "path" | "subtree", invocation?: t.Node, ): boolean => { - if (!state.ambientWrites.covers(path) && !state.ambientEscapes.covers(path)) + if ( + scope === "path" && + !state.ambientWrites.covers(path) && + !state.ambientEscapes.covers(path) + ) return false; let cache = ambientPathEffectCaches.get(state); if ( @@ -106,12 +110,19 @@ export const semanticAmbientPathMayHaveChangedAt = ( }; ambientPathEffectCaches.set(state, cache); } - const key = JSON.stringify(path); + const key = JSON.stringify([scope, path]); let effects = cache.byPath.get(key); if (effects === undefined) { const matches = ( effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number], - ): boolean => ambientEffectCovers(effect, path); + ): boolean => + scope === "path" + ? ambientEffectCovers(effect, path) + : effect.path + .slice(0, path.length) + .every((key, index) => + semanticPropertyPathKeyMatches(key, path[index]!), + ); effects = [ ...state.ambientWriteEffects.filter(matches), ...state.ambientEscapeEffects.filter(matches), @@ -127,6 +138,30 @@ export const semanticAmbientPathMayHaveChangedAt = ( ); }; +/** Query retained ambient effects at a selected read, preserving unknown timing. */ +export const semanticAmbientPathMayHaveChangedAt = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly string[], + invocation?: t.Node, +): boolean => + semanticAmbientEffectsMayHaveChangedAt(read, state, path, "path", invocation); + +/** Check an object's whole surface, including descendant accessors and aliases. */ +export const semanticAmbientObjectMayHaveChangedAt = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly string[], + invocation?: t.Node, +): boolean => + semanticAmbientEffectsMayHaveChangedAt( + read, + state, + path, + "subtree", + invocation, + ); + const retainedAmbientEffects = new WeakMap< JavaScriptSemanticAnalysisState, Map> diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts new file mode 100644 index 00000000000..05b367cc91f --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts @@ -0,0 +1,128 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; + +const proofFor = (source: string, method = "fill") => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected array-call fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: (boolean | undefined)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property, { name: method }) + ) + results.push( + isIntrinsicNonEscapingArrayCall(node, index.bindings, index), + ); + }, + }); + return results; +}; + +it.each([ + "fill(0)", + "copyWithin(0, 1)", + "reverse()", + "push(0)", + "pop()", + "shift()", + "unshift(0)", +])("preserves unrelated array iteration after discarded native %s", (call) => { + const source = `const a = new Array(3); a.${call}; const urls = ['/api']; const [url] = urls; fetch(url);`; + expect(proofFor(source, call.slice(0, call.indexOf("(")))).toEqual([true]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); +}); + +it.each([ + "new Array(3).fill(0);", + "const a = new globalThis.Array(3); const b = a; b.fill(0);", + "const a = new Array('/old', '/other'); a.fill('/new');", + "const a = new Array(3); a.fill(0); a.fill = patch;", + "const a = new Array(3); a.fill(0); Array.prototype[0] = replacement;", + "Map.prototype[0] = replacement; const a = new Array(3); a.fill(0);", +])("proves only prior effects relevant to a pristine array: %s", (source) => { + expect(proofFor(source)).toEqual([true]); +}); + +it.each([ + "Array.prototype.fill = patch; const a = new Array(3); a.fill(0);", + "Array.prototype[0] = replacement; const a = new Array(3); a.fill(0);", + "Object.prototype[0] = replacement; const a = new Array(3); a.fill(0);", + "Array.prototype[key] = replacement; const a = new Array(3); a.fill(0);", + "Object.defineProperty(Array.prototype, '0', {set(value) { patch(this); }}); const a = new Array(3); a.fill(0);", + "Object.defineProperty(Object.prototype, '0', {set(value) { patch(this); }}); const a = new Array(3); a.fill(0);", + "consume(Array.prototype); const a = new Array(3); a.fill(0);", + "consume(Object.prototype); const a = new Array(3); a.fill(0);", + "Object.setPrototypeOf(Array.prototype, custom); const a = new Array(3); a.fill(0);", + "const a = new Array(3); Object.setPrototypeOf(a, custom); a.fill(0);", + "const a = new Array(3); Object.defineProperty(a, '0', {set(value) { patch(this); }}); a.fill(0);", + "const a = new Array(3); const b = a; b[0] = value; a.fill(0);", + "const a = new Array(3); consume(a); a.fill(0);", + "const a = new Array(3); a.fill = patch; a.fill(0);", + "const a = new Array(3); a.fill({}, 0);", + "const a = new Array(3); a.fill(0, {valueOf() { patch(a); return 0; }});", + "const a = new Array(3); a.fill(...args);", + "const a = new Array(3); a.fill(value);", + "const a = new Array(value); a.fill(0);", + "const a = new Array(...args); a.fill(0);", + "const a = new Array(3); consume(a.fill(0));", + "const a = []; a.fill(0);", + "const a = new Array(3); a.fill(0, (Array.prototype[0] = replacement, 0));", + "const a = new Array(3); a.fill(0, (Object.prototype[0] = replacement, 0));", + "const a = new Array(3); a.fill(0, (eval(code), 0));", + "const a = new Array(3); a.fill(0, (Object.setPrototypeOf(a, custom) ? 0 : 0));", + "const a = new Array(3); a.fill(0, ((Array.prototype[0] = replacement) ? 0 : 0));", + "const a = new Array(3); a.fill(0, ((Object.prototype[0] = replacement) ? 0 : 0));", + "const a = new Array(3); a.fill(0, (eval(code) ? 0 : 0));", + "function patch() { Array.prototype[0] = replacement; } const a = new Array(3); a.fill(0);", + "function run(Array) { const a = new Array(3); a.fill(0); }", + "function run(a) { a.fill(0); }", +])("retains receiver uncertainty outside the pristine proof: %s", (source) => { + expect(proofFor(source)).toEqual([false]); +}); + +it.each(["sort()", "splice(0)", "slice(0)", "concat(0)", "forEach(callback)"])( + "does not admit additional callback or species contracts: %s", + (call) => { + expect( + proofFor( + `const a = new Array(3); a.${call};`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); + }, +); + +it("does not restore stale literal-array slots after a native mutation", () => { + expect( + analyzeJavaScriptStaticSource( + "const a = ['/old']; a.fill('/new'); fetch(a[0]);", + ).endpoints.map(({ value }) => value), + ).not.toContain("/old"); +}); + +it("reconsiders earlier pristine proofs after a deferred receiver escapes", () => { + expect( + proofFor(`function run() { + const a = new Array(3); a.fill(0); + const b = new Array(3); b[0] = 1; b.fill(0); + }`), + ).toEqual([false, false]); +}); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.ts b/src/domain/javascript/javascriptSemanticArrayOperations.ts new file mode 100644 index 00000000000..82628519c17 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArrayOperations.ts @@ -0,0 +1,97 @@ +import * as t from "@babel/types"; + +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientObjectMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { semanticPrimitiveCandidates } from "./javascriptSemanticPrimitives.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptBindingIndex, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +const PRISTINE_ARRAY_METHODS = new Set([ + "fill", + "copyWithin", + "reverse", + "push", + "pop", + "shift", + "unshift", +]); + +/** These native mutations have no callbacks on pristine arrays with primitive arguments. */ +export const isIntrinsicNonEscapingArrayCall = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => { + const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) || + !PRISTINE_ARRAY_METHODS.has( + semanticStaticPropertyName(callee.property, callee.computed) ?? "", + ) || + // Some methods return this; no returned-reference proof is introduced here. + !t.isExpressionStatement(state.parentsByNode.get(node)) + ) + return false; + const primitiveArguments = (arguments_: typeof node.arguments): boolean => + arguments_.every( + (argument) => + !t.isSpreadElement(argument) && + semanticPrimitiveCandidates( + evaluateSemanticExpression(argument, state), + ) !== null, + ); + if (!primitiveArguments(node.arguments)) return false; + let receiver = unwrapJavaScriptExpression(callee.object).node; + const seen = new Set(); + while (t.isIdentifier(receiver)) { + if (index?.referenceMayHaveChangedAt(node, receiver, [])) return false; + const binding = resolveSemanticBindingState(state, receiver, receiver.name); + if (binding === undefined || seen.has(binding.bindingId)) return false; + seen.add(binding.bindingId); + const initializer = semanticStableInitializer(binding, state); + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !semanticBindingAtRead(binding, receiver, state).hasDefiniteInitializer + ) + return false; + receiver = unwrapJavaScriptExpression(initializer.node).node; + } + // Literal arrays retain element values. Skipping their mutation without a + // value update would restore stale slots; new Array already evaluates unknown. + if ( + !t.isNewExpression(receiver) || + !isUnmodifiedGlobalReference(receiver.callee, state, "Array") || + !primitiveArguments(receiver.arguments) + ) + return false; + // Holes may select inherited numeric accessors, not only the named method. + // Include argument evaluation, which runs before the native mutation begins. + if ( + ["Array", "Object"].some((owner) => + semanticAmbientObjectMayHaveChangedAt( + node, + state, + ["global", owner, "prototype"], + node, + ), + ) || + semanticOpaqueGlobalMayHaveChanged(node, state) + ) + return false; + // Even direct constructions wait for propagated effects and convergence. + return index === undefined ? undefined : true; +}; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index d44e46ddac7..ae3a4089b19 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -4,6 +4,7 @@ import { createAmbientPathResolver, retainSemanticAmbientEffect, } from "./javascriptSemanticAmbientWrites.js"; +import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; import { isIntrinsicNonEscapingMapCall } from "./javascriptSemanticMapOperations.js"; import { isIntrinsicNonEscapingXhrOpen } from "./javascriptNetworkRequests.js"; import { @@ -361,9 +362,11 @@ export function* collectSemanticMemberMutationsSteps( index?: Pick, ): boolean | undefined => { const map = isIntrinsicNonEscapingMapCall(node, state, index); - return map === false - ? isIntrinsicNonEscapingXhrOpen(node, state, index) - : map; + if (map !== false) return map; + const xhr = isIntrinsicNonEscapingXhrOpen(node, state, index); + return xhr === false + ? isIntrinsicNonEscapingArrayCall(node, state, index) + : xhr; }; const deferredIntrinsicReceivers = new Set< t.CallExpression | t.OptionalCallExpression @@ -1851,7 +1854,7 @@ export function* collectSemanticMemberMutationsSteps( reference, ); } - // Proven native Map and pristine XHR calls do not expose their receivers. + // Proven native Map and pristine XHR/Array calls do not expose receivers. // Settle genuine effects first, then replay every unproven receiver escape. // Keep proven sites until convergence: later propagated effects can revoke them. let receiverIndex = 0; From ba119f329b155cd425df0c52e2529beddb62f2cc Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 02:49:12 +0800 Subject: [PATCH 25/28] fix(javascript): preserve builtin receiver and getter effects --- .../javascript/javascriptNetworkRequests.ts | 64 ++-- ...icAnalysis.rejected_event_listener.test.ts | 24 ++ .../javascriptSemanticArrayOperations.ts | 55 ++- ...scriptSemanticBuiltinModuleEffects.test.ts | 200 +++++++++++ .../javascriptSemanticIntrinsicReceivers.ts | 70 ++++ .../javascriptSemanticMapOperations.ts | 53 +-- .../javascriptSemanticMemberMutations.ts | 11 +- ...javascriptSemanticObjectOperations.test.ts | 315 ++++++++++++++++++ .../javascriptSemanticObjectOperations.ts | 73 ++++ .../javascriptSemanticRequireOrigin.ts | 202 ++++++++--- 10 files changed, 892 insertions(+), 175 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticIntrinsicReceivers.ts create mode 100644 src/domain/javascript/javascriptSemanticObjectOperations.test.ts create mode 100644 src/domain/javascript/javascriptSemanticObjectOperations.ts diff --git a/src/domain/javascript/javascriptNetworkRequests.ts b/src/domain/javascript/javascriptNetworkRequests.ts index 9b375934349..12830edd57b 100644 --- a/src/domain/javascript/javascriptNetworkRequests.ts +++ b/src/domain/javascript/javascriptNetworkRequests.ts @@ -3,15 +3,14 @@ import { semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; -import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; -import { semanticPrimitiveCandidates } from "./javascriptSemanticPrimitives.js"; import { - resolveSemanticBindingState, - semanticStableInitializer, - type JavaScriptBindingIndex, - type JavaScriptSemanticAnalysisState, + semanticConstructedReceiver, + semanticPrimitiveArguments, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import * as t from "@babel/types"; import { @@ -60,13 +59,7 @@ const isXhrOpenRequest = ( // Primitive arguments also avoid user code during WebIDL string conversion. if ( purpose === "receiver" && - node.arguments.some( - (argument) => - t.isSpreadElement(argument) || - semanticPrimitiveCandidates( - evaluateSemanticExpression(argument, bindings), - ) === null, - ) + !semanticPrimitiveArguments(node.arguments, bindings) ) return false; const prototypeOwners = @@ -89,40 +82,21 @@ const isXhrOpenRequest = ( ) ) return false; - let receiver = unwrapJavaScriptExpression(callee.object).node; - const seen = new Set(); - while (t.isIdentifier(receiver)) { - if ( - index !== undefined && - (purpose === "receiver" - ? index.referenceMayHaveChangedAt(node, receiver, []) - : index.referenceMayHaveChangedAt(node, receiver, ["open"]) || - index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) - ) - return false; - const binding = resolveSemanticBindingState( - bindings, - receiver, - receiver.name, - ); - if (binding === undefined || seen.has(binding.bindingId)) return false; - seen.add(binding.bindingId); - const initializer = semanticStableInitializer(binding, bindings); - if ( - initializer === undefined || - initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, bindings).hasDefiniteInitializer - ) - return false; - receiver = unwrapJavaScriptExpression(initializer.node).node; - } + const receiver = semanticConstructedReceiver(callee.object, node, bindings, { + paths: purpose === "receiver" ? [[]] : [["open"], ["__proto__"]], + index, + }); if ( - !t.isNewExpression(receiver) || - !isUnmodifiedGlobalReference(receiver.callee, bindings, "XMLHttpRequest") || + receiver === undefined || + !isUnmodifiedGlobalReference( + receiver.construction.callee, + bindings, + "XMLHttpRequest", + ) || semanticOpaqueGlobalMayHaveChanged(callee, bindings) ) return false; - return index === undefined && seen.size > 0 ? undefined : true; + return index === undefined && receiver.viaBinding ? undefined : true; }; /** A pristine native open is safe for the mutation producer, not every XHR call. */ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index e56f0a6066b..6b3fcd6c3fe 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -1556,3 +1556,27 @@ e.on("ready");`; expect(eventsForSource(source(false))).toEqual([]); }, ); + +it.each([ + "process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule.apply(null, ['events'])", + "Reflect.apply(process.getBuiltinModule, null, ['events'])", + "process.getBuiltinModule.bind(null, 'events')()", + "process.getBuiltinModule.bind(null)('events')", +])( + "retains native-listener uncertainty after normalized getter effects: %s", + (getter) => { + expect( + eventsForSource(`import {EventEmitter} from 'node:events'; + import {replacement} from './patch.js'; + ${getter}.EventEmitter.prototype.on = replacement; + const e = new EventEmitter(); e.on('ready');`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + expect( + eventsForSource(`import {EventEmitter} from 'node:events'; + import {replacement} from './patch.js'; + const e = new EventEmitter(); e.on('ready'); + ${getter}.EventEmitter.prototype.on = replacement;`), + ).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.ts b/src/domain/javascript/javascriptSemanticArrayOperations.ts index 82628519c17..8dd1dff975a 100644 --- a/src/domain/javascript/javascriptSemanticArrayOperations.ts +++ b/src/domain/javascript/javascriptSemanticArrayOperations.ts @@ -8,15 +8,14 @@ import { isUnmodifiedGlobalReference, semanticAmbientObjectMayHaveChangedAt, } from "./javascriptSemanticAmbientWrites.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; -import { semanticPrimitiveCandidates } from "./javascriptSemanticPrimitives.js"; -import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; import { - resolveSemanticBindingState, - semanticStableInitializer, - type JavaScriptBindingIndex, - type JavaScriptSemanticAnalysisState, + semanticConstructedReceiver, + semanticPrimitiveArguments, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; const PRISTINE_ARRAY_METHODS = new Set([ @@ -45,37 +44,21 @@ export const isIntrinsicNonEscapingArrayCall = ( !t.isExpressionStatement(state.parentsByNode.get(node)) ) return false; - const primitiveArguments = (arguments_: typeof node.arguments): boolean => - arguments_.every( - (argument) => - !t.isSpreadElement(argument) && - semanticPrimitiveCandidates( - evaluateSemanticExpression(argument, state), - ) !== null, - ); - if (!primitiveArguments(node.arguments)) return false; - let receiver = unwrapJavaScriptExpression(callee.object).node; - const seen = new Set(); - while (t.isIdentifier(receiver)) { - if (index?.referenceMayHaveChangedAt(node, receiver, [])) return false; - const binding = resolveSemanticBindingState(state, receiver, receiver.name); - if (binding === undefined || seen.has(binding.bindingId)) return false; - seen.add(binding.bindingId); - const initializer = semanticStableInitializer(binding, state); - if ( - initializer === undefined || - initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, state).hasDefiniteInitializer - ) - return false; - receiver = unwrapJavaScriptExpression(initializer.node).node; - } + if (!semanticPrimitiveArguments(node.arguments, state)) return false; + const receiver = semanticConstructedReceiver(callee.object, node, state, { + paths: [[]], + index, + }); // Literal arrays retain element values. Skipping their mutation without a // value update would restore stale slots; new Array already evaluates unknown. if ( - !t.isNewExpression(receiver) || - !isUnmodifiedGlobalReference(receiver.callee, state, "Array") || - !primitiveArguments(receiver.arguments) + receiver === undefined || + !isUnmodifiedGlobalReference( + receiver.construction.callee, + state, + "Array", + ) || + !semanticPrimitiveArguments(receiver.construction.arguments, state) ) return false; // Holes may select inherited numeric accessors, not only the named method. diff --git a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts index cb4f603fda9..16a59aebc93 100644 --- a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts +++ b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts @@ -327,3 +327,203 @@ const after = new acquired.EventEmitter();`); expect(eventConstructorPaths(state)).toEqual([null, null]); }, ); + +it.each([ + "process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule.apply(null, ['events'])", + "Reflect.apply(process.getBuiltinModule, null, ['events'])", + "Function.prototype.call.call(process.getBuiltinModule, null, 'events')", + "process.getBuiltinModule.call.call(process.getBuiltinModule, null, 'events')", + "process.getBuiltinModule.bind(null, 'events')()", + "process.getBuiltinModule.bind(null)('events')", + "process.getBuiltinModule.bind(null).bind(null, 'events')()", + "(process.getBuiltinModule?.call(null, 'events'))", + "globalThis.process.getBuiltinModule.apply(null, ['node:events'])", +])("normalizes effective builtin-getter arguments: %s", (getter) => { + const source = `${getter}.EventEmitter.prototype.on = replacement;`; + const state = indexed(source); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); +}); + +it.each([ + "const get = process.getBuiltinModule; get.call(null, 'events')", + "const { getBuiltinModule: get } = process; get.apply(null, ['events'])", + "const host = process; host.getBuiltinModule.call(null, 'events')", + "const holder = { get: process.getBuiltinModule }; holder.get.call(null, 'events')", + "const get = process.getBuiltinModule.bind(null, 'events'); const alias = get; alias()", + "const host = process; const get = host.getBuiltinModule.bind(null); get('events')", + "import process from 'node:process'; process.getBuiltinModule.call(null, 'events')", + "import { getBuiltinModule as get } from 'process'; get.call(null, 'events')", + "import * as process from 'node:process'; process.getBuiltinModule.apply(null, ['events'])", + "import { getBuiltinModule as get } from 'process'; const alias = get; Reflect.apply(alias, null, ['events'])", +])( + "uses existing getter provenance without a separate alias walker: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it.each([ + "process.getBuiltinModule.call.call(other, null, 'events')", + "(0, process.getBuiltinModule.call)(null, 'events')", + "const call = process.getBuiltinModule.call; call(null, 'events')", + "process.getBuiltinModule.call(null, specifier)", + "process.getBuiltinModule.apply(null, args)", + "process.getBuiltinModule.apply(null, [, 'events'])", + "process.getBuiltinModule.call(...args)", + "process.getBuiltinModule.bind(null, specifier)()", + "process.getBuiltinModule.call = other; process.getBuiltinModule.call(null, 'events')", + "Function.prototype.apply = other; process.getBuiltinModule.apply(null, ['events'])", + "Reflect.apply = other; Reflect.apply(process.getBuiltinModule, null, ['events'])", + "process.getBuiltinModule.bind = other; process.getBuiltinModule.bind(null, 'events')()", + "import process from 'process'; process.getBuiltinModule.call = other; process.getBuiltinModule.call(null, 'events')", + "const process = other; process.getBuiltinModule.call(null, 'events')", + "const Reflect = other; Reflect.apply(process.getBuiltinModule, null, ['events'])", +])( + "does not guess getter arguments through unknown invocation facts: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); + +it.each([ + "process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule.bind(null, 'events')()", +])( + "does not promote normalized getter calls to loader authority: %s", + (getter) => { + const state = indexed(`const events = ${getter};`); + const expression = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + )?.initializers[0]?.node; + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toMatchObject({ specifier: "events", loaderAuthority: "candidate" }); + expect(semanticRequireOriginCandidate(expression, state)).toBeUndefined(); + }, +); + +it.each([ + "import {getBuiltinModule as get} from 'process'; get.bind(null, 'events')()", + "const holder = { get: process.getBuiltinModule }; holder.get.bind(null, 'events')()", +])( + "does not invent bound-capture facts for unsupported sources: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); + +it("preserves candidate-only direct acquisition after a getter replacement", () => { + expect( + indexed( + "process.getBuiltinModule = replacement; process.getBuiltinModule('events').EventEmitter.prototype.on = other;", + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); +}); + +it("invalidates the getter helper reader when a shared process effect is indexed later", () => { + const state = indexed( + "import process from 'process'; const events = process.getBuiltinModule.call(null, 'events');", + ); + const expression = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + )?.initializers[0]?.node; + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toBeDefined(); + state.requiredModuleEffects.set("process", [ + ...(state.requiredModuleEffects.get("process") ?? []), + { kind: "write", path: ["getBuiltinModule", "call"], node: undefined }, + ]); + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toBeUndefined(); +}); + +it.each([ + "process.getBuiltinModule('fs');", + "process.getBuiltinModule.call(null, 'fs');", + "import process from 'process'; process.getBuiltinModule.call(null, 'fs');", + "const get = process.getBuiltinModule.bind(null); get('fs');", +])( + "does not treat a prior getter invocation as a helper replacement: %s", + (prefix) => { + expect( + indexed( + `${prefix} process.getBuiltinModule.call(null, 'events').EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it.each([ + "const get = process.getBuiltinModule; process.getBuiltinModule = other; get.call(null, 'events')", + "process.getBuiltinModule = other; process.getBuiltinModule.call(null, 'events')", + "const host = process; process = other; host.getBuiltinModule.call(null, 'events')", +])( + "retains a possible getter without requiring an unchanged parent slot: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it.each([ + "globalThis.process = other; globalThis.process.getBuiltinModule.call(null, 'events')", + "globalThis.process = other; globalThis.process.getBuiltinModule.bind(null, 'events')()", + "globalThis.process.getBuiltinModule.call = other; globalThis.process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule[key] = other; process.getBuiltinModule.call(null, 'events')", + "globalThis[key].getBuiltinModule.call = other; globalThis.process.getBuiltinModule.call(null, 'events')", +])( + "does not mistake a fallback intrinsic path for a candidate getter: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticIntrinsicReceivers.ts b/src/domain/javascript/javascriptSemanticIntrinsicReceivers.ts new file mode 100644 index 00000000000..97893027844 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticIntrinsicReceivers.ts @@ -0,0 +1,70 @@ +import * as t from "@babel/types"; + +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { semanticPrimitiveCandidates } from "./javascriptSemanticPrimitives.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptBindingIndex, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; + +/** Resolve only stable, initialized identifier chains to one construction. */ +export const semanticConstructedReceiver = ( + expression: t.Node, + invocation: t.Node, + state: JavaScriptSemanticAnalysisState, + { + paths, + index, + }: { + readonly paths: readonly JavaScriptSemanticPropertyPath[]; + readonly index: + | Pick + | undefined; + }, +): + | { readonly construction: t.NewExpression; readonly viaBinding: boolean } + | undefined => { + let receiver = unwrapJavaScriptExpression(expression).node; + const seen = new Set(); + while (t.isIdentifier(receiver)) { + if ( + index !== undefined && + paths.some((path) => + index.referenceMayHaveChangedAt(invocation, receiver, path), + ) + ) + return undefined; + const binding = resolveSemanticBindingState(state, receiver, receiver.name); + if (binding === undefined || seen.has(binding.bindingId)) return undefined; + seen.add(binding.bindingId); + const initializer = semanticStableInitializer(binding, state); + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !semanticBindingAtRead(binding, receiver, state).hasDefiniteInitializer + ) + return undefined; + receiver = unwrapJavaScriptExpression(initializer.node).node; + } + return t.isNewExpression(receiver) + ? { construction: receiver, viaBinding: seen.size > 0 } + : undefined; +}; + +/** Primitive candidates exclude coercion hooks; evaluating them can still have effects. */ +export const semanticPrimitiveArguments = ( + arguments_: t.CallExpression["arguments"], + state: JavaScriptSemanticAnalysisState, +): boolean => + arguments_.every( + (argument) => + !t.isSpreadElement(argument) && + semanticPrimitiveCandidates( + evaluateSemanticExpression(argument, state), + ) !== null, + ); diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts index d51a5030e30..68d43a4705e 100644 --- a/src/domain/javascript/javascriptSemanticMapOperations.ts +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -9,12 +9,10 @@ import { semanticAmbientPathMayHaveChangedAt, } from "./javascriptSemanticAmbientWrites.js"; import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { - resolveSemanticBindingState, - semanticStableInitializer, - type JavaScriptBindingIndex, - type JavaScriptSemanticAnalysisState, +import { semanticConstructedReceiver } from "./javascriptSemanticIntrinsicReceivers.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; /** Prove an unchanged Map operation; undefined requests shared member-mutation facts. */ @@ -57,46 +55,29 @@ const isIntrinsicMapOperation = ( ), ); if (prototypeMemberMayHaveChanged(method, callee, node)) return false; - let receiver = unwrapJavaScriptExpression(callee.object).node; - const seen = new Set(); - while (t.isIdentifier(receiver)) { - if ( - index !== undefined && - (index.referenceMayHaveChangedAt(node, receiver, [method]) || - index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) - ) - return false; - const binding = resolveSemanticBindingState( - bindings, - receiver, - receiver.name, - ); - if (binding === undefined || seen.has(binding.bindingId)) return false; - seen.add(binding.bindingId); - const initializer = semanticStableInitializer(binding, bindings); - if ( - initializer === undefined || - initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, bindings).hasDefiniteInitializer - ) - return false; - receiver = unwrapJavaScriptExpression(initializer.node).node; - } + const receiver = semanticConstructedReceiver(callee.object, node, bindings, { + paths: [[method], ["__proto__"]], + index, + }); if ( - !t.isNewExpression(receiver) || - !isUnmodifiedGlobalReference(receiver.callee, bindings, "Map") + receiver === undefined || + !isUnmodifiedGlobalReference(receiver.construction.callee, bindings, "Map") ) return false; // Iterable construction reads and invokes the instance's set hook. A changed // adder can install its own get/delete before the constructed value is read. // The adder is selected after arguments evaluate, not at the callee read. if ( - receiver.arguments.length > 0 && - prototypeMemberMayHaveChanged("set", receiver, receiver) + receiver.construction.arguments.length > 0 && + prototypeMemberMayHaveChanged( + "set", + receiver.construction, + receiver.construction, + ) ) return false; if (semanticOpaqueGlobalMayHaveChanged(callee, bindings)) return false; - return index === undefined && seen.size > 0 ? undefined : true; + return index === undefined && receiver.viaBinding ? undefined : true; }; /** Endpoint filtering applies only to the route-like get/delete methods. */ diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index ae3a4089b19..ecc1b55565a 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -6,6 +6,7 @@ import { } from "./javascriptSemanticAmbientWrites.js"; import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; import { isIntrinsicNonEscapingMapCall } from "./javascriptSemanticMapOperations.js"; +import { isIntrinsicNonEscapingObjectCall } from "./javascriptSemanticObjectOperations.js"; import { isIntrinsicNonEscapingXhrOpen } from "./javascriptNetworkRequests.js"; import { isIntrinsicTypedArrayConstruction, @@ -364,9 +365,11 @@ export function* collectSemanticMemberMutationsSteps( const map = isIntrinsicNonEscapingMapCall(node, state, index); if (map !== false) return map; const xhr = isIntrinsicNonEscapingXhrOpen(node, state, index); - return xhr === false - ? isIntrinsicNonEscapingArrayCall(node, state, index) - : xhr; + if (xhr !== false) return xhr; + const array = isIntrinsicNonEscapingArrayCall(node, state, index); + return array === false + ? isIntrinsicNonEscapingObjectCall(node, state, index) + : array; }; const deferredIntrinsicReceivers = new Set< t.CallExpression | t.OptionalCallExpression @@ -1854,7 +1857,7 @@ export function* collectSemanticMemberMutationsSteps( reference, ); } - // Proven native Map and pristine XHR/Array calls do not expose receivers. + // Proven native Map and pristine XHR/Array/Object calls do not expose receivers. // Settle genuine effects first, then replay every unproven receiver escape. // Keep proven sites until convergence: later propagated effects can revoke them. let receiverIndex = 0; diff --git a/src/domain/javascript/javascriptSemanticObjectOperations.test.ts b/src/domain/javascript/javascriptSemanticObjectOperations.test.ts new file mode 100644 index 00000000000..f77b03c645b --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectOperations.test.ts @@ -0,0 +1,315 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { + createXhrOpenRequestQuery, + isIntrinsicNonEscapingXhrOpen, +} from "./javascriptNetworkRequests.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; +import { semanticConstructedReceiver } from "./javascriptSemanticIntrinsicReceivers.js"; +import { + isIntrinsicMapLookup, + isIntrinsicNonEscapingMapCall, +} from "./javascriptSemanticMapOperations.js"; +import { isIntrinsicNonEscapingObjectCall } from "./javascriptSemanticObjectOperations.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; + +type ReceiverProof = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, + index?: Pick, +) => boolean | undefined; + +const fixture = (source: string, method: string, mutations = true) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected intrinsic-receiver fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + if (mutations) completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const calls: (t.CallExpression | t.OptionalCallExpression)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + (t.isMemberExpression(node.callee) || + t.isOptionalMemberExpression(node.callee)) && + semanticStaticPropertyName( + node.callee.property, + node.callee.computed, + ) === method + ) + calls.push(node); + }, + }); + return { index, calls }; +}; + +const proofFor = ( + source: string, + method = "hasOwnProperty", + proof: ReceiverProof = isIntrinsicNonEscapingObjectCall, +) => { + const { index, calls } = fixture(source, method); + return calls.map((call) => proof(call, index.bindings, index)); +}; + +it.each([ + "hasOwnProperty('x')", + "propertyIsEnumerable('x')", + "valueOf()", + "isPrototypeOf(0)", + "toString()", + "toLocaleString()", +])( + "preserves unrelated array iteration after pristine native Object.%s", + (call) => { + for (const receiver of ["new Object()", "o"]) { + const source = `${receiver === "o" ? "const o = new Object();" : ""} + ${receiver}.${call}; const urls = ['/api']; const [url] = urls; fetch(url);`; + expect(proofFor(source, call.slice(0, call.indexOf("(")))).toEqual([ + true, + ]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); + } + }, +); + +it.each([ + "new globalThis.Object().hasOwnProperty('x');", + "const o = new Object(); const a = o; const b = a; b.hasOwnProperty('x');", + "const o = new Object(); o?.['hasOwnProperty']?.('x');", + "const o = new Object(); const result = o.hasOwnProperty('x');", + "const o = new Object(); o.hasOwnProperty(flag ? 'x' : 'y');", + "const o = new Object(); o.hasOwnProperty();", + "const o = new Object(); o.hasOwnProperty('x'); o.hasOwnProperty = patch;", + "const o = new Object(); o.hasOwnProperty('x'); o[key] = replacement;", + "const o = new Object(); o.hasOwnProperty('x'); Object.prototype[0] = replacement;", + "const o = new Object(); o.hasOwnProperty('x'); Object.prototype[Symbol.toStringTag] = 'Changed';", + "Map.prototype[0] = replacement; const o = new Object(); o.hasOwnProperty('x');", +])("proves only prior effects relevant to an empty Object: %s", (source) => { + expect(proofFor(source)).toEqual([true]); +}); + +it.each([ + "function run(Object) { new Object().hasOwnProperty('x'); }", + "Object = Replacement; new Object().hasOwnProperty('x');", + "globalThis.Object = Replacement; new globalThis.Object().hasOwnProperty('x');", + "new Object(proxy).hasOwnProperty('x');", + "new Object('boxed').hasOwnProperty('x');", + "new Object(null).hasOwnProperty('x');", + "new Object(...args).hasOwnProperty('x');", + "new Proxy(new Object(), handler).hasOwnProperty('x');", + "const o = Object(); o.hasOwnProperty('x');", + "const o = {}; o.hasOwnProperty('x');", + "const o = make(); o.hasOwnProperty('x');", + "const o = new Object(); o.x = 1; o.hasOwnProperty('x');", + "const o = new Object(); o[key] = replacement; o.hasOwnProperty('x');", + "const o = new Object(); const alias = o; alias.x = 1; o.hasOwnProperty('x');", + "const o = new Object(); consume(o); o.hasOwnProperty('x');", + "const o = new Object(); o.hasOwnProperty = patch; o.hasOwnProperty('x');", + "const o = new Object(); Object.setPrototypeOf(o, custom); o.hasOwnProperty('x');", + "const o = new Object(); Object.defineProperty(o, 'x', {get() { patch(this); }}); o.hasOwnProperty('x');", + "Object.prototype.hasOwnProperty = patch; new Object().hasOwnProperty('x');", + "Object.prototype[0] = replacement; new Object().hasOwnProperty('x');", + "Object.prototype[key] = replacement; new Object().hasOwnProperty('x');", + "consume(Object.prototype); new Object().hasOwnProperty('x');", + "Object.setPrototypeOf(Object.prototype, custom); new Object().hasOwnProperty('x');", + "function patch() { Object.prototype.x = replacement; } new Object().hasOwnProperty('x');", + "new Object().hasOwnProperty({toString() { patch(Object.prototype); return 'x'; }});", + "new Object().hasOwnProperty(proxy);", + "new Object().hasOwnProperty(...args);", + "new Object().hasOwnProperty(unknown);", + "eval(code); new Object().hasOwnProperty('x');", +])("retains receiver uncertainty outside the pristine proof: %s", (source) => { + expect(proofFor(source)).toEqual([false]); +}); + +it.each([ + "((o.x = 1) ? 'x' : 'y')", + "(Object.setPrototypeOf(o, custom) ? 'x' : 'y')", + "((Object.prototype.x = replacement) ? 'x' : 'y')", + "(eval(code) ? 'x' : 'y')", + "(consume(o) ? 'x' : 'y')", +])("includes effects while evaluating primitive arguments: %s", (argument) => { + expect( + proofFor(`const o = new Object(); o.hasOwnProperty(${argument});`), + ).toEqual([false]); +}); + +it.each([ + [ + "Object.prototype[Symbol.toStringTag] = 'Changed'; new Object().toString();", + "toString", + ], + [ + "const o = new Object(); o[Symbol.toStringTag] = 'Changed'; o.toString();", + "toString", + ], + [ + "Object.defineProperty(Object.prototype, Symbol.toStringTag, {get() { patch(this); }}); new Object().toString();", + "toString", + ], + [ + "const o = new Object(); o.toString = patch; o.toLocaleString();", + "toLocaleString", + ], + [ + "Object.prototype.toString = patch; new Object().toLocaleString();", + "toLocaleString", + ], + ["new Object().isPrototypeOf(new Proxy({}, handler));", "isPrototypeOf"], + ["const o = new Object(); consume(o.valueOf());", "valueOf"], + ["const result = new Object().valueOf();", "valueOf"], +] as const)( + "excludes coercion, tag, locale and returned-receiver hooks: %s", + (source, method) => { + expect(proofFor(source, method)).toEqual([false]); + }, +); + +it.each([ + "propertyIsEnumerable('x')", + "isPrototypeOf()", + "toString()", + "toLocaleString()", +])("permits consumed primitive results: %s", (call) => { + const method = call.slice(0, call.indexOf("(")); + expect( + proofFor(`const o = new Object(); const result = o.${call};`, method), + ).toEqual([true]); +}); + +it.each([ + "__defineGetter__('x', callback)", + "__lookupGetter__('x')", + "unknown()", +])("does not admit additional Object method contracts: %s", (call) => { + expect( + proofFor(`new Object().${call};`, call.slice(0, call.indexOf("("))), + ).toEqual([false]); +}); + +it.each([ + [ + "new Object().hasOwnProperty('x');", + "hasOwnProperty", + isIntrinsicNonEscapingObjectCall, + undefined, + ], + ["new Array(3).fill(0);", "fill", isIntrinsicNonEscapingArrayCall, undefined], + ["new Map().get('x');", "get", isIntrinsicNonEscapingMapCall, true], + [ + "new XMLHttpRequest().open('GET', '/api');", + "open", + isIntrinsicNonEscapingXhrOpen, + true, + ], + [ + "const o = new Object(); o.hasOwnProperty('x');", + "hasOwnProperty", + isIntrinsicNonEscapingObjectCall, + undefined, + ], + [ + "const o = new Array(3); o.fill(0);", + "fill", + isIntrinsicNonEscapingArrayCall, + undefined, + ], + [ + "const o = new Map(); o.get('x');", + "get", + isIntrinsicNonEscapingMapCall, + undefined, + ], + [ + "const o = new XMLHttpRequest(); o.open('GET', '/api');", + "open", + isIntrinsicNonEscapingXhrOpen, + undefined, + ], +] as const)( + "preserves each consumer's deferred-proof contract: %s", + (source, method, proof, initial) => { + const { index, calls } = fixture(source, method, false); + expect(calls).toHaveLength(1); + expect(proof(calls[0]!, index.bindings)).toBe(initial); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + expect(proof(calls[0]!, index.bindings, index)).toBe(true); + }, +); + +it.each([ + "o.hasOwnProperty('x'); const o = new Object();", + "const a = o; const o = new Object(); a.hasOwnProperty('x');", + "let o; if (flag) o = new Object(); o.hasOwnProperty('x');", + "let o = new Object(); o = other; o.hasOwnProperty('x');", + "const {o} = {o: new Object()}; o.hasOwnProperty('x');", + "const a = b; const b = a; a.hasOwnProperty('x');", + "function run(o) { o.hasOwnProperty('x'); }", +])( + "rejects unstable, projected, cyclic or uninitialized receiver chains: %s", + (source) => { + const { index, calls } = fixture(source, "hasOwnProperty"); + const call = calls[0]!; + if (!t.isMemberExpression(call.callee)) + throw new Error("Expected member call"); + expect( + semanticConstructedReceiver(call.callee.object, call, index.bindings, { + paths: [[]], + index, + }), + ).toBeUndefined(); + expect(isIntrinsicNonEscapingObjectCall(call, index.bindings, index)).toBe( + false, + ); + }, +); + +it("keeps Map member paths narrower than the whole receiver", () => { + const source = "const o = new Map(); o.unrelated = 1; o.get('x');"; + expect(proofFor(source, "get", isIntrinsicNonEscapingMapCall)).toEqual([ + true, + ]); + expect(proofFor(source, "get", isIntrinsicMapLookup)).toEqual([true]); +}); + +it("keeps XHR request identity separate from the nonescaping receiver proof", () => { + const { index, calls } = fixture( + "const o = new XMLHttpRequest(); o.onreadystatechange = callback; o.open('GET', '/api');", + "open", + ); + expect(isIntrinsicNonEscapingXhrOpen(calls[0]!, index.bindings, index)).toBe( + false, + ); + expect(createXhrOpenRequestQuery(index)(calls[0]!)).toBe(true); +}); + +it.each([ + "new Object().hasOwnProperty('x');", + "const a = new Object(); a.hasOwnProperty('x');", +])( + "reconsiders pristine proofs after deferred effects reach Object.prototype: %s", + (first) => { + expect( + proofFor( + `function run() { ${first} const b = new Object(); b.x = 1; b.hasOwnProperty('x'); }`, + ), + ).toEqual([false, false]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticObjectOperations.ts b/src/domain/javascript/javascriptSemanticObjectOperations.ts new file mode 100644 index 00000000000..12bbb29d262 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectOperations.ts @@ -0,0 +1,73 @@ +import * as t from "@babel/types"; + +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientObjectMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { + semanticConstructedReceiver, + semanticPrimitiveArguments, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +const PRISTINE_OBJECT_METHODS = new Set([ + "hasOwnProperty", + "propertyIsEnumerable", + "isPrototypeOf", + "toString", + "toLocaleString", + "valueOf", +]); + +/** These methods cannot expose an empty native Object with no receiver/prototype hooks. */ +export const isIntrinsicNonEscapingObjectCall = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => { + const callee = unwrapJavaScriptExpression(node.callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const method = semanticStaticPropertyName(callee.property, callee.computed); + if ( + !PRISTINE_OBJECT_METHODS.has(method ?? "") || + // valueOf returns the receiver; no returned-reference proof is added here. + (method === "valueOf" && + !t.isExpressionStatement(state.parentsByNode.get(node))) || + // Property keys can coerce, and isPrototypeOf can walk a Proxy argument. + !semanticPrimitiveArguments(node.arguments, state) + ) + return false; + const receiver = semanticConstructedReceiver(callee.object, node, state, { + paths: [[]], + index, + }); + if ( + receiver === undefined || + receiver.construction.arguments.length !== 0 || + !isUnmodifiedGlobalReference(receiver.construction.callee, state, "Object") + ) + return false; + // Include argument evaluation and the whole prototype surface: toString + // reads Symbol.toStringTag, and toLocaleString invokes this.toString. + if ( + semanticAmbientObjectMayHaveChangedAt( + node, + state, + ["global", "Object", "prototype"], + node, + ) || + semanticOpaqueGlobalMayHaveChanged(node, state) + ) + return false; + // Direct constructions also wait for propagated effects and convergence. + return index === undefined ? undefined : true; +}; diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 9220d7b109c..642cd2efc4e 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -685,10 +685,10 @@ const loaderInvocationInterops = new WeakMap< } >(); -const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { - const prior = loaderInvocationInterops.get(state); - if (prior?.writes === state.ambientWriteEffects.length) return prior.interop; - const interop = createSemanticModuleInterop(state); +const retainAmbientInvocationWrites = ( + state: JavaScriptSemanticAnalysisState, + interop: ReturnType, +): void => { for (const effect of state.ambientWriteEffects) if (effect.path[0] === "global") interop.retainWrite(effect.node ?? state.program, [ @@ -699,6 +699,13 @@ const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { key === null || typeof key === "object" ? null : String(key), ), ]); +}; + +const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { + const prior = loaderInvocationInterops.get(state); + if (prior?.writes === state.ambientWriteEffects.length) return prior.interop; + const interop = createSemanticModuleInterop(state); + retainAmbientInvocationWrites(state, interop); loaderInvocationInterops.set(state, { writes: state.ambientWriteEffects.length, interop, @@ -1701,31 +1708,137 @@ const moduleAcquisitionAmbientReaders = new WeakMap< ReturnType >(); -/** Candidate acquisition effects consume aliases from the shared ambient owner. */ -const hasGlobalBuiltinGetterAlias = ( +/** Candidate acquisition effects consume existing module and ambient selections. */ +const moduleAcquisitionGetterPath = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, -): boolean => { - const root = semanticMemberChain(callee)?.root; +): readonly string[] | undefined => { + const getterPath = (path: readonly (string | null)[]) => + path[0] === "getBuiltinModule" && + path + .slice(1) + .every( + (member) => + member === "call" || member === "apply" || member === "bind", + ) + ? ["candidate-module", "process", ...(path as readonly string[])] + : undefined; + const chain = semanticMemberChain(callee); + if (chain?.root !== undefined && chain.root !== null) { + const origin = + semanticGlobalProcessOrigin(chain.root, state) ?? + semanticGlobalProcessPathOrigin(callee, state); + if (origin !== undefined) { + const path = getterPath( + origin.importedPath.length === 0 ? chain.members : origin.importedPath, + ); + if (path !== undefined) return path; + } + } + for (const origin of evaluateSemanticExpressionProvenance(callee, state) + .origins) { + if (origin.specifier.replace(/^node:/u, "") !== "process") continue; + const path = getterPath( + origin.resolutionMode !== "require" && + origin.importedPath[0] === "default" + ? origin.importedPath.slice(1) + : origin.importedPath, + ); + if (path !== undefined) return path; + } + const root = chain?.root; if ( !t.isIdentifier(root) || resolveSemanticBindingState(state, root, root.name) === undefined ) - return false; + return undefined; let read = moduleAcquisitionAmbientReaders.get(state); if (read === undefined) { - // Possible-effect mode selects aliases at each read and does not cache a - // result across mutation-index updates. It grants no native authority. + // Possible-effect mode selects aliases at each read without caching across + // mutation-index updates. It grants no native loader authority. read = createAmbientPathResolver(state, true); moduleAcquisitionAmbientReaders.set(state, read); } - return read(callee, callee).paths.some( - (path) => - path.length === 3 && - path[0] === "global" && - path[1] === "process" && - path[2] === "getBuiltinModule", - ); + for (const path of read(callee, callee).paths) { + if (path[0] !== "global" || path[1] !== "process") continue; + const selected = getterPath(path.slice(2)); + if (selected !== undefined) return selected; + } + return undefined; +}; + +const moduleAcquisitionInvocationInterops = new WeakMap< + JavaScriptSemanticAnalysisState, + { + readonly writes: number; + readonly processEffects: number; + readonly interop: ReturnType; + } +>(); + +/** Normalize generic calls and existing bound captures at the invocation owner. */ +const moduleAcquisitionInvocationInterop = ( + state: JavaScriptSemanticAnalysisState, +) => { + const effects = state.requiredModuleEffects.get("process") ?? []; + const prior = moduleAcquisitionInvocationInterops.get(state); + if (prior?.writes === state.ambientWriteEffects.length) { + let helperWriteAdded = false; + for (let index = prior.processEffects; index < effects.length; index += 1) + if (effects[index]?.kind === "write") { + helperWriteAdded = true; + break; + } + // Ordinary calls append escapes. Do not repeatedly rescan their history or + // rebuild the invocation reader when no helper-write facts were added. + if (!helperWriteAdded) { + moduleAcquisitionInvocationInterops.set(state, { + ...prior, + processEffects: effects.length, + }); + return prior.interop; + } + } + const intrinsic = loaderInvocationInterop(state); + const interop = createSemanticModuleInterop(state, { + intrinsicPath: (node) => + moduleAcquisitionGetterPath(node, state) ?? intrinsic.intrinsicPath(node), + }); + retainAmbientInvocationWrites(state, interop); + // Replacing the candidate getter does not disprove its possible result. + // Only writes below that function affect generic helper argument mapping; + // otherwise even an earlier captured getter would lose its candidate path. + const retainGetterHelperWrite = ( + node: t.Node | undefined, + path: readonly unknown[], + ) => { + if (path.length < 2) return; + interop.retainWrite(node ?? state.program, [ + "candidate-module", + "process", + ...path.map((key) => + key === null || typeof key === "object" ? null : String(key), + ), + ]); + }; + for (const effect of state.ambientWriteEffects) + if ( + effect.path[0] === "global" && + effect.path[1] !== undefined && + semanticPropertyPathKeyMatches(effect.path[1], "process") + ) + retainGetterHelperWrite(effect.node, effect.path.slice(2)); + // Imported/default process objects share these existing mutation records. + // Invocation escapes are not helper replacements; retain actual writes only. + for (const effect of effects) + if (effect.kind === "write") + retainGetterHelperWrite(effect.node, effect.path); + moduleAcquisitionInvocationInterops.set(state, { + writes: state.ambientWriteEffects.length, + processEffects: effects.length, + interop, + }); + return interop; }; /** @@ -1759,47 +1872,28 @@ export const semanticModuleAcquisitionEffectOrigin = ( // An unawaited import is a Promise, not the acquired module namespace. if (!t.isCallExpression(call) && !t.isOptionalCallExpression(call)) return undefined; - const specifier = stringValue(call.arguments[0]); - if (specifier === undefined) return undefined; - const callee = unwrapJavaScriptExpression(call.callee).node; - let globalProcessGetter = false; + // A direct candidate getter never promised an unchanged native function. + // Keep that mutation-only contract; generic helpers additionally need their + // invocation semantics to determine which arguments reach the candidate. + const directPath = moduleAcquisitionGetterPath(call.callee, state); + const invocation = + directPath?.length === 3 + ? { kind: "known", path: directPath, arguments: call.arguments } + : moduleAcquisitionInvocationInterop(state).intrinsicInvocation(call); if ( - (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && - semanticStaticPropertyKey(callee.property, callee.computed) === - "getBuiltinModule" - ) { - const receiver = unwrapJavaScriptExpression(callee.object).node; - const processOrigin = - semanticGlobalProcessOrigin(receiver, state) ?? - semanticGlobalProcessPathOrigin(receiver, state); - globalProcessGetter = processOrigin?.importedPath.length === 0; - } - const importedProcessGetter = - !globalProcessGetter && - evaluateSemanticExpressionProvenance(callee, state).origins.some( - (origin) => { - if (origin.specifier.replace(/^node:/u, "") !== "process") return false; - const path = origin.importedPath; - // Only an ESM default denotes the namespace, not a literal require property. - return ( - (path.length === 1 && path[0] === "getBuiltinModule") || - (origin.resolutionMode !== "require" && - path.length === 2 && - path[0] === "default" && - path[1] === "getBuiltinModule") - ); - }, - ); - return globalProcessGetter || - importedProcessGetter || - hasGlobalBuiltinGetterAlias(callee, state) - ? { + invocation?.kind !== "known" || + invocation.path.join(".") !== "candidate-module.process.getBuiltinModule" + ) + return undefined; + const specifier = stringValue(invocation.arguments?.[0]); + return specifier === undefined + ? undefined + : { specifier, importedPath: chain.members, resolutionMode: "require", loaderAuthority: "candidate", - } - : undefined; + }; }; /** Check one unknown-host wrapper candidate at its source-ordered read. */ From 6b18b7007a1945bc17cc56e87e86accba4b6623e Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 03:11:33 +0800 Subject: [PATCH 26/28] fix(javascript): preserve pristine Array read certainty --- .../javascriptSemanticArrayOperations.test.ts | 138 ++++++++++++++++++ .../javascriptSemanticArrayOperations.ts | 14 +- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts index 05b367cc91f..4163fc9a3af 100644 --- a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts +++ b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts @@ -42,6 +42,15 @@ it.each([ "pop()", "shift()", "unshift(0)", + "includes(0)", + "indexOf(0)", + "lastIndexOf(0)", + "at(0)", + "join(',')", + "toString()", + "keys()", + "values()", + "entries()", ])("preserves unrelated array iteration after discarded native %s", (call) => { const source = `const a = new Array(3); a.${call}; const urls = ['/api']; const [url] = urls; fetch(url);`; expect(proofFor(source, call.slice(0, call.indexOf("(")))).toEqual([true]); @@ -126,3 +135,132 @@ it("reconsiders earlier pristine proofs after a deferred receiver escapes", () = }`), ).toEqual([false, false]); }); + +it.each([ + "includes(0)", + "indexOf(0)", + "lastIndexOf(0)", + "at(0)", + "join(',')", + "toString()", + "keys()", + "values()", + "entries()", +])( + "keeps read-result consumption outside the pristine receiver contract: %s", + (call) => { + expect( + proofFor( + `const a = new Array(3); consume(a.${call});`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); + }, +); + +it.each([ + "new Array(3).includes(0);", + "const a = new globalThis.Array('a', 'b'); const b = a; b.includes('a');", + "const a = new Array(3); a.includes(0); Array.prototype[0] = replacement;", +])( + "retains pristine Array read certainty without new shape facts: %s", + (source) => { + expect(proofFor(source, "includes")).toEqual([true]); + }, +); + +it.each([ + [ + "includes", + "Object.defineProperty(Array.prototype, '0', {get() { patch(); return 0; }}); const a = new Array(3); a.includes(0);", + ], + [ + "includes", + "Object.defineProperty(Object.prototype, '0', {get() { patch(); return 0; }}); const a = new Array(3); a.includes(0);", + ], + ["includes", "const a = new Array(3); a[0] = object; a.includes(0);"], + [ + "includes", + "const a = new Array(3); a.includes(0, {valueOf() {patch(a); return 0;}});", + ], + ["includes", "const a = new Array(3); a.includes(0, (eval(code) ? 0 : 0));"], + [ + "includes", + "const a = new Array(3); a.includes(0, ((Array.prototype[0] = replacement) ? 0 : 0));", + ], + ["join", "const a = new Array(object, 0); a.join(',');"], + [ + "join", + "const a = new Array(3); a.join({toString() {patch(a); return ',';}});", + ], + [ + "toString", + "Array.prototype.join = replacement; const a = new Array(3); a.toString();", + ], + ["toString", "const a = new Array(3); a.join = replacement; a.toString();"], + ["includes", "const a = [0]; a.includes(0);"], + ["values", "const a = new Array(3); consume(a); a.values();"], +])("does not exempt a read with observable hooks: %s", (method, source) => { + expect(proofFor(source, method)).toEqual([false]); +}); + +it.each([ + "toLocaleString()", + "slice()", + "concat()", + "flat()", + "map(callback)", + "filter(callback)", + "find(callback)", + "some(callback)", + "every(callback)", + "reduce(callback)", + "sort()", + "toSorted()", + "toReversed()", + "toSpliced(0)", + "with(0, 1)", +])( + "does not add callback, element-prototype or copying contracts: %s", + (call) => { + expect( + proofFor( + `const a = new Array(1, 2); a.${call};`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); + }, +); + +it.each([ + "new Array(3).includes(0);", + "const a = new Array(3); a.includes(0);", +])( + "defers even direct pristine reads until propagated effects converge: %s", + (source) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected read fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: (boolean | undefined)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) + results.push(isIntrinsicNonEscapingArrayCall(node, index.bindings)); + }, + }); + expect(results).toEqual([undefined]); + }, +); + +it("does not exempt locale conversion through mutable element prototypes", () => { + expect( + proofFor( + "Number.prototype.toLocaleString = patch; const a = new Array(1, 2); a.toLocaleString();", + "toLocaleString", + ), + ).toEqual([false]); +}); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.ts b/src/domain/javascript/javascriptSemanticArrayOperations.ts index 8dd1dff975a..7d66303d49a 100644 --- a/src/domain/javascript/javascriptSemanticArrayOperations.ts +++ b/src/domain/javascript/javascriptSemanticArrayOperations.ts @@ -26,9 +26,18 @@ const PRISTINE_ARRAY_METHODS = new Set([ "pop", "shift", "unshift", + "includes", + "indexOf", + "lastIndexOf", + "at", + "join", + "toString", + "keys", + "values", + "entries", ]); -/** These native mutations have no callbacks on pristine arrays with primitive arguments. */ +/** These native operations have no callbacks on pristine arrays with primitive arguments. */ export const isIntrinsicNonEscapingArrayCall = ( node: t.CallExpression | t.OptionalCallExpression, state: JavaScriptSemanticAnalysisState, @@ -40,7 +49,8 @@ export const isIntrinsicNonEscapingArrayCall = ( !PRISTINE_ARRAY_METHODS.has( semanticStaticPropertyName(callee.property, callee.computed) ?? "", ) || - // Some methods return this; no returned-reference proof is introduced here. + // Some results retain this (including iterators); no returned-reference + // proof is introduced even for methods whose result is primitive. !t.isExpressionStatement(state.parentsByNode.get(node)) ) return false; From 9b39943e371151adf6e1f50a83cad06f09279ad4 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 17:10:09 +0800 Subject: [PATCH 27/28] fix(javascript): preserve Array iteration read timing --- .../javascript/javascriptSemanticAnalysis.ts | 2 +- ...scriptSemanticArrayIterationTiming.test.ts | 414 ++++++++++++++++++ .../javascriptSemanticArrayOperations.test.ts | 66 ++- .../javascriptSemanticArrayOperations.ts | 34 +- .../javascriptSemanticMemberMutations.ts | 56 ++- .../javascriptSemanticMutationInitializers.ts | 5 +- .../javascriptSemanticReferenceSources.ts | 16 +- .../javascriptSemanticReturnedReferences.ts | 7 +- .../javascript/javascriptSemanticState.ts | 3 +- 9 files changed, 547 insertions(+), 56 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticArrayIterationTiming.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 8d4015e845d..de8c9dd5b9c 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1164,7 +1164,7 @@ const createState = ( bindings: new Map(), }; return { - arrayIterationUnknown: false, + arrayIterationEffects: [], memberMutationFactsComplete: false, program, opaqueEffects: { diff --git a/src/domain/javascript/javascriptSemanticArrayIterationTiming.test.ts b/src/domain/javascript/javascriptSemanticArrayIterationTiming.test.ts new file mode 100644 index 00000000000..aa1cb23c3d5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArrayIterationTiming.test.ts @@ -0,0 +1,414 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticKnownArrayIteration, + semanticKnownArrayIterationAt, + semanticReferenceIterationUnknown, +} from "./javascriptSemanticReferenceSources.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js"; + +const indexFor = (source: string, collect = true) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected iteration timing fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + if (collect) completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const calls: t.CallExpression[] = []; + const spreads: t.SpreadElement[] = []; + const captures: t.Node[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) calls.push(node); + if (t.isSpreadElement(node)) spreads.push(node); + if (t.isForOfStatement(node)) captures.push(node.right); + if ( + t.isVariableDeclarator(node) && + t.isArrayPattern(node.id) && + node.init != null + ) + captures.push(node.init); + }, + }); + return { index, calls, spreads, captures }; +}; + +const endpointValues = (source: string) => + analyzeJavaScriptStaticSource(source).endpoints.map(({ value }) => value); +const allocation = "const urls=['/api'];"; +const capture = "const [url]=urls;"; +const fetchCaptured = "fetch(url);"; +const emptyArray: JavaScriptSemanticValue = { + status: "array", + items: [], + unknownItems: false, + omittedItems: 0, +}; + +it.each([ + "consume(new Array(3));", + "consume(Array.prototype);", + "consume(Object.prototype);", + "consume([].__proto__);", + "consume([].constructor.prototype);", + "Array.prototype[Symbol.iterator]=patch;", + "Object.prototype[0]=patch;", + "Array.prototype.constructor.prototype[Symbol.iterator]=patch;", +])("orders prototype effects at the actual capture: %s", (effect) => { + expect(endpointValues(allocation + capture + effect + fetchCaptured)).toEqual( + ["/api"], + ); + expect( + endpointValues(allocation + effect + capture + fetchCaptured), + ).not.toContain("/api"); + expect( + endpointValues(effect + allocation + capture + fetchCaptured), + ).not.toContain("/api"); +}); + +it.each([ + "function mutate(){Array.prototype[Symbol.iterator]=patch;}", + "function mutate(){consume(new Array(3));}", + "if(flag){Array.prototype[Symbol.iterator]=patch;}", + "with(scope){Array.prototype[Symbol.iterator]=patch;}", + "const Array=local;with(scope){consume(Array.prototype);}", +])("retains preceding or unordered effects: %s", (effect) => { + expect( + endpointValues(allocation + effect + capture + fetchCaptured), + ).not.toContain("/api"); +}); + +it.each([ + "Array=other;", + "globalThis.Array=other;", + "consume(Array);", + "Array.prototype.fill=patch;", + "Array.prototype.constructor.prototype.fill=patch;", + "consume(Array.prototype.fill);", + "const {...copy}=Array;consume(copy);", + "consume({...Array});", + "const Array={prototype:{}};consume(Array.prototype);", + "function mutate(Array){Array.prototype[Symbol.iterator]=patch;}", + "[].__proto__=patch;", + "const globalThis={Array:{prototype:{}}};consume(globalThis.Array.prototype);", +])("does not widen the original prototype-effect domain: %s", (effect) => { + expect(endpointValues(effect + allocation + capture + fetchCaptured)).toEqual( + ["/api"], + ); +}); + +it.each([ + "consume(Array[key]);", + "Array.prototype[key]=patch;", + "Array.prototype[0]=patch;", + "Array.prototype.__proto__=patch;", + "Array.prototype.constructor.prototype.constructor.prototype[0]=patch;", + "consume({...Array.prototype});", + "const {...copy}=Array.prototype;consume(copy);", + "consume({...([].__proto__)});", + "consume({...([].constructor.prototype)});", +])( + "retains unknown, numeric, inherited, and copied prototype effects: %s", + (effect) => { + expect( + endpointValues(effect + allocation + capture + fetchCaptured), + ).not.toContain("/api"); + }, +); + +it("keeps own iterator writes ordered around destructuring capture", () => { + const effect = "urls[Symbol.iterator]=patch;"; + expect(endpointValues(allocation + capture + effect + fetchCaptured)).toEqual( + ["/api"], + ); + expect( + endpointValues(allocation + effect + capture + fetchCaptured), + ).not.toContain("/api"); +}); + +it.each([ + ["consume(...urls,(Array.prototype[Symbol.iterator]=patch,0));", true], + ["consume((Array.prototype[Symbol.iterator]=patch,0),...urls);", false], + ["consume(...urls);Array.prototype[Symbol.iterator]=patch;", true], + ["Array.prototype[Symbol.iterator]=patch;consume(...urls);", false], +] as const)("uses the original spread operand: %s", (body, known) => { + const { index, spreads } = indexFor(allocation + body); + expect(spreads).toHaveLength(1); + expect( + semanticKnownArrayIterationAt(spreads[0]!.argument, index.bindings), + ).toBe(known); +}); + +it("orders one prototype effect independently between two argument reads", () => { + const { index, spreads } = indexFor( + "const left=['/left'];const right=['/right'];consume(...left,(Array.prototype[Symbol.iterator]=patch,0),...right);", + ); + expect( + spreads.map(({ argument }) => + semanticKnownArrayIterationAt(argument, index.bindings), + ), + ).toEqual([true, false]); +}); + +it.each(["const [url]=urls;", "const [...rest]=urls;"])( + "checks actual iteration after allocation for %s", + (read) => { + for (const before of [false, true]) { + const effect = "consume(new Array(3));"; + const { index, captures } = indexFor( + allocation + (before ? effect + read : read + effect), + ); + expect(captures).toHaveLength(1); + expect( + semanticReferenceIterationUnknown( + { iterationSources: [{ node: captures[0]!, projection: [] }] }, + index.bindings, + ), + ).toBe(before); + } + }, +); + +it("does not treat unknown array items as an iterator mutation, including empty arrays", () => { + const { index } = indexFor("const value=[];"); + const read = index.bindings.program.body[0]!; + const unknownItems: JavaScriptSemanticValue = { + ...emptyArray, + status: "array", + items: [], + unknownItems: true, + omittedItems: null, + }; + expect(semanticKnownArrayIteration(emptyArray, index.bindings, read)).toBe( + true, + ); + expect(semanticKnownArrayIteration(unknownItems, index.bindings, read)).toBe( + true, + ); + index.bindings.arrayIterationEffects.push(undefined); + expect(semanticKnownArrayIteration(emptyArray, index.bindings, read)).toBe( + false, + ); + expect(semanticKnownArrayIteration(unknownItems, index.bindings, read)).toBe( + false, + ); +}); + +it.each([ + "(flag?[]:[]).__proto__", + "(flag&&[]).__proto__", + "(0,[]).constructor.prototype", + "{nested:(flag?[]:[]).__proto__}", +])( + "replays cached literal prototype leaves with each original call site: %s", + (result) => { + const { index, calls } = indexFor( + `function source(){return ${result};} consume(source());consume(source());`, + ); + const mutations = calls.filter((call) => + t.isIdentifier(call.callee, { name: "source" }), + ); + expect(mutations).toHaveLength(2); + expect(index.bindings.arrayIterationEffects).toEqual(mutations); + const original = [...index.bindings.arrayIterationEffects]; + completeSemanticSteps( + collectSemanticMemberMutationsSteps( + index.bindings.program, + index.bindings, + index.referenceMayHaveChangedAt, + ), + ); + expect(index.bindings.arrayIterationEffects).toEqual(original); + }, +); + +it("invalidates returned-reference results when iteration effects are discovered", () => { + const { index, calls } = indexFor( + "consume(new Array(3));function first(){return a;}function second(){return b;}const values=[first,second];const selected=[...values][0];selected();", + false, + ); + const invocation = calls.find((call) => + t.isIdentifier(call.callee, { name: "selected" }), + ); + if (invocation === undefined) throw new Error("Expected selected invocation"); + const returnedReferences = createSemanticReturnedReferences(index.bindings); + const names = () => + [...returnedReferences(invocation.callee, invocation).callables].map( + (node) => (t.isFunctionDeclaration(node) ? node.id?.name : undefined), + ); + expect(names()).toEqual(["first"]); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + expect(index.bindings.arrayIterationEffects.length).toBeGreaterThan(0); + expect(names()).toEqual(["first", "second"]); +}); + +it("checks a copied callable at its spread capture rather than its array initializer", () => { + const source = + "function first(){return a;}function second(){return b;}const values=[first,second];"; + for (const before of [false, true]) { + const effect = "consume(new Array(3));"; + const copy = "const selected=[...values][0];"; + const { index, calls } = indexFor( + source + (before ? effect + copy : copy + effect) + "selected();", + ); + const invocation = calls.find((call) => + t.isIdentifier(call.callee, { name: "selected" }), + ); + if (invocation === undefined) + throw new Error("Expected selected invocation"); + const result = createSemanticReturnedReferences(index.bindings)( + invocation.callee, + invocation, + ); + expect( + [...result.callables].map((node) => + t.isFunctionDeclaration(node) ? node.id?.name : undefined, + ), + ).toEqual(before ? ["first", "second"] : ["first"]); + } +}); + +it.each([ + ["for(const url of urls){}", false], + ["for(const url of urls){}consume(new Array(3));", false], + ["consume(new Array(3));for(const url of urls){}", true], +] as const)( + "orders effects around top-level for-of entry: %s", + (body, unknown) => { + const { index, captures } = indexFor(allocation + body); + expect( + semanticReferenceIterationUnknown( + { iterationSources: [{ node: captures[0]!, projection: [] }] }, + index.bindings, + ), + ).toBe(unknown); + }, +); + +it("orders shared iteration facts independently for two reads of the same operand", () => { + const { index, spreads } = indexFor( + allocation + + "consume(...urls,(Array.prototype[Symbol.iterator]=patch,0),...urls);", + ); + // Isolate the iteration owner: the general value owner conservatively loses + // this binding's shape when the second spread also exposes its root. + expect( + spreads.map(({ argument }) => + semanticKnownArrayIteration(emptyArray, index.bindings, argument), + ), + ).toEqual([true, false]); +}); + +it("invalidates a warm returned-reference cache on the iteration revision alone", () => { + const { index, calls } = indexFor( + "function first(){return a;}function second(){return b;}const values=[first,second];const selected=[...values][0];selected();", + ); + const invocation = calls.find((call) => + t.isIdentifier(call.callee, { name: "selected" }), + ); + if (invocation === undefined) throw new Error("Expected selected invocation"); + const returnedReferences = createSemanticReturnedReferences(index.bindings); + const names = () => + [...returnedReferences(invocation.callee, invocation).callables].map( + (node) => (t.isFunctionDeclaration(node) ? node.id?.name : undefined), + ); + const revisions = () => [ + index.bindings.ambientWriteEffects.length, + index.bindings.ambientEscapeEffects.length, + ...[...index.bindings.bindingsById.values()].flatMap((binding) => [ + binding.memberWrites.length, + binding.escapedPaths.length, + ]), + ]; + expect(names()).toEqual(["first"]); + const unchanged = revisions(); + index.bindings.arrayIterationEffects.push(undefined); + expect(revisions()).toEqual(unchanged); + expect(names()).toEqual(["first", "second"]); +}); + +it.each([ + ["['/api']", "source[0]"], + ["[{url:'/api'}]", "source[0].url"], +])( + "does not escape an earlier for-of source after a later prototype effect: %s", + (value, selected) => { + const create = `const source=${value};`; + const loop = "for(const item of source){}"; + const effect = "consume(new Array(3));"; + expect( + endpointValues(create + loop + `fetch(${selected});` + effect), + ).toEqual(["/api"]); + expect( + endpointValues(create + effect + loop + `fetch(${selected});`), + ).not.toContain("/api"); + }, +); + +it.each([ + "for(const item of (consume(new Array(3)),source)){}", + "for(const item of (Array.prototype[Symbol.iterator]=patch,source)){}", +])("retains effects inside the for-of iterable expression: %s", (loop) => { + const { index, captures } = indexFor("const source=['/api'];" + loop); + expect(index.bindings.arrayIterationEffects.length).toBeGreaterThan(0); + expect( + index.bindings.arrayIterationEffects.every( + (effect) => + semanticEffectTiming(captures[0]!, effect, index.bindings) !== "after", + ), + ).toBe(true); + expect( + semanticKnownArrayIteration(emptyArray, index.bindings, captures[0]!), + ).toBe(false); +}); + +it.each([ + "for(const read of source){}", + "for(const item of source){read;}", + "while(flag){for(const item of read){}}", + "for(;;){for(const item of read){}}", + "function run(){for(const item of read){}}", + "for(const key in read){}", + "class Example{value=read;}", +])( + "does not extend the for-of entry proof to repeated or delayed reads: %s", + (body) => { + const { index, calls } = indexFor( + "const source=['/api'];" + body + "consume(new Array(3));", + ); + let read: t.Identifier | undefined; + traverseJavaScriptAst(index.bindings.program, { + enter(node) { + if (t.isIdentifier(node, { name: "read" })) read = node; + }, + }); + const effect = calls.find((node) => + t.isIdentifier(node.callee, { name: "consume" }), + ); + if (read === undefined || effect === undefined) + throw new Error("Expected read and effect"); + expect(semanticEffectTiming(read, effect, index.bindings)).toBe("unknown"); + }, +); + +it("keeps missing source ownership and missing effects unordered", () => { + const { index, calls } = indexFor("consume(new Array(3));"); + expect( + semanticEffectTiming(t.identifier("unowned"), calls[0], index.bindings), + ).toBe("unknown"); + expect( + semanticEffectTiming(index.bindings.program, undefined, index.bindings), + ).toBe("unknown"); +}); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts index 4163fc9a3af..bc9138dfe62 100644 --- a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts +++ b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts @@ -143,20 +143,36 @@ it.each([ "at(0)", "join(',')", "toString()", + "push(0)", + "pop()", + "shift()", + "unshift(0)", +])("permits only proven primitive results to be consumed: %s", (call) => { + const method = call.slice(0, call.indexOf("(")); + for (const use of [`const result = a.${call};`, `consume(a.${call});`]) { + const source = `const a = new Array(3); ${use} const urls=['/api']; const [url]=urls; fetch(url);`; + expect(proofFor(source, method)).toEqual([true]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); + } +}); + +it.each([ + "fill(0)", + "copyWithin(0, 1)", + "reverse()", "keys()", "values()", "entries()", -])( - "keeps read-result consumption outside the pristine receiver contract: %s", - (call) => { - expect( - proofFor( - `const a = new Array(3); consume(a.${call});`, - call.slice(0, call.indexOf("(")), - ), - ).toEqual([false]); - }, -); +])("keeps retained receiver results discarded-only: %s", (call) => { + expect( + proofFor( + `const a = new Array(3); consume(a.${call});`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); +}); it.each([ "new Array(3).includes(0);", @@ -264,3 +280,31 @@ it("does not exempt locale conversion through mutable element prototypes", () => ), ).toEqual([false]); }); + +it.each([ + "const a = new Array(object, 0); const result = a.pop();", + "const a = new Array(3); a[0] = object; const result = a.at(0);", + "const a = new Array(3); a.fill(object); const result = a.shift();", + "const a = new Array(3); consume(a); const result = a.pop();", + "Object.defineProperty(Array.prototype, '0', {get() { return object; }}); const a = new Array(3); const result = a.at(0);", + "const a = new Array(3); const result = a.includes(0, (eval(code) ? 0 : 0));", + "const a = new Array(3); const result = a.includes(0, ((Array.prototype[0] = object) ? 0 : 0));", +])( + "does not certify consumed results beyond the existing primitive/pristine facts: %s", + (source) => { + const method = /result = a\.(\w+)/u.exec(source)?.[1]; + expect(proofFor(source, method)).toEqual([false]); + }, +); + +it.each(["at(0)", "pop()", "shift()"])( + "keeps primitive results after admitted slot mutations: %s", + (call) => { + const method = call.slice(0, call.indexOf("(")); + const source = `const a = new Array(3); a.fill('/first'); a.copyWithin(1, 0); a.reverse(); a.push('/last'); a.unshift('/start'); const result = a.${call}; const urls=['/api']; const [url]=urls; fetch(url);`; + expect(proofFor(source, method).at(-1)).toBe(true); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.ts b/src/domain/javascript/javascriptSemanticArrayOperations.ts index 7d66303d49a..35f43397059 100644 --- a/src/domain/javascript/javascriptSemanticArrayOperations.ts +++ b/src/domain/javascript/javascriptSemanticArrayOperations.ts @@ -18,20 +18,23 @@ import type { JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -const PRISTINE_ARRAY_METHODS = new Set([ - "fill", - "copyWithin", - "reverse", - "push", - "pop", - "shift", - "unshift", +// Primitive-only construction and prior admitted mutations cannot add object slots. +const PRISTINE_ARRAY_PRIMITIVE_RESULTS = new Set([ "includes", "indexOf", "lastIndexOf", "at", "join", "toString", + "push", + "pop", + "shift", + "unshift", +]); +const PRISTINE_ARRAY_RETAINED_RESULTS = new Set([ + "fill", + "copyWithin", + "reverse", "keys", "values", "entries", @@ -44,14 +47,15 @@ export const isIntrinsicNonEscapingArrayCall = ( index?: Pick, ): boolean | undefined => { const callee = unwrapJavaScriptExpression(node.callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const method = + semanticStaticPropertyName(callee.property, callee.computed) ?? ""; if ( - (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) || - !PRISTINE_ARRAY_METHODS.has( - semanticStaticPropertyName(callee.property, callee.computed) ?? "", - ) || - // Some results retain this (including iterators); no returned-reference - // proof is introduced even for methods whose result is primitive. - !t.isExpressionStatement(state.parentsByNode.get(node)) + !PRISTINE_ARRAY_PRIMITIVE_RESULTS.has(method) && + (!PRISTINE_ARRAY_RETAINED_RESULTS.has(method) || + // Receiver-returning methods and iterators retain the array itself. + !t.isExpressionStatement(state.parentsByNode.get(node))) ) return false; if (!semanticPrimitiveArguments(node.arguments, state)) return false; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index ecc1b55565a..40c933fb0c7 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -125,6 +125,20 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => { ); }; +/** A literal's inherited reference can expose the native Array prototype. */ +const arrayLiteralIterationMayChange = ( + node: t.Node, + path: PropertyPath, + effect: ValueEffect, +): boolean => + t.isArrayExpression(node) && + ((path[0] === "__proto__" && + (effect === "escape" || path.length > 1) && + arrayPrototypeIterationMayChange(path.slice(1))) || + (path[0] === "constructor" && + path[1] === "prototype" && + arrayPrototypeIterationMayChange(path.slice(2)))); + /** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */ /** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */ @@ -495,6 +509,14 @@ export function* collectSemanticMemberMutationsSteps( const observablePaths = observableMutationPaths(state); const recordedRequiredEffects = new Set(); let recordedEffectCount = 0; + const iterationEffects = new Set(state.arrayIterationEffects); + const recordArrayIterationEffect = (mutation: t.Node | undefined): void => { + if (iterationEffects.has(mutation)) return; + iterationEffects.add(mutation); + state.arrayIterationEffects.push(mutation); + recordedEffectCount += 1; + clearSemanticValueCaches(state); + }; const recordRequiredEffect = ( specifier: string, path: PropertyPath, @@ -603,7 +625,6 @@ export function* collectSemanticMemberMutationsSteps( recordRequiredEffect("node:process", path.slice(2), "write", node); const selections = new Map(); const coveringPaths = new Map(); - state.arrayIterationUnknown = false; // Definition collection finishes before this phase, so a scope's bindings no // longer change and one node resolves to one binding. Repeated visits of the // same node otherwise repeat the same scope walk. @@ -892,6 +913,8 @@ export function* collectSemanticMemberMutationsSteps( ) continue; const expression = unwrapJavaScriptExpression(current.node).node; + if (arrayLiteralIterationMayChange(expression, current.path, effect)) + recordArrayIterationEffect(mutation); const moduleEffectOrigin = semanticRequireOriginCandidate(expression, state, true)?.origin ?? semanticModuleAcquisitionEffectOrigin( @@ -958,7 +981,7 @@ export function* collectSemanticMemberMutationsSteps( (current.path[1] === "prototype" || current.path[1] === null) && arrayPrototypeIterationMayChange(current.path.slice(2))) ) - state.arrayIterationUnknown = true; + recordArrayIterationEffect(mutation); if (lookup?.kind !== "uncertain-binding") continue; binding = lookup.binding; } @@ -1460,16 +1483,6 @@ export function* collectSemanticMemberMutationsSteps( effect: ValueEffect, widenedGetterResult = false, ): readonly ReferencedValue[] { - if ( - t.isArrayExpression(expression) && - ((path[0] === "__proto__" && - (effect === "escape" || path.length > 1) && - arrayPrototypeIterationMayChange(path.slice(1))) || - (path[0] === "constructor" && - path[1] === "prototype" && - arrayPrototypeIterationMayChange(path.slice(2)))) - ) - state.arrayIterationUnknown = true; const references = referencedValues( expression, path, @@ -1521,7 +1534,8 @@ export function* collectSemanticMemberMutationsSteps( if ( value.iterableFallbackPath !== undefined || t.isMemberExpression(node) || - t.isOptionalMemberExpression(node) + t.isOptionalMemberExpression(node) || + arrayLiteralIterationMayChange(node, value.path, "escape") ) { leaves.push(value); return true; @@ -1758,7 +1772,6 @@ export function* collectSemanticMemberMutationsSteps( ) { const effectsBefore = recordedEffectCount; const iterablesBefore = iterableReferences.length; - const iterationBefore: boolean = state.arrayIterationUnknown; for (const [index, reference] of pendingReferences.splice(0).entries()) { if (index % 64 === 0) yield; const { initializer } = reference; @@ -1838,10 +1851,18 @@ export function* collectSemanticMemberMutationsSteps( continue; } } - const slot = semanticSelectedReferenceSlot(reference, state); + const slot = semanticSelectedReferenceSlot( + reference, + state, + reference.node, + ); // Custom iteration can yield the iterable itself as well as any child. // Treat its root as escaped, including writes through yielded references. - const knownArray = semanticKnownArrayIteration(slot?.value, state); + const knownArray = semanticKnownArrayIteration( + slot?.value, + state, + reference.node, + ); if (knownArray && reference.iterationOnly) continue; markValue( reference.node, @@ -1870,8 +1891,7 @@ export function* collectSemanticMemberMutationsSteps( } if ( recordedEffectCount === effectsBefore && - iterableReferences.length === iterablesBefore && - state.arrayIterationUnknown === iterationBefore + iterableReferences.length === iterablesBefore ) break; } diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index cdc464c2003..14f411c3c8b 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -165,7 +165,7 @@ const positionPrecedes = ( left.declaratorIndex >= 0 && left.declaratorIndex < right.declaratorIndex)); -/** Statement position of a node evaluated once by the program, outside loops and functions. */ +/** Statement position of a node evaluated once by the program, outside repeated execution. */ const computeTopLevelStatementPosition = ( node: t.Node | undefined, parents: WeakMap, @@ -178,7 +178,8 @@ const computeTopLevelStatementPosition = ( (t.isFunction(node) && (child === undefined || !semanticMethodDefinitionExpression(node, child))) || - t.isLoop(node) + // The iterable is selected once on entry; an outer loop still repeats it. + (t.isLoop(node) && !(t.isForOfStatement(node) && child === node.right)) ) return null; if (delayedInstanceFieldValue(node, child)) return null; diff --git a/src/domain/javascript/javascriptSemanticReferenceSources.ts b/src/domain/javascript/javascriptSemanticReferenceSources.ts index 3a97d3a1570..f2f7c99ef0b 100644 --- a/src/domain/javascript/javascriptSemanticReferenceSources.ts +++ b/src/domain/javascript/javascriptSemanticReferenceSources.ts @@ -42,14 +42,17 @@ interface ReferenceSource { readonly iterationSources?: readonly ReferenceSource[]; } -/** Native array iteration preserves numeric slots only while its iterator is intact. */ +/** Native array iteration preserves numeric slots only while its iterator is intact at the read. */ export const semanticKnownArrayIteration = ( value: JavaScriptSemanticValue | undefined, state: JavaScriptSemanticAnalysisState, + read: t.Node, ): boolean => - !state.arrayIterationUnknown && value?.status === "array" && - value.items.every((item) => semanticArrayIndex(item.name) !== null); + value.items.every((item) => semanticArrayIndex(item.name) !== null) && + !state.arrayIterationEffects.some( + (effect) => semanticEffectTiming(read, effect, state) !== "after", + ); /** Invocation spreads use the same native-iteration proof at their source read. */ export const semanticKnownArrayIterationAt = ( @@ -59,6 +62,7 @@ export const semanticKnownArrayIterationAt = ( semanticKnownArrayIteration( evaluateSemanticExpression(node, state, node), state, + node, ); /** Numeric destructuring projections do not select slots of custom iterators. */ @@ -84,7 +88,7 @@ const selectedReferenceIterationKnown = ( // inventory can prove every possible nested iterable without treating // a computed object property or custom iterator as an own-index read. if ( - !semanticKnownArrayIteration(value, state) || + !semanticKnownArrayIteration(value, state, source.node) || value.status !== "array" || value.unknownItems ) @@ -101,7 +105,9 @@ const selectedReferenceIterationKnown = ( } values = [...selected]; } - return values.every((value) => semanticKnownArrayIteration(value, state)); + return values.every((value) => + semanticKnownArrayIteration(value, state, source.node), + ); }; /** Exclude references whose source slot or destructuring fallback is absent. */ diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index 4a3e93cb006..86511546bac 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -107,7 +107,7 @@ export const createSemanticReturnedReferences = ( { readonly result: SemanticReturnedReferenceResult; readonly invocation: t.Node | undefined; - readonly iterationUnknown: boolean; + readonly iterationEffects: number; readonly ambientWrites: number; readonly dependencies: readonly { readonly binding: JavaScriptSemanticBindingState; @@ -122,7 +122,7 @@ export const createSemanticReturnedReferences = ( if ( existing !== undefined && existing.invocation === invocation && - existing.iterationUnknown === state.arrayIterationUnknown && + existing.iterationEffects === state.arrayIterationEffects.length && existing.ambientWrites === state.ambientWriteEffects.length && existing.dependencies.every( ({ binding, writes, escapes }) => @@ -525,6 +525,7 @@ export const createSemanticReturnedReferences = ( source.capturedSelectionAt, )?.value, state, + source.capturedSelectionAt, ) ) projection = [null, ...source.path.slice(1)]; @@ -688,7 +689,7 @@ export const createSemanticReturnedReferences = ( results.set(callee, { result, invocation, - iterationUnknown: state.arrayIterationUnknown, + iterationEffects: state.arrayIterationEffects.length, ambientWrites: state.ambientWriteEffects.length, dependencies: [...dependencies].map(([binding, counts]) => ({ binding, diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 408ccc0e217..ba8325fdf5d 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -149,7 +149,8 @@ export type SemanticPrototypeOwner = (typeof semanticPrototypeOwners)[number]; /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { - arrayIterationUnknown: boolean; + /** Append-only sites classified by the native-array iteration effect owner. */ + readonly arrayIterationEffects: (t.Node | undefined)[]; /** Receiver isolation requires the completed member-effect inventory. */ memberMutationFactsComplete: boolean; readonly program: t.Program; From be61d5c84543bf88ce7e1179c740c4000f77afe9 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Tue, 10 Nov 2026 20:10:42 +0800 Subject: [PATCH 28/28] fix(javascript): preserve native inspection and discarded-result proofs --- .../javascript/javascriptAstValues.test.ts | 97 +++++ src/domain/javascript/javascriptAstValues.ts | 59 +++ .../javascriptSemanticAmbientWrites.ts | 61 +++- .../javascriptSemanticAnalysis.calls.test.ts | 66 ++++ .../javascript/javascriptSemanticAnalysis.ts | 8 +- ...ascriptSemanticArgumentInspections.test.ts | 338 ++++++++++++++++++ .../javascriptSemanticArgumentInspections.ts | 136 +++++++ .../javascriptSemanticArrayOperations.test.ts | 39 ++ .../javascriptSemanticArrayOperations.ts | 3 +- .../javascript/javascriptSemanticCalls.ts | 33 +- .../javascriptSemanticMapOperations.ts | 3 +- .../javascriptSemanticMemberMutations.ts | 31 +- ...javascriptSemanticObjectOperations.test.ts | 42 +++ .../javascriptSemanticObjectOperations.ts | 3 +- src/domain/webBundleAnalyzer.test.ts | 92 +++++ 15 files changed, 967 insertions(+), 44 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticArgumentInspections.test.ts create mode 100644 src/domain/javascript/javascriptSemanticArgumentInspections.ts diff --git a/src/domain/javascript/javascriptAstValues.test.ts b/src/domain/javascript/javascriptAstValues.test.ts index 8ff1052439f..e24d0ee5787 100644 --- a/src/domain/javascript/javascriptAstValues.test.ts +++ b/src/domain/javascript/javascriptAstValues.test.ts @@ -2,6 +2,7 @@ import * as t from "@babel/types"; import { expect, it } from "vitest"; import { + javaScriptExpressionResultIsUsed, semanticMemberChain, semanticStaticPropertyKey, staticObjectProperty, @@ -10,6 +11,102 @@ import { staticObjectPropertyNames, } from "./javascriptAstValues.js"; +it("keeps missing and mismatched result-parent edges conservative", () => { + const call = t.callExpression(t.identifier("invoke"), []); + const other = t.numericLiteral(0); + const parents = new WeakMap(); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); + for (const parent of [ + t.expressionStatement(other), + t.unaryExpression("void", other), + t.sequenceExpression([other]), + t.parenthesizedExpression(other), + t.tsAsExpression(other, t.tsUnknownKeyword()), + t.conditionalExpression(other, other, other), + t.logicalExpression("&&", other, other), + t.forStatement(other, null, other, t.emptyStatement()), + ]) { + parents.set(call, parent); + if (t.isExpression(parent)) + parents.set(parent, t.expressionStatement(parent)); + expect(javaScriptExpressionResultIsUsed(call, parents), parent.type).toBe( + true, + ); + } +}); + +it("follows an explicit grouping wrapper through a final comma operand", () => { + const call = t.callExpression(t.identifier("invoke"), []); + const wrapper = t.parenthesizedExpression(call); + const sequence = t.sequenceExpression([t.numericLiteral(0), wrapper]); + const parents = new WeakMap([ + [call, wrapper], + [wrapper, sequence], + ]); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); + parents.set(sequence, t.expressionStatement(sequence)); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(false); + parents.set(sequence, t.callExpression(t.identifier("consume"), [sequence])); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); + parents.delete(sequence); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); +}); + +it.each([128, 256, 512])( + "indexes comma membership once for %i sibling calls per parent map", + (count) => { + const calls = Array.from({ length: count }, () => + t.callExpression(t.identifier("invoke"), []), + ); + const sequence = t.sequenceExpression(calls); + let memberReads = 0; + sequence.expressions = new Proxy(sequence.expressions, { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/u.test(key)) memberReads += 1; + return Reflect.get(target, key, receiver); + }, + }); + const parentMap = () => + new WeakMap([ + ...calls.map((call): [t.Node, t.Node] => [call, sequence]), + [sequence, t.expressionStatement(sequence)], + ]); + const parents = parentMap(); + for (const call of calls) + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(false); + expect(memberReads).toBeLessThanOrEqual(count * 2); + const firstReads = memberReads; + for (const call of calls) + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(false); + expect(memberReads - firstReads).toBeLessThanOrEqual(count); + const repeatedReads = memberReads; + const independentParents = parentMap(); + for (const call of calls) + expect(javaScriptExpressionResultIsUsed(call, independentParents)).toBe( + false, + ); + // A different analysis owns its own membership, even for the same AST. + expect(memberReads - repeatedReads).toBe(firstReads); + }, +); + +it("keeps sequence membership isolated and rejects mismatches after indexing", () => { + const first = t.callExpression(t.identifier("first"), []); + const second = t.callExpression(t.identifier("second"), []); + const firstSequence = t.sequenceExpression([first, t.numericLiteral(0)]); + const secondSequence = t.sequenceExpression([second, t.numericLiteral(0)]); + const parents = new WeakMap([ + [first, firstSequence], + [second, secondSequence], + ]); + expect(javaScriptExpressionResultIsUsed(first, parents)).toBe(false); + expect(javaScriptExpressionResultIsUsed(second, parents)).toBe(false); + parents.set(first, secondSequence); + expect(javaScriptExpressionResultIsUsed(first, parents)).toBe(true); + parents.delete(first); + expect(javaScriptExpressionResultIsUsed(first, parents)).toBe(true); +}); + it("keeps string spread indices separate from named descriptor fields", () => { for (const text of ["", "x", "😀", "x".repeat(1_000_000)]) { const original = t.objectProperty( diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 2f26a7ecbaf..ef49cf3f2a4 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -50,6 +50,65 @@ export const javaScriptExpressionWrapperTarget = ( ? node.expression : undefined; +// Parent facts and sequence children belong to one immutable parsed tree. Retain +// only child membership; result use still follows the current outer parent edges. +const sequenceMembersByParents = new WeakMap< + Pick, "get">, + WeakMap> +>(); + +/** Follow only transparent result edges to an explicit value-discarding context. */ +export const javaScriptExpressionResultIsUsed = ( + node: t.Node, + parents: Pick, "get">, +): boolean => { + let child = node; + for ( + let parent = parents.get(child); + parent !== undefined; + parent = parents.get(child) + ) { + if (javaScriptExpressionWrapperTarget(parent) === child) { + child = parent; + continue; + } + if ( + (t.isConditionalExpression(parent) && + (parent.consequent === child || parent.alternate === child)) || + (t.isLogicalExpression(parent) && parent.right === child) + ) { + child = parent; + continue; + } + if (t.isSequenceExpression(parent)) { + let sequences = sequenceMembersByParents.get(parents); + if (sequences === undefined) { + sequences = new WeakMap(); + sequenceMembersByParents.set(parents, sequences); + } + let members = sequences.get(parent); + if (members === undefined) { + members = new Set(parent.expressions); + sequences.set(parent, members); + } + if (!members.has(child)) return true; + if (parent.expressions.at(-1) !== child) return false; + child = parent; + continue; + } + if (t.isExpressionStatement(parent)) return parent.expression !== child; + if (t.isUnaryExpression(parent, { operator: "void" })) + return parent.argument !== child; + // for/of and for/in heads use their expressions. A C-style for initializer + // or update discards the value, but a variable initializer still stores it. + return !( + t.isForStatement(parent) && + (parent.init === child || parent.update === child) + ); + } + return true; +}; + /** * Remove wrappers that affect TypeScript or Flow checking but not runtime * expression identity. The returned depth lets bounded evaluators account for diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 89af9bf2fd4..21ff882df5e 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -698,8 +698,15 @@ export function* indexJavaScriptAmbientWritesSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, observeOpaqueInvocation?: (node: t.Node) => void, - observeWriteSite?: (node: t.Node) => void, + options: { + readonly observeWriteSite?: (node: t.Node) => void; + readonly nativeInspectionReceiver?: ( + node: t.CallExpression | t.OptionalCallExpression, + effectsCollected?: boolean, + ) => boolean | undefined; + } = {}, ): Generator { + const { observeWriteSite, nativeInspectionReceiver } = options; const ambientReads = createAmbientPathResolver(state, false); const browserReceiverMethods = new Set(); const storedSites = new WeakSet(); @@ -710,6 +717,7 @@ export function* indexJavaScriptAmbientWritesSteps( node: t.Node; target: t.Node; browserReceiver: boolean; + nativeInspection?: boolean; projection?: AmbientProjection; }[] = []; const stores = new WeakMap< @@ -778,8 +786,12 @@ export function* indexJavaScriptAmbientWritesSteps( (node, invocation) => { observeOpaqueInvocation?.(node); // Escaping objects can change contents without replacing their identity. - const retainEscape = (target: t.Node, browserReceiver = false) => { - escapes.push({ target, node, browserReceiver }); + const retainEscape = ( + target: t.Node, + browserReceiver = false, + nativeInspection = false, + ) => { + escapes.push({ target, node, browserReceiver, nativeInspection }); }; for (const argument of t.isTaggedTemplateExpression(node) ? node.quasi.expressions @@ -797,18 +809,27 @@ export function* indexJavaScriptAmbientWritesSteps( ["open", "fetch"].includes(invocation.path[1] ?? "") ? invocation.path[1] : undefined; + const inspectionReceiver = + nativeInspectionReceiver !== undefined && + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + nativeInspectionReceiver(node) !== false; if ( !t.isNewExpression(node) && (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && - !( - invocation?.kind === "known" && - ["Object", "Reflect"].includes(invocation.path[1] ?? "") - ) + (inspectionReceiver || + !( + invocation?.kind === "known" && + ["Object", "Reflect"].includes(invocation.path[1] ?? "") + )) ) { if (browserMethod !== undefined) browserReceiverMethods.add(browserMethod); - retainEscape(callee.object, browserMethod !== undefined); + retainEscape( + callee.object, + browserMethod !== undefined, + inspectionReceiver, + ); } }, ); @@ -901,7 +922,12 @@ export function* indexJavaScriptAmbientWritesSteps( // if no write or opaque escape can replace its native member before this // invocation; ambiguous execution order remains conservative. const resolverReceivers: typeof escapes = []; + const inspectionReceivers = new Set<(typeof escapes)[number]>(); for (const effect of escapes) { + if (effect.nativeInspection) { + inspectionReceivers.add(effect); + continue; + } const { node, target } = effect; const callee = t.isCallExpression(node) || t.isOptionalCallExpression(node) @@ -979,6 +1005,25 @@ export function* indexJavaScriptAmbientWritesSteps( ) ) retainSemanticAmbientEffect(state, "escape", ["global"], undefined); + // Do not prove a receiver during traversal: a later-declared activation can + // escape its callable first. Settle ordinary effects, then monotonically + // replay failed candidates until no remaining native proof can be revoked. + while (inspectionReceivers.size > 0) { + const effectsBefore = state.ambientEscapeEffects.length; + let examined = 0; + for (const effect of inspectionReceivers) { + if (examined++ % 64 === 0) yield; + const { node } = effect; + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + nativeInspectionReceiver?.(node, true) === true + ) + continue; + inspectionReceivers.delete(effect); + retainEscapeEffect(effect); + } + if (state.ambientEscapeEffects.length === effectsBefore) break; + } } export type GlobalReferenceRead = diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index 0f80b47dcaf..14756e19013 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -13,9 +13,25 @@ describe("JavaScript semantic call result use", () => { 'consume("unique")!;', 'consume("unique") as unknown;', '(consume("unique") as unknown)!;', + 'consume("unique") satisfies unknown;', + 'void consume("unique");', + 'const value = void consume("unique");', + 'const value = (consume("unique"), 0);', + '(0, consume("unique"));', + 'void (0, consume("unique"));', + 'const value = ((0, consume("unique")), 1);', + 'void ((0, consume("unique")) as unknown)!;', + 'flag ? consume("unique") : 0;', + 'flag ? 0 : consume("unique");', + 'flag && consume("unique");', + 'flag || consume("unique");', + 'flag ?? consume("unique");', + 'void ((flag ? (0, consume("unique")) : 0) as unknown)!;', + 'const value = ((flag && consume("unique")), 0);', ])("does not treat %s as a used call result", (source) => { const ir = analyzeJavaScriptSemantics(source); expect(ir.callSites[0]?.resultUsed).toBe(false); + expect(ir.callResultFlows).toEqual([]); }); it("treats an asserted call initializer as a used result", () => { @@ -23,12 +39,62 @@ describe("JavaScript semantic call result use", () => { 'const value = consume("unique")!;', ); expect(used.callSites[0]?.resultUsed).toBe(true); + expect(used.callResultFlows).toMatchObject([ + { + callSiteId: used.callSites[0]?.callSiteId, + bindingId: topLevelBinding(used, "value").bindingId, + }, + ]); + }); + + it.each([ + 'const value = (0, consume("unique"));', + 'void store(consume("unique"));', + 'void (holder.value = consume("unique"));', + 'void consume("unique").value;', + 'void +consume("unique");', + 'function run() { return consume("unique"); }', + 'const run = () => consume("unique");', + 'async function run() { void await consume("unique"); }', + 'function* run() { void (yield consume("unique")); }', + 'for (; consume("unique");) {}', + 'for (const value of consume("unique")) {}', + 'for (const value in consume("unique")) {}', + 'consume("unique") ? 0 : 1;', + 'consume("unique") && 0;', + 'const value = flag ? consume("unique") : 0;', + 'const value = flag && consume("unique");', + 'void (holder.value = flag ? consume("unique") : 0);', + 'void store(flag && consume("unique"));', + 'function run() { return flag ? consume("unique") : 0; }', + 'async function run() { void await (flag && consume("unique")); }', + 'function* run() { void (yield (flag ? 0 : consume("unique"))); }', + ])("preserves a consumed or unsupported result context: %s", (source) => { + const ir = analyzeJavaScriptSemantics(source); + const call = ir.callSites.find((site) => + site.arguments.some((argument) => argument.literal === "unique"), + ); + expect(call?.resultUsed).toBe(true); }); + it.each([ + 'const value = flag ? consume("unique") : 0;', + 'const value = flag && consume("unique");', + ])( + "does not invent a direct assigned-result flow through alternatives: %s", + (source) => { + const ir = analyzeJavaScriptSemantics(source); + expect(ir.callSites[0]?.resultUsed).toBe(true); + expect(ir.callResultFlows).toEqual([]); + }, + ); + it.each([ 'for (notify("token"); running;) {}', 'for (; running; notify("token")) {}', 'for (notify("token")!; running;) {}', + 'for ((0, notify("token")); running;) {}', + 'for (; running; (0, notify("token")!)) {}', ])("does not treat %s as a used call result", (source) => { const ir = analyzeJavaScriptSemantics(source); const call = ir.callSites.find((site) => diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index c0f189f1d05..903cead21b9 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -80,6 +80,7 @@ import { semanticBundledRequireOriginCandidate, } from "./javascriptBundledRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import { createSemanticArgumentInspectionQuery } from "./javascriptSemanticArgumentInspections.js"; import { semanticBindingAtRead, semanticEffectTiming, @@ -220,7 +221,12 @@ function* indexSemanticAmbientWritesSteps( createAmbientPathResolver, createSemanticModuleInterop, }), - (node) => observePossibleCommonJsDelegateWrite(node, state), + { + observeWriteSite: (node) => + observePossibleCommonJsDelegateWrite(node, state), + nativeInspectionReceiver: + createSemanticArgumentInspectionQuery(state).receiver, + }, ); if (state.opaqueEffects.globalSites.length > 0) clearSemanticValueCaches(state); diff --git a/src/domain/javascript/javascriptSemanticArgumentInspections.test.ts b/src/domain/javascript/javascriptSemanticArgumentInspections.test.ts new file mode 100644 index 00000000000..5fa728d4b7e --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArgumentInspections.test.ts @@ -0,0 +1,338 @@ +import { runInNewContext } from "node:vm"; +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { createSemanticArgumentInspectionQuery } from "./javascriptSemanticArgumentInspections.js"; +import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import type { JavaScriptSemanticPrecisionInput } from "./javascriptSemanticPrecision.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const METHODS = [ + "Array.isArray", + "Object.keys", + "Object.values", + "Object.entries", + "Object.getOwnPropertyNames", + "Object.getOwnPropertySymbols", + "Object.getOwnPropertyDescriptors", + "Reflect.ownKeys", +]; +const capture = "const urls=['/api'];const [url]=urls;fetch(url);"; +const endpoints = ( + source: string, + precision?: JavaScriptSemanticPrecisionInput, +) => + analyzeJavaScriptStaticSource(source, precision).endpoints.map( + ({ value }) => value, + ); + +const fixture = ( + source: string, + mutations = true, + precision?: JavaScriptSemanticPrecisionInput, +) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected native-inspection fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps( + parsed.program, + undefined, + precision, + ), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + const calls: (t.CallExpression | t.OptionalCallExpression)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) + calls.push(node); + }, + }); + if (mutations) completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const query = createSemanticArgumentInspectionQuery(index.bindings); + return { index, calls, query }; +}; + +const proof = (source: string) => { + const { index, calls, query } = fixture(source); + const call = calls.at(-1)!; + return query.argument(call, call.arguments[0]!, index); +}; + +it.each(METHODS)("preserves unrelated iteration after native %s", (method) => { + for (const allocation of [ + "new Object()", + "new Array(3)", + "new Array('a', 'b')", + ]) { + for (const source of [ + `${method}(${allocation});`, + `const value=${allocation};const alias=value;void ${method}(alias);`, + ]) { + expect(proof(source)).toBe(true); + expect(endpoints(source + capture)).toEqual(["/api"]); + } + } +}); + +it.each([ + "Array.isArray([]);", + "Object.keys({});", + "new Array(3);", + "const value=new Array(3);globalThis.Array.isArray(value);", + "const value=new Object();Object?.['keys']?.(value);", + "const a=new Array(3);Array.isArray(a);Array.isArray(a);", + "const o=new Object();Object.keys(o);Object.values(o);Object.entries(o);", + "const a=new Array(3);const o=new Object();Array.isArray(a);Object.keys(o);Reflect.ownKeys(a);Object.getOwnPropertyDescriptors(o);", + "const o=new Object();Object.keys(o, 0);", + "const a=new Array(3);const result=Array.isArray(a);consume(result);", + "const o=new Object();Object.keys(o);o.x=1;", + "Array.isArray(new Array(3));Array.isArray=patch;", + "Object.keys(new Object());consume(Object);", + "Object.keys(new Object());Object=Replacement;", +])("retains ordinary controls and read-site ordering: %s", (source) => { + expect(endpoints(source + capture)).toEqual(["/api"]); +}); + +it.each([ + "Array.isArray=patch;Array.isArray(new Array(3));", + "Object.keys=patch;Object.keys(new Object());", + "globalThis.Array.isArray=patch;Array.isArray(new Array(3));", + "consume(Array);Array.isArray(new Array(3));", + "consume(Object);Object.keys(new Object());", + "consume(globalThis);Array.isArray(new Array(3));", + "eval(code);Array.isArray(new Array(3));", + "const a=new Array(3);const alias=a;alias.x=1;Array.isArray(a);", + "const a=new Array(3);consume(a);Array.isArray(a);", + "const o=new Object();Object.setPrototypeOf(o, custom);Object.keys(o);", + "Object.prototype[0]=patch;Object.keys(new Object());", + "Array.prototype[0]=patch;Object.values(new Array(3));", + "const a=new Array(3);Array.isArray(a);consume(a);Array.isArray(a);", + "const o=new Object();Object.keys(o);Object.keys(o,(Object.prototype[0]=patch));", + "const o=new Object();Object.keys(o,(consume(o),0));", + "Object.keys(new Object(),eval(code));", + "Object.keys(new Object(),...args);", + "Array.isArray(...[new Array(3)]);", + "function inspect(){Array.isArray(new Array(3));}function change(){consume(Array);}", + "function inspect(){Object.keys(new Object());}function change(){Object.keys=patch;}", + "function inspect(){Object.keys(new Object());}function change(){consume(new Object());}", +])("keeps prior, unordered and argument-evaluation effects: %s", (source) => { + expect(endpoints(source + capture)).not.toContain("/api"); +}); + +it.each([ + "function inspect(Array){Array.isArray(new Array(3));}", + "Array=Replacement;Array.isArray(new Array(3));", + "Object=Replacement;Object.keys(new Object());", + "Object.keys(new Object(proxy));", + "Object.keys(new Object(null));", + "Object.keys(new Proxy(new Object(), handler));", + "Object.keys(new Array({}));", + "const o={get x(){return value}};Object.values(o);", + "const o={get x(){return value}};Object.keys(o);", + "Object.keys(unknown);", + "Object.keys({});", + "const o=Object();Object.keys(o);", + "let o;if(flag)o=new Object();Object.keys(o);", + "Object.keys(o);const o=new Object();", + "const {o}={o:new Object()};Object.keys(o);", + "const a=b;const b=a;Object.keys(a);", + "Array.isArray.call(null,new Array(3));", + "const check=Array.isArray;check(new Array(3));", + "const check=Array.isArray.bind(null);check(new Array(3));", + "Reflect.apply(Array.isArray,null,[new Array(3)]);", + "Object.getPrototypeOf(new Object());", + "Object.is(new Object());", + "Number.isFinite(new Array(3));", + "const result=Object.keys(new Object());", + "const result=Object.getOwnPropertyDescriptors(new Object());", +])("does not add shape, conversion or invocation authority: %s", (source) => { + expect(proof(source)).toBe(false); +}); + +it("defers both producers and retains source sites through repeated collection", () => { + const source = "const a=new Array(3);Array.isArray(a);Array.isArray(a);"; + const { index, calls, query } = fixture(source, false); + for (const call of calls) { + expect(query.receiver(call)).toBeUndefined(); + expect(query.argument(call, call.arguments[0]!)).toBeUndefined(); + } + expect(index.bindings.ambientEscapeEffects).toEqual([]); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + for (const call of calls) + expect(query.argument(call, call.arguments[0]!, index)).toBe(true); + const effects = [...index.bindings.ambientEscapeEffects]; + completeSemanticSteps( + collectSemanticMemberMutationsSteps( + index.bindings.program, + index.bindings, + index.referenceMayHaveChangedAt, + ), + ); + expect(index.bindings.ambientEscapeEffects).toEqual(effects); + expect(index.bindings.arrayIterationEffects).toEqual([]); +}); + +it("rechecks earlier ambient candidates after a later failed native receiver", () => { + const source = + "function read(){Object.values(new Object());}function inspect(){Object.keys(new Object());}Object.keys=patch;"; + const { index, calls, query } = fixture(source, false); + for (const call of calls) { + expect(query.receiver(call, true)).toBe(false); + expect( + index.bindings.ambientEscapeEffects.some(({ node }) => node === call), + ).toBe(true); + } +}); + +it("rechecks earlier member candidates after a later failed argument", () => { + const source = + "function first(){Array.isArray(new Array(3));}function second(){Object.keys(new Object(proxy));}"; + const { index, calls, query } = fixture(source); + const first = calls[0]!; + expect(query.argument(first, first.arguments[0]!, index)).toBe(false); + expect(index.bindings.arrayIterationEffects).toContain(first); +}); + +it.each(METHODS.filter((method) => method !== "Array.isArray"))( + "retains returned-container escapes from %s", + (method) => { + for (const source of [ + `consume(${method}(new Object()));`, + `const result=${method}(new Object());consume(result);`, + `void (holder.result=${method}(new Object()));`, + `${method}(new Object()).length;`, + `${method}(new Object())&&0;`, + `const result=(0,${method}(new Object()));`, + ]) + expect(endpoints(source + capture)).not.toContain("/api"); + }, +); + +it.each(METHODS.filter((method) => method !== "Array.isArray"))( + "retains return and await argument escapes from %s at their call sites", + (method) => { + for (const source of [ + `function read(){const value=new Object();return ${method}(value);}`, + `async function read(){const value=new Object();return ${method}(value);}`, + `async function read(){const value=new Object();await ${method}(value);}`, + `async function read(){const value=new Object();return await ${method}(value);}`, + `async function read(){const value=new Object();void(await ${method}(value));}`, + ]) { + const { index, calls, query } = fixture(source); + const call = calls[0]!; + const argument = call.arguments[0]!; + // The native callable still keeps its receiver, but retaining/awaiting + // its returned container cannot grant an argument escape exemption. + expect(query.receiver(call, true)).toBe(true); + expect(query.argument(call, argument, index)).toBe(false); + expect(index.bindingForReferenceAt(argument)?.escapedPaths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ node: call, path: [] }), + ]), + ); + expect(index.bindings.arrayIterationEffects).toContain(call); + } + }, +); + +it.each([ + "function read(){const value=new Array(3);return Array.isArray(value);}", + "async function read(){const value=new Array(3);return Array.isArray(value);}", + "async function read(){const value=new Array(3);await Array.isArray(value);}", + "async function read(){const value=new Array(3);return await Array.isArray(value);}", +])( + "permits the primitive inspection result in retained contexts: %s", + (source) => { + const { index, calls, query } = fixture(source); + const call = calls[0]!; + const argument = call.arguments[0]!; + expect(query.argument(call, argument, index)).toBe(true); + expect(index.bindingForReferenceAt(argument)?.escapedPaths).toEqual([]); + expect(index.bindings.arrayIterationEffects).toEqual([]); + }, +); + +it.each(METHODS.filter((method) => method !== "Array.isArray"))( + "uses the shared discarded-result owner for %s", + (method) => { + for (const source of [ + `void ${method}(new Object());`, + `(${method}(new Object()),0);`, + `const value=(${method}(new Object()),0);`, + `flag?${method}(new Object()):0;`, + `flag?0:${method}(new Object());`, + `flag&&${method}(new Object());`, + `flag||${method}(new Object());`, + ]) { + expect(proof(source)).toBe(true); + expect(endpoints(source + capture)).toEqual(["/api"]); + } + }, +); + +it("does not mistake primitive result slots for an unreachable prototype", () => { + const source = "consume(Object.keys(new Object()));" + capture; + const fetched: string[] = []; + runInNewContext( + "function consume(value){value.__proto__[Symbol.iterator]=function*(){yield '/changed'}};" + + source, + { fetch: (value: string) => fetched.push(value) }, + { timeout: 1000 }, + ); + expect(fetched).toEqual(["/changed"]); + expect(endpoints(source)).not.toContain("/api"); +}); + +it("keeps native getter, Proxy and conversion boundaries distinct", () => { + const events: Record = runInNewContext( + `(() => { + let hits=[]; + const value={get x(){hits.push('getter');return 1},[Symbol.toPrimitive](){hits.push('conversion');return 1}}; + const proxy=new Proxy(value,{ + ownKeys(target){hits.push('ownKeys');return Reflect.ownKeys(target)}, + getOwnPropertyDescriptor(target,key){hits.push('descriptor');return Reflect.getOwnPropertyDescriptor(target,key)} + }); + const result={}; + for(const [name,operation,target] of [['array',Array.isArray,proxy],['keys',Object.keys,value],['proxy',Object.keys,proxy],['values',Object.values,value],['number',Number,value]]){ + hits=[];operation(target);result[name]=hits.slice(); + } + return result; + })()`, + {}, + { timeout: 1000 }, + ); + expect(events).toEqual({ + array: [], + keys: [], + proxy: ["ownKeys", "descriptor"], + values: ["getter"], + number: ["conversion"], + }); + for (const operation of [ + "const value={get x(){Array.prototype[Symbol.iterator]=patch}};Object.values(value);", + "const value=new Proxy({}, {ownKeys(){Array.prototype[Symbol.iterator]=patch;return []}});Object.keys(value);", + "Number({valueOf(){Array.prototype[Symbol.iterator]=patch;return 1}});", + ]) + expect(endpoints(operation + capture)).not.toContain("/api"); +}); + +it("keeps an exhausted primitive proof unknown", () => { + const source = + "const size=flag?1:2;const a=new Array(size);Array.isArray(a);"; + expect(endpoints(source + capture)).toEqual(["/api"]); + const { index, calls, query } = fixture(source, true, { + primitive_candidates: 1, + }); + const call = calls.at(-1)!; + expect(query.argument(call, call.arguments[0]!, index)).toBe(false); + expect( + endpoints(source + capture, { primitive_candidates: 1 }), + ).not.toContain("/api"); +}); diff --git a/src/domain/javascript/javascriptSemanticArgumentInspections.ts b/src/domain/javascript/javascriptSemanticArgumentInspections.ts new file mode 100644 index 00000000000..0435db32924 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArgumentInspections.ts @@ -0,0 +1,136 @@ +import * as t from "@babel/types"; + +import { javaScriptExpressionResultIsUsed } from "./javascriptAstValues.js"; +import { + createGlobalReferenceReader, + semanticAmbientObjectMayHaveChangedAt, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { globalReferenceNamespace } from "./javascriptSemanticGlobalReference.js"; +import { + semanticConstructedReceiver, + semanticPrimitiveArguments, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +type InspectionCall = t.CallExpression | t.OptionalCallExpression; +type GlobalPath = readonly [string, ...string[]]; + +const INSPECTIONS: readonly GlobalPath[] = [ + ["Array", "isArray"], + ["Object", "keys"], + ["Object", "values"], + ["Object", "entries"], + ["Object", "getOwnPropertyNames"], + ["Object", "getOwnPropertySymbols"], + ["Object", "getOwnPropertyDescriptors"], + ["Reflect", "ownKeys"], +]; + +/** One selected-callee authority for native inspection receivers and arguments. */ +export const createSemanticArgumentInspectionQuery = ( + state: JavaScriptSemanticAnalysisState, +) => { + const readers = new Map( + [...INSPECTIONS, ["Array"] as const, ["Object"] as const].map((path) => [ + path.join("."), + createGlobalReferenceReader(state, path), + ]), + ); + const candidates = new WeakMap(); + const candidate = (node: InspectionCall): GlobalPath | undefined => { + const cached = candidates.get(node); + if (cached !== undefined) return cached ?? undefined; + const path = INSPECTIONS.find( + (path) => + globalReferenceNamespace(node.callee, state, path) !== undefined, + ); + candidates.set(node, path ?? null); + return path; + }; + const unchanged = ( + callee: t.Node, + invocation: t.Node, + path: GlobalPath, + ): boolean => + readers.get(path.join("."))?.(callee).status === "unmodified" && + !semanticAmbientPathMayHaveChangedAt( + callee, + state, + ["global", ...path], + invocation, + ) && + !semanticOpaqueGlobalMayHaveChanged(callee, state); + + /** Undefined holds a candidate until all ordinary effects have been gathered. */ + const receiver = ( + node: InspectionCall, + effectsCollected = false, + ): boolean | undefined => { + const path = candidate(node); + if (path === undefined) return false; + return effectsCollected ? unchanged(node.callee, node, path) : undefined; + }; + + return { + receiver, + argument: ( + node: InspectionCall, + argument: t.Node, + index?: Pick, + ): boolean | undefined => { + // Spread evaluation and additional arguments retain their ordinary effects. + if ( + node.arguments[0] !== argument || + node.arguments.some((item) => t.isSpreadElement(item)) + ) + return false; + const native = receiver(node, index !== undefined); + if (native !== true) return native; + // A returned container can expose shared prototypes even when all its + // own slots are primitive. Until its identity is retained, only the + // boolean Array.isArray result may be consumed without escaping a source. + if ( + candidate(node)?.[0] !== "Array" && + javaScriptExpressionResultIsUsed(node, state.parentsByNode) + ) + return false; + const source = semanticConstructedReceiver(argument, node, state, { + paths: [[]], + index, + }); + if (source === undefined) return false; + const construction = source.construction; + const owner = unchanged(construction.callee, construction, ["Object"]) + ? "Object" + : unchanged(construction.callee, construction, ["Array"]) + ? "Array" + : undefined; + if ( + owner === undefined || + (owner === "Object" + ? construction.arguments.length !== 0 + : !semanticPrimitiveArguments(construction.arguments, state)) + ) + return false; + // The existing pristine-construction proof excludes Proxy sources, own + // mutations, retained object slots, and inherited hooks. In particular, + // values/entries may invoke getters outside this deliberately narrow proof. + return ( + !(owner === "Array" ? ["Array", "Object"] : ["Object"]).some( + (prototype) => + semanticAmbientObjectMayHaveChangedAt( + node, + state, + ["global", prototype, "prototype"], + node, + ), + ) && !semanticOpaqueGlobalMayHaveChanged(node, state) + ); + }, + }; +}; diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts index bc9138dfe62..8661b3d8e37 100644 --- a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts +++ b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts @@ -59,6 +59,34 @@ it.each([ ]); }); +it.each([ + "void new Array(3).fill(0);", + "const unused = (new Array(3).fill(0), 1);", + "(0, new Array(3).fill(0));", + "void (0, new Array(3).fill(0));", + "const unused = ((0, new Array(3).fill(0)), 1);", + "(new Array(3).fill(0) as unknown)!;", + "for (new Array(3).fill(0); false;) {}", + "for (; false; (0, new Array(3).fill(0))) {}", + "flag ? new Array(3).fill(0) : 0;", + "flag ? 0 : new Array(3).fill(0);", + "flag && new Array(3).fill(0);", + "flag || new Array(3).fill(0);", + "flag ?? new Array(3).fill(0);", + "void ((flag ? (0, new Array(3).fill(0)) : 0) as unknown)!;", + "const unused = ((flag && new Array(3).fill(0)), 0);", +])( + "preserves unrelated destructuring after a discarded Array result: %s", + (source) => { + expect(proofFor(source)).toEqual([true]); + expect( + analyzeJavaScriptStaticSource( + `${source} const urls = ['/api']; const [url] = urls; fetch(url);`, + ).endpoints.map(({ value }) => value), + ).toEqual(["/api"]); + }, +); + it.each([ "new Array(3).fill(0);", "const a = new globalThis.Array(3); const b = a; b.fill(0);", @@ -103,6 +131,17 @@ it.each([ "function patch() { Array.prototype[0] = replacement; } const a = new Array(3); a.fill(0);", "function run(Array) { const a = new Array(3); a.fill(0); }", "function run(a) { a.fill(0); }", + "void new Array(3).fill(0, (Array.prototype[0] = replacement, 0));", + "void new Array(3).fill(0, { valueOf() { return 0; } });", + "void new Array(3).fill(...args);", + "function run(Array) { void new Array(3).fill(0); }", + "void consume(new Array(3).fill(0));", + "void (holder.value = new Array(3).fill(0));", + "const value = (0, new Array(3).fill(0));", + "new Array(3).fill(0) ? 0 : 1;", + "new Array(3).fill(0) && 0;", + "const value = flag ? new Array(3).fill(0) : 0;", + "void consume(flag && new Array(3).fill(0));", ])("retains receiver uncertainty outside the pristine proof: %s", (source) => { expect(proofFor(source)).toEqual([false]); }); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.ts b/src/domain/javascript/javascriptSemanticArrayOperations.ts index 35f43397059..9f6428f7180 100644 --- a/src/domain/javascript/javascriptSemanticArrayOperations.ts +++ b/src/domain/javascript/javascriptSemanticArrayOperations.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import { + javaScriptExpressionResultIsUsed, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -55,7 +56,7 @@ export const isIntrinsicNonEscapingArrayCall = ( !PRISTINE_ARRAY_PRIMITIVE_RESULTS.has(method) && (!PRISTINE_ARRAY_RETAINED_RESULTS.has(method) || // Receiver-returning methods and iterators retain the array itself. - !t.isExpressionStatement(state.parentsByNode.get(node))) + javaScriptExpressionResultIsUsed(node, state.parentsByNode)) ) return false; if (!semanticPrimitiveArguments(node.arguments, state)) return false; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 1442a921fcc..c4b57340f57 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -12,7 +12,7 @@ import type { import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js"; import { - javaScriptExpressionWrapperTarget, + javaScriptExpressionResultIsUsed, readExactJavaScriptLiteral, semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -107,7 +107,7 @@ export const collectJavaScriptSemanticCalls = ( t.isOptionalCallExpression(node) || t.isNewExpression(node) ) - collectCallSite(node, parent, owner, context); + collectCallSite(node, owner, context); context.ancestors.push(node); }, exit: (node) => { @@ -125,35 +125,8 @@ export const collectJavaScriptSemanticCalls = ( }; }; -/** TypeScript and grouping wrappers do not consume a call result. */ -const callResultIsUsed = ( - call: t.Node, - parent: t.Node | null, - ancestors: readonly t.Node[], -): boolean => { - let current = parent; - let child = call; - let index = ancestors.length - 1; - while ( - current !== null && - javaScriptExpressionWrapperTarget(current) !== undefined - ) { - child = current; - index -= 1; - current = index >= 0 ? (ancestors[index] ?? null) : null; - } - if (t.isExpressionStatement(current)) return false; - // for/of and for/in heads use their expressions. A C-style for initializer - // or update discards the value. - return !( - t.isForStatement(current) && - (current.init === child || current.update === child) - ); -}; - const collectCallSite = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, - parent: t.Node | null, owner: JavaScriptSemanticCallable | undefined, context: CallCollectionContext, ): void => { @@ -173,7 +146,7 @@ const collectCallSite = ( callerCallableId: owner?.callableId ?? null, location: range(node), calleeLocation: range(node.callee), - resultUsed: callResultIsUsed(node, parent, context.ancestors), + resultUsed: javaScriptExpressionResultIsUsed(node, state.parentsByNode), resolution: resolution.callableIds.length === 1 && resolution.complete ? "exact" diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts index 68d43a4705e..8761cbcae7d 100644 --- a/src/domain/javascript/javascriptSemanticMapOperations.ts +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import { + javaScriptExpressionResultIsUsed, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -38,7 +39,7 @@ const isIntrinsicMapOperation = ( method !== "keys" && method !== "values" && method !== "entries") || - !t.isExpressionStatement(bindings.parentsByNode.get(node))))) + javaScriptExpressionResultIsUsed(node, bindings.parentsByNode)))) ) return false; const prototypeMemberMayHaveChanged = ( diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 0f34a78c974..d5117bb9212 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -7,6 +7,7 @@ import { import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; import { isIntrinsicNonEscapingMapCall } from "./javascriptSemanticMapOperations.js"; import { isIntrinsicNonEscapingObjectCall } from "./javascriptSemanticObjectOperations.js"; +import { createSemanticArgumentInspectionQuery } from "./javascriptSemanticArgumentInspections.js"; import { isIntrinsicNonEscapingXhrOpen } from "./javascriptNetworkRequests.js"; import { isIntrinsicTypedArrayConstruction, @@ -371,10 +372,13 @@ export function* collectSemanticMemberMutationsSteps( state: JavaScriptSemanticAnalysisState, referenceMayHaveChangedAt: JavaScriptBindingIndex["referenceMayHaveChangedAt"], ): Generator { + const inspection = createSemanticArgumentInspectionQuery(state); const intrinsicReceiverCall = ( node: t.CallExpression | t.OptionalCallExpression, index?: Pick, ): boolean | undefined => { + const inspected = inspection.receiver(node, index !== undefined); + if (inspected !== false) return inspected; const map = isIntrinsicNonEscapingMapCall(node, state, index); if (map !== false) return map; const xhr = isIntrinsicNonEscapingXhrOpen(node, state, index); @@ -387,6 +391,10 @@ export function* collectSemanticMemberMutationsSteps( const deferredIntrinsicReceivers = new Set< t.CallExpression | t.OptionalCallExpression >(); + const deferredInspectionArguments = new Map< + t.CallExpression | t.OptionalCallExpression, + t.Node + >(); // The binding pass already retained callable nodes. Ordinary data-only code // must not pay an alias walk for every member read or assignment. const accessorKeys = { @@ -1746,7 +1754,14 @@ export function* collectSemanticMemberMutationsSteps( mutation: node, originAt: node, }); - else markEscaped(argument, node); + else { + const inspected = t.isNewExpression(node) + ? false + : inspection.argument(node, argument); + if (inspected === false) markEscaped(argument, node); + else if (!t.isNewExpression(node)) + deferredInspectionArguments.set(node, argument); + } // Constructing a member does not pass its container as `this`. if (!t.isNewExpression(node)) { const intrinsicCall = intrinsicReceiverCall(node); @@ -1767,7 +1782,8 @@ export function* collectSemanticMemberMutationsSteps( while ( pendingReferences.length > 0 || iterableReferences.length > 0 || - deferredIntrinsicReceivers.size > 0 + deferredIntrinsicReceivers.size > 0 || + deferredInspectionArguments.size > 0 ) { const effectsBefore = recordedEffectCount; const iterablesBefore = iterableReferences.length; @@ -1877,6 +1893,17 @@ export function* collectSemanticMemberMutationsSteps( reference, ); } + let inspectionIndex = 0; + for (const [node, argument] of deferredInspectionArguments) { + if (inspectionIndex++ % 64 === 0) yield; + if ( + inspection.argument(node, argument, { referenceMayHaveChangedAt }) === + true + ) + continue; + deferredInspectionArguments.delete(node); + markEscaped(argument, node); + } // Proven native Map and pristine XHR/Array/Object calls do not expose receivers. // Settle genuine effects first, then replay every unproven receiver escape. // Keep proven sites until convergence: later propagated effects can revoke them. diff --git a/src/domain/javascript/javascriptSemanticObjectOperations.test.ts b/src/domain/javascript/javascriptSemanticObjectOperations.test.ts index f77b03c645b..c39bae04a51 100644 --- a/src/domain/javascript/javascriptSemanticObjectOperations.test.ts +++ b/src/domain/javascript/javascriptSemanticObjectOperations.test.ts @@ -105,6 +105,34 @@ it.each([ expect(proofFor(source)).toEqual([true]); }); +it.each([ + "void new Object().valueOf();", + "const unused = (new Object().valueOf(), 1);", + "(0, new Object().valueOf());", + "void (0, new Object().valueOf());", + "const unused = ((0, new Object().valueOf()), 1);", + "(new Object().valueOf() as unknown)!;", + "for (new Object().valueOf(); false;) {}", + "for (; false; (0, new Object().valueOf())) {}", + "flag ? new Object().valueOf() : 0;", + "flag ? 0 : new Object().valueOf();", + "flag && new Object().valueOf();", + "flag || new Object().valueOf();", + "flag ?? new Object().valueOf();", + "void ((flag ? (0, new Object().valueOf()) : 0) as unknown)!;", + "const unused = ((flag && new Object().valueOf()), 0);", +])( + "preserves unrelated destructuring after a discarded Object result: %s", + (source) => { + expect(proofFor(source, "valueOf")).toEqual([true]); + expect( + analyzeJavaScriptStaticSource( + `${source} const urls = ['/api']; const [url] = urls; fetch(url);`, + ).endpoints.map(({ value }) => value), + ).toEqual(["/api"]); + }, +); + it.each([ "function run(Object) { new Object().hasOwnProperty('x'); }", "Object = Replacement; new Object().hasOwnProperty('x');", @@ -175,6 +203,20 @@ it.each([ ["new Object().isPrototypeOf(new Proxy({}, handler));", "isPrototypeOf"], ["const o = new Object(); consume(o.valueOf());", "valueOf"], ["const result = new Object().valueOf();", "valueOf"], + ["void consume(new Object().valueOf());", "valueOf"], + ["void (holder.value = new Object().valueOf());", "valueOf"], + ["const result = (0, new Object().valueOf());", "valueOf"], + ["Object.prototype.valueOf = patch; void new Object().valueOf();", "valueOf"], + [ + "void new Object().valueOf((Object.prototype.x = replacement, 0));", + "valueOf", + ], + ["void new Object().valueOf(...args);", "valueOf"], + ["function run(Object) { void new Object().valueOf(); }", "valueOf"], + ["new Object().valueOf() ? 0 : 1;", "valueOf"], + ["new Object().valueOf() && 0;", "valueOf"], + ["const result = flag ? new Object().valueOf() : 0;", "valueOf"], + ["void consume(flag && new Object().valueOf());", "valueOf"], ] as const)( "excludes coercion, tag, locale and returned-receiver hooks: %s", (source, method) => { diff --git a/src/domain/javascript/javascriptSemanticObjectOperations.ts b/src/domain/javascript/javascriptSemanticObjectOperations.ts index 12bbb29d262..e3623fffdda 100644 --- a/src/domain/javascript/javascriptSemanticObjectOperations.ts +++ b/src/domain/javascript/javascriptSemanticObjectOperations.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import { + javaScriptExpressionResultIsUsed, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -41,7 +42,7 @@ export const isIntrinsicNonEscapingObjectCall = ( !PRISTINE_OBJECT_METHODS.has(method ?? "") || // valueOf returns the receiver; no returned-reference proof is added here. (method === "valueOf" && - !t.isExpressionStatement(state.parentsByNode.get(node))) || + javaScriptExpressionResultIsUsed(node, state.parentsByNode)) || // Property keys can coerce, and isPrototypeOf can walk a Proxy argument. !semanticPrimitiveArguments(node.arguments, state) ) diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 911948265c0..b3d1e27a842 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -166,6 +166,98 @@ it.each([ "const records = new Map(); new Map().set('x', 1); records.get('/accounts');", false, ], + [ + "const records = new Map(); void new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const unused = (new Map().set('x', 1), 0); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); (0, new Map().set('x', 1)); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); void (0, new Map().set('x', 1)); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const unused = ((0, new Map().set('x', 1)), 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); (new Map().set('x', 1) as unknown)!; records.get('/accounts');", + false, + ], + [ + "const records = new Map(); for (new Map().set('x', 1); false;) {} records.get('/accounts');", + false, + ], + [ + "const records = new Map(); for (; false; (0, new Map().set('x', 1))) {} records.get('/accounts');", + false, + ], + [ + "const records = new Map(); void new Map().keys(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); flag ? new Map().set('x', 1) : 0; records.get('/accounts');", + false, + ], + [ + "const records = new Map(); flag ? 0 : new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); flag && new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); void ((flag ? (0, new Map().set('x', 1)) : 0) as unknown)!; records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const unused = ((flag && new Map().set('x', 1)), 0); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); new Map().set('x', 1) ? 0 : 1; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set('x', 1) && 0; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const result = flag ? new Map().set('x', 1) : 0; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void consume(flag && new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void consume(new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const result = (0, new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); Map.prototype.set = patch; void new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void new Map().set(records, 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void new Map().set(...expose(records)); records.get('/accounts');", + true, + ], [ "const records = new Map(); const other = new Map(); other.set('x', 1); records.delete('/accounts');", false,