fix(javascript-semantics): keep emitted data out of dispatch listener fields (#2952)

* fix(javascript-semantics): keep emitted data out of dispatch listener fields

The EventEmitter operation collector read argument position 1 as the
listener for every event method, so a dispatch such as
bus.emit("ready", data) reported the data binding and its range as the
listener. Only registration and removal name a listener there; emit
passes its remaining arguments to listeners that already exist. Read the
listener argument only for register and remove operations.

Fixes #2574

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(javascript-semantics): read listener arguments only for listener-taking methods

removeAllListeners accepts only an optional event name, so gating the
listener argument on operation kind still reported an ignored second
argument as a listener. Gate on the methods whose argument position 1
names a listener instead: the registration methods, off, and
removeListener.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
saurabh sharma
2026-10-14 00:18:54 +05:30
committed by GitHub
parent 0bc422d7be
commit 090b454350
2 changed files with 81 additions and 1 deletions
@@ -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"],
]);
});
});
@@ -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({