Skip to content

fix(sync): a member call on a local typed by an imported class links when the class's module changes - #2637

Merged
colbymchenry merged 3 commits into
mainfrom
claude/typed-local-member-retry
Oct 18, 2026
Merged

colbymchenry merged 3 commits into
mainfrom
claude/typed-local-member-retry

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

What

codegraph sync (and the watcher) left a member call on a local typed by an imported class unlinked. This happened when the class's module was added later, came back after a delete, or gained the class in an edit:

import { LRUCache } from './lru-cache';
const cache = new LRUCache<string, number>(10);   // or `cache: LRUCache<K, V>`, a typed parameter,
cache.get(key);                                    // `new lib.LRUCache()`, a default-imported class

A fresh index links cache.get to LRUCache.get (instance-method, 0.9) by reading the local's declaration. While the module was missing or empty, the call failed and was parked under its member's name, get. A name that common is past the retry's 500 ceiling, so no sync ever retried it. Seen in folo (cache.get ×11 in lru-cache.test.ts, newChatInstance.resumeStream) and milo-ui (astCache.get).

This is a follow-up to #2630, which did the same for a member of the import binding itself (store.get).

Fix

In importBindingTail (import-resolver.ts), when a direct member call's receiver is not an import binding, the parking decision now reads the receiver's declared or constructed type the way resolution does. It uses inferLocalReceiverType, now exported. That function is memoized per file, scope and receiver, so the call the matcher just tried costs a lookup. The decision then takes the import binding the type is written through: LRUCache, or lib of lib.LRUCache.

The call is parked under that module's key, exactly as #2630 parks a binding member. It keeps #2630's other two triggers: its member's name under the shared ceiling, and its receiver.

Exceptions:

  • Calls only. TypeScript records a typed local's member only as a call. A method of this passed as a value is looked up in the enclosing class, whatever this is declared as.
  • Arrays excluded. A local or parameter declared as an array of the class (results: URI[]) keeps its member's tail, as on main. Resolution reads the element type there too, but results.push(…) is the array's method, which no edit of the class's module links. To tell, inference now reports the line it read the declaration from (decl.line). On vscode these calls were 60% of what a first version parked (8,192 of 13,704), and they nearly doubled what a uri.ts edit retries (1,228 → 1,996 refs, below).
  • A built-in or package type, a type the file declares itself, a factory's result and a file that imports nothing keep their own tail.

Fresh indexes are unchanged. Only name_tail of failed calls moves.

Validation

