diff --git a/.gitignore b/.gitignore index 92027b529..bbebd405d 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ local_docs/ # Local agent scratch / review prompts (never commit) .tmp/ .agents/ +.context/ diff --git a/gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts index e8e7e6987..8b77464fb 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts @@ -75,18 +75,21 @@ export function csharpImportOwningScope( // ─── receiverBinding ────────────────────────────────────────────────────── /** Look up `this` or `base` in the function scope's type bindings. - * Returns `null` for free functions (no `this`), static methods (no - * `this` binding synthesized), and non-Function scopes. * * `this` and `base` are synthesized as type bindings on instance - * methods during capture emission (receiver-binding.ts, planned for a - * follow-up unit). Until that synthesis lands this hook returns `null` - * for every instance method, which matches the legacy fallback - * behavior — the central extractor then walks the enclosing class - * scope to recover the receiver type. + * methods during capture emission (`receiver-binding.ts`) — `this` + * for every method inside a class/struct/record/interface body, and + * `base` additionally for methods of a class-like type with an + * explicit `base_list`. This hook therefore returns a non-null + * `TypeRef` for instance-method bodies. * - * Matches `pythonReceiverBinding`'s shape so the two provider wirings - * stay symmetric. */ + * Returns `null` for: + * - static methods (no `this` synthesized) + * - free functions / module-level code (no enclosing class) + * - non-Function scopes + * + * Matches `pythonReceiverBinding`'s shape so the two provider + * wirings stay symmetric. */ export function csharpReceiverBinding(functionScope: Scope): TypeRef | null { if (functionScope.kind !== 'Function') return null; return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('base') ?? null; 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 b5021eded..e6c160467 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 @@ -25,6 +25,7 @@ import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import { findCallableBindingInScope, findClassBindingInScope } from '../scope/walkers.js'; +import { narrowOverloadCandidates } from './overload-narrowing.js'; export function emitFreeCallFallback( graph: KnowledgeGraph, @@ -141,52 +142,14 @@ function pickImplicitThisOverload( } 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; - } - } + // O(1) reverse-lookup via inverse map on WorkspaceResolutionIndex. + const classDefId = workspaceIndex.classScopeIdToDefId.get(classScopeId); if (classDefId === undefined) return undefined; const overloads = model.methods.lookupAllByOwner(classDefId, site.name); if (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]; - } + const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes); return candidates[0]; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts new file mode 100644 index 000000000..922afb36c --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -0,0 +1,68 @@ +/** + * Overload narrowing — pick candidates from a list of same-named + * method / function overloads using the call-site's arity and + * argument-type signals. + * + * Used by both `receiver-bound-calls.ts::pickOverload` (explicit + * receiver member call) and `free-call-fallback.ts::pickImplicitThisOverload` + * (implicit `this` free-call inside a class-like body). Shared to keep + * narrowing semantics in lockstep across the two sites. + * + * Semantics (first-wins; callers take `result[0]`): + * 1. If `argCount` is undefined, arity is a pass-through. + * 2. Exact-required-match wins over variadic. Variadic is detected + * via a `parameterTypes` entry equal to `'params'` or starting + * with `'params '` (C# `params` / variadic marker). + * 3. If the arity filter empties the set, fall back to the full + * overload list rather than returning nothing — the caller still + * needs a best-effort candidate. + * 4. If `argTypes` is present, filter further by per-slot type + * equality. An empty string in `argTypes[i]` means "unknown" and + * counts as a match. Mismatches disqualify. A non-empty typed + * result wins; otherwise return the arity-filtered candidates. + * 5. Empty input returns empty output. + */ + +import type { SymbolDefinition } from 'gitnexus-shared'; + +export function narrowOverloadCandidates( + overloads: readonly SymbolDefinition[], + argCount: number | undefined, + argTypes: readonly string[] | undefined, +): readonly SymbolDefinition[] { + if (overloads.length === 0) return []; + + const arityMatches: readonly SymbolDefinition[] = + 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: readonly SymbolDefinition[] = + 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 > 0) return typed; + } + + return candidates; +} 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 5463e97ca..c6ab49e72 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 @@ -10,8 +10,11 @@ * MRO walk skipping self * 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver * 3. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def - * 4. **Case 2 (class-name)** — receiver resolves to a Class binding → - * MRO walk on that class + * 4. **Case 2 (class-name / static receiver)** — receiver resolves to a + * class-like binding (Class/Interface/Struct/Record/Enum/Trait) → MRO + * walk on that class. Also handles static-style invocations + * (`ILogger.Warn(...)`) with kind-aware reason/confidence for + * read/write ACCESSES. * 5. **Case 3 (dotted typeBinding for namespace prefix)** — * `typeRef.rawName` like `models.User` * 6. **Case 3b (chain-typebinding)** — `typeRef.rawName` has a dot @@ -47,6 +50,7 @@ import { import { tryEmitEdge } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; +import { narrowOverloadCandidates } from './overload-narrowing.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -260,15 +264,22 @@ export function emitReceiverBoundCalls( if (memberDef !== undefined) break; } if (memberDef !== undefined) { + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : memberDef.filePath !== parsed.filePath + ? 'import-resolved' + : 'global'; + const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; const ok = tryEmitEdge( graph, scopes, nodeLookup, site, memberDef, - memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + reason, seen, - 0.85, + confidence, collapse, ); if (ok) emitted++; @@ -410,46 +421,6 @@ export function emitReceiverBoundCalls( } } } - - // ── Case 5: class-as-receiver (static call / type member) ──── - // `Animal.Classify()` — receiver name resolves to a Class - // binding, not a typeBinding. Look up the member on the class's - // MRO chain. Python syntactically collapses this with free - // calls; C# (and other statically-typed languages) distinguish - // via the member_access_expression shape. - if (typeRef === undefined) { - const classDef = findClassBindingInScope(site.inScope, receiverName, scopes); - if (classDef !== undefined) { - const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; - let memberDef: SymbolDefinition | undefined; - for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, model); - if (memberDef !== undefined) break; - } - if (memberDef !== undefined) { - const reason = - site.kind === 'write' || site.kind === 'read' - ? site.kind - : memberDef.filePath !== parsed.filePath - ? 'import-resolved' - : 'global'; - const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - reason, - seen, - confidence, - collapse, - ); - if (ok) emitted++; - handledSites.add(siteKey); - } - } - } } } @@ -475,44 +446,6 @@ function pickOverload( } 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]; + const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes); + return candidates[0] ?? overloads[0]; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index c92483b4e..13b52f280 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -24,6 +24,26 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; +/** + * True when a def's `type` names a class-like declaration — every kind + * that collapses to `@scope.class` in the scope-extractor query contract. + * + * Semantics widened historically from `'Class' | 'Interface'` to cover + * C#-shape languages (struct, record, enum, trait). Languages that emit + * only `'Class'` are unaffected — the extra kinds never appear in their + * parsed output. + */ +export function isClassLike(t: string): boolean { + return ( + t === 'Class' || + t === 'Interface' || + t === 'Struct' || + t === 'Record' || + t === 'Enum' || + t === 'Trait' + ); +} + /** * Walk the scope chain from `startScope` looking for a typeBinding * named `receiverName`. Returns the TypeRef or undefined if no binding @@ -49,7 +69,11 @@ export function findReceiverTypeBinding( } /** - * Look up a class-kind binding by name in the given scope's chain. + * Look up a class-like binding by name in the given scope's chain. + * + * "Class-like" covers `Class | Interface | Struct | Record | Enum | + * Trait` via the shared `isClassLike` predicate — every kind that + * collapses to `@scope.class` in the scope-extractor query contract. * * Walks the scope chain upward and consults TWO sources at each step: * 1. `scope.bindings` — populated during scope-extraction Pass 2 with @@ -75,7 +99,7 @@ export function findClassBindingInScope( const localBindings = scope.bindings.get(receiverName); if (localBindings !== undefined) { for (const b of localBindings) { - if (b.def.type === 'Class' || b.def.type === 'Interface') return b.def; + if (isClassLike(b.def.type)) return b.def; } } @@ -83,7 +107,7 @@ export function findClassBindingInScope( const importedBindings = finalizedScopeBindings?.get(receiverName); if (importedBindings !== undefined) { for (const b of importedBindings) { - if (b.def.type === 'Class' || b.def.type === 'Interface') return b.def; + if (isClassLike(b.def.type)) return b.def; } } @@ -184,18 +208,6 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void { // on `class U: def save(self): def helper(): ...` — helper.ownerId will // remain undefined. The theoretical concern is real only if the // extractor ever stops creating scopes for inner defs. - // Class-like def types: Class scope covers C#'s interface/struct/ - // record/enum too (they all collapse to @scope.class per the query - // contract). Interface default methods land as children of the - // Interface def here. - const isClassLike = (t: string): boolean => - t === 'Class' || - t === 'Interface' || - t === 'Struct' || - t === 'Record' || - t === 'Enum' || - t === 'Trait'; - for (const scope of parsed.scopes) { // Methods: function scope whose parent is a Class scope. Owner is // the parent's class-like def. @@ -243,7 +255,7 @@ export function findEnclosingClassDef( const scope = scopes.scopeTree.getScope(currentId); if (scope === undefined) return undefined; if (scope.kind === 'Class') { - const cd = scope.ownedDefs.find((d) => d.type === 'Class'); + const cd = scope.ownedDefs.find((d) => isClassLike(d.type)); if (cd !== undefined) return cd; } currentId = scope.parent; diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts index 8b1314b0e..d8c5d134c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts @@ -12,6 +12,9 @@ * passes can read `scope.bindings`, `scope.typeBindings`, and * `scope.ownedDefs`. SemanticModel's `TypeRegistry` carries class * metadata but not the `Scope`. + * - `classScopeIdToDefId` — inverse of `classScopeByDefId`. O(1) + * reverse lookup (Scope.id → class def nodeId) for the implicit- + * `this` overload picker. * - `moduleScopeByFile` — file path → `Scope` of the root `Module`. * Used by cross-file return-type propagation, `findExportedDef`, * and `findExportedDefByName`'s workspace-wide fallback. @@ -36,12 +39,18 @@ * Build cost is O(totalScopes). Read-only after construction. */ -import type { ParsedFile, Scope } from 'gitnexus-shared'; +import type { ParsedFile, Scope, ScopeId } from 'gitnexus-shared'; +import { isClassLike } from './scope/walkers.js'; export interface WorkspaceResolutionIndex { /** Class def `nodeId` → that class's `Scope`. */ readonly classScopeByDefId: ReadonlyMap; + /** Inverse of `classScopeByDefId`: class `Scope.id` → class def `nodeId`. + * Built in the same pass; used by the implicit-`this` overload picker + * in `free-call-fallback.ts` to skip an O(C) reverse scan. */ + readonly classScopeIdToDefId: ReadonlyMap; + /** Module scope by file path. */ readonly moduleScopeByFile: ReadonlyMap; } @@ -50,6 +59,7 @@ export function buildWorkspaceResolutionIndex( parsedFiles: readonly ParsedFile[], ): WorkspaceResolutionIndex { const classScopeByDefId = new Map(); + const classScopeIdToDefId = new Map(); const moduleScopeByFile = new Map(); for (const parsed of parsedFiles) { @@ -58,10 +68,13 @@ export function buildWorkspaceResolutionIndex( for (const scope of parsed.scopes) { if (scope.kind !== 'Class') continue; - const cd = scope.ownedDefs.find((d) => d.type === 'Class'); - if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope); + const cd = scope.ownedDefs.find((d) => isClassLike(d.type)); + if (cd !== undefined) { + classScopeByDefId.set(cd.nodeId, scope); + classScopeIdToDefId.set(scope.id, cd.nodeId); + } } } - return { classScopeByDefId, moduleScopeByFile }; + return { classScopeByDefId, classScopeIdToDefId, moduleScopeByFile }; } diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/ILogger.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/ILogger.cs new file mode 100644 index 000000000..1cd962e4f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/ILogger.cs @@ -0,0 +1,6 @@ +namespace App; + +public interface ILogger +{ + public static void Warn(string msg) { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/Runner.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/Runner.cs new file mode 100644 index 000000000..0cf3c65a4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/Runner.cs @@ -0,0 +1,9 @@ +namespace App; + +public class Runner +{ + public void Go() + { + ILogger.Warn("hi"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/BaseEntity.cs b/gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/BaseEntity.cs new file mode 100644 index 000000000..f744db935 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/BaseEntity.cs @@ -0,0 +1,6 @@ +namespace Models; + +public record BaseEntity +{ + public virtual bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/UserRecord.cs b/gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/UserRecord.cs new file mode 100644 index 000000000..c13b9fcb2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/UserRecord.cs @@ -0,0 +1,10 @@ +namespace Models; + +public record UserRecord : BaseEntity +{ + public override bool Save() + { + base.Save(); + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-struct-overloads/src/Calc.cs b/gitnexus/test/fixtures/lang-resolution/csharp-struct-overloads/src/Calc.cs new file mode 100644 index 000000000..62e124c4c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-struct-overloads/src/Calc.cs @@ -0,0 +1,13 @@ +namespace Math; + +public struct Calc +{ + public int Add(int a) { return a; } + public int Add(int a, int b) { return a + b; } + + public void Run() + { + Add(1); + Add(1, 2); + } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index e2cf06850..600206735 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -2216,3 +2216,137 @@ describe('C# parse completeness (#903 regression)', () => { expect(targets).toContain('IFoo → Bar'); }); }); + +// --------------------------------------------------------------------------- +// Finding 1: record inheritance + base.Save() resolves via isClassLike widening +// --------------------------------------------------------------------------- + +describe('C# record base resolution (record inheritance + base.Save)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-record-base'), () => {}); + }, 60000); + + it('detects BaseEntity and UserRecord', () => { + // Records project as label 'Record' (class-like) in the graph. + const records = getNodesByLabel(result, 'Record'); + const classes = getNodesByLabel(result, 'Class'); + const all = [...records, ...classes]; + expect(all).toContain('BaseEntity'); + expect(all).toContain('UserRecord'); + }); + + it('does not emit a spurious self-EXTENDS (record heritage not emitted by C# heritage queries)', () => { + // NOTE: C# tree-sitter heritage queries cover class/interface + // declarations but not `record_declaration`, so records don't + // emit an EXTENDS edge today. The record-base linkage is still + // visible via `base.Save()` resolution (next test). This + // assertion pins the negative invariant so a future heritage + // extension for records can flip both tests at once. + const extends_ = getRelationships(result, 'EXTENDS'); + const selfExtend = extends_.find((e) => e.source === 'UserRecord' && e.target === 'UserRecord'); + expect(selfExtend).toBeUndefined(); + }); + + it('resolves base.Save() inside UserRecord.Save to BaseEntity.Save (not self)', () => { + const calls = getRelationships(result, 'CALLS'); + const baseSave = calls.find( + (c) => + c.source === 'Save' && + c.target === 'Save' && + c.targetFilePath === 'src/Models/BaseEntity.cs', + ); + expect(baseSave).toBeDefined(); + // No self-call: no CALLS edge where target is Save in UserRecord.cs. + const selfSave = calls.find( + (c) => + c.source === 'Save' && + c.target === 'Save' && + c.targetFilePath === 'src/Models/UserRecord.cs', + ); + expect(selfSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Finding 4: struct overload dispatch exercises the extracted +// narrowOverloadCandidates utility via implicit-this free calls. +// --------------------------------------------------------------------------- + +describe('C# struct overload dispatch (implicit-this narrowing)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-struct-overloads'), () => {}); + }, 60000); + + it('detects Calc struct', () => { + const structs = getNodesByLabel(result, 'Struct'); + const classes = getNodesByLabel(result, 'Class'); + const all = [...structs, ...classes]; + expect(all).toContain('Calc'); + }); + + it('detects two Add overloads with distinct parameterCount', () => { + const methods = getNodesByLabelFull(result, 'Method').filter((m) => m.name === 'Add'); + expect(methods.length).toBeGreaterThanOrEqual(2); + const arities = methods.map((m) => m.properties.parameterCount as number).sort(); + expect(arities).toContain(1); + expect(arities).toContain(2); + }); + + it('Run() -> Add emits CALLS edges to distinct Add overloads (implicit-this narrowing)', () => { + const calls = getRelationships(result, 'CALLS'); + const runToAdd = calls.filter((c) => c.source === 'Run' && c.target === 'Add'); + // The registry-primary pipeline exercises `pickImplicitThisOverload` + // + `narrowOverloadCandidates` and MUST resolve both Add(int) and + // Add(int, int) to distinct targets. A silent regression in either + // helper would drop an edge or merge both onto one target — pin + // exact counts so either failure mode surfaces immediately. + // The legacy DAG path (REGISTRY_PRIMARY_CSHARP=0) does not + // implement implicit-`this` struct overload narrowing, so we + // accept any count there; the registry-primary path remains the + // authoritative guarantee. + if (process.env['REGISTRY_PRIMARY_CSHARP'] !== '0') { + expect(runToAdd.length).toBe(2); + const targetIds = new Set(runToAdd.map((c) => c.rel.targetId)); + expect(targetIds.size).toBe(2); + } else { + expect(runToAdd.length).toBeLessThanOrEqual(2); + if (runToAdd.length >= 2) { + const targetIds = new Set(runToAdd.map((c) => c.rel.targetId)); + expect(targetIds.size).toBe(runToAdd.length); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Finding 5: merged Case 2 covers Interface static-style invocation +// (`ILogger.Warn(...)` from a class method). +// --------------------------------------------------------------------------- + +describe('C# interface receiver static invocation (merged Case 2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-interface-receiver-static'), + () => {}, + ); + }, 60000); + + it('detects ILogger interface and Runner class', () => { + expect(getNodesByLabel(result, 'Interface')).toContain('ILogger'); + expect(getNodesByLabel(result, 'Class')).toContain('Runner'); + }); + + it('Go() -> ILogger.Warn CALLS edge points at src/ILogger.cs with import-resolved or global reason', () => { + const calls = getRelationships(result, 'CALLS'); + const warnCall = calls.find((c) => c.source === 'Go' && c.target === 'Warn'); + expect(warnCall).toBeDefined(); + expect(warnCall!.targetFilePath).toBe('src/ILogger.cs'); + expect(['import-resolved', 'global']).toContain(warnCall!.rel.reason); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts new file mode 100644 index 000000000..810c3a79b --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts @@ -0,0 +1,129 @@ +/** + * Unit tests for `narrowOverloadCandidates` — the shared overload- + * narrowing utility used by `receiver-bound-calls.ts::pickOverload` + * (explicit receiver member call) and + * `free-call-fallback.ts::pickImplicitThisOverload` (implicit-`this` + * free call). + * + * The utility is pure (data in / data out), so tests build synthetic + * `SymbolDefinition` stubs — no fixtures, no pipeline. + */ + +import { describe, it, expect } from 'vitest'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import { narrowOverloadCandidates } from '../../../src/core/ingestion/scope-resolution/passes/overload-narrowing.js'; + +const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: overrides.filePath ?? 'x.cs', + type: overrides.type ?? 'Method', + ...overrides, +}); + +describe('narrowOverloadCandidates — empty input', () => { + it('returns empty output for empty overload list', () => { + expect(narrowOverloadCandidates([], 1, ['int'])).toEqual([]); + expect(narrowOverloadCandidates([], undefined, undefined)).toEqual([]); + }); +}); + +describe('narrowOverloadCandidates — arity filtering', () => { + const add1 = mkDef({ nodeId: 'add:1', parameterCount: 1, requiredParameterCount: 1 }); + const add2 = mkDef({ nodeId: 'add:2', parameterCount: 2, requiredParameterCount: 2 }); + const add3 = mkDef({ nodeId: 'add:3', parameterCount: 3, requiredParameterCount: 3 }); + + it('passes all overloads through when argCount is undefined', () => { + const result = narrowOverloadCandidates([add1, add2, add3], undefined, undefined); + expect(result.map((d) => d.nodeId)).toEqual(['add:1', 'add:2', 'add:3']); + }); + + it('filters out overloads whose max is below argCount (non-variadic)', () => { + const result = narrowOverloadCandidates([add1, add2, add3], 2, undefined); + expect(result.map((d) => d.nodeId)).toEqual(['add:2']); + }); + + it('filters out overloads whose required-count exceeds argCount', () => { + const result = narrowOverloadCandidates([add1, add2, add3], 1, undefined); + expect(result.map((d) => d.nodeId)).toEqual(['add:1']); + }); + + it('accepts argCount above max when `params` variadic marker is present', () => { + const writeLine = mkDef({ + nodeId: 'wl:1', + parameterCount: 2, + requiredParameterCount: 1, + parameterTypes: ['string', 'params object[]'], + }); + const result = narrowOverloadCandidates([writeLine], 5, undefined); + expect(result.map((d) => d.nodeId)).toEqual(['wl:1']); + }); + + it('accepts argCount above max when bare `params` marker is present', () => { + const variadic = mkDef({ + nodeId: 'v:1', + parameterCount: 1, + requiredParameterCount: 0, + parameterTypes: ['params'], + }); + const result = narrowOverloadCandidates([variadic], 4, undefined); + expect(result.map((d) => d.nodeId)).toEqual(['v:1']); + }); + + it('falls back to the full overload list when arity filter empties it', () => { + // argCount=5 doesn't match any overload (none variadic, all have max < 5). + const result = narrowOverloadCandidates([add1, add2, add3], 5, undefined); + expect(result.map((d) => d.nodeId)).toEqual(['add:1', 'add:2', 'add:3']); + }); +}); + +describe('narrowOverloadCandidates — type narrowing', () => { + const byInt = mkDef({ + nodeId: 'm:int', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['int'], + }); + const byString = mkDef({ + nodeId: 'm:string', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + }); + + it('picks the overload whose parameterTypes[i] equals argTypes[i]', () => { + const result = narrowOverloadCandidates([byInt, byString], 1, ['string']); + expect(result.map((d) => d.nodeId)).toEqual(['m:string']); + }); + + it('treats empty-string argTypes slot as "unknown" and matches every candidate', () => { + const result = narrowOverloadCandidates([byInt, byString], 1, ['']); + // Both candidates survive because "" is an unknown slot. + expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); + }); + + it('falls through to arity-filtered candidates when type filter matches nothing', () => { + const result = narrowOverloadCandidates([byInt, byString], 1, ['bool']); + // Type mismatch against both — falls back to arity candidates. + expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); + }); + + it('skips the type filter entirely when argTypes is undefined', () => { + const result = narrowOverloadCandidates([byInt, byString], 1, undefined); + expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); + }); + + it('skips the type filter entirely when argTypes is empty', () => { + const result = narrowOverloadCandidates([byInt, byString], 1, []); + expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); + }); + + it('disqualifies an overload with missing parameterTypes under type filter', () => { + const noTypes = mkDef({ + nodeId: 'm:notypes', + parameterCount: 1, + requiredParameterCount: 1, + }); + const result = narrowOverloadCandidates([byInt, noTypes], 1, ['int']); + expect(result.map((d) => d.nodeId)).toEqual(['m:int']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts index 5c5e13815..7cae18120 100644 --- a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts @@ -40,7 +40,7 @@ function parsePython(source: string, filePath: string) { } describe('WorkspaceResolutionIndex — scope-only maps', () => { - it('exposes classScopeByDefId and moduleScopeByFile only', () => { + it('exposes classScopeByDefId, classScopeIdToDefId, and moduleScopeByFile', () => { const parsed = parsePython( ` class User: @@ -50,6 +50,7 @@ class User: ); const index = buildWorkspaceResolutionIndex([parsed]); expect(index.classScopeByDefId).toBeInstanceOf(Map); + expect(index.classScopeIdToDefId).toBeInstanceOf(Map); expect(index.moduleScopeByFile).toBeInstanceOf(Map); // No symbol-indexed duplicates. expect((index as { memberByOwner?: unknown }).memberByOwner).toBeUndefined(); @@ -197,3 +198,49 @@ class User: expect(found?.qualifiedName).toBe('User.save'); }); }); + +describe('classScopeIdToDefId — inverse-map invariant', () => { + it('classScopeIdToDefId is populated in sync with classScopeByDefId and is an exact inverse', () => { + const parsed = parsePython( + ` +class User: + def save(self) -> bool: + return True + +class Admin: + def promote(self) -> None: + pass +`, + 'mod.py', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + + // Same size — the two maps are populated in lockstep. + expect(index.classScopeIdToDefId.size).toBe(index.classScopeByDefId.size); + expect(index.classScopeIdToDefId.size).toBe(2); + + // Forward → reverse round-trip. + for (const [defId, scope] of index.classScopeByDefId) { + expect(index.classScopeIdToDefId.get(scope.id)).toBe(defId); + } + + // Reverse → forward round-trip. + for (const [scopeId, defId] of index.classScopeIdToDefId) { + const scope = index.classScopeByDefId.get(defId); + expect(scope).toBeDefined(); + expect(scope!.id).toBe(scopeId); + } + }); + + it('classScopeIdToDefId is empty for a file with no classes', () => { + const parsed = parsePython( + ` +def helper() -> int: + return 42 +`, + 'mod.py', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + expect(index.classScopeIdToDefId.size).toBe(0); + }); +});