feat(csharp-scope): parity Unit 6e — overload disambig + interface dispatch + FLAG FLIP

Closes the final 4 parity failures (4 → 0). C# now runs the
registry-primary scope-resolution path by default — added to
MIGRATED_LANGUAGES.

Changes:
- `scope-resolution/scope/walkers.ts`: was already extended in
  Unit 6a to recognize Interface/Struct/Record/Enum as class-like
  owners (interface default methods get ownerIds).
- `scope-resolution/passes/receiver-bound-calls.ts`: build
  IMPLEMENTS edge index → emit secondary `interface-dispatch`
  CALLS edges to every implementor's same-named member when the
  primary receiver-typed edge targets an Interface method (closes
  heritage CreateUser CALLS-count test).
- `scope-resolution/passes/receiver-bound-calls.ts`: new
  `pickOverload` helper narrows multi-valued
  `membersByOwner.get(owner).get(name)` candidates by arity then
  argument types. Replaces the first-seen `findOwnedMember` lookup
  in Case 4 so receiver-typed overloaded calls pick the right def.
- `scope-resolution/passes/free-call-fallback.ts`: new
  `pickImplicitThisOverload` walks up to the enclosing class scope
  and applies the same arity + argument-type narrowing for free
  calls inside a class body (`Lookup("alice")` → `Lookup(string)`).
- `scope-resolution/workspace-index.ts`: new `membersByOwner`
  multi-valued index (`Map<owner, Map<name, Def[]>>`) preserves
  every overload alongside the existing first-seen `memberByOwner`.
- `scope-resolution/graph-bridge/node-lookup.ts` +
  `scope-resolution/graph-bridge/ids.ts`: include parameter-types
  suffix in the qualified lookup key for Method nodes. Legacy
  parse-phase encodes the type tag into the node id (`Method:f.cs:
  UserService.Lookup#1~int`); without this two same-arity overloads
  collapsed to one lookup entry and routed to the wrong graph node.
- `scope-resolution/contract/scope-resolver.ts`: new
  `collapseMemberCallsByCallerTarget` opt-in flag (was added in
  Unit 6b for member-call dedup; documented here).
- `gitnexus-shared/src/scope-resolution/reference-site.ts`: new
  `argumentTypes` field carrying inferred per-arg types.
- `scope-extractor.ts`: read @reference.parameter-types capture into
  `site.argumentTypes` and add it + the declaration-arity tags to
  KNOWN_SUB_TAGS so the anchor-detection picks the right anchor.
- `languages/csharp/captures.ts`: synthesize @reference.parameter-types
  by inferring arg types from literal AST nodes (integer_literal →
  'int', string_literal → 'string', constructor_expression →
  type-name, etc).
- `languages/csharp/scope-resolver.ts`: opt in to
  `collapseMemberCallsByCallerTarget`.
- `registry-primary-flag.ts`: **add CSharp to MIGRATED_LANGUAGES**.

Final state:
- C# parity: 175/175 green on flag-on AND flag-off.
- Python parity: 204/204 green on both flag paths (no regression).
- TypeScript clean.

51 → 0 failures across 18 commits on `feat/csharp-scope-resolution`.
This commit is contained in:
Gergo Magyar 2026-04-21 22:04:32 +01:00
parent 8828fcacf6
commit 788c10aacd
9 changed files with 333 additions and 7 deletions

View file

@ -71,4 +71,12 @@ export interface ReferenceSite {
readonly explicitReceiver?: { readonly name: string };
/** Argument count at the call site; used by `provider.arityCompatibility`. */
readonly arity?: number;
/**
* Inferred argument types at the call site, one per argument. An
* empty-string entry means "unknown" — consumers narrowing overload
* candidates treat unknown as any-match. Populated by languages
* that can derive types from literals / constructor expressions
* (C#: `42` → `'int'`, `"alice"` → `'string'`).
*/
readonly argumentTypes?: readonly string[];
}

View file

