Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 418db11e6d fix(javascript): scope this emitters to the receiver that binds them
Every `this.on/emit/off` receiver had the same complete emitter key "this",
so same-named events in unrelated classes joined one resolved event node.

A new receiver owner scopes `this` to its binder: a class member's local
class hierarchy (instance or static side), an object literal's methods, an
ordinary function, or the program. Arrow functions and computed member keys
keep the enclosing receiver. A hierarchy with several local classes, or an
ambiguous base, joins its operations only as candidates (partial), so base
and subclass registrations stay connected without claiming one exact
emitter.

Fixes #3360

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 05:15:10 +00:00
3 changed files with 277 additions and 10 deletions

No files matched your search

@@ -0,0 +1,93 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
/** Group event methods by emitter key, keeping each operation's resolution. */
const emitters = (source: string) => {
const groups = new Map<string, string[]>();
for (const { emitterKey, method, resolution } of analyzeJavaScriptSemantics(
source,
).eventOperations)
groups.set(emitterKey, [
...(groups.get(emitterKey) ?? []),
`${method}:${resolution}`,
]);
return [...groups.values()];
};
it("keeps this receivers of unrelated classes in separate emitters", () => {
expect(
emitters(`
import { EventEmitter } from "node:events";
class Producer extends EventEmitter {
run() { this.on("ready", () => {}); }
}
class Unrelated extends EventEmitter {
run() { this.emit("ready"); }
}
const stable = new EventEmitter();
stable.on("ready", () => {});
stable.emit("ready");
`),
).toEqual([
["on:complete"],
["emit:complete"],
["on:complete", "emit:complete"],
]);
});
it("shares one class receiver across its members", () => {
expect(
emitters(`
class Service extends EventEmitter {
constructor() { super(); this.on("ready", () => {}); }
start() { this.emit("ready"); }
stop = () => this.off("ready");
}
`),
).toEqual([["on:complete", "emit:complete", "off:complete"]]);
});
it("keeps a local class hierarchy as one candidate receiver", () => {
expect(
emitters(`
class Base extends EventEmitter { run() { this.on("ready", () => {}); } }
class Child extends Base { start() { this.emit("ready"); } }
`),
).toEqual([["on:partial", "emit:partial"]]);
});
it("scopes this to the function or object that binds it", () => {
expect(
emitters(`
class Owner extends EventEmitter {
run() {
this.on("ready", () => {});
function detached() { this.emit("ready"); }
const nested = () => this.emit("ready");
}
}
const host = {
open() { this.on("ready", () => {}); },
close: function () { this.emit("ready"); },
};
this.emit("ready");
`),
).toEqual([
["on:complete", "emit:complete"],
["emit:complete"],
["on:complete", "emit:complete"],
["emit:complete"],
]);
});
it("keeps static members apart from instances", () => {
expect(
emitters(`
class Registry extends EventEmitter {
static boot() { this.emit("ready"); }
run() { this.on("ready", () => {}); }
}
`),
).toEqual([["emit:complete"], ["on:complete"]]);
});
@@ -13,6 +13,7 @@ import {
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { createSemanticThisReceivers } from "./javascriptSemanticThisReceivers.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
@@ -91,7 +92,12 @@ export const collectJavaScriptSemanticAsyncEffects = (
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): AsyncEffectAnalysis => {
const events: JavaScriptSemanticEventOperation[] = [];
const events: EventCollection = {
state,
receivers: createSemanticThisReceivers(state),
output: [],
classReceivers: new Map(),
};
const timers: TimerCandidate[] = [];
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack = createSemanticCallableIdStack(callableIds);
@@ -99,9 +105,10 @@ export const collectJavaScriptSemanticAsyncEffects = (
traverseJavaScriptAst(program, {
enter: (node, parent) => {
callableStack.enter(node, parent);
if (t.isClass(node)) events.receivers.record(node);
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const ownerCallableId = callableStack.callables.at(-1) ?? null;
collectEvent(node, ownerCallableId, state, events);
collectEvent(node, ownerCallableId, events);
collectTimer({
node,
ownerCallableId,
@@ -118,17 +125,43 @@ export const collectJavaScriptSemanticAsyncEffects = (
},
});
return {
eventOperations: events,
eventOperations: resolveClassReceiverEvents(events),
timerOperations: resolveTimers(timers),
};
};
interface EventCollection {
readonly state: JavaScriptSemanticAnalysisState;
readonly receivers: ReturnType<typeof createSemanticThisReceivers>;
readonly output: JavaScriptSemanticEventOperation[];
/** Class receivers, whose exactness is known once every class is recorded. */
readonly classReceivers: Map<
JavaScriptSemanticEventOperation,
t.ClassDeclaration | t.ClassExpression
>;
}
/** A class receiver shared by several local classes is only a candidate. */
const resolveClassReceiverEvents = ({
output,
receivers,
classReceivers,
}: EventCollection): JavaScriptSemanticEventOperation[] =>
output.map((operation) => {
const klass = classReceivers.get(operation);
return klass === undefined ||
operation.resolution !== "complete" ||
receivers.exact(klass)
? operation
: { ...operation, resolution: "partial" };
});
const collectEvent = (
node: t.CallExpression | t.OptionalCallExpression,
ownerCallableId: string | null,
state: JavaScriptSemanticAnalysisState,
output: JavaScriptSemanticEventOperation[],
collection: EventCollection,
): void => {
const { state, output } = collection;
const member = dataEffectMemberCallee(node);
if (member === null) return;
const method = EVENT_METHODS.find(
@@ -142,9 +175,9 @@ const collectEvent = (
const listenerArgument = EVENT_LISTENER_METHODS.includes(method)
? node.arguments[1]
: undefined;
const emitter = emitterIdentity(member.object, state);
const emitter = emitterIdentity(member.object, collection);
const eventName = dataEffectLiteralString(eventArgument);
output.push({
const operation: JavaScriptSemanticEventOperation = {
eventId: `event:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind,
method,
@@ -163,7 +196,10 @@ const collectEvent = (
: emitter.complete
? "complete"
: "partial",
});
};
output.push(operation);
if (emitter.klass !== null)
collection.classReceivers.set(operation, emitter.klass);
};
const eventKind = (
@@ -178,11 +214,12 @@ const eventKind = (
const emitterIdentity = (
node: t.Expression | t.Super,
state: JavaScriptSemanticAnalysisState,
{ state, receivers }: EventCollection,
): {
readonly key: string;
readonly bindingId: string | null;
readonly complete: boolean;
readonly klass: t.ClassDeclaration | t.ClassExpression | null;
} => {
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
@@ -191,19 +228,23 @@ const emitterIdentity = (
key: `binding:${binding.bindingId}`,
bindingId: binding.bindingId,
complete: true,
klass: null,
};
return {
key: `global:${node.name}`,
bindingId: null,
complete: !semanticResolutionBlocked(state, node, node.name),
klass: null,
};
}
// Each binder of `this` supplies its own receiver.
if (t.isThisExpression(node))
return { key: "this", bindingId: null, complete: true };
return { ...receivers.receiver(node), bindingId: null, complete: true };
return {
key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
bindingId: null,
complete: false,
klass: null,
};
};
@@ -0,0 +1,133 @@
import * as t from "@babel/types";
import { semanticClassCandidates } from "./javascriptSemanticObjectReferences.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
type SemanticClass = t.ClassDeclaration | t.ClassExpression;
/** A `this` receiver scoped to the class, object, function, or program binding it. */
export interface SemanticThisReceiver {
readonly key: string;
/** The class whose instances or constructor supply this receiver. */
readonly klass: SemanticClass | null;
}
const nodeKey = (node: t.Node): string =>
`${String(node.start ?? -1)}:${String(node.end ?? -1)}`;
const isComputedMemberKey = (owner: t.Node, child: t.Node): boolean =>
(t.isClassMethod(owner) ||
t.isClassPrivateMethod(owner) ||
t.isObjectMethod(owner) ||
t.isClassProperty(owner) ||
t.isClassAccessorProperty(owner)) &&
owner.key === child;
/**
* Scope `this` receivers to the owner that binds them. A class member's
* receiver is shared by its local class hierarchy, since a base method may run
* on a subclass instance; a hierarchy with several classes, or an ambiguous
* base, only identifies a candidate receiver.
*/
export const createSemanticThisReceivers = (
state: JavaScriptSemanticAnalysisState,
) => {
const roots = new Map<SemanticClass, SemanticClass>();
const classes: SemanticClass[] = [];
// Hierarchy sizes are resolved only when a class receiver needs them.
let familySizes: Map<SemanticClass, number> | undefined;
const ambiguous = new Set<SemanticClass>();
const rootOf = (klass: SemanticClass): SemanticClass => {
const cached = roots.get(klass);
if (cached !== undefined) return cached;
const seen = new Set<SemanticClass>([klass]);
let current = klass;
while (current.superClass != null) {
const bases = semanticClassCandidates(current.superClass, state);
if (bases.length > 1) ambiguous.add(klass);
const base = bases.length === 1 ? bases[0] : undefined;
if (base === undefined || seen.has(base)) break;
seen.add(base);
current = base;
}
roots.set(klass, current);
return current;
};
const classReceiver = (
klass: SemanticClass,
isStatic: boolean,
): SemanticThisReceiver => ({
key: `this:class:${nodeKey(rootOf(klass))}:${isStatic ? "static" : "instance"}`,
klass,
});
const receiverOwner = (owner: t.Node): SemanticThisReceiver | undefined => {
const parent = state.parentsByNode.get(owner);
if (t.isClassMethod(owner) || t.isClassPrivateMethod(owner)) {
const klass = parent && state.parentsByNode.get(parent);
return klass !== undefined && t.isClass(klass)
? classReceiver(klass, owner.static)
: undefined;
}
if (
t.isObjectMethod(owner) ||
(t.isFunctionExpression(owner) &&
t.isObjectProperty(parent) &&
parent.value === owner)
) {
const object = t.isObjectMethod(owner)
? parent
: parent && state.parentsByNode.get(parent);
if (object !== undefined && t.isObjectExpression(object))
return { key: `this:object:${nodeKey(object)}`, klass: null };
}
return undefined;
};
return {
/** Record every class so hierarchy sizes are known after traversal. */
record: (klass: SemanticClass): void => {
classes.push(klass);
},
receiver: (node: t.ThisExpression): SemanticThisReceiver => {
let child: t.Node = node;
let owner = state.parentsByNode.get(node);
while (owner !== undefined) {
if (isComputedMemberKey(owner, child)) {
// A computed key evaluates in the enclosing scope.
} else if (t.isFunction(owner) && !t.isArrowFunctionExpression(owner)) {
return (
receiverOwner(owner) ?? {
key: `this:function:${nodeKey(owner)}`,
klass: null,
}
);
} else if (
t.isClassProperty(owner) ||
t.isClassPrivateProperty(owner) ||
t.isClassAccessorProperty(owner) ||
t.isStaticBlock(owner)
) {
// Field initializers and static blocks run with the class receiver.
const body = state.parentsByNode.get(owner);
const klass = body && state.parentsByNode.get(body);
if (klass !== undefined && t.isClass(klass))
return classReceiver(klass, t.isStaticBlock(owner) || owner.static);
}
child = owner;
owner = state.parentsByNode.get(owner);
}
return { key: "this:program", klass: null };
},
/** A receiver is exact only for a lone class with an unambiguous base. */
exact: (klass: SemanticClass): boolean => {
if (familySizes === undefined) {
familySizes = new Map();
for (const member of classes) {
const root = rootOf(member);
familySizes.set(root, (familySizes.get(root) ?? 0) + 1);
}
}
const root = rootOf(klass);
return !ambiguous.has(klass) && familySizes.get(root) === 1;
},
};
};