refactor(scope-resolution): address PR #1019 review findings

Resolves all 5 findings from the automated review on
feat/csharp-scope-resolution. Shared ingestion code stays
language-agnostic; C# (and every class-like language) benefits.

F1 [high] Broaden class-like predicate
  Hoist `isClassLike` in `scope/walkers.ts` to an exported top-level
  helper covering Class | Interface | Struct | Record | Enum | Trait.
  Use it in `findClassBindingInScope`, `findEnclosingClassDef`, and
  `buildWorkspaceResolutionIndex` so C# records, structs, interfaces,
  and enums participate in scope chains and receiver binding the same
  way Python classes do.

F2 [medium] Remove stale comment in csharp simple-hooks
  `csharpReceiverBinding`'s doc claimed this/base synthesis was
  "planned for a follow-up"; synthesis has been implemented in
  receiver-binding.ts since the migration landed. Rewrite the doc to
  describe the actual behavior (non-null TypeRef on instance-method
  bodies, null on static/free functions).

F3 [medium] O(1) reverse lookup for classScopeId -> classDefId
  Add `classScopeIdToDefId: ReadonlyMap<ScopeId, string>` to
  `WorkspaceResolutionIndex`, populated as the inverse of
  `classScopeByDefId`. Replace the O(C) linear scan in
  `pickImplicitThisOverload` (free-call-fallback.ts) with an O(1)
  `Map.get` — turns per-site reverse resolution from linear in class
  count to constant time for every free call.

F4 [low] Extract narrowOverloadCandidates shared utility
  New `passes/overload-narrowing.ts` centralizes the arity + argument-
  type narrowing previously duplicated across `pickOverload`
  (receiver-bound-calls.ts) and `pickImplicitThisOverload`
  (free-call-fallback.ts). Both callsites now share identical
  narrowing semantics; variadic `params T` handling is preserved.
  Return type is `readonly SymbolDefinition[]` with no defensive
  spreads (allocations saved on the hot path).

F5 [low] Merge unreachable Case 5 into Case 2
  `Case 5` in `receiver-bound-calls.ts` was dead code — `Case 2`
  pre-empted it for every static/class-name receiver. Delete Case 5
  and lift its kind-aware read/write ACCESSES reason/confidence logic
  into Case 2 so static-style member access (e.g. `Interface.Member`,
  `TypeName.StaticMember`) gets the correct edge metadata.

Tests
  - New unit tests for `narrowOverloadCandidates` covering empty
    input, arity filtering, variadic params, type narrowing, and
    fallback semantics.
  - New unit tests for `classScopeIdToDefId` verifying inverse
    invariant and empty index behavior.
  - New C# integration fixtures and tests:
      * csharp-record-base — record inheritance + `base.Save()`
      * csharp-struct-overloads — struct with implicit-this overload
        narrowing (pinned exact edge count under registry-primary)
      * csharp-interface-receiver-static — interface-qualified static-
        style call exercises the merged Case 2.
  - Full runs green:
      * scope-resolution unit: 406/406
      * csharp integration (registry-primary): 197/197
      * csharp integration (legacy DAG): 197/197
      * python integration (regression guard): 204/204

Chore
  - Add `.context/` to root `.gitignore` to prevent agent scratch
    files from being committed.

Made-with: Cursor
This commit is contained in:
Gergo Magyar 2026-04-23 09:50:51 +01:00
parent c91cc7dd1e
commit 5a1bce7fa1
15 changed files with 502 additions and 155 deletions

1
.gitignore vendored
View file

@ -106,3 +106,4 @@ local_docs/
# Local agent scratch / review prompts (never commit)
.tmp/
.agents/
.context/

View file

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

View file

@ -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];
}

View file

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

View file

@ -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];
}

View file

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

View file

@ -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<string, Scope>;
/** 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<ScopeId, string>;
/** Module scope by file path. */
readonly moduleScopeByFile: ReadonlyMap<string, Scope>;
}
@ -50,6 +59,7 @@ export function buildWorkspaceResolutionIndex(
parsedFiles: readonly ParsedFile[],
): WorkspaceResolutionIndex {
const classScopeByDefId = new Map<string, Scope>();
const classScopeIdToDefId = new Map<ScopeId, string>();
const moduleScopeByFile = new Map<string, Scope>();
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 };
}

View file

@ -0,0 +1,6 @@
namespace App;
public interface ILogger
{
public static void Warn(string msg) { }
}

View file

@ -0,0 +1,9 @@
namespace App;
public class Runner
{
public void Go()
{
ILogger.Warn("hi");
}
}

View file

@ -0,0 +1,6 @@
namespace Models;
public record BaseEntity
{
public virtual bool Save() { return true; }
}

View file

@ -0,0 +1,10 @@
namespace Models;
public record UserRecord : BaseEntity
{
public override bool Save()
{
base.Save();
return true;
}
}

View file

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

View file

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

View file

@ -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<SymbolDefinition> & { 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']);
});
});

View file

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