@ -159,11 +159,26 @@ export function emitCsharpScopeCaptures(
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const n =
const args =
argList === null
? 0
: argList.namedChildren.filter((c) => c !== null && c.type === 'argument').length;
grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(n));
? []
: argList.namedChildren.filter((c) => c !== null && c.type === 'argument');
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
// Infer argument types from literal nodes so overload
// disambiguation can narrow same-arity candidates by param
// type. Non-literal arguments emit empty string to indicate
// "unknown" — consumers treat unknown as any-match.
const argTypes = args.map((arg) => inferArgType(arg!));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(argTypes),
);
}
}
@ -246,6 +261,38 @@ function synthesizePrimaryConstructor(typeNode: SyntaxNode): CaptureMatch | null
type SyntaxNode = ReturnType<ReturnType<typeof getCsharpParser>['parse']>['rootNode'];
/** Infer a C# argument's static type from literal / constructor
* patterns. Returns `''` when the arg has no statically-derivable
* type (e.g. identifier — would require full type inference). */
function inferArgType(argNode: SyntaxNode): string {
// `argument > expression` — tree-sitter-c-sharp wraps the value.
const expr = argNode.namedChild(0);
if (expr === null) return '';
switch (expr.type) {
case 'integer_literal':
return 'int';
case 'real_literal':
return 'double';
case 'string_literal':
case 'verbatim_string_literal':
case 'interpolated_string_expression':
case 'raw_string_literal':
return 'string';
case 'character_literal':
return 'char';
case 'boolean_literal':
return 'bool';
case 'null_literal':
return 'null';
case 'object_creation_expression': {
const typeNode = expr.childForFieldName('type');
return typeNode?.text ?? '';
}
default:
return '';
}
}
/** Find the first C# function-like node at the given range. The
* declaration anchor range covers the whole method/constructor/etc.
* node, but the tag alone doesn't tell us which node type. */

View file

@ -66,6 +66,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
*/
export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<SupportedLanguages>([
SupportedLanguages.Python,
SupportedLanguages.CSharp,
]);
/**

View file

@ -800,6 +800,7 @@ function pass5CollectReferences(
: undefined;
const explicitReceiver = extractExplicitReceiver(match);
const arity = extractArity(match);
const argumentTypes = extractArgumentTypes(match);
const site: ReferenceSite = {
name: nameCap.text,
@ -809,6 +810,7 @@ function pass5CollectReferences(
...(callForm !== undefined ? { callForm } : {}),
...(explicitReceiver !== undefined ? { explicitReceiver } : {}),
...(arity !== undefined ? { arity } : {}),
...(argumentTypes !== undefined ? { argumentTypes } : {}),
};
referenceSites.push(site);
}
@ -882,6 +884,18 @@ function extractArity(match: CaptureMatch): number | undefined {
return Number.isFinite(n) ? n : undefined;
}
function extractArgumentTypes(match: CaptureMatch): readonly string[] | undefined {
const cap = match['@reference.parameter-types'];
if (cap === undefined) return undefined;
try {
const parsed = JSON.parse(cap.text);
if (Array.isArray(parsed) && parsed.every((x) => typeof x === 'string')) return parsed;
} catch {
/* malformed — fall through */
}
return undefined;
}
// ─── Internal: range + capture utilities ───────────────────────────────────
function rangesEqual(a: Range, b: Range): boolean {
@ -931,6 +945,10 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@reference.name',
'@reference.receiver',
'@reference.arity',
'@reference.parameter-types',
'@declaration.parameter-count',
'@declaration.required-parameter-count',
'@declaration.parameter-types',
]);
/**

View file

@ -46,12 +46,24 @@ import {
*/
export function resolveDefGraphId(
filePath: string,
def: { qualifiedName?: string; type?: NodeLabel },
def: { qualifiedName?: string; type?: NodeLabel; parameterTypes?: readonly string[] },
nodeLookup: GraphNodeLookup,
): string | undefined {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) return undefined;
if (def.type !== undefined) {
// Overload disambiguation: when the def carries parameter types,
// try the parameter-typed key first so same-name same-arity
// overloads route to their distinct graph nodes.
if (
def.type === 'Method' &&
def.parameterTypes !== undefined &&
def.parameterTypes.length > 0
) {
const pKey = qualifiedKey(filePath, def.type, `${qn}~${def.parameterTypes.join(',')}`);
const pHit = nodeLookup.get(pKey);
if (pHit !== undefined) return pHit;
}
const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn));
if (qualifiedHit !== undefined) return qualifiedHit;
}