Fresh-index A/B (byte-identical controls)

  • 19 repos (the fix(sync): a member reference through an import binding links its module when the module changes #2630 kit): nodes, edges and files are identical to main in every repo. Unresolved refs are identical except the name_tail of failed calls: folo 23, milo-ui 86, vben 47, takenote 9, proshop 6, ghostfolio 6, infrahub 2; the other 12 repos are unchanged.
  • vscode (15,358 files): nodes (519,891), edges (1,985,832), files and refs (829,796) are identical. 5,734 failed calls move from their own tail to a module key.

Sync vs a fresh index

Every 7th file is held out, emptied, or deleted and then restored. Each arm's synced graph is compared with its own fresh index; numbers are differing dump lines.

scenario main fix what the fix relinks
folo hold 64 62 newChatInstance.resumeStream
folo gut 198 174 cache.get ×11, newChatInstance.resumeStream
folo drop 220 198 cache.get ×11
milo-ui gut 854 852 astCache.get
milo-ui drop 6 0 astCache.get

All other scenarios are unchanged: 19 hold, 17 gut and 17 drop per arm. None got worse, and the residue buckets are otherwise identical. The arrays exclusion changed none of these (fix with and without it: same buckets in all 27 runs on the affected repos).

Left over, on both arms:

Cost on vscode

Parking decisions. 817,145 failed JS-family refs, each first attempted as resolution does, single thread, two runs per arm: 1,376 / 1,371 ms → 2,229 / 2,241 ms, so +0.86 s per full index. For scale, the attempts themselves take ~68 s and the index takes 219 s on both arms.

Parked under a module key. 6,336 → 12,070 refs, under 194 → 662 keys. None crosses the 5,000 module ceiling.

What a sync retries. The changed files' names and module keys go through the retry lookup, and what it finds is re-attempted. Nothing changed, so nothing resolves.

changed refs main → fix re-attempt main → fix
uri.ts 1,228 → 1,585 568 → 875 ms
lifecycle.ts 1,883 → 1,968 1,041 → 1,104 ms
event.ts 4,387 → 4,570 1,439 → 1,453 ms
editor/common/model.ts 3,076 → 3,262 1,472 → 1,489 ms
dom.ts 5,087 → 5,295 2,773 → 2,802 ms
50 files 9,455 → 10,701 3,233 → 3,570 ms
1,000 files 67,436 → 70,273 18.3 → 19.2 s

Per changed file, over all 15,358 files: mean 302.5 → 326.2, p50 98 → 112, p90 800 → 860, p99 2,341 → 2,521.

A real codegraph sync of a uri.ts edit, four syncs per arm (the edit and its restore, twice): the failed-ref retry phase took 687 / 696 / 698 / 732 ms on main (1,217 refs) and 989 / 1,014 / 989 / 974 ms with the fix (1,574 refs), so +0.3 s. The whole sync took 98–108 s on both arms; whole-graph synthesis takes most of it.

Array exclusion trade-off. A fresh index does link a few array calls to the element class's method: 15 on vscode, such as distinctElements.find on an ExplorerItem[] → ExplorerItem::find. These are wrong 0.9 edges from the same mis-inference. With this PR, a sync doesn't re-create them when the class's module returns. Stopping the mis-inference itself is a separate fresh-edge change, flagged as a follow-up.

Tests

__tests__/sync-typed-local-member-retry.test.ts has 4 tests, all of which fail on main 986de95 (run in a detached worktree):

  • Three sync scenarios: the module added after the callers, restored after a delete, and given the class in an edit. Each is compared with a fresh index of the final tree in a second folder. 501 failed extApi.get calls put get past the ceiling.
  • The parked tails:
    • typed locals wait under module:lru-cache or module:store: new, an annotation, a typed parameter, a namespace import and a default import;
    • these keep their own tail: new Map(), a package's class, a same-file class, a factory's result, a local and a parameter declared as arrays of the class, and a this.peek method value.

Mutations:

  • Without the calls restriction, this.peek goes to module:factory.
  • Without the array check, caches.push goes to module:factory.

Each fails the tails test.

Full suite (Windows), on the final code: 592 files passed (6 skipped), 7,255 tests passed, 0 failed (82 skipped).

🤖 Generated with Claude Code

colbymchenry and others added 3 commits October 18, 2026 11:49
…when the class's module changes

A failed `cache.get(k)` after `const cache = new LRUCache(10)` (or an
annotated local, a typed parameter, a default or namespace import of the
class) was parked under its member's name, `get`, which more failed refs
share than the retry's ceiling, so a sync that added or restored the
class's module never retried it. It now waits under the key of the module
the type's binding imports, as a member of the binding itself does (#2630).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…log entry

Resolution types a receiver by its declaration for a member call; a
method passed as a value off `this` is looked up in the enclosing class
whatever `this` is declared as, so only calls wait for the module of the
receiver's type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to its member's tail

Resolution reads `results: URI[]` as a `URI`, but `results.push(…)` is the
array's method, which no edit of the class's module links. On vscode those
calls were 60% of what the typed-local parking added (8,192 of 13,704), and
retrying them nearly doubled what an edit of `uri.ts` retried. Inference
now reports the line it read the declaration from, and a local annotated
as an array of the type keeps its member's tail, as on main.

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.

1 participant