Skip to content

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

Merged
colbymchenry merged 1 commit into
mainfrom
claude/pr-2766-this-field-element
Oct 23, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/pr-2766-this-field-element

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

A TypeScript or JavaScript method called on an element of a class field, like this.handlers[type].handle() or this.items[0].save(), now links to the method of the type the field is declared to hold (Item[], Array<Item>, ReadonlyArray<Item>, Record<string, Handler>), or to nothing. On main it kept the bare method name, which exact-matched whichever project method shared it, often another class's: this.listeners[type].push(h) reached an unrelated UndoStack.push.

Root cause

A member chain rooted at this collapses to the bare method name, so the resolver can read the owner off the enclosing class (keepsBareTsJsReceiver, and the kernel's keeps_bare_receiver). A subscript in that chain leaves the resolver nothing to read the receiver from, so the bare name went to name matching.

The fix (from the contributor)

  • Extraction, both engines. this.<field>[k].<m>() is emitted as this.<field>[].<m>, and this['svc'].m() as this.svc.m, the field itself (tsThisSubscriptReceiver / this_subscript_of). Any other subscript in a this-rooted chain (this.a.b[k].m(), this[k].m()), and one under window (window['App'].init()), now emits nothing, as arr[0].run() already did.
  • Resolution. matchTsThisFieldCall reads the field's declaration on the calling class or a base, as it does for this.<field>.<m>(). The field reader also records the element type of X[], Array<X>, ReadonlyArray<X> and Record<K, X>, and the method resolves on that type or nowhere. There is no element type for a type parameter of the declaring class, a union or intersection with another type (| null and | undefined keep it, also when the union goes on below), an array of arrays, or a non-plain element.
  • tsClassTypeParameter reads a class's type-parameter list from its keyword to the list's own >, over several lines and through braces in constraints (<T extends { id: string }>).

What we changed on top

Validation

Tests. ts-this-field-element.test.ts (the contributor's) fails 10 of 11 on main with both the kernel and wasm (CODEGRAPH_KERNEL=0), each case linking to the decoy class, and passes here on both. The new ts-array-annotated-receivers case fails on the PR as written (the type-alias half) and on main (the brace-constraint half), and passes here. The contributor's extraction cases in expression-receiver-calls and kernel-tsjs-parity pass.

A/B against main 9c4794d (graph dumps by natural keys, kernel built per arm; the main commits after it touch only the viewer and tests):

Repo Lang Files Added Retargeted Typed (same target) Removed
flask (control) Python 91 0 0 0 0, byte-identical
gin (control) Go 110 0 0 0 0, byte-identical
vscode src/vs/base + src/vs/editor TS 1,395 19 24 35 45
pixijs TS 1,629 7 5 5 15
typeorm TS 3,652 0 2 2 4
excalidraw TS 731 0 0 0 1
three.js JS 1,763 0 0 0 116
playcanvas engine JS 2,326 0 0 0 119
pdf.js JS 595 0 0 0 53

Every changed edge was read at its call site:

  • Added and retargeted, all right. They land on the element type the field declares. vscode's this.modelLineProjections[i].getViewLineCount() moves from the class's own method to IModelLineProjection (17 sites), this._lenses[i].dispose() from CodeLensContribution::dispose to CodeLensWidget::dispose, and this._chunks[0].slice() from a project slice function to VSBuffer::slice. pixijs's this.buffers[0].update() moves from RopeGeometry::update to Buffer::update. typeorm's this.connectedQueryRunners[0].release() now reaches the declared QueryRunner interface rather than one driver's runner.
  • TS removals: 50 of 65 were wrong. These are bare-name guesses on strings, arrays, Sets, Maps and DOM nodes: vscode's this._lines[i].substring() → OffsetRange::substring ×13, .focus() on DOM elements, and pixijs's this._gpuData[key]?.destroy() → the class's own destroy. The 15 right ones lost were right only by name, in shapes the PR leaves unresolved by design. Those are a getter (this.viewItems), an index-signature field, a cast ((this.filters[0] as MaskFilter)), a mixin object's this, a T[] type parameter, a union type alias, and deep chains like this.replacements[0].range.getStartPosition().
  • JS removals: about 230 of 288 were wrong. JS fields have no written type, so every element call now goes unlinked. Examples: three.js .set() → Capsule::set ×41 and .copy() → NURBSCurve::copy ×22, playcanvas SVG setAttribute → StandardMaterial::setAttribute ×27, pdf.js classList.add → Word64::add. About 58 were right by luck (Color::setHex, GraphNode::setLocalPosition, PDFThumbnailView::toggleSelected). This is the deliberate trade the PR describes, the same one arr[0].run() already makes.
  • window['App'].init(): no such call in any of the 9 repos. The one window-rooted subscript chain changed was excalidraw's window.visualDebug.data[…].push(element), which had linked to BinaryHeap::push (wrong, removed).

Kernel parity (scripts/kernel-parity.mjs) holds on every TS/JS repo above and on this repository's src/ and __tests__/, with the same deferral counts as main.

Cost: the index CPU time is within noise. Medians of 3 interleaved runs: pixijs 32.4 s → 33.3 s and three.js 64.7 s → 67.9 s, against a spread of 28.6–33.8 s and 60.1–72.6 s between runs of the same arm.

Suite (CODEGRAPH_KERNEL_EXPECT=1 npx vitest run --maxWorkers=4 --minWorkers=1, on this commit): 756 files, 9,028 tests, 8,921 passed, 82 skipped. The 25 that failed were all in the four daemon suites (mcp-daemon, daemon-older-version, daemon-silent-holder, daemon-pid-reuse), because the run's environment had CODEGRAPH_NO_DAEMON=1, which their spawned servers inherit. Run again with the daemon allowed, those four files pass: 58 passed, 1 skipped. Main then moved by one PHP-only commit (5c5bfd4, import-resolver.ts). After rebasing onto it, with the changed lines identical, this PR's test files, the PHP alias test and the resolution suites pass: 8 files, 401 tests.

Lands #2766 by @inth3shadows.

🤖 Generated with Claude Code

…'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>
@colbymchenry
colbymchenry merged commit 449eefa into main Oct 23, 2026
@colbymchenry
colbymchenry deleted the claude/pr-2766-this-field-element branch October 23, 2026 17:50
colbymchenry pushed a commit that referenced this pull request Oct 23, 2026
A TypeScript or JavaScript call on a constructed object, new C(args).m(),
reached the resolver as the bare method name in both engines, so exact
matching bound it to any project method called m: new RegExp(p).exec(s)
to a database wrapper's exec, new Array(n).fill(0) to a text class's
fill, new TextEncoder().encode(s) to a project encode function.

Both engines now emit new C().m (new ns.C().m for a namespaced class, and
nothing for a constructor that is not a plain name or member chain). The
resolver looks m up on C with resolveMethodOnType, inherited methods on
the conformance pass once extends edges exist, an aliased named import
mapped back to its class, and a JS built-in (RegExp, Map, Date...) never
a project class unless the file imports or declares one. An ES5
constructor function keeps its prototype method.

Our changes: the lookup passes the file the call's file imports the class
from (tsImportedTypeFile), so of two classes sharing a name the imported
one is the one constructed (the PR as written took the first indexed copy,
at 0.9); new this.constructor() and a static new this() keep the bare
method name, as a call on this does (the PR dropped three.js's clone
idiom, 29 links to a class's own copy); rebased onto PR #2852, which
edits the same keepsBareTsJsReceiver and test lists; resolved the import
list conflict with rustChainedCallByName from PR #2846; CHANGELOG bullet
moved next to the JS/TS receiver bullets.

Validation: the new tests fail 4 of 6 on main 449eefa with both engines
and pass here. A/B against main, kernel per arm: flask, gin and vite
byte-identical. codegraph 48 links added (new ToolHandler(cg).execute), 2
retargeted and 14 typed, all right, 58 removed, 57 wrong (RegExp exec and
test, Array fill, Set add) and 1 right one lost to the grammar's parse of
new nodeSqlite!.DatabaseSync(). excalidraw 22 wrong removed (TextEncoder,
TextDecoder, URLSearchParams, Array fill), 3 typed. pixijs 14 retargeted
and 278 typed, all right, 7 wrong removed. pdf.js 5 added, 47 retargeted
and 68 typed, all right, 76 wrong removed (Array keys and fill, typed
arrays, Response). three.js 33 added, 404 retargeted (Capsule::set to
Matrix4::set and Matrix3::set, NURBSCurve::copy to the class's own copy)
and 429 typed, all right; 135 removed, 133 wrong (UINumber onChange, which
three.js attaches at run time, TextDecoder, typed arrays, URL) and 2 right
only by name (Loader methods inherited through a base imported from the
three package). No default-imported class under another name lost an edge.
Kernel parity holds on all six TS/JS repos and this repository, 0 diffs.
Index CPU time unchanged or lower: codegraph 81.5 s to 79.5 s, three.js
72.0 s to 71.2 s (medians of 3, interleaved).

Original PR #1945 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants