diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts new file mode 100644 index 0000000000..3e217f94d0 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -0,0 +1,115 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * A listener must be a function, and Node throws `ERR_INVALID_ARG_TYPE` before + * anything is registered or removed when it is not. + * + * Measured on Node 24.22.1: + * + * emitter.on(1) -> THREW ERR_INVALID_ARG_TYPE + * emitter.on("x", 1) -> THREW ERR_INVALID_ARG_TYPE + * emitter.on("x", null) -> THREW ERR_INVALID_ARG_TYPE + * emitter.on("x", true) -> THREW ERR_INVALID_ARG_TYPE + * emitter.on("x", {} / []) -> THREW ERR_INVALID_ARG_TYPE + * emitter.once / addListener / + * prependListener / prependOnceListener / off / removeListener, same + * emitter.on("x", () => {}) -> ok + * emitter.on("x", function () {}) -> ok + */ +const eventsFor = (call: string) => + analyzeJavaScriptSemantics( + `import { EventEmitter } from "node:events";\nconst e = new EventEmitter();\nfunction g(fn, args) { ${call}; }`, + ).eventOperations.map(({ kind, method, eventName }) => ({ + kind, + method, + eventName, + })); + +it.each([ + ["a numeric listener", `e.on("x", 1)`], + ["a string listener", `e.on("x", "fn")`], + ["a template-literal listener", 'e.on("x", `fn`)'], + ["a boolean listener", `e.on("x", true)`], + ["a null listener", `e.on("x", null)`], + ["a void listener", `e.on("x", void 0)`], + ["a BigInt listener", `e.on("x", 0n)`], + ["a RegExp listener", `e.on("x", /re/)`], + ["an object listener", `e.on("x", {})`], + ["an array listener", `e.on("x", [])`], + ["a once numeric listener", `e.once("x", 1)`], + ["an addListener numeric listener", `e.addListener("x", 1)`], + ["a prependListener numeric listener", `e.prependListener("x", 1)`], + ["a prependOnceListener numeric listener", `e.prependOnceListener("x", 1)`], + ["a removeListener numeric listener", `e.removeListener("x", 1)`], + ["an off numeric listener", `e.off("x", 1)`], +] as const)("emits no event operation for %s", (_name, call) => { + expect(eventsFor(call)).toEqual([]); +}); + +it.each([ + ["an arrow listener", `e.on("x", () => {})`, "on"], + ["a function-expression listener", `e.on("x", function () {})`, "on"], + ["a class-expression listener", `e.on("x", class {})`, "on"], + ["an identifier listener", `e.on("x", fn)`, "on"], + ["a call-result listener", `e.on("x", make())`, "on"], + ["a spread listener", `e.on("x", ...listeners)`, "on"], + ["an once arrow listener", `e.once("x", () => {})`, "once"], + [ + "a removeListener identifier", + `e.removeListener("x", fn)`, + "removeListener", + ], +] as const)("keeps the event operation for %s", (_name, call, method) => { + expect(eventsFor(call)).toEqual([ + { kind: expect.any(String) as unknown as string, method, eventName: "x" }, + ]); +}); + +/** + * `emit` passes its remaining arguments to listeners that already exist, so it + * does not validate them the way a registration does. + */ +it("keeps an emit with a literal payload", () => { + expect(eventsFor(`e.emit("x", 1)`)).toEqual([ + { kind: "dispatch", method: "emit", eventName: "x" }, + ]); +}); + +/** `removeAllListeners` takes only an optional event name, never a listener. */ +it("keeps removeAllListeners with a literal event name", () => { + expect(eventsFor(`e.removeAllListeners("x")`)).toEqual([ + { kind: "remove", method: "removeAllListeners", eventName: "x" }, + ]); +}); + +/** + * Two review findings. + * + * Babel stores `1e999` as a `NumericLiteral` holding `Infinity`, which the + * exact-value reader refuses to price, so the non-finite value was missed: + * + * e.on("x", 1e999) -> THREW ERR_INVALID_ARG_TYPE + * + * A leading spread shifts the runtime argument positions, so argument 1 is not + * provably the listener: + * + * e.on(...["x", fn], 1) -> registers fn; the trailing 1 is ignored + */ +it.each([ + ["an overflowing numeric listener", `e.on("x", 1e999)`], + ["another overflowing numeric listener", `e.on("x", 1e400)`], +] as const)("emits no event operation for %s", (_name, call) => { + expect(eventsFor(call)).toEqual([]); +}); + +it.each([ + ["a leading spread", `e.on(...args, 1)`, "on"], + ["a leading spread with a literal array", `e.on(...["x", fn], 1)`, "on"], + ["a leading spread on once", `e.once(...args, 0)`, "once"], +] as const)("keeps the event operation for %s", (_name, call, method) => { + expect(eventsFor(call)).toEqual([ + { kind: "register", method, eventName: null }, + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 591a0f4e36..ea9e990bd1 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -7,7 +7,11 @@ import type { JavaScriptSemanticTimerOperation, } from "./javascriptSemanticIr.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; -import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, @@ -127,6 +131,36 @@ export const collectJavaScriptSemanticAsyncEffects = ( }; }; +/** + * A listener must be a function, and Node throws `ERR_INVALID_ARG_TYPE` before + * anything is registered or removed when it is not. An exactly readable literal + * that cannot be a function is therefore not an event operation, while an + * identifier, a call result or a spread may still supply one and keeps its + * operation. + * + * A class expression passes Node's own `typeof` check, so it is kept. + */ +const rejectsEventListener = (listener: t.Node): boolean => { + if (t.isSpreadElement(listener)) return false; + const value = unwrapJavaScriptExpression(listener).node; + if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value)) + return false; + if (t.isClassExpression(value)) return false; + // A numeric literal is always a number, including the non-finite value Babel + // stores for `1e999`, which the exact-value reader refuses to price. + if (t.isNumericLiteral(value)) return true; + // Every unary operator yields a primitive, and a primitive is never callable. + return ( + t.isUnaryExpression(value) || + readExactJavaScriptLiteral(value).found || + t.isTemplateLiteral(value) || + t.isBigIntLiteral(value) || + t.isRegExpLiteral(value) || + t.isArrayExpression(value) || + t.isObjectExpression(value) + ); +}; + const collectEvent = ( node: t.CallExpression | t.OptionalCallExpression, ownerCallableId: string | null, @@ -146,6 +180,15 @@ const collectEvent = ( const listenerArgument = EVENT_LISTENER_METHODS.includes(method) ? node.arguments[1] : undefined; + // 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`. + if ( + !t.isSpreadElement(eventArgument) && + listenerArgument !== undefined && + rejectsEventListener(listenerArgument) + ) + return; const emitter = emitterIdentity(member.object, state); const eventName = dataEffectLiteralString(eventArgument); output.push({