fix(resolution): name-free steps were vetoed by the construction selector (#2766)

A defect I introduced two commits earlier, not a pre-existing one.

`foldReceiverChain` opens each step with:

    if (options.constructionSyntax?.selector === step.name) return undefined;

Correct as written. It became wrong the moment name-free step kinds were
added: `step.name` is `undefined` for `await` and `index`, and
`constructionSyntax?.selector` is `undefined` for any language that
declares none — so `undefined === undefined` matched EVERY name-free step
and vetoed the whole fold before it ran.

That is why subscript and await receivers minted a chain, fired the Case 0
gate, had a resolvable base binding, and still produced no edge. The fold
logic downstream was fine; nothing downstream ever executed. Three reading
passes missed it because the line looks obviously correct — you have to
watch `step.name` be `undefined` at runtime. An instrumented run found it
immediately, which is the lesson: when captures, plumbing, payload and
gate all verify clean, stop reading and instrument.

Guarded on `step.name !== undefined`. Load-bearing, not defensive.

Shape matrix, RESOLVES 46 -> 55, VISIBLE-GAP 32 -> 23:

  awaitParen    now resolves in typescript, python, csharp, kotlin, dart
  indexElement  now resolves in typescript, cpp, go, python, rust, kotlin

Every canonical TypeScript shape now resolves, which broke the #2744
drop-recorder test: it needs a receiver that FAILS to type, and its own
comment records that `!` served until structural typing resolved it, then
await-paren served until this fix. Both were shapes the resolver merely
did not SUPPORT yet, so each improvement moved the goalposts. Re-fixtured
to an UNANNOTATED parameter — no type information exists, so no resolver
work can type it. Stable by construction rather than by
not-yet-implemented.

Verified: resolver integration + scope-resolution unit 4367 green,
typescript resolver 270 green, scope-capture unchanged (this is resolution,
not capture), callDrops unchanged at 102.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-07-31 20:31:20 +00:00
parent aefb162280
commit 10d872529e
3 changed files with 29 additions and 16 deletions

View file

@ -29,9 +29,9 @@
"plainDeepChain": "RESOLVES",
"optionalChain": "RESOLVES",
"nonNullAssert": "RESOLVES",
"awaitParen": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "RESOLVES",
"indexElement": "VISIBLE-GAP",
"indexElement": "RESOLVES",
"fourHopChain": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
@ -60,7 +60,7 @@
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "VISIBLE-GAP",
"indexElement": "VISIBLE-GAP",
"indexElement": "RESOLVES",
"fieldReceiverCall": "INVISIBLE-GAP",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "INVISIBLE-GAP"
@ -72,7 +72,7 @@
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "VISIBLE-GAP",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "RESOLVES",
"decoratedFieldType": "RESOLVES"
@ -94,9 +94,9 @@
"plainDeepChain": "RESOLVES",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "N/A",
"indexElement": "VISIBLE-GAP",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"
@ -118,7 +118,7 @@
"plainDeepChain": "RESOLVES",
"optionalChain": "INVISIBLE-GAP",
"nonNullAssert": "VISIBLE-GAP",
"awaitParen": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "VISIBLE-GAP",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
@ -144,7 +144,7 @@
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "INVISIBLE-GAP",
"indexElement": "VISIBLE-GAP",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "RESOLVES",
"decoratedFieldType": "INVISIBLE-GAP"
@ -168,7 +168,7 @@
"nonNullAssert": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "RESOLVES",
"indexElement": "VISIBLE-GAP",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"

View file

@ -479,7 +479,15 @@ export function foldReceiverChain(
// first. That turned a correct edge into a WRONG one (Ruby
// `Factory.new.run` → `Product.run`), which is the failure mode this whole
// line of work exists to avoid. Decline and let the cascade answer.
if (options.constructionSyntax?.selector === step.name) return undefined;
// `step.name !== undefined` is load-bearing, not defensive. The name-free
// step kinds (`await`, `index`) carry no name, and a language with no
// `constructionSyntax` has no selector — so the bare equality below was
// `undefined === undefined`, which matched EVERY name-free step and vetoed
// the whole fold before it ran. That is why subscript and await receivers
// minted a chain, fired the gate, and still produced no edge.
if (step.name !== undefined && options.constructionSyntax?.selector === step.name) {
return undefined;
}
// A position whose declared type named no class can only be advanced by an
// unwrapping step. Folding an ordinary member off it would look the member

View file

@ -3344,12 +3344,17 @@ export class Service {
`,
'main.ts': `import { Service } from './models';
export async function droppedCall(svc: Service): Promise<void> {
// An await-parenthesized receiver. Structural typing does NOT cover this
// shape — \`extractMixedChain\` reaches \`await …\`, which is not a chain node,
// so no chain is minted and the site still reaches the drop recorder. The
// \`!\` spelling used to serve here until structural typing resolved it.
(await svc.getUserAsync()).save();
export async function droppedCall(svc): Promise<void> {
// An UNANNOTATED parameter. The chain mints fine, but the base has no type
// binding to resolve against, so the site reaches the drop recorder.
//
// Third fixture for this case: \`!\` served until structural typing resolved
// it, then the await-parenthesized form served until name-free step kinds
// resolved that too. Both were shapes the resolver merely did not SUPPORT
// yet, so each fix moved the goalposts. An untyped receiver carries no type
// information at all, so no amount of resolver work can type it — which is
// what makes it a stable choice rather than the next one to be fixed.
svc.getUser().save();
}
export function droppedWrite(svc: Service | null): void {