refactor(resolution): carry the declared type through each fold step (#2766)

`foldReceiverChain` carried a bare `SymbolDefinition` between steps, and
`typeOfMemberOnClass` collapsed each member's declared type to a class
immediately. That is fine while every step is an ordinary member, and
fatal for a step that needs the WRAPPER: no workspace class is named
`Promise<User>` or `[]Repo`, so the position resolved to nothing and the
step after it had already returned `undefined`.

Per-step state becomes a `FoldState` carrying the declared type text
alongside the resolved class. Two consequences worth naming:

- A member whose declared type names no class no longer returns
  `undefined`. It returns the declared type with `unresolvedDeclaredType`
  set, because unwrapping that string is exactly how such shapes become
  resolvable. Returning `undefined` there is what stranded them.
- That marker is load-bearing in the other direction too: an ORDINARY
  step off such a position declines, because folding onward would look
  the next member up on the previous class — a wrong owner, silently.

The base carries its declared type as well, so a chain whose first step
is an unwrap (a subscript applied directly to the base) has the container
spelling available instead of declining at step one.

No observable behaviour change on its own — both the old and new code
decline the same positions; this only stops discarding the information
the next two units need. Groundwork, so the check that matters is that
nothing moved: scope-resolution unit suite 90 files / 1371 tests green,
resolver integration 50 files / 2995 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-07-31 16:24:08 +00:00
parent e718eebca0
commit 2d1f52cc4e

View file

@ -298,25 +298,72 @@ function resolveConstructionExpressionClass(
* member of this name?"), which is a guess. Structure exists precisely so this
* does not have to guess.
*/
/**
* One position in a fold: the class the chain has reached, PLUS the declared
* type text that produced it.
*
* The declared type is carried because a step can need the WRAPPER rather than
* the class it resolved to. `typeOfMemberOnClass` collapses `Promise<User>` to
* the class `User`... except it cannot, because no workspace class is named
* `Promise<User>` — so an await or index step reached through a bare
* `SymbolDefinition` would find the previous step had already returned
* `undefined`. Keeping `declaredTypeName` alongside the class is what lets those
* steps unwrap one layer instead of typing against nothing.
*
* `undefined` when the position was reached by a route that has no declared type
* to report (a static class receiver, say). A step needing one declines rather
* than guessing.
*/
interface FoldState {
readonly def: SymbolDefinition;
readonly declaredTypeName?: string;
readonly declaredAtScope?: ScopeId;
/**
* The declared type named no class in the workspace, so `def` is the PREVIOUS
* position rather than this member's type. Only an unwrapping step (await,
* index) can make progress from here; any other step must decline, because
* folding on against the previous class would silently look the next member up
* on the wrong owner.
*/
readonly unresolvedDeclaredType?: boolean;
}
function typeOfMemberOnClass(
owner: SymbolDefinition,
memberName: string,
scopes: ScopeResolutionIndexes,
index: WorkspaceResolutionIndex,
options: ResolveCompoundReceiverOptions,
): SymbolDefinition | undefined {
): FoldState | undefined {
const classScopeByDefId = index.classScopeByDefId;
const ownerChain = [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)];
for (const ownerId of ownerChain) {
const classScope = classScopeByDefId.get(ownerId);
const memberType = classScope?.typeBindings.get(memberName);
if (memberType !== undefined) {
return findClassBindingInScope(
const def = findClassBindingInScope(
memberType.declaredAtScope,
memberType.rawName,
scopes,
options.stripTypePreservingDecoration,
);
// The declared type is reported even when it resolved to no class:
// `Promise<User>` and `[]Repo` name nothing in the workspace, and an
// await or index step unwrapping them is exactly how they become
// resolvable. Returning `undefined` here would strand those shapes.
if (def === undefined) {
return {
def: owner,
declaredTypeName: memberType.rawName,
declaredAtScope: memberType.declaredAtScope,
unresolvedDeclaredType: true,
};
}
return {
def,
declaredTypeName: memberType.rawName,
declaredAtScope: memberType.declaredAtScope,
};
}
// Languages whose binding-scope hook hoists a method's return-type binding
// out of the class body and onto an ancestor (Module) scope keep NOTHING in
@ -332,7 +379,13 @@ function typeOfMemberOnClass(
if (curScope === undefined) break;
const hoisted = curScope.typeBindings.get(memberName);
if (hoisted !== undefined) {
return findClassBindingInScope(hoisted.declaredAtScope, hoisted.rawName, scopes);
const def = findClassBindingInScope(hoisted.declaredAtScope, hoisted.rawName, scopes);
if (def === undefined) return undefined;
return {
def,
declaredTypeName: hoisted.rawName,
declaredAtScope: hoisted.declaredAtScope,
};
}
curId = curScope.parent;
}
@ -374,13 +427,23 @@ export function foldReceiverChain(
// `receiverChain` is dropped before resolving the base: it describes the
// whole receiver, and handing it back to the resolver would re-enter this
// fold on the base and never terminate.
let current = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, {
const baseDef = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, {
...options,
fieldFallback: false,
receiverChain: undefined,
strictBaseBinding: true,
});
if (current === undefined) return undefined;
if (baseDef === undefined) return undefined;
// The base's own declared type is carried too, so a chain whose FIRST step is
// an unwrap (`repos[0].save()` — index applied directly to the base) has the
// container spelling available. Without it that shape would decline at step 1.
const baseBinding = findReceiverTypeBinding(inScope, chain.baseReceiverName, scopes);
let current: FoldState = {
def: baseDef,
declaredTypeName: baseBinding?.rawName,
declaredAtScope: baseBinding?.declaredAtScope,
};
for (const step of chain.steps) {
// Construction is NOT an ordinary member lookup. `Factory.new` on a class
@ -395,11 +458,17 @@ export function foldReceiverChain(
// line of work exists to avoid. Decline and let the cascade answer.
if (options.constructionSyntax?.selector === step.name) return undefined;
const next = typeOfMemberOnClass(current, step.name, scopes, index, options);
// 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
// up on the PREVIOUS class — a wrong owner, silently.
if (current.unresolvedDeclaredType === true) return undefined;
const next = typeOfMemberOnClass(current.def, step.name, scopes, index, options);
if (next === undefined) return undefined;
current = next;
}
return current;
// A chain that ended on an unresolved declared type never reached a class.
if (current.unresolvedDeclaredType === true) return undefined;
return current.def;
}
export function resolveCompoundReceiverClass(