feat(scope-resolution): split shape-like from class-like, linking alias fields

Completes A4: a field on a TypeScript object-type alias now links to the code
that reads it, the last unanswerable half of "who breaks if I remove this?"
for a TS frontend that models contracts as `type X = { … }`.

`isClassLike` answered two questions that only coincide for classes:
  1. does this declare MEMBERS I can look up?   — a SHAPE (structural)
  2. does this participate in inheritance / MRO? — a NOMINAL TYPE
An object-type alias is (1) and emphatically not (2) — it has no supertypes
and no place in a linearization. Widening `isClassLike` to buy (1) would have
enrolled every language's aliases (Rust type_item, Kotlin/Swift/Dart
typealias, C typedef) into MRO and heritage, so the two questions now get two
predicates. Call sites split by which they ask, and their names already said
which: `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase`
keep `isClassLike`; receiver typing and member OWNERSHIP take `isShapeLike`.

Three parts, each necessary and none sufficient alone:
- `findShapeBindingInScope`, mirroring `findValueBindingInScope`'s established
  relationship to `findClassBindingInScope` (same walker, different accepted
  def-type), consulted only AFTER the class lookup misses so a class of the
  same name always wins.
- `populateClassOwnedMembers` uses it, so alias members get an `ownerId` and
  are registered under the alias. Without this the receiver resolved to the
  alias and then found no members under it.
- The TS scope query tags aliases `@declaration.type_alias`, not
  `@declaration.type`: `normalizeNodeLabel` accepts typealias / type_alias and
  has no "type" case, so the old tag mapped to NO label and TypeScript aliases
  produced no scope-resolution def at all. Kotlin and Dart already spelled it
  this way; TypeScript alone was on the dead tag.

An earlier attempt concluded a further "scope-attachment gate" existed. That
was wrong and is worth recording: scope extraction runs in the parse WORKER,
which loads built `dist`, so the retag was never executed. Rebuilt, the alias
hoists to Module scope exactly as the interface does. Same trap as the parse
query — `src` edits to anything the worker runs are invisible until
`npm run build`.

Typedef and Union stay out of `isShapeLike` deliberately: they belong
conceptually (the union_item note on MEMBER_OWNER_NODE_TYPES records the same
gap) but neither is wired as a member container, so including them would widen
a predicate nothing exercises.

Verified on the full resolver matrix: 3173 tests, golden unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 03:49:32 +03:00
parent a49d33a792
commit 1320cc6db3
4 changed files with 77 additions and 31 deletions

View file

