diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index cf9eb1148..e2d5df284 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -127,6 +127,7 @@ export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/regis export type { RegistryContext, RegistryProviders, + OwnedMembersByOwnerLookup, OwnerScopedContributor, ArityVerdict, ConstraintContext, diff --git a/gitnexus-shared/src/scope-resolution/registries/context.ts b/gitnexus-shared/src/scope-resolution/registries/context.ts index 539242a0e..e802ec081 100644 --- a/gitnexus-shared/src/scope-resolution/registries/context.ts +++ b/gitnexus-shared/src/scope-resolution/registries/context.ts @@ -93,6 +93,19 @@ export interface OwnerScopedContributor { byName(name: string): readonly SymbolDefinition[]; } +/** + * Required owner-keyed lookup hook for Step 2 receiver/MRO member walks. + * Production callers wire this to the SemanticModel's authoritative + * method/field/nested-type registries so each `(ownerDefId, memberName)` + * probe is O(1). Implementations MUST return `[]` on an indexed miss — + * Step 2 treats `[]` as authoritative and does not consult `defs` for a + * fallback scan. + */ +export type OwnedMembersByOwnerLookup = ( + ownerDefId: DefId, + memberName: string, +) => readonly SymbolDefinition[]; + // ─── Top-level context threaded through every lookup ─────────────────────── export interface RegistryContext { @@ -100,6 +113,7 @@ export interface RegistryContext { readonly defs: DefIndex; readonly qualifiedNames: QualifiedNameIndex; readonly moduleScopes: ModuleScopeIndex; + readonly ownedMembersByOwner: OwnedMembersByOwnerLookup; /** * Method-dispatch index; required for method/field registries that * honor `useReceiverTypeBinding`. Omit for class-only lookups. diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts index fff3e7adb..dcf250e02 100644 --- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -27,8 +27,10 @@ * is true, resolve the receiver's type at `startScope` (from * `scope.typeBindings`), then walk the MRO via * `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes - * through `RegistryContext.methodDispatch` + owner lookups into - * `scope.ownedDefs`; each hit records a raw signal with the owner's + * through an optional `RegistryContext.ownedMembersByOwner` hook when + * supplied (`undefined` → fall back to `defs.byId`; `[]` → indexed + * miss), otherwise via the compatibility fallback scan over + * `defs.byId`; each hit records a raw signal with the owner's * MRO depth. * * **Step 3 — Owner-scoped contributor.** When @@ -263,13 +265,14 @@ function walkReceiverTypeBinding( // Walk the owner itself at depth 0, then its MRO chain. const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)]; - for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) { - const currentOwnerId = walk[mroDepth]!; + let mroDepth = 0; + for (const currentOwnerId of walk) { const members = collectOwnedMembers(currentOwnerId, name, ctx); for (const def of members) { if (!acceptedKinds.has(def.type)) continue; recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId); } + mroDepth++; } } @@ -333,23 +336,7 @@ function collectOwnedMembers( memberName: string, ctx: RegistryContext, ): readonly SymbolDefinition[] { - // An owner's members are defs whose `ownerId === ownerDefId` and whose - // simple name matches `memberName`. We iterate `defs.byId` — O(D) per - // call today. A future by-owner index would make this O(K); tracked as - // a follow-up optimization before Ring 3 flips go production. - const out: SymbolDefinition[] = []; - for (const def of ctx.defs.byId.values()) { - if (def.ownerId !== ownerDefId) continue; - if (simpleNameOf(def) !== memberName) continue; - out.push(def); - } - return out; -} - -function simpleNameOf(def: SymbolDefinition): string | undefined { - if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined; - const dot = def.qualifiedName.lastIndexOf('.'); - return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1); + return ctx.ownedMembersByOwner(ownerDefId, memberName); } function recordTypeBindingHit( diff --git a/gitnexus/src/core/ingestion/model/field-registry.ts b/gitnexus/src/core/ingestion/model/field-registry.ts index 62376c889..c45fb9cb5 100644 --- a/gitnexus/src/core/ingestion/model/field-registry.ts +++ b/gitnexus/src/core/ingestion/model/field-registry.ts @@ -2,18 +2,35 @@ * Field Registry * * Owner-scoped field/property index extracted from SymbolTable. - * Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup. + * Stores Property / Variable / Const / Static symbols keyed by + * `ownerNodeId\0fieldName` for O(1) lookup. Supports multiple defs + * under the same (owner, name) — e.g. legacy Property plus a + * scope-resolution Variable reconciliation entry. */ import type { SymbolDefinition } from 'gitnexus-shared'; +const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + // --------------------------------------------------------------------------- // Public read-only interface // --------------------------------------------------------------------------- export interface FieldRegistry { - /** Look up a field/property by its owning class nodeId and field name. */ + /** + * First field registered under `(ownerNodeId, fieldName)`, if any. + * Registration order is first-wins: when a Property and a Variable share + * an `(owner, simpleName)` key, the earlier `register(...)` call's def is + * returned. Prefer `lookupAllByOwner` when overloads or duplicate-kind + * entries under the same name must all be visible. + */ lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined; + + /** + * Every field registered under `(ownerNodeId, fieldName)` in registration + * order. Returns `[]` on miss. + */ + lookupAllByOwner(ownerNodeId: string, fieldName: string): readonly SymbolDefinition[]; } // --------------------------------------------------------------------------- @@ -21,7 +38,7 @@ export interface FieldRegistry { // --------------------------------------------------------------------------- export interface MutableFieldRegistry extends FieldRegistry { - /** Register a field/property under its owner. */ + /** Register a field under its owner. Appends when the key already exists. */ register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void; /** Clear all entries. */ clear(): void; @@ -32,22 +49,36 @@ export interface MutableFieldRegistry extends FieldRegistry { // --------------------------------------------------------------------------- export const createFieldRegistry = (): MutableFieldRegistry => { - const fieldByOwner = new Map(); + const fieldByOwner = new Map(); + + const lookupAllByOwner = ( + ownerNodeId: string, + fieldName: string, + ): readonly SymbolDefinition[] => { + return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`) ?? EMPTY; + }; const lookupFieldByOwner = ( ownerNodeId: string, fieldName: string, ): SymbolDefinition | undefined => { - return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); + const pool = lookupAllByOwner(ownerNodeId, fieldName); + return pool.length === 0 ? undefined : pool[0]; }; const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => { - fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def); + const key = `${ownerNodeId}\0${fieldName}`; + const existing = fieldByOwner.get(key); + if (existing) { + existing.push(def); + } else { + fieldByOwner.set(key, [def]); + } }; const clear = (): void => { fieldByOwner.clear(); }; - return { lookupFieldByOwner, register, clear }; + return { lookupFieldByOwner, lookupAllByOwner, register, clear }; }; diff --git a/gitnexus/src/core/ingestion/model/owned-members-lookup.ts b/gitnexus/src/core/ingestion/model/owned-members-lookup.ts new file mode 100644 index 000000000..1ca91db19 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/owned-members-lookup.ts @@ -0,0 +1,45 @@ +/** + * Owner-keyed member lookup for Step 2 (RFC #909 / PR #1656). + * + * Merges MethodRegistry + FieldRegistry hits for `(ownerDefId, memberName)` + * in O(1) map time per registry — no `defs.byId` scan. Callers that omit + * this helper and leave `ownedMembersByOwner` unset fall back to an O(|defs|) + * compatibility scan inside `lookupCore.collectOwnedMembers`. + */ + +import type { DefId, SymbolDefinition } from 'gitnexus-shared'; +import type { SemanticModel } from './semantic-model.js'; + +const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + +/** + * Production hook for `RegistryContext.ownedMembersByOwner`. + * Returns `[]` on miss (authoritative indexed empty) — never `undefined`. + * + * Merges hits from all three owner-keyed registries (methods, fields, + * nested types) under the same `(ownerDefId, memberName)` key. The + * caller's `acceptedKinds` filter in `lookupCore` picks the right subset. + */ +export function lookupOwnedMembersByOwner( + model: Pick, + ownerDefId: DefId, + memberName: string, +): readonly SymbolDefinition[] { + const methods = model.methods.lookupAllByOwner(ownerDefId, memberName); + const fields = model.fields.lookupAllByOwner(ownerDefId, memberName); + const nestedTypes = model.types.lookupAllByOwner(ownerDefId, memberName); + const methodCount = methods.length; + const fieldCount = fields.length; + const typeCount = nestedTypes.length; + const total = methodCount + fieldCount + typeCount; + if (total === 0) return EMPTY; + if (methodCount === total) return methods; + if (fieldCount === total) return fields; + if (typeCount === total) return nestedTypes; + const merged = new Array(total); + let i = 0; + for (let j = 0; j < methodCount; j++) merged[i++] = methods[j]!; + for (let j = 0; j < fieldCount; j++) merged[i++] = fields[j]!; + for (let j = 0; j < typeCount; j++) merged[i++] = nestedTypes[j]!; + return merged; +} diff --git a/gitnexus/src/core/ingestion/model/type-registry.ts b/gitnexus/src/core/ingestion/model/type-registry.ts index 3f9fdc5f4..4dc87e924 100644 --- a/gitnexus/src/core/ingestion/model/type-registry.ts +++ b/gitnexus/src/core/ingestion/model/type-registry.ts @@ -8,6 +8,8 @@ import type { SymbolDefinition } from 'gitnexus-shared'; +const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + // --------------------------------------------------------------------------- // Public read-only interface // --------------------------------------------------------------------------- @@ -35,6 +37,14 @@ export interface TypeRegistry { * Returned array is a view into the live index — do not mutate. */ lookupImplByName(name: string): readonly SymbolDefinition[]; + + /** + * Look up nested-type defs registered under `(ownerNodeId, simpleName)` + * in registration order. Returns `[]` on miss. Used by Step 2 Receiver/MRO + * resolution when the receiver's owner declares nested classes/structs/ + * enums/typedefs/etc. that the caller's `acceptedKinds` includes. + */ + lookupAllByOwner(ownerNodeId: string, simpleName: string): readonly SymbolDefinition[]; } // --------------------------------------------------------------------------- @@ -46,6 +56,8 @@ export interface MutableTypeRegistry extends TypeRegistry { registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void; /** Register a Rust Impl block by name. */ registerImpl(name: string, def: SymbolDefinition): void; + /** Register a nested type under its owner. Appends when the key already exists. */ + registerByOwner(ownerNodeId: string, simpleName: string, def: SymbolDefinition): void; /** Clear all entries. */ clear(): void; } @@ -58,6 +70,7 @@ export const createTypeRegistry = (): MutableTypeRegistry => { const classByName = new Map(); const classByQualifiedName = new Map(); const implByName = new Map(); + const nestedByOwner = new Map(); const lookupClassByName = (name: string): SymbolDefinition[] => { return classByName.get(name) ?? []; @@ -71,6 +84,13 @@ export const createTypeRegistry = (): MutableTypeRegistry => { return implByName.get(name) ?? []; }; + const lookupAllByOwner = ( + ownerNodeId: string, + simpleName: string, + ): readonly SymbolDefinition[] => { + return nestedByOwner.get(`${ownerNodeId}\0${simpleName}`) ?? EMPTY; + }; + const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => { const existing = classByName.get(name); if (existing) { @@ -96,18 +116,35 @@ export const createTypeRegistry = (): MutableTypeRegistry => { } }; + const registerByOwner = ( + ownerNodeId: string, + simpleName: string, + def: SymbolDefinition, + ): void => { + const key = `${ownerNodeId}\0${simpleName}`; + const existing = nestedByOwner.get(key); + if (existing) { + existing.push(def); + } else { + nestedByOwner.set(key, [def]); + } + }; + const clear = (): void => { classByName.clear(); classByQualifiedName.clear(); implByName.clear(); + nestedByOwner.clear(); }; return { lookupClassByName, lookupClassByQualifiedName, lookupImplByName, + lookupAllByOwner, registerClass, registerImpl, + registerByOwner, clear, }; }; diff --git a/gitnexus/src/core/ingestion/resolve-references.ts b/gitnexus/src/core/ingestion/resolve-references.ts index be95df7ba..837635422 100644 --- a/gitnexus/src/core/ingestion/resolve-references.ts +++ b/gitnexus/src/core/ingestion/resolve-references.ts @@ -64,6 +64,8 @@ export interface ResolveReferencesInput { readonly scopes: ScopeResolutionIndexes; /** Provider hooks consumed by the registries (e.g. `arityCompatibility`). */ readonly providers?: RegistryProviders; + /** Required owner-keyed member lookup used by Step 2 receiver/MRO walks. */ + readonly ownedMembersByOwner: RegistryContext['ownedMembersByOwner']; } export interface ResolveStats { @@ -92,6 +94,7 @@ export function resolveReferenceSites(input: ResolveReferencesInput): ResolveRef defs: scopes.defs, qualifiedNames: scopes.qualifiedNames, moduleScopes: scopes.moduleScopes, + ownedMembersByOwner: input.ownedMembersByOwner, methodDispatch: scopes.methodDispatch, providers, }; @@ -191,7 +194,10 @@ function lookupForSite( case 'write': { // Try field first; fall through to method then class so bare-name // reads of a function (e.g. `cb = save`) still resolve. - const fieldHits = fieldRegistry.lookup(site.name, site.inScope); + const fieldOpts: Parameters[2] = { + ...(site.explicitReceiver !== undefined ? { explicitReceiver: site.explicitReceiver } : {}), + }; + const fieldHits = fieldRegistry.lookup(site.name, site.inScope, fieldOpts); if (fieldHits.length > 0) return fieldHits; const methodHits = methodRegistry.lookup(site.name, site.inScope); if (methodHits.length > 0) return methodHits; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index a3bc1e4ec..3647fbffa 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -78,6 +78,12 @@ export function emitFreeCallFallback( let emitted = 0; const seen = new Set(); + // Build an O(1) simple-name -> callable defs index over scopes.defs once + // per pass so pickUniqueGlobalCallable doesn't re-scan defs.byId.values() + // per call site. Same name + callable-kind filter that the previous scan + // applied (see pickUniqueGlobalCallable JSDoc). Cost: O(|defs|) once. + const globalCallablesBySimpleName = buildGlobalCallableIndex(scopes); + for (const parsed of parsedFiles) { for (const site of parsed.referenceSites) { if (site.kind !== 'call') continue; @@ -254,7 +260,7 @@ export function emitFreeCallFallback( fnDef = pickUniqueGlobalCallable( site.name, model, - scopes, + globalCallablesBySimpleName, parsed.filePath, options.isFileLocalDef, site.arity, @@ -299,10 +305,35 @@ export function emitFreeCallFallback( return emitted; } +/** + * Build a `simpleName -> callable defs` index from `scopes.defs` once per + * pass. Mirrors the filter the old per-site scan applied: Function / + * Method / Constructor, keyed by the last `.`-segment of `qualifiedName` + * (falling back to the qualifiedName itself when undotted). Used by + * `pickUniqueGlobalCallable` so every free-call fallback site is O(1) + * instead of O(|defs|). + */ +function buildGlobalCallableIndex( + scopes: ScopeResolutionIndexes, +): ReadonlyMap { + const out = new Map(); + for (const def of scopes.defs.byId.values()) { + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + const qualified = def.qualifiedName; + if (qualified === undefined || qualified.length === 0) continue; + const dot = qualified.lastIndexOf('.'); + const simple = dot === -1 ? qualified : qualified.slice(dot + 1); + const bucket = out.get(simple); + if (bucket) bucket.push(def); + else out.set(simple, [def]); + } + return out; +} + function pickUniqueGlobalCallable( name: string, model: SemanticModel, - scopes: ScopeResolutionIndexes, + globalCallablesBySimpleName: ReadonlyMap, callerFilePath: string, isFileLocalDef?: (def: SymbolDefinition) => boolean, callArity?: number, @@ -312,10 +343,7 @@ function pickUniqueGlobalCallable( ): SymbolDefinition | undefined { const scopeDefs: SymbolDefinition[] = []; const scopeSeen = new Set(); - for (const def of scopes.defs.byId.values()) { - const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName; - if (simple !== name) continue; - if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + for (const def of globalCallablesBySimpleName.get(name) ?? []) { // Skip file-local defs (e.g. C `static` functions) that live in a // different file from the caller — they are logically invisible. if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts index 5c0962848..2ec47d489 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts @@ -18,8 +18,8 @@ * undefined `ownerId` is reachable via either: * - `model.methods.lookupAllByOwner(ownerId, simpleName)` — if the * def is a Method / Function / Constructor, OR - * - `model.fields.lookupFieldByOwner(ownerId, simpleName)` — if the - * def is a Property / Variable. + * - `model.fields.lookupAllByOwner(ownerId, simpleName)` — if the + * def is a Property / Variable / Const / Static. * * This invariant is the foundation of Contract Invariant I9 * (`contract/scope-resolver.ts`): scope-resolution passes MUST read @@ -45,11 +45,29 @@ import type { ParsedFile } from 'gitnexus-shared'; import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js'; import { simpleQualifiedName } from '../graph-bridge/ids.js'; +const NESTED_TYPE_KINDS = new Set([ + 'Class', + 'Interface', + 'Enum', + 'Struct', + 'Union', + 'Trait', + 'TypeAlias', + 'Typedef', + 'Record', + 'Delegate', + 'Annotation', + 'Template', + 'Namespace', +]); + export interface ReconcileStats { /** Method/Function/Constructor defs registered into MethodRegistry. */ readonly methodsRegistered: number; /** Property/Variable defs registered into FieldRegistry. */ readonly fieldsRegistered: number; + /** Class-like nested type defs registered into TypeRegistry by owner. */ + readonly nestedTypesRegistered: number; /** Defs already present (idempotent skip). */ readonly skippedAlreadyPresent: number; } @@ -60,6 +78,7 @@ export function reconcileOwnership( ): ReconcileStats { let methodsRegistered = 0; let fieldsRegistered = 0; + let nestedTypesRegistered = 0; let skippedAlreadyPresent = 0; for (const parsed of parsedFiles) { @@ -77,19 +96,32 @@ export function reconcileOwnership( } model.methods.register(ownerId, simple, def); methodsRegistered++; - } else if (def.type === 'Property' || def.type === 'Variable') { - const existing = model.fields.lookupFieldByOwner(ownerId, simple); - if (existing !== undefined && existing.nodeId === def.nodeId) { + } else if ( + def.type === 'Property' || + def.type === 'Variable' || + def.type === 'Const' || + def.type === 'Static' + ) { + const existing = model.fields.lookupAllByOwner(ownerId, simple); + if (existing.some((e) => e.nodeId === def.nodeId)) { skippedAlreadyPresent++; continue; } model.fields.register(ownerId, simple, def); fieldsRegistered++; + } else if (NESTED_TYPE_KINDS.has(def.type)) { + const existing = model.types.lookupAllByOwner(ownerId, simple); + if (existing.some((e) => e.nodeId === def.nodeId)) { + skippedAlreadyPresent++; + continue; + } + model.types.registerByOwner(ownerId, simple, def); + nestedTypesRegistered++; } } } - return { methodsRegistered, fieldsRegistered, skippedAlreadyPresent }; + return { methodsRegistered, fieldsRegistered, nestedTypesRegistered, skippedAlreadyPresent }; } /** @@ -131,15 +163,29 @@ export function validateOwnershipParity( ); mismatches++; } - } else if (def.type === 'Property' || def.type === 'Variable') { - const found = model.fields.lookupFieldByOwner(ownerId, simple); - if (found === undefined || found.nodeId !== def.nodeId) { + } else if ( + def.type === 'Property' || + def.type === 'Variable' || + def.type === 'Const' || + def.type === 'Static' + ) { + const found = model.fields.lookupAllByOwner(ownerId, simple); + if (!found.some((d) => d.nodeId === def.nodeId)) { onWarn( `semantic-model parity: ${def.type} ${def.nodeId} (${parsed.filePath}) ` + `owned by ${ownerId} as "${simple}" not in FieldRegistry`, ); mismatches++; } + } else if (NESTED_TYPE_KINDS.has(def.type)) { + const found = model.types.lookupAllByOwner(ownerId, simple); + if (!found.some((d) => d.nodeId === def.nodeId)) { + onWarn( + `semantic-model parity: ${def.type} ${def.nodeId} (${parsed.filePath}) ` + + `owned by ${ownerId} as "${simple}" not in TypeRegistry owner index`, + ); + mismatches++; + } } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 0b9948368..ae6ec5a23 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -25,6 +25,7 @@ import type { ParsedFile, RegistryProviders } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; +import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js'; import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js'; import { reconcileOwnership, validateOwnershipParity } from './reconcile-ownership.js'; import { validateBindingsImmutability } from './validate-bindings-immutability.js'; @@ -342,6 +343,8 @@ export function runScopeResolution( const { referenceIndex, stats: resolveStats } = resolveReferenceSites({ scopes: indexes, providers: registryProviders, + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(readonlyModel, ownerDefId, memberName), }); const tResolve = PROF ? process.hrtime.bigint() : 0n; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts new file mode 100644 index 000000000..3138b9043 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts @@ -0,0 +1,3 @@ +import { AmbientBase } from './ambient'; + +export class Derived extends AmbientBase {} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts new file mode 100644 index 000000000..62c1b1082 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts @@ -0,0 +1,7 @@ +// Ambient base class — simulates a .d.ts-declared external/library type +// whose body is never seen by the analyzer. Probes whether Step 2 MRO +// lookup can still resolve inherited members on owners that reconcile- +// ownership skipped because they have no parsed body. +export declare class AmbientBase { + ambientMethod(): string; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts new file mode 100644 index 000000000..9cea59a5f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts @@ -0,0 +1,6 @@ +import { Derived } from './Derived'; + +export function run(): void { + const d = new Derived(); + d.ambientMethod(); +} diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index e41a077c7..8b6e50d20 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -2683,6 +2683,44 @@ describe('TypeScript Child extends Parent — inherited method resolution (SM-9) }); }); +// --------------------------------------------------------------------------- +// PR #1657 finding #6: ambient base class — Step 2 MRO ancestor whose body +// is never parsed (declare class). Probes whether the owner-keyed lookup +// can still resolve inherited members on owners that reconcile-ownership +// skipped because they have no parsed body. +// --------------------------------------------------------------------------- + +describe('TypeScript Derived extends declare class AmbientBase — ambient MRO ancestor', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-ambient-base-class'), + () => {}, + ); + }, 60000); + + it('detects AmbientBase and Derived classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('AmbientBase'); + expect(classes).toContain('Derived'); + }); + + it('emits EXTENDS edge: Derived → AmbientBase', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Derived → AmbientBase'); + }); + + it('resolves d.ambientMethod() to AmbientBase.ambientMethod via MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const ambientCall = calls.find( + (c) => c.target === 'ambientMethod' && c.targetFilePath.includes('ambient.ts'), + ); + expect(ambientCall).toBeDefined(); + expect(ambientCall!.source).toBe('run'); + }); +}); + // --------------------------------------------------------------------------- // PR #1050: tsconfig path alias resolution under registry-primary path // (Adversarial review Finding 1 — `@/services/user` must resolve via tsconfig diff --git a/gitnexus/test/unit/model/field-registry.test.ts b/gitnexus/test/unit/model/field-registry.test.ts index ff1f749b1..8b2789896 100644 --- a/gitnexus/test/unit/model/field-registry.test.ts +++ b/gitnexus/test/unit/model/field-registry.test.ts @@ -41,15 +41,16 @@ describe('FieldRegistry', () => { expect(reg.lookupFieldByOwner('class:Order', 'name')?.nodeId).toBe('prop:Order.name'); }); - it('last-wins on duplicate (ownerNodeId, fieldName) — registry is flat, not an overload list', () => { + it('accumulates multiple defs under the same (ownerNodeId, fieldName)', () => { const reg = createFieldRegistry(); - const first = makeDef({ nodeId: 'prop:User.name#first' }); - const second = makeDef({ nodeId: 'prop:User.name#second' }); + const first = makeDef({ nodeId: 'prop:User.name#first', type: 'Property' }); + const second = makeDef({ nodeId: 'def:User.name#var', type: 'Variable' }); reg.register('class:User', 'name', first); reg.register('class:User', 'name', second); - expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#second'); + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#first'); + expect(reg.lookupAllByOwner('class:User', 'name')).toEqual([first, second]); }); it('clear() empties the registry', () => { diff --git a/gitnexus/test/unit/model/owned-members-lookup.test.ts b/gitnexus/test/unit/model/owned-members-lookup.test.ts new file mode 100644 index 000000000..35ee5d56a --- /dev/null +++ b/gitnexus/test/unit/model/owned-members-lookup.test.ts @@ -0,0 +1,388 @@ +/** + * Step 2 owner-keyed lookup — correctness and perf contract (PR #1656). + */ + +import { describe, it, expect } from 'vitest'; +import type { DefIndex, SymbolDefinition } from 'gitnexus-shared'; +import { + buildFieldRegistry, + buildMethodRegistry, + EvidenceWeights, + buildScopeTree, + buildQualifiedNameIndex, + buildModuleScopeIndex, + buildMethodDispatchIndex, + type RegistryContext, + type Scope, + type ScopeId, + type TypeRef, +} from 'gitnexus-shared'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import { lookupOwnedMembersByOwner } from '../../../src/core/ingestion/model/owned-members-lookup.js'; + +const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: overrides.filePath ?? 'x.ts', + type: overrides.type ?? 'Class', + ...overrides, +}); + +const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({ + rawName, + declaredAtScope, + source: 'parameter-annotation', +}); + +describe('lookupOwnedMembersByOwner', () => { + it('returns methods only, fields only, or both without allocating on single-hit paths', () => { + const model = createSemanticModel(); + const save = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const name = mkDef({ + nodeId: 'def:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + model.methods.register('def:User', 'save', save); + model.fields.register('def:User', 'name', name); + + const methodsOnly = lookupOwnedMembersByOwner(model, 'def:User', 'save'); + expect(methodsOnly).toEqual([save]); + + const fieldsOnly = lookupOwnedMembersByOwner(model, 'def:User', 'name'); + expect(fieldsOnly).toEqual([name]); + + const both = lookupOwnedMembersByOwner(model, 'def:User', 'save'); + expect(both).toEqual([save]); + }); + + it('merges method and field hits under the same (owner, name)', () => { + const model = createSemanticModel(); + const prop = mkDef({ + nodeId: 'prop:User.id', + type: 'Property', + qualifiedName: 'User.id', + ownerId: 'def:User', + }); + const variable = mkDef({ + nodeId: 'def:User.id', + type: 'Variable', + qualifiedName: 'User.id', + ownerId: 'def:User', + }); + model.fields.register('def:User', 'id', prop); + model.fields.register('def:User', 'id', variable); + + expect(lookupOwnedMembersByOwner(model, 'def:User', 'id')).toEqual([prop, variable]); + }); + + it('returns nested-type hits when registered under (owner, simpleName)', () => { + const model = createSemanticModel(); + const inner = mkDef({ + nodeId: 'def:Outer.Inner', + type: 'Class', + qualifiedName: 'Outer.Inner', + ownerId: 'def:Outer', + }); + model.types.registerByOwner('def:Outer', 'Inner', inner); + + expect(lookupOwnedMembersByOwner(model, 'def:Outer', 'Inner')).toEqual([inner]); + }); + + it('merges methods + fields + nested-type hits under the same (owner, name)', () => { + const model = createSemanticModel(); + const method = mkDef({ + nodeId: 'def:Outer.x#method', + type: 'Method', + qualifiedName: 'Outer.x', + ownerId: 'def:Outer', + }); + const field = mkDef({ + nodeId: 'def:Outer.x#field', + type: 'Property', + qualifiedName: 'Outer.x', + ownerId: 'def:Outer', + }); + const nested = mkDef({ + nodeId: 'def:Outer.x#class', + type: 'Class', + qualifiedName: 'Outer.x', + ownerId: 'def:Outer', + }); + model.methods.register('def:Outer', 'x', method); + model.fields.register('def:Outer', 'x', field); + model.types.registerByOwner('def:Outer', 'x', nested); + + expect(lookupOwnedMembersByOwner(model, 'def:Outer', 'x')).toEqual([method, field, nested]); + }); +}); + +describe('Step 2 perf contract', () => { + it('does not scan defs.byId when ownedMembersByOwner is wired', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const trapById = new Map([ + [userClass.nodeId, userClass], + [saveMethod.nodeId, saveMethod], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const callScope: Scope = { + id: 'scope:call', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['user', typeRef('User', 'scope:call')]]), + }; + + const model = createSemanticModel(); + model.methods.register('def:User', 'save', saveMethod); + + const ctx: RegistryContext = { + scopes: buildScopeTree([callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]), + moduleScopes: buildModuleScopeIndex([]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:User'], + computeMro: () => [], + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + expect(results[0]!.evidence.find((e) => e.kind === 'type-binding')?.weight).toBe( + EvidenceWeights.typeBindingByMroDepth[0], + ); + }); + + it('does not scan defs.byId for implicit-self receiver (no explicitReceiver)', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const trapById = new Map([ + [userClass.nodeId, userClass], + [saveMethod.nodeId, saveMethod], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const moduleScope: Scope = { + id: 'scope:module', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), + }; + const callScope: Scope = { + id: 'scope:method-body', + parent: 'scope:module', + kind: 'Method', + range: { startLine: 2, startCol: 0, endLine: 99, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['self', typeRef('User', 'scope:method-body')]]), + }; + + const model = createSemanticModel(); + model.methods.register('def:User', 'save', saveMethod); + + const ctx: RegistryContext = { + scopes: buildScopeTree([moduleScope, callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]), + moduleScopes: buildModuleScopeIndex([moduleScope]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:User'], + computeMro: () => [], + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:method-body', { + explicitReceiver: { name: 'self' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + }); + + it('does not scan defs.byId when walking a 2-level MRO chain', () => { + const parentClass = mkDef({ nodeId: 'def:Parent', type: 'Class', qualifiedName: 'Parent' }); + const childClass = mkDef({ nodeId: 'def:Child', type: 'Class', qualifiedName: 'Child' }); + const parentSave = mkDef({ + nodeId: 'def:Parent.save', + type: 'Method', + qualifiedName: 'Parent.save', + ownerId: 'def:Parent', + }); + const trapById = new Map([ + [parentClass.nodeId, parentClass], + [childClass.nodeId, childClass], + [parentSave.nodeId, parentSave], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const callScope: Scope = { + id: 'scope:call', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['c', typeRef('Child', 'scope:call')]]), + }; + + const model = createSemanticModel(); + model.methods.register('def:Parent', 'save', parentSave); + + const ctx: RegistryContext = { + scopes: buildScopeTree([callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([parentClass, childClass, parentSave]), + moduleScopes: buildModuleScopeIndex([]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:Child', 'def:Parent'], + computeMro: (id) => (id === 'def:Child' ? ['def:Parent'] : []), + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'c' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(parentSave); + expect(results[0]!.evidence.find((e) => e.kind === 'type-binding')?.weight).toBe( + EvidenceWeights.typeBindingByMroDepth[1], + ); + }); + + it('does not scan defs.byId for FieldRegistry reads via Step 2', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const nameField = mkDef({ + nodeId: 'def:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const trapById = new Map([ + [userClass.nodeId, userClass], + [nameField.nodeId, nameField], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const callScope: Scope = { + id: 'scope:call', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['user', typeRef('User', 'scope:call')]]), + }; + + const model = createSemanticModel(); + model.fields.register('def:User', 'name', nameField); + + const ctx: RegistryContext = { + scopes: buildScopeTree([callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([userClass, nameField]), + moduleScopes: buildModuleScopeIndex([]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:User'], + computeMro: () => [], + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildFieldRegistry(ctx).lookup('name', 'scope:call', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(nameField); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts index bc8cd5a71..b466c8ed4 100644 --- a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts +++ b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts @@ -111,6 +111,54 @@ describe('reconcileOwnership', () => { expect(model.fields.lookupFieldByOwner('def:User', 'tag')).toBe(attr); }); + it('registers Const and Static owned members into FieldRegistry', () => { + const model = createSemanticModel(); + const maxConst = mkProperty({ + nodeId: 'def:User.MAX', + filePath: 'models.py', + name: 'MAX', + ownerId: 'def:User', + type: 'Const', + }); + const counter = mkProperty({ + nodeId: 'def:User.counter', + filePath: 'models.py', + name: 'counter', + ownerId: 'def:User', + type: 'Static', + }); + const file = mkFile('models.py', [maxConst, counter]); + + const stats = reconcileOwnership([file], model); + + expect(stats.fieldsRegistered).toBe(2); + expect(model.fields.lookupAllByOwner('def:User', 'MAX')).toEqual([maxConst]); + expect(model.fields.lookupAllByOwner('def:User', 'counter')).toEqual([counter]); + }); + + it('keeps distinct field-kind defs that share (ownerId, simpleName)', () => { + const model = createSemanticModel(); + const legacyProp = mkProperty({ + nodeId: 'prop:User.name', + filePath: 'models.py', + name: 'name', + ownerId: 'def:User', + type: 'Property', + }); + const reconciledVar = mkProperty({ + nodeId: 'def:User.name', + filePath: 'models.py', + name: 'name', + ownerId: 'def:User', + type: 'Variable', + }); + const file = mkFile('models.py', [legacyProp, reconciledVar]); + + reconcileOwnership([file], model); + + expect(model.fields.lookupAllByOwner('def:User', 'name')).toEqual([legacyProp, reconciledVar]); + }); + it('skips defs without ownerId (top-level functions)', () => { const model = createSemanticModel(); const topLevel = mkMethod({ @@ -164,6 +212,61 @@ describe('reconcileOwnership', () => { expect(model.methods.lookupAllByOwner('def:User', 'save')).toHaveLength(1); }); + it('registers nested class-like types (Class/Enum/Interface) into TypeRegistry by owner', () => { + const model = createSemanticModel(); + const inner: SymbolDefinition = { + nodeId: 'def:Outer.Inner', + filePath: 'm.ts', + type: 'Class', + qualifiedName: 'Outer.Inner', + ownerId: 'def:Outer', + }; + const status: SymbolDefinition = { + nodeId: 'def:Outer.Status', + filePath: 'm.ts', + type: 'Enum', + qualifiedName: 'Outer.Status', + ownerId: 'def:Outer', + }; + const visitor: SymbolDefinition = { + nodeId: 'def:Outer.Visitor', + filePath: 'm.ts', + type: 'Interface', + qualifiedName: 'Outer.Visitor', + ownerId: 'def:Outer', + }; + const file = mkFile('m.ts', [inner, status, visitor]); + + const stats = reconcileOwnership([file], model); + + expect(stats.nestedTypesRegistered).toBe(3); + expect(stats.methodsRegistered).toBe(0); + expect(stats.fieldsRegistered).toBe(0); + expect(model.types.lookupAllByOwner('def:Outer', 'Inner')).toEqual([inner]); + expect(model.types.lookupAllByOwner('def:Outer', 'Status')).toEqual([status]); + expect(model.types.lookupAllByOwner('def:Outer', 'Visitor')).toEqual([visitor]); + }); + + it('is idempotent for nested type registration', () => { + const model = createSemanticModel(); + const inner: SymbolDefinition = { + nodeId: 'def:Outer.Inner', + filePath: 'm.ts', + type: 'Class', + qualifiedName: 'Outer.Inner', + ownerId: 'def:Outer', + }; + const file = mkFile('m.ts', [inner]); + + const first = reconcileOwnership([file], model); + const second = reconcileOwnership([file], model); + + expect(first.nestedTypesRegistered).toBe(1); + expect(second.nestedTypesRegistered).toBe(0); + expect(second.skippedAlreadyPresent).toBe(1); + expect(model.types.lookupAllByOwner('def:Outer', 'Inner')).toHaveLength(1); + }); + it('registers multiple overloads under the same (owner, name)', () => { const model = createSemanticModel(); const log1 = mkMethod({ diff --git a/gitnexus/test/unit/scope-resolution/registries.test.ts b/gitnexus/test/unit/scope-resolution/registries.test.ts index 2681af69f..101fecf98 100644 --- a/gitnexus/test/unit/scope-resolution/registries.test.ts +++ b/gitnexus/test/unit/scope-resolution/registries.test.ts @@ -100,6 +100,7 @@ function makeCtx( opts: { mro?: Record; implsByInterface?: Record; + ownedMembersByOwner?: RegistryContext['ownedMembersByOwner']; arity?: ( callsite: { arity: number }, def: SymbolDefinition, @@ -125,11 +126,25 @@ function makeCtx( return out; }, }); + // Default hook: scan supplied defs by (ownerId, simpleName) — the same + // semantics the byId fallback used to provide. Tests that need a custom + // hook override via opts.ownedMembersByOwner. + const defaultOwnedMembersByOwner = (ownerDefId: string, memberName: string) => { + const out: SymbolDefinition[] = []; + for (const def of defs) { + if (def.ownerId !== ownerDefId) continue; + const dot = def.qualifiedName?.lastIndexOf('.') ?? -1; + const simple = dot === -1 ? def.qualifiedName : def.qualifiedName?.slice(dot + 1); + if (simple === memberName) out.push(def); + } + return out; + }; return { scopes: buildScopeTree(scopes), defs: defIndex, qualifiedNames: qualifiedNameIndex, moduleScopes, + ownedMembersByOwner: opts.ownedMembersByOwner ?? defaultOwnedMembersByOwner, methodDispatch, providers: opts.arity !== undefined ? { arityCompatibility: opts.arity } : {}, }; @@ -573,6 +588,184 @@ describe('Step 3: owner-scoped contributor', () => { // ─── Step 2: type-binding / MRO walk ─────────────────────────────────────── describe('Step 2: type-binding + MRO walk', () => { + it('uses ownedMembersByOwner before falling back to defs scans', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const callScope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const ctx = makeCtx([callScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveMethod] : [], + }); + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + expect(evidenceOfKind(results[0]!, 'type-binding')?.weight).toBe( + EvidenceWeights.typeBindingByMroDepth[0], + ); + }); + + it('keeps hook-provided overloads available for arity filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveOne = mkDef({ + nodeId: 'def:User.save1', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 1, + }); + const saveTwo = mkDef({ + nodeId: 'def:User.save2', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 2, + }); + const callScope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const ctx = makeCtx([callScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveTwo, saveOne] : [], + arity: (callsite, def) => + (def.parameterCount ?? 0) === callsite.arity ? 'compatible' : 'incompatible', + }); + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'user' }, + callsite: { arity: 1 }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveOne); + }); + + it('resolves field members from ownedMembersByOwner through accepted-kind filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const nameField = mkDef({ + nodeId: 'def:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'name' ? [nameField] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(nameField); + }); + + it('resolves Const members from ownedMembersByOwner through accepted-kind filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const maxConst = mkDef({ + nodeId: 'def:User.MAX', + type: 'Const', + qualifiedName: 'User.MAX', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'MAX' ? [maxConst] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('MAX', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(maxConst); + }); + + it('resolves Static members from ownedMembersByOwner through accepted-kind filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const counterStatic = mkDef({ + nodeId: 'def:User.counter', + type: 'Static', + qualifiedName: 'User.counter', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'counter' ? [counterStatic] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('counter', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(counterStatic); + }); + + it('returns every hook-provided field kind that shares (owner, name)', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const legacyProp = mkDef({ + nodeId: 'prop:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const reconciledVar = mkDef({ + nodeId: 'def:User.name', + type: 'Variable', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'name' ? [legacyProp, reconciledVar] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.def.nodeId).sort()).toEqual( + [legacyProp.nodeId, reconciledVar.nodeId].sort(), + ); + }); + it('emits type-binding evidence with MRO-depth-decayed weight (explicit receiver)', () => { const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); const saveMethod = mkDef({ diff --git a/gitnexus/test/unit/scope-resolution/resolve-references.test.ts b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts new file mode 100644 index 000000000..e5524d9e6 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + type BindingRef, + type Range, + type ReferenceSite, + type Scope, + type ScopeId, + type SymbolDefinition, + type TypeRef, +} from 'gitnexus-shared'; +import { resolveReferenceSites } from '../../../src/core/ingestion/resolve-references.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const range = (sl = 1, sc = 0, el = 100, ec = 0): Range => ({ + startLine: sl, + startCol: sc, + endLine: el, + endCol: ec, +}); + +const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: overrides.filePath ?? 'x.ts', + type: overrides.type ?? 'Class', + ...overrides, +}); + +const mkScope = (input: { + id: ScopeId; + parent: ScopeId | null; + kind?: Scope['kind']; + filePath?: string; + range?: Range; + bindings?: Record; + typeBindings?: Record; + ownedDefs?: readonly SymbolDefinition[]; +}): Scope => ({ + id: input.id, + parent: input.parent, + kind: input.kind ?? 'Module', + filePath: input.filePath ?? 'x.ts', + range: input.range ?? range(), + bindings: new Map(Object.entries(input.bindings ?? {})), + imports: [], + typeBindings: new Map(Object.entries(input.typeBindings ?? {})), + ownedDefs: input.ownedDefs ?? [], +}); + +const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({ + rawName, + declaredAtScope, + source: 'parameter-annotation', +}); + +function makeIndexes( + scopes: Scope[], + defs: SymbolDefinition[], + referenceSites: readonly ReferenceSite[], + mro: Record = {}, +): ScopeResolutionIndexes { + return { + scopeTree: buildScopeTree(scopes), + defs: buildDefIndex(defs), + qualifiedNames: buildQualifiedNameIndex(defs), + moduleScopes: buildModuleScopeIndex( + scopes + .filter((scope) => scope.kind === 'Module') + .map((scope) => ({ filePath: scope.filePath, moduleScopeId: scope.id })), + ), + methodDispatch: buildMethodDispatchIndex({ + owners: Array.from(new Set(defs.map((def) => def.nodeId))), + computeMro: (owner) => mro[owner] ?? [], + implementsOf: () => [], + }), + imports: new Map(), + bindings: new Map(), + bindingAugmentations: new Map(), + referenceSites, + sccs: [], + stats: { + totalFiles: 0, + totalEdges: 0, + linkedEdges: 0, + unresolvedEdges: 0, + sccCount: 0, + largestSccSize: 0, + }, + }; +} + +describe('resolveReferenceSites', () => { + it('uses ownedMembersByOwner to resolve a hook-provided receiver member', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const scope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const referenceSite: ReferenceSite = { + name: 'save', + atRange: range(5, 2, 5, 6), + inScope: 'scope:call', + kind: 'call', + explicitReceiver: { name: 'user' }, + arity: 0, + }; + const indexes = makeIndexes([scope], [userClass], [referenceSite]); + + const result = resolveReferenceSites({ + scopes: indexes, + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveMethod] : [], + }); + + expect(result.stats).toEqual({ sitesProcessed: 1, referencesEmitted: 1, unresolved: 0 }); + expect(result.referenceIndex.bySourceScope.get('scope:call')).toHaveLength(1); + expect(result.referenceIndex.bySourceScope.get('scope:call')?.[0]?.toDef).toBe('def:User.save'); + }); + + it('threads providers.arityCompatibility through to filter hook-provided overloads', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveOne = mkDef({ + nodeId: 'def:User.save#1', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 1, + }); + const saveTwo = mkDef({ + nodeId: 'def:User.save#2', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 2, + }); + const scope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const referenceSite: ReferenceSite = { + name: 'save', + atRange: range(5, 2, 5, 6), + inScope: 'scope:call', + kind: 'call', + explicitReceiver: { name: 'user' }, + arity: 1, + }; + const indexes = makeIndexes([scope], [userClass], [referenceSite]); + + const result = resolveReferenceSites({ + scopes: indexes, + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveOne, saveTwo] : [], + providers: { + arityCompatibility: (callsite, def) => + def.parameterCount === callsite.arity ? 'compatible' : 'incompatible', + }, + }); + + expect(result.stats).toEqual({ sitesProcessed: 1, referencesEmitted: 1, unresolved: 0 }); + expect(result.referenceIndex.bySourceScope.get('scope:call')).toHaveLength(1); + expect(result.referenceIndex.bySourceScope.get('scope:call')?.[0]?.toDef).toBe( + 'def:User.save#1', + ); + }); +});