refactor(resolution): one elementTypeOf hook for both container routes (#2766)

The contract carried TWO near-identical hooks answering one question —
"element type of this container" — differing only in the syntactic route
taken to reach it:

  unwrapCollectionAccessor  property view (`dict.Values`), C# only
  unwrapCollectionElement   subscript (`repos[0]`), TypeScript only

So a language implementing one silently got nothing for the other: C#
parsed `Dictionary<K,V>` for `.Values` but returned nothing for
`list[0]`, and TypeScript did the reverse — for no principled reason,
just because each hook was added by whoever needed that route. Two
entries that would each accrete one implementation per language.

Collapsed to `elementTypeOf(containerType, via)` where `via` is
`{kind:'index'}` or `{kind:'accessor', name}`. C#'s existing dictionary
parser now serves the subscript route too, correctly yielding the VALUE
type — `dict["k"]` is a V, never a K. TypeScript answers the index route
and declines the accessor route, because it spells collection views as
method calls that the call-expression branch already handles.

Honest scope note: this did NOT flip C#'s `indexElement` bench cell. That
gap has a different, undiagnosed cause. What this fixes is the structural
duplication and C#'s inability to answer the index route at all — not
that cell.

Still consulted ONLY where the source performed the access. It is not a
general type normalizer: unwrapping a container at a bare class lookup
would let `repos.find(x)` fold to `Repo.find`, since a container's member
set is not its element's.

Shape matrix unchanged at 55/23/18. 4366 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-01 06:38:58 +00:00
parent a8cec54789
commit 5a89747c96
6 changed files with 69 additions and 62 deletions

View file

@ -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<K, V>` / `IDictionary<K, V>` /
@ -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;
}

View file

@ -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

View file

@ -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<K,V>` / `Record<K,V>` (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<K,V>.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<K,V>.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

View file

@ -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<K,V>` 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

View file

@ -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<K,V>-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);
}

View file

@ -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.