@ -162,8 +162,12 @@ export const TYPESCRIPT_SCOPE_QUERY = `
(enum_declaration
name: (identifier) @declaration.name) @declaration.enum
;; Tagged @declaration.type_alias, NOT @declaration.type: normalizeNodeLabel
;; accepts typealias / type_alias and has no "type" case, so the old tag mapped
;; to no label and TypeScript aliases produced NO scope-resolution def at all.
;; Kotlin and Dart already spell it this way.
(type_alias_declaration
name: (type_identifier) @declaration.name) @declaration.type
name: (type_identifier) @declaration.name) @declaration.type_alias
(internal_module
name: (identifier) @declaration.name) @declaration.namespace

View file

@ -60,6 +60,7 @@ import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import { collectNamespaceTargets } from '../scope/namespace-targets.js';
import {
findClassBindingInScope,
findShapeBindingInScope,
findEnclosingClassDef,
isReceiverOwnedButUnbound,
findExportedDef,
@ -140,6 +141,16 @@ function resolveClassBindingForName(
const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration);
if (direct !== undefined) return direct;
// A receiver may be typed as an object-type ALIAS, which declares members
// exactly as an interface does but is not class-like, so it binds nothing
// above. Tried only after the class lookup misses, so a class of the same
// name always wins and nothing that resolved before changes. Confined to
// this helper on purpose: its two callers are member dispatch and an
// origin-classifying diagnostic, never inheritance — see `isShapeLike` for
// why the two questions must not share a predicate.
const shape = findShapeBindingInScope(scopeId, rawClassName, scopes);
if (shape !== undefined) return shape;
if (!rawClassName.includes('<')) return undefined;
const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, '');
if (baseName.length === 0) return undefined;

View file

@ -175,6 +175,37 @@ export function isClassLike(t: string): boolean {
);
}
/**
* Does this label declare MEMBERS addressable by name?
*
* `isClassLike` answers two questions that only coincide for classes:
* 1. does this declare members I can look up? — a SHAPE (structural)
* 2. does this participate in inheritance / MRO? — a NOMINAL TYPE
*
* A TypeScript object-type alias answers YES to (1) and emphatically NO to
* (2): it declares the same `property_signature` members as the interface
* beside it, but has no supertypes and no place in a linearization. Answering
* (2) "yes" merely to buy (1) is what widening `isClassLike` would do, and it
* would enrol every language's aliases (Rust `type_item`, Kotlin/Swift/Dart
* typealias, C `typedef`) into MRO and heritage.
*
* So the two questions get two predicates. Use THIS one where the question is
* "find the shape so I can look up a member"; keep `isClassLike` where the
* question is inheritance. The call sites announce which they are:
* `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase` are
* (2); receiver typing is (1).
*
* NOT YET INCLUDED, deliberately: `Typedef` and `Union`. They belong here
* conceptually — the `union_item` note on `MEMBER_OWNER_NODE_TYPES` records
* the same gap, that a union owns fields captured as `Property` yet is not a
* recognized owner — but neither is wired as a member container today, so
* adding them would widen a predicate nothing exercises. They join when their
* containers do, with fixtures.
*/
export function isShapeLike(t: string): boolean {
return isClassLike(t) || t === 'TypeAlias';
}
/**
* Walk the scope chain from `startScope` looking for a typeBinding
* named `receiverName`. Returns the TypeRef or undefined if no binding
@ -794,6 +825,24 @@ export function findValueBindingInScope(
return walkScopeChain(startScope, receiverName, scopes, (def) => isOwnableValueLabel(def.type));
}
/**
* Look up a SHAPE binding (class-like, or an object-type alias) by name.
*
* Mirrors `findClassBindingInScope` exactly; only the accepted def-type
* predicate differs — the same relationship `findValueBindingInScope` has to
* it. Exists so a receiver typed as an object-type alias can reach that
* alias's members WITHOUT the alias becoming eligible as an inheritance base:
* `findClassBindingInScope` is what `resolveInheritanceBaseInScope` calls, so
* widening that one would answer a question about hierarchies with a shape.
*/
export function findShapeBindingInScope(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
return walkScopeChain(startScope, receiverName, scopes, (def) => isShapeLike(def.type));
}
/**
* Generic scope-chain walker. Walks from `startScope` toward the root,
* consulting both the local `scope.bindings` channel and the dual-source
@ -1139,13 +1188,19 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void {
// on `class U: def save(self): def helper(): ...` — helper.ownerId will
// remain undefined. The theoretical concern is real only if the
// extractor ever stops creating scopes for inner defs.
// `isShapeLike`, not `isClassLike`: OWNERSHIP is question (1) — "which
// declaration do these members belong to?" — and an object-type alias owns
// members exactly as the interface beside it does. Without this its members
// get no `ownerId`, so nothing is registered under the alias and a receiver
// typed as one finds the owner but never its members. Inheritance/MRO keep
// `isClassLike`; see the predicate's docstring.
for (const scope of parsed.scopes) {
// Methods: function scope whose parent is a Class scope. Owner is
// the parent's class-like def.
// the parent's shape def.
if (scope.parent !== null) {
const parentScope = scopesById.get(scope.parent);
if (parentScope !== undefined && parentScope.kind === 'Class') {
const classDef = parentScope.ownedDefs.find((d) => isClassLike(d.type));
const classDef = parentScope.ownedDefs.find((d) => isShapeLike(d.type));
if (classDef !== undefined) {
for (const def of scope.ownedDefs) {
(def as { ownerId?: string }).ownerId = classDef.nodeId;
@ -1157,7 +1212,7 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void {
// Class-body fields: defs directly owned by a Class scope (the
// class-like def itself excluded).
if (scope.kind === 'Class') {
const classDef = scope.ownedDefs.find((d) => isClassLike(d.type));
const classDef = scope.ownedDefs.find((d) => isShapeLike(d.type));
if (classDef !== undefined) {
for (const def of scope.ownedDefs) {
if (def === classDef) continue;

View file

@ -77,31 +77,7 @@ describe('TypeScript type-alias and interface members (A4)', () => {
expect(readersOf('ifaceSlots')).toContain('renderIface');
});
// ALIAS field -> consumer is still unlinked. Diagnosis, traced to the end so
// the next attempt starts from facts rather than from this list again:
//
// 1. The graph side is COMPLETE and symmetric with the interface:
// `Property:contracts.ts:LiveModeConfig.bookSlots` is owner-qualified
// and carries `HAS_PROPERTY LiveModeConfig->bookSlots`.
// 2. Resolution enters `resolveClassBindingForName('LiveModeConfig')`
// (verified by instrumentation) and misses.
// 3. It misses because the module scope binds `LiveModeIface:Interface`,
// `renderAlias`, `renderIface` — and NOT `LiveModeConfig`. The alias has
// no binding on the receiver's scope chain at all.
// 4. The TS scope query tags aliases `@declaration.type`, but
// `normalizeNodeLabel` accepts only `typealias` / `type_alias` and has
// no `type` case, so it returns undefined. Kotlin and Dart use
// `@declaration.type_alias`; TypeScript is alone on the dead tag.
// 5. Retagging it to `@declaration.type_alias` is NECESSARY BUT NOT
// SUFFICIENT — tried, and the binding still does not appear on the
// chain, so a second gate exists in how a declaration anchored on a
// node that is ALSO a `@scope.class` anchor gets attached (the alias
// appears to bind inside its own scope instead of hoisting to Module,
// where `interface_declaration` evidently does hoist).
//
// A widened predicate (`isShapeLike`) plus a mirrored
// `findShapeBindingInScope` were also built and REVERTED: with no binding on
// the chain they never fire, and shipping inert widening is worse than none.
// Fix step 5 first; the rest is then a small, testable change.
it.todo('links an alias field to its consumer');
it('links an alias field to its consumer', () => {
expect(readersOf('bookNotionalUsdt')).toContain('renderAlias');
});
});