diff --git a/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts index d0ea89789..bca732185 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts @@ -1,16 +1,18 @@ +import type { ElementAccessRoute } from '../../scope-resolution/contract/scope-resolver.js'; +import { extractElementTypeFromString } from '../../type-extractors/shared.js'; + /** - * C# collection-accessor unwrapping. + * C# container element-type unwrapping. * * When the compound-receiver resolver encounters a trailing * `.Values` / `.Keys` on a dotted member-access chain, it calls the - * provider's `unwrapCollectionAccessor` hook to find the element + * provider's `elementTypeOf` hook to find the element * type. This module supplies the C# implementation — recognizing * Dictionary-family generics and returning the value or key type. * - * Other languages (Python, Java, TypeScript) use method-call syntax - * for the same access (`.values()` / `.keys()`), which the compound- - * receiver's call-expression branch already handles; they leave this - * hook undefined. + * Other languages (Python, Java, TypeScript) use method-call syntax for the + * same access (`.values()` / `.keys()`), which the compound-receiver's + * call-expression branch already handles; they answer only the `index` route. */ /** Extract (K, V) from `Dictionary` / `IDictionary` / @@ -46,12 +48,19 @@ function extractDictionaryArgs(rawName: string): { key: string; value: string } * receiver / accessor combination we don't recognize, letting the * compound-receiver pass fall through to the regular field walk. */ -export function unwrapCsharpCollectionAccessor( - receiverType: string, - accessor: string, +export function unwrapCsharpElementType( + containerType: string, + via: ElementAccessRoute, ): string | undefined { - if (accessor !== 'Values' && accessor !== 'Keys') return undefined; - const args = extractDictionaryArgs(receiverType); + const args = extractDictionaryArgs(containerType); + // Subscript on a dictionary yields the VALUE type — `dict["k"]` is a V, never + // a K. Previously this route returned nothing at all for C#, because the + // dictionary parse below was reachable only from the accessor hook. + if (via.kind === 'index') { + if (args !== undefined) return args.value; + return extractElementTypeFromString(containerType); + } + if (via.name !== 'Values' && via.name !== 'Keys') return undefined; if (args === undefined) return undefined; - return accessor === 'Values' ? args.value : args.key; + return via.name === 'Values' ? args.value : args.key; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 469d52363..eb1d3b10d 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -21,7 +21,7 @@ import { } from './index.js'; import { populateCsharpNamespaceSiblings } from './namespace-siblings.js'; import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js'; -import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js'; +import { unwrapCsharpElementType } from './accessor-unwrap.js'; const csharpScopeResolver: ScopeResolver = { // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). @@ -89,7 +89,7 @@ const csharpScopeResolver: ScopeResolver = { // `data.Values` / `data.Keys` on Dictionary-like receivers unwrap // to the value / key element type. Other languages use method-call // syntax for the same access and leave this hook undefined. - unwrapCollectionAccessor: unwrapCsharpCollectionAccessor, + elementTypeOf: unwrapCsharpElementType, // C# matches legacy DAG by collapsing member-call CALLS edges to // `(caller, target)` — multiple `g.Greet(...)` sites from Main diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index 2e6b56f65..cac155a66 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -107,17 +107,12 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { } const typescriptScopeResolver: ScopeResolver = { - // Collections only, consulted ONLY by an index step (see the contract field). - // Delegates to the shared, bracket-balanced extractor rather than a local - // regex: that helper is already used by seven language type-extractors, and it - // covers `Map` / `Record` (returning the VALUE type, which is what a - // subscript yields) where a hand-rolled single-arg regex returned undefined - // and made `cache["k"].save()` decline. - // - // Deliberately NOT part of `stripTypePreservingDecoration`: a container - // changes the member set, so unwrapping it at the bare class lookup would let - // `repos.find(x)` fold to `User.find`. - unwrapCollectionElement: (typeName) => extractElementTypeFromString(typeName), + // One hook, both routes. TypeScript exposes collection views as METHOD calls + // (`.values()`), which the call-expression branch already handles, so the + // accessor route yields nothing here — but it is now the same hook rather + // than a second one left undefined. + elementTypeOf: (containerType, via) => + via.kind === 'index' ? extractElementTypeFromString(containerType) : undefined, // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). constructionSyntax: { keyword: 'new' }, @@ -168,10 +163,10 @@ const typescriptScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, - // TypeScript uses `.values()` / `.keys()` method-call syntax for - // collection views -- no property-style accessors like C#'s - // `Dictionary.Values`. Leave `unwrapCollectionAccessor` - // undefined and let the regular member-call branch handle them. + // TypeScript uses `.values()` / `.keys()` method-call syntax for collection + // views -- no property-style accessors like C#'s `Dictionary.Values` -- + // so `elementTypeOf` answers only the `index` route and lets the regular + // member-call branch handle the rest. // // `collapseMemberCallsByCallerTarget` left undefined (= false) -- // TypeScript legacy DAG emits one edge per call site, so diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index ffa0576c7..bd83e2de8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -15,7 +15,7 @@ * - propagatesReturnTypesAcrossImports (default true) * - fieldFallbackOnMethodLookup (default true — turn OFF for * statically-typed languages; the heuristic over-connects) - * - unwrapCollectionAccessor — property-style collection views + * - elementTypeOf — container element type, by subscript or accessor * - collapseMemberCallsByCallerTarget — one edge per caller/target * - populateNamespaceSiblings — cross-file implicit visibility * - hoistTypeBindingsToModule — enable ONLY when method return @@ -314,6 +314,11 @@ export interface ImportResolutionContext { * `RegistryProviders.constraintCompatibility`'s third parameter. */ export type { ConstraintContext } from 'gitnexus-shared'; +/** How a container's element was reached in the source. */ +export type ElementAccessRoute = + | { readonly kind: 'index' } + | { readonly kind: 'accessor'; readonly name: string }; + export interface ScopeResolver { /** Identity for telemetry + per-language flag check. */ readonly language: SupportedLanguages; @@ -709,22 +714,28 @@ export interface ScopeResolver { readonly fieldFallbackOnMethodLookup?: boolean; /** - * Unwrap a property-style collection accessor on a typed receiver - * to its element type. Called by `resolveCompoundReceiverClass` - * when walking dotted member-access chains of the form - * `receiver.Accessor`. The provider returns the element type's - * simple name, or `undefined` when the accessor doesn't unwrap — - * in which case the regular field-walk resumes. + * Element type of a container, reached either by a subscript (`repos[0]`) or + * by a property-style collection view (`dict.Values`). Returns the element + * type's simple name, or `undefined` when the container does not unwrap by + * that route — in which case the caller resumes its normal walk. * - * Use this only for languages that expose collection views as - * properties rather than method calls; languages whose collection - * views are `.values()` / `.keys()` method calls leave this - * undefined and let the normal call-expression branch handle them. + * ONE hook for both routes, deliberately. They were previously two + * (`unwrapCollectionAccessor` for the property route, `unwrapCollectionElement` + * for the subscript route), which meant a language implementing one silently + * got nothing for the other: C# parsed `Dictionary` for `.Values` but + * returned nothing for `list[0]`, and TypeScript did the reverse. Two entries + * answering one question, each accreting an implementation per language. + * + * `via` carries the route so a provider can distinguish them where it matters + * (a `Dictionary` yields its VALUE type by subscript but either type by + * accessor name); a provider that does not care can ignore it. + * + * Consulted ONLY where the source actually performed the access. It is NOT a + * general type-name normalizer: unwrapping a container at a bare class lookup + * would let `repos.find(x)` fold to `Repo.find`, because a container's member + * set is not its element's. */ - readonly unwrapCollectionAccessor?: ( - receiverType: string, - accessor: string, - ) => string | undefined; + readonly elementTypeOf?: (containerType: string, via: ElementAccessRoute) => string | undefined; /** * Collapse member-call CALLS edges by `(caller, target)` rather @@ -1167,7 +1178,6 @@ export interface ScopeResolver { * both kinds of path land on the element type without the core needing to know * which is which. */ - readonly unwrapCollectionElement?: DecorationStripper; /** * Whether the compound-receiver resolver should strip C-style cast @@ -1195,7 +1205,7 @@ export interface ScopeResolver { * * A second opting language must extend the classifier grammar or * convert this toggle into a per-language classifier hook (the - * `unwrapCollectionAccessor` pattern) — do not flip this flag for + * `elementTypeOf` pattern) — do not flip this flag for * another language as-is. * * Known non-goal: the compound-receiver options built from this diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 34810a3a2..da5f74918 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -21,7 +21,7 @@ */ import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; -import type { ScopeResolver } from '../contract/scope-resolver.js'; +import type { ElementAccessRoute, ScopeResolver } from '../contract/scope-resolver.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import { stripTemplateArguments } from '../../utils/template-arguments.js'; @@ -82,10 +82,9 @@ interface ResolveCompoundReceiverOptions { * Dictionary-typed receiver yields V (C#), etc. Returns the * element type's simple name, or `undefined` to let the regular * field-walk handle the access. */ - readonly unwrapCollectionAccessor?: ( - receiverType: string, - accessor: string, - ) => string | undefined; + /** Container -> element, by subscript or accessor. See the `ScopeResolver` + * field of the same name for why the two routes share one hook. */ + readonly elementTypeOf?: (containerType: string, via: ElementAccessRoute) => string | undefined; /** Walk up from the class scope to ancestor (Module) scopes when * looking up a method's return-type typeBinding. Only enable for * languages that hoist return-type bindings to Module scope (C#); @@ -129,10 +128,6 @@ interface ResolveCompoundReceiverOptions { * by the shared lookup's other callers — see the contract's own note on why * this is opt-in rather than global. */ readonly stripTypePreservingDecoration?: DecorationStripper; - /** Collection -> element unwrap, consulted ONLY by an index step. See the - * `ScopeResolver` field of the same name for why this is separate from the - * type-preserving stripper. */ - readonly unwrapCollectionElement?: DecorationStripper; } /** Is this hop the language's construction selector applied to the class @@ -508,7 +503,7 @@ export function foldReceiverChain( // annotation) needs exactly one unwrap here. const declared = current.declaredTypeName; const element = - declared === undefined ? undefined : options.unwrapCollectionElement?.(declared); + declared === undefined ? undefined : options.elementTypeOf?.(declared, { kind: 'index' }); if (element !== undefined) { const scopeForLookup = current.declaredAtScope ?? inScope; const elementClass = findClassBindingInScope( @@ -884,7 +879,7 @@ export function resolveCompoundReceiverClass( // the final segment and unwraps the receiver's generic, return // the element class directly. Resolved before the field-walk // because Dictionary-family types aren't local class defs. - if (options.unwrapCollectionAccessor !== undefined && parts.length >= 2) { + if (options.elementTypeOf !== undefined && parts.length >= 2) { const last = parts[parts.length - 1]; const headInner = parts[0]; if (last === undefined || headInner === undefined) return undefined; @@ -912,7 +907,7 @@ export function resolveCompoundReceiverClass( prefixType = cur; } if (prefixType !== undefined) { - const elemName = options.unwrapCollectionAccessor(prefixType.rawName, last); + const elemName = options.elementTypeOf(prefixType.rawName, { kind: 'accessor', name: last }); if (elemName !== undefined) { return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes); } 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 4ccc826fd..40fb3528f 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 @@ -94,12 +94,11 @@ type ReceiverBoundProviderSubset = Pick< | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' - | 'unwrapCollectionAccessor' + | 'elementTypeOf' | 'hoistTypeBindingsToModule' | 'stripReceiverCastExpressions' | 'constructionSyntax' | 'stripTypePreservingDecoration' - | 'unwrapCollectionElement' | 'resolveQualifiedReceiverMember' | 'resolveReceiverMember' | 'resolveThisViaEnclosingClass' @@ -184,12 +183,11 @@ export function emitReceiverBoundCalls( const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true; const compoundOpts = { fieldFallback, - unwrapCollectionAccessor: provider.unwrapCollectionAccessor, + elementTypeOf: provider.elementTypeOf, hoistTypeBindingsToModule, stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true, constructionSyntax: provider.constructionSyntax, stripTypePreservingDecoration: provider.stripTypePreservingDecoration, - unwrapCollectionElement: provider.unwrapCollectionElement, }; // Build an interface → implementors map from IMPLEMENTS edges.