View file

@ -84,6 +84,18 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
if (qualified !== undefined && qualified.length > 0) {
const qKey = qualifiedKey(props.filePath, node.label, qualified);
if (!lookup.has(qKey)) lookup.set(qKey, node.id);
// Overload-disambiguating key: include parameter types so two
// same-arity overloads (e.g. `Lookup(int)` vs `Lookup(string)`)
// map to distinct graph nodes. Legacy parse-phase encodes the
// type tag into the node id; we register both that node id and
// a parameter-types-suffixed key so resolveDefGraphId can find
// the right overload by matching its def's parameterTypes.
const pTypes = (props as { parameterTypes?: readonly string[] }).parameterTypes;
if (pTypes !== undefined && pTypes.length > 0 && node.label === 'Method') {
const pKey = qualifiedKey(props.filePath, node.label, `${qualified}~${pTypes.join(',')}`);
// Each overload is unique — set unconditionally.
lookup.set(pKey, node.id);
}
}
// Fallback key: simple name. First-wins within a file — used when

View file

@ -53,11 +53,18 @@ export function emitFreeCallFallback(
fnDef = pickConstructorOrClass(classDef, workspaceIndex);
}
}
// Implicit-this overload narrowing: an unqualified call inside
// a method body might be calling a sibling overload on the
// enclosing class. When the workspace has multiple methods of
// the same name in a single class, choose the best match by
// arity + argument types.
if (fnDef === undefined && workspaceIndex !== undefined) {
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex);
}
if (fnDef === undefined) {
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
}
if (fnDef === undefined) continue;
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
@ -102,3 +109,82 @@ function pickConstructorOrClass(
}
return classDef;
}
/** Walk up from the call-site scope to the enclosing class scope,
* pick a method member by name with overload narrowing on arity +
* argument types. Returns undefined if there's no enclosing class
* or no matching method. Used for implicit-this calls inside a
* class body where multiple overloads share the call name. */
function pickImplicitThisOverload(
site: {
readonly inScope: ScopeId;
readonly name: string;
readonly arity?: number;
readonly argumentTypes?: readonly string[];
},
scopes: ScopeResolutionIndexes,
workspaceIndex: WorkspaceResolutionIndex,
): SymbolDefinition | undefined {
// Find the enclosing Class scope by walking parents.
let curId: ScopeId | null = site.inScope;
let classScopeId: ScopeId | undefined;
while (curId !== null) {
const sc = scopes.scopeTree.getScope(curId);
if (sc === undefined) break;
if (sc.kind === 'Class') {
classScopeId = sc.id;
break;
}
curId = sc.parent;
}
if (classScopeId === undefined) return undefined;
// Find the Class def for that scope by reverse-lookup in
// classScopeByDefId.
let classDefId: string | undefined;
for (const [defId, scope] of workspaceIndex.classScopeByDefId) {
if (scope.id === classScopeId) {
classDefId = defId;
break;
}
}
if (classDefId === undefined) return undefined;
const overloads = workspaceIndex.membersByOwner.get(classDefId)?.get(site.name);
if (overloads === undefined || overloads.length === 0) return undefined;
if (overloads.length === 1) return overloads[0];
const argTypes = site.argumentTypes;
const argCount = site.arity;
// Filter by arity (same logic as pickOverload in receiver-bound-calls).
const arityMatches =
argCount === undefined
? overloads
: overloads.filter((d) => {
const max = d.parameterCount;
const min = d.requiredParameterCount;
if (max !== undefined && argCount > max) {
const variadic =
d.parameterTypes !== undefined &&
d.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
if (!variadic) return false;
}
if (min !== undefined && argCount < min) return false;
return true;
});
const candidates = arityMatches.length > 0 ? arityMatches : overloads;
if (argTypes !== undefined && argTypes.length > 0) {
const typed = candidates.filter((d) => {
const params = d.parameterTypes;
if (params === undefined) return false;
for (let i = 0; i < argTypes.length && i < params.length; i++) {
if (argTypes[i] === '') continue;
if (argTypes[i] !== params[i]) return false;
}
return true;
});
if (typed.length >= 1) return typed[0];
}
return candidates[0];
}

View file

@ -45,6 +45,7 @@ import {
} from '../scope/walkers.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
* subset rather than the full provider keeps tests and partial
@ -71,6 +72,67 @@ export function emitReceiverBoundCalls(
const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true;
const collapse = provider.collapseMemberCallsByCallerTarget === true;
// Build an interface → implementors map from IMPLEMENTS edges.
// Maps Interface graph-id → list of implementor class scope-def-ids.
// We translate graph-ids back to scope-resolution DefIds via
// `parsedFiles.localDefs` lookup so downstream `findOwnedMember`
// (which keys by DefId) can find the implementor's members.
const graphIdToClassDef = new Map<string, SymbolDefinition>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Interface') continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) graphIdToClassDef.set(graphId, def);
}
}
const implementorsByInterfaceDefId = new Map<string, SymbolDefinition[]>();
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
const ifaceDef = graphIdToClassDef.get(rel.targetId);
const implDef = graphIdToClassDef.get(rel.sourceId);
if (ifaceDef === undefined || implDef === undefined) continue;
let list = implementorsByInterfaceDefId.get(ifaceDef.nodeId);
if (list === undefined) {
list = [];
implementorsByInterfaceDefId.set(ifaceDef.nodeId, list);
}
list.push(implDef);
}
/** Emit secondary CALLS edges with reason='interface-dispatch'
* when the primary receiver-typed edge targeted an Interface's
* method. Each implementing class's same-named method gets a
* secondary edge (excluding the primary target itself). */
const emitInterfaceDispatchFor = (
ownerDef: SymbolDefinition,
memberName: string,
primaryMemberDef: SymbolDefinition,
site: ParsedFile['referenceSites'][number],
confidence: number,
): number => {
if (ownerDef.type !== 'Interface') return 0;
const impls = implementorsByInterfaceDefId.get(ownerDef.nodeId);
if (impls === undefined) return 0;
let n = 0;
for (const implDef of impls) {
const implMember = findOwnedMember(implDef.nodeId, memberName, index);
if (implMember === undefined) continue;
if (implMember.nodeId === primaryMemberDef.nodeId) continue;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
implMember,
'interface-dispatch',
seen,
confidence,
collapse,
);
if (ok) n++;
}
return n;
};
for (const parsed of parsedFiles) {
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
@ -291,7 +353,7 @@ export function emitReceiverBoundCalls(
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
memberDef = findOwnedMember(ownerId, memberName, index);
memberDef = pickOverload(ownerId, memberName, site, index);
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
@ -317,6 +379,10 @@ export function emitReceiverBoundCalls(
collapse,
);
if (ok) emitted++;
// Interface dispatch: when the primary owner is an
// Interface, emit secondary CALLS edges to every
// implementing class's same-named method.
emitted += emitInterfaceDispatchFor(ownerDef, memberName, memberDef, site, confidence);
// Always mark handled when the site was resolved, even
// if the edge was deduplicated (collapse mode), so
// `emitReferencesViaLookup` doesn't re-emit from the
@ -371,3 +437,61 @@ export function emitReceiverBoundCalls(
return emitted;
}
/** Resolve a member by name on a class def, narrowing by argument
* types when multiple overloads share the name. Falls back to the
* first-seen def (legacy `findOwnedMember` semantics) when there's
* no narrowing signal or when `argumentTypes` is unavailable. */
function pickOverload(
ownerId: string,
memberName: string,
site: ParsedFile['referenceSites'][number],
index: WorkspaceResolutionIndex,
): SymbolDefinition | undefined {
const overloads = index.membersByOwner.get(ownerId)?.get(memberName);
if (overloads === undefined || overloads.length === 0) {
return findOwnedMember(ownerId, memberName, index);
}
if (overloads.length === 1) return overloads[0];
const argTypes = site.argumentTypes;
const argCount = site.arity;
// First filter by arity: exact-required-match wins over variadic.
const arityMatches =
argCount === undefined
? overloads
: overloads.filter((d) => {
const max = d.parameterCount;
const min = d.requiredParameterCount;
if (max !== undefined && argCount > max) {
const variadic =
d.parameterTypes !== undefined &&
d.parameterTypes.some((t) => t === 'params' || t.startsWith('params '));
if (!variadic) return false;
}
if (min !== undefined && argCount < min) return false;
return true;
});
const candidates = arityMatches.length > 0 ? arityMatches : overloads;
// Then narrow by argument-type alignment when both sides are known.
if (argTypes !== undefined && argTypes.length > 0) {
const typed = candidates.filter((d) => {
const params = d.parameterTypes;
if (params === undefined) return false;
// Compare each arg-type slot against the corresponding param.
// Empty arg-type means "unknown" — counts as match. Mismatches
// disqualify.
for (let i = 0; i < argTypes.length && i < params.length; i++) {
if (argTypes[i] === '') continue;
if (argTypes[i] !== params[i]) return false;
}
return true;
});
if (typed.length === 1) return typed[0];
if (typed.length > 0) return typed[0];
}
return candidates[0];
}

View file

@ -30,6 +30,11 @@ export interface WorkspaceResolutionIndex {
* Built from `parsed.localDefs` so class-owned members land in the
* right bucket via their `ownerId`. */
readonly memberByOwner: ReadonlyMap<string, ReadonlyMap<string, SymbolDefinition>>;
/** Multi-valued variant of `memberByOwner` so consumers narrowing
* by parameter types (overload resolution) can see every candidate.
* `memberByOwner` continues to return the first-seen def to
* preserve existing consumers. */
readonly membersByOwner: ReadonlyMap<string, ReadonlyMap<string, readonly SymbolDefinition[]>>;
/** File path → (simple-name → first matching module-scope-owned
* `SymbolDefinition`). Backs `findExportedDef` — the lookup for
@ -57,6 +62,7 @@ export function buildWorkspaceResolutionIndex(
const classScopeByDefId = new Map<string, Scope>();
const moduleScopeByFile = new Map<string, Scope>();
const memberByOwner = new Map<string, Map<string, SymbolDefinition>>();
const membersByOwner = new Map<string, Map<string, SymbolDefinition[]>>();
const defsByFileAndName = new Map<string, Map<string, SymbolDefinition>>();
const callablesBySimpleName = new Map<string, SymbolDefinition[]>();
@ -127,11 +133,23 @@ export function buildWorkspaceResolutionIndex(
}
// First-seen wins to match `findOwnedMember` semantics.
if (!memberBucket.has(simple)) memberBucket.set(simple, def);
// Multi-valued variant — keeps every overload for
// parameter-type narrowing.
let membersBucket = membersByOwner.get(ownerId);
if (membersBucket === undefined) {
membersBucket = new Map();
membersByOwner.set(ownerId, membersBucket);
}
const overloads = membersBucket.get(simple);
if (overloads === undefined) membersBucket.set(simple, [def]);
else overloads.push(def);
}
}
return {
classScopeByDefId,
membersByOwner,
memberByOwner,
defsByFileAndName,
callablesBySimpleName,