diff --git a/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts index 03e25032a6..792b8a0ea3 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts @@ -130,3 +130,74 @@ describe("JavaScript semantic analysis: runtime boundaries 1", () => { expect(ir.objectOperations.length).toBeGreaterThan(0); }); }); + +describe("JavaScript semantic analysis: event dispatch data", () => { + it("keeps emitted data out of dispatch listener fields", () => { + const ir = analyzeJavaScriptSemantics(` + import { EventEmitter } from "node:events"; + const bus = new EventEmitter(); + const data = 1; + const handler = () => {}; + bus.on("ready", handler); + bus.emit("ready", data); + bus.emit("ready"); + bus.off("ready", handler); + bus.removeAllListeners("ready", data); + `); + const handler = ir.bindings.find(({ name }) => name === "handler"); + expect(handler).toBeDefined(); + expect( + ir.eventOperations.map( + ({ kind, method, listenerBindingId, listenerLocation }) => ({ + kind, + method, + listenerBindingId, + hasListenerLocation: listenerLocation !== null, + }), + ), + ).toEqual([ + { + kind: "register", + method: "on", + listenerBindingId: handler?.bindingId, + hasListenerLocation: true, + }, + { + kind: "dispatch", + method: "emit", + listenerBindingId: null, + hasListenerLocation: false, + }, + { + kind: "dispatch", + method: "emit", + listenerBindingId: null, + hasListenerLocation: false, + }, + { + kind: "remove", + method: "off", + listenerBindingId: handler?.bindingId, + hasListenerLocation: true, + }, + { + kind: "remove", + method: "removeAllListeners", + listenerBindingId: null, + hasListenerLocation: false, + }, + ]); + expect( + ir.eventOperations.map(({ eventName, resolution }) => [ + eventName, + resolution, + ]), + ).toEqual([ + ["ready", "complete"], + ["ready", "complete"], + ["ready", "complete"], + ["ready", "complete"], + ["ready", "complete"], + ]); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index f7914c20f7..5b3068101c 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -37,6 +37,13 @@ const EVENT_REMOVE_METHODS = [ "removeListener", "removeAllListeners", ] as const; +/** + * Methods whose argument position 1 names a listener. `removeAllListeners` + * takes only an optional event name, and `emit` passes its remaining + * arguments as data to listeners that already exist. + */ +const EVENT_LISTENER_METHODS: readonly JavaScriptSemanticEventOperation["method"][] = + [...EVENT_REGISTER_METHODS, "off", "removeListener"]; const EVENT_METHODS = [ ...EVENT_REGISTER_METHODS, ...EVENT_REMOVE_METHODS, @@ -137,7 +144,9 @@ const collectEvent = ( if (method === undefined) return; const kind = eventKind(method); const eventArgument = node.arguments[0]; - const listenerArgument = node.arguments[1]; + const listenerArgument = EVENT_LISTENER_METHODS.includes(method) + ? node.arguments[1] + : undefined; const emitter = emitterIdentity(member.object, state); const eventName = dataEffectLiteralString(eventArgument); output.push({