fix(js,ts): a call on an element of a class field reaches the element's type

A TypeScript or JavaScript method called on an element of a class field,
like this.handlers[type].handle() or this.items[0].save(), kept the bare
method name: a member chain rooted at this collapses to it so the resolver
can read the owner off the enclosing class. A subscript in the chain leaves
the resolver nothing to read, so the bare name exact-matched any project
method of that name, often another class's (this.listeners[type].push(h)
reached an unrelated UndoStack.push).

Both engines now emit this.<field>[k].<m>() as this.<field>[].<m>, and
this['svc'].m() as this.svc.m; any other subscript in a this-rooted chain,
or under window, emits nothing, as arr[0].run() already did. The resolver
reads the element type the field's declaration writes (X[], Array<X>,
ReadonlyArray<X>, Record<K, X>), on the calling class or a base, and
resolves the method on it or leaves the call unresolved: a type parameter
of the declaring class, a union with another type, an array of arrays or a
non-plain element hold no one type. The class type-parameter reader now
reads from the keyword to the list's own >, over several lines and through
braces in constraints.

Our changes: kept escapeRegExp from PR #2769 in the conflict; the
type-parameter reader also accepts the type keyword, since the T['p'] path
passes type-alias owners and the PR as written read State<Item>['current']
as the project class Item (new case in ts-array-annotated-receivers);
CHANGELOG covers JavaScript and asks for a re-index.

Validation: the element tests fail 10 of 11 on main with both engines; the
type-alias case fails on the PR as written and its brace half on main. A/B
against main, kernel built per arm: flask and gin byte-identical. TS:
pixijs 7 links added, 5 retargeted and 5 typed, all right, 15 removed (9
wrong, 6 right only by name: a cast, a mixin's this, a T[] parameter, a
merged mixin member, an index-signature type, an any field); typeorm 3
wrong removed, 1 right lost, 2 retargeted to the declared interface;
excalidraw 1 wrong removed; vscode's base and editor 19 added, 24
retargeted and 35 typed, all right, 45 removed (37 wrong, 8 right only by
name: a getter, an index-signature field, deep chains, a T[] parameter, a
union alias). JS: three.js, playcanvas and pdf.js
lose 288 bare-name guesses, about 230 of them wrong (array, Map and DOM
calls on project classes). Kernel parity holds on every repo with
deferrals unchanged. Index CPU time within noise (pixijs 32.4 s to 33.3 s,
three.js 64.7 s to 67.9 s, medians of 3 interleaved).

Original PR #2766 by @inth3shadows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric Minish
2026-10-23 17:50:28 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 5c5bfd4051
commit 449eefa7da
8 changed files with 376 additions and 16 deletions
+41 -2
View File
@@ -1658,6 +1658,12 @@ impl<'t> Walker<'t> {
// resolver can read its declared type (#1496). Mirrors
// TreeSitterExtractor.extractCall.
callee_name = format!("this.{field}.{method_name}");
} else if let Some(element) = receiver.and_then(|r| self.this_subscript_of(r)) {
// `this.<field>[k].<method>()` — an element of the
// field, typed by the resolver from the field's
// declaration; `this['svc']` is the field itself.
// Mirrors TreeSitterExtractor.extractCall.
callee_name = format!("{element}.{method_name}");
} else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
// Call receiver — `make().run()` (#1683): keep the inner
// callee as `<inner>().<method>`, or emit nothing when it
@@ -1739,6 +1745,36 @@ impl<'t> Walker<'t> {
Some(self.text(property).to_string())
}
/// A receiver subscripting the enclosing class's object: `this.<field>[k]`
/// → `this.<field>[]`, `this['svc']` → `this.svc`; None for any other
/// subscript. Mirrors tsThisSubscriptReceiver.
fn this_subscript_of(&self, receiver: Node<'t>) -> Option<String> {
if receiver.kind() != "subscript_expression" {
return None;
}
let object = peel_receiver(receiver.child_by_field_name("object")?);
if object.kind() == "this" {
let index = receiver.child_by_field_name("index")?;
if index.kind() != "string" {
return None;
}
let text = self.text(index);
let quoted = text.len() >= 2
&& (text.starts_with('\'') || text.starts_with('"'))
&& text.ends_with(&text[..1]);
let key = if quoted { &text[1..text.len() - 1] } else { return None };
let mut chars = key.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_' || first == '$')
|| !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
{
return None;
}
return Some(format!("this.{key}"));
}
self.this_field_of(object).map(|field| format!("this.{field}[]"))
}
/// `await f<T>(x)` parses with `await f` as the callee (#2586), and a
/// prefix operator over a generic call the same way
/// (`!settings.get<boolean>('X')`, `void apiRequest<T>()`); the call is to
@@ -2084,11 +2120,14 @@ fn peel_receiver(node: Node<'_>) -> Node<'_> {
}
/// `this` / `super`, a member chain rooted at either or at `window`, or
/// `new C()` still collapse to the bare method name (mirrors
/// keepsBareTsJsReceiver).
/// `new C()` still collapse to the bare method name; a subscript anywhere in
/// the chain does not (mirrors keepsBareTsJsReceiver).
fn keeps_bare_receiver(node: Node<'_>, src: &str) -> bool {
let mut cur = node;
while matches!(cur.kind(), "member_expression" | "subscript_expression") {
if cur.kind() == "subscript_expression" {
return false;
}
match cur.child_by_field_name("object") {
Some(object) => cur = peel_receiver(object),
None => return false,