Skip to content

fix(resolution): type an awaited receiver instead of guessing a same-named method (#1840) - #1855

Open
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1840-awaited-receiver-builtin
Open

fix(resolution): type an awaited receiver instead of guessing a same-named method (#1840)#1855
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1840-awaited-receiver-builtin

Conversation

@maxmilian

Copy link
Copy Markdown
Contributor

Fixes #1840

The path

const listed = await listPaths() is neither = new X() nor : X, so the TS/JS declaration patterns leave the receiver untyped. listed.split('\0') then reaches the bare-name strategy that resolves a method when the project declares exactly one with that name, and records a persisted edge into PaneManager::split at confidence 0.7 — the resolvedBy: "instance-method" / refName: "listed.split" in the report.

The #1566 guard that already declines a builtin receiver cannot help here: it needs an inferred type to fire, and inference produced nothing.

The change

Second chance for the local-receiver inference, JS/TS only, only after the declaration patterns miss. Follow an awaited bare callee to its declared return annotation and unwrap one Promise<...> layer.

The annotation is read from the callee's declaration line rather than from node metadata. The TS/JS extractors do not populate returnType — only Java, Go, C#, Swift, Dart, ObjC and Pascal implement getReturnType — and teaching them to would change extraction output for every TypeScript index and need a version bump. A bounded source scan is already the idiom next door (inferPhpAssignedPropertyType), so this stays inside resolution.

Only a bare callee qualifies. await svc.load() names a member whose owner would itself have to be inferred first, and guessing there is the mistake being fixed.

A primitive then joins the builtin receivers that decline rather than guess. A receiver typed string calls a built-in string method, never a project method. TS_PRIMITIVE_TYPES is deliberately separate from JS_BUILT_INS: those are runtime globals a receiver can be constructed from, and that set is also read in resolution/index.ts, so widening it would change behavior beyond this path.

Precision and recall both move the right way

The second test is the one worth reading. With a receiver whose name does not capitalize onto a class (so the capitalized-receiver strategy cannot carry it):

export async function makeEngine(): Promise<Engine> { return new Engine(); }
const handle = await makeEngine();
handle.run();

On main that resolves to nothingEngine::run is simply missing from the graph. With this change it resolves. So this is not only the removal of a false edge; the same inference recovers a true one that the guessing strategies never found.

On the hot path (red line 3)

inferLocalReceiverType runs for every receiver.method() ref, which is why the declaration scan carries INFER_SCAN_STATES. The new lookup follows the same discipline: it runs only after the declaration patterns miss, and its result — including null, which is the common case — is memoized per (file, scope, language, receiver) in a per-context WeakMap, cleared alongside the others in clearNameMatcherMemos. One scan and one node lookup per key, not per ref.

Relationship to #1844

They do not overlap, and I checked rather than assumed: applying the failing test below to #1844's head (95dccf9) still fails, so that PR does not cover this case. #1844 rewrites nested-receiver evidence (holder.values.get(...), Map.get/set/has) and bumps extraction 26 → 27; this change touches neither the extractor nor the extraction version, and its edit to js-builtins.ts is a new appended export. If #1844 lands first I am happy to rebase.

Validation

Node 24.19.0, macOS.

  • New tests in __tests__/resolution.test.ts, written before the fix, both red on main:
    • the reported case — main gives ['PaneManager::split', 'listPaths'], expected ['listPaths'];
    • the awaited-type case — main gives ['makeEngine'], expected ['Engine::run', 'makeEngine'].
  • Reverting only the two source files, with the tests kept, fails both again.
  • __tests__/resolution.test.ts in full: 212 passed, including the #1566 / #1790 builtin-receiver tests this sits next to.
  • tsc --noEmit: clean.
  • Full engine suite, compared against the same checkout with this change reverted: identical failure set, line for line (36 files / 141 tests both ways; passing 4253 → 4254, the delta being the tests added here). Those failures are environmental in my checkout — the cli-* suites shell out to node and need a dist build, and this machine's default Node is 26 while the package requires <25. They are not touched by this change either way.
  • Kernel parity and Windows were not exercised; this change is TypeScript-side resolution only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K5hLnmSWco1QjCAgMcWdcA

…named method

`const listed = await listPaths()` is neither `= new X()` nor `: X`, so the
TS/JS declaration patterns left the receiver untyped. `listed.split('\0')`
then fell through to the bare-name strategy that resolves a method when the
project declares exactly one with that name — recording a persisted call edge
into an unrelated class's `split` at confidence 0.7.

Give the local-receiver inference a second chance for JS/TS: follow an awaited
bare callee to its declared return annotation and unwrap one `Promise<...>`
layer. The annotation is read from the callee's declaration line rather than
from node metadata, because the TS/JS extractors do not populate `returnType`
(only Java, Go, C#, Swift, Dart, ObjC and Pascal do) and teaching them to
would change extraction output for every TypeScript index.

A primitive then joins the builtin receivers that decline rather than guess
(colbymchenry#1566): a receiver typed `string` calls a built-in string method, never a
project method. `TS_PRIMITIVE_TYPES` stays separate from `JS_BUILT_INS` —
those are runtime globals a receiver can be constructed from, and the set is
also read elsewhere in resolution.

The second chance runs only after the declaration patterns miss, and its
result is memoized per (file, scope, receiver) like the declaration scan it
follows, so the hot path keeps paying one scan per key rather than one per ref.

Both directions are covered: the false edge is gone, and a receiver bound to
`await makeEngine()` now resolves `Engine::run`, which previously resolved to
nothing at all.

Fixes colbymchenry#1840

Co-Authored-By: Claude Opus 5 (1M context) <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.

TypeScript: split() on an awaited Promise<string> result resolves to an unrelated class method (v1.6.0)

1 participant