diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index c9abdef7e..67ed23e6b 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -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[]; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 8270c20a5..dc8356346 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -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['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. */ diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 05e59feec..5398890b5 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -66,6 +66,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; */ export const MIGRATED_LANGUAGES: ReadonlySet = new Set([ SupportedLanguages.Python, + SupportedLanguages.CSharp, ]); /** diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index ec702e3a2..1e6bb56ca 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -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 = new Set([ '@reference.name', '@reference.receiver', '@reference.arity', + '@reference.parameter-types', + '@declaration.parameter-count', + '@declaration.required-parameter-count', + '@declaration.parameter-types', ]); /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 5f12a3e6f..f2262574b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index db4c6024c..70aa875fb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -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 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 ad8324062..35464ce3d 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 @@ -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]; +} 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 432f71825..ba77427ca 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 @@ -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(); + 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(); + 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]; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts index 2d2170c92..6f43505f5 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts @@ -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>; + /** 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>; /** 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(); const moduleScopeByFile = new Map(); const memberByOwner = new Map>(); + const membersByOwner = new Map>(); const defsByFileAndName = new Map>(); const callablesBySimpleName = new Map(); @@ -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,