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
+42 -2
View File
@@ -684,16 +684,42 @@ function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: S
return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(chain) ? { name: chain, at: cur } : null;
}
/**
* A TS/JS receiver that subscripts the enclosing class's object: an element
* of a field, `this.<field>[k]` → `this.<field>[]`, or a field named by a
* string key, `this['svc']` → `this.svc`. Null for any other subscript.
* Mirrored in the kernel's this_subscript_of.
*/
function tsThisSubscriptReceiver(node: SyntaxNode, source: string): string | null {
if (node.type !== 'subscript_expression') return null;
const objectNode = getChildByField(node, 'object');
if (!objectNode) return null;
const object = peelTsJsReceiver(objectNode);
if (object.type === 'this') {
const index = getChildByField(node, 'index');
const key = index?.type === 'string' ? /^(['"])([A-Za-z_$][\w$]*)\1$/.exec(getNodeText(index, source))?.[2] : undefined;
return key ? `this.${key}` : null;
}
if (object.type !== 'member_expression' || getChildByField(object, 'object')?.type !== 'this') return null;
const property = getChildByField(object, 'property');
if (!property || !THIS_FIELD_PROPERTY_TYPES.has(property.type)) return null;
return `this.${getNodeText(property, source)}[]`;
}
/**
* Whether a TS/JS receiver still collapses to the bare method name: `this` /
* `super` (the resolver reads the owner off the enclosing class), a member
* chain rooted at either or at `window` (the project-global escape of
* {@link isUnresolvedTsJsChain}), and `new C()` (its class is written at the
* call).
* call). A subscript anywhere in the chain does not: the resolver cannot
* read the receiver back from it, so the bare name would exact-match any
* project method of that name. (An element of a this-field is typed instead,
* see {@link tsThisSubscriptReceiver}.)
*/
function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean {
let cur: SyntaxNode | null = node;
while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) {
if (cur.type === 'subscript_expression') return false;
const object = getChildByField(cur, 'object');
cur = object ? peelTsJsReceiver(object) : null;
}
@@ -6022,6 +6048,8 @@ export class TreeSitterExtractor {
: rawReceiver && this.language === 'swift'
? peelSwiftCallWrappers(rawReceiver, this.source)
: rawReceiver;
// `this.<field>[]` / `this.<name>` of a subscript receiver, set by its branch below.
let thisSubscript: string | null = null;
// A LITERAL receiver — `", ".join(...)`, `"x".toUpperCase()`,
// `5.times`, `[].concat(...)` — calls a builtin of the literal's
// type, never a project symbol. The bare-name fallback below let
@@ -6215,6 +6243,18 @@ export class TreeSitterExtractor {
// its name keeps the `#` (#1987).
const fieldName = getNodeText(getChildByField(receiver, 'property')!, this.source);
calleeName = `this.${fieldName}.${methodName}`;
} else if (
TS_JS_CHAIN_LANGUAGES.has(this.language) &&
receiver &&
(thisSubscript = tsThisSubscriptReceiver(receiver, this.source)) !== null
) {
// An element of a field of the enclosing class —
// `this.listeners[type].push()`, `this.items[0].save()` — kept
// as `this.<field>[].<method>`: the resolver reads the element
// type off the field's declaration (`Item[]`, `Record<K, V>`)
// or leaves it unresolved. `this['svc'].start()` is the field
// itself. Mirrored in the kernel's extract_call.
calleeName = `${thisSubscript}.${methodName}`;
} else if (
(this.language === 'typescript' ||
this.language === 'javascript' ||
@@ -6276,7 +6316,7 @@ export class TreeSitterExtractor {
) {
// Any other TS/JS receiver is an expression with no static
// type here — `(a ?? b).map()`, `f().list.map()`,
// `arr[0].run()`, `(() => {}).call()`. The bare method name this
// `arr[0].run()`, `this.a.b[k].run()`, `(() => {}).call()`. The bare method name this
// used to emit exact-matched whichever project method shared the
// name (`(await list()).map()` onto an adapter class's `map`).
// Emit nothing: a silent miss, never a wrong edge. Mirrored in