mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
c91cc7dd1e
commit
5a1bce7fa1
15 changed files with 502 additions and 155 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -106,3 +106,4 @@ local_docs/
|
|||
# Local agent scratch / review prompts (never commit)
|
||||
.tmp/
|
||||
.agents/
|
||||
.context/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/ILogger.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/ILogger.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace App;
|
||||
|
||||
public interface ILogger
|
||||
{
|
||||
public static void Warn(string msg) { }
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/Runner.cs
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/csharp-interface-receiver-static/src/Runner.cs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace App;
|
||||
|
||||
public class Runner
|
||||
{
|
||||
public void Go()
|
||||
{
|
||||
ILogger.Warn("hi");
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/BaseEntity.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/BaseEntity.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Models;
|
||||
|
||||
public record BaseEntity
|
||||
{
|
||||
public virtual bool Save() { return true; }
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/UserRecord.cs
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/csharp-record-base/src/Models/UserRecord.cs
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace Models;
|
||||
|
||||
public record UserRecord : BaseEntity
|
||||
{
|
||||
public override bool Save()
|
||||
{
|
||||
base.Save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/csharp-struct-overloads/src/Calc.cs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/csharp-struct-overloads/src/Calc.cs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
129
gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts
Normal file
129
gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue