diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 9063916ec..04886ccb4 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index cbe3112bb..0f0c5676a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index cf9c3560e..b13989c90 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -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; diff --git a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts index c21daf336..4911054b9 100644 --- a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts @@ -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'); + }); });