feat(csharp-scope): Unit 4 — mergeBindings (using precedence)

Three-tier shadowing, same shape as Python's LEGB merge:
  0: local      — class members, locals, parameters
  1: using      — namespace / named / reexport (equal tier; compiler
                  requires explicit qualifier if two using collide)
  2: wildcard   — `using static X.Y;` static-member imports

Within the surviving tier, de-dup by DefId (last-write-wins) so a
re-declared `using` cleanly replaces its earlier binding. Explicit
interface implementations bind under their qualified name in the
extractor layer, so they don't collide with plain simple names here.

7 new tests pin precedence + dedup semantics. 54/54 C# scope-resolution
unit tests pass.
This commit is contained in:
Gergo Magyar 2026-04-21 17:22:40 +01:00
parent dd7e553d07
commit 3c79058088
2 changed files with 122 additions and 1 deletions

View file

@ -0,0 +1,62 @@
/**
* C# shadowing precedence for the `mergeBindings` hook.
*
* Tier ranking (lower wins in shadowing):
*
* - 0: `local` — a class member, method, local variable, or parameter
* declared in this scope.
* - 1: `import` / `namespace` / `reexport` — `using System;`,
* `using System.Collections.Generic;`, `using Alias = Foo;`.
* All three using flavors that introduce a name at this scope
* tier together; the compiler resolves ambiguity by requiring
* an explicit qualifier when two `using`s collide, but for
* receiver-typed dispatch we treat them as equivalent tiers.
* - 2: `wildcard` — `using static System.Math;` brings static
* members in; any local or `using` with the same simple name
* shadows.
*
* Explicit interface implementations (`void IFoo.Bar() { }`) bind under
* the qualified name in the extractor layer, so they never collide with
* a plain `Bar` at this layer.
*
* Within a surviving tier we de-dup by `DefId`, last-write-wins so a
* `using` re-declared further down the file cleanly replaces the
* earlier binding.
*/
import type { BindingRef, Scope } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function csharpMergeBindings(
_scope: Scope,
bindings: readonly BindingRef[],
): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -16,7 +16,16 @@ import {
csharpImportOwningScope,
csharpReceiverBinding,
} from '../../../../src/core/ingestion/languages/csharp/simple-hooks.js';
import type { CaptureMatch, ParsedImport, Scope, ScopeTree, TypeRef } from 'gitnexus-shared';
import { csharpMergeBindings } from '../../../../src/core/ingestion/languages/csharp/merge-bindings.js';
import type {
BindingRef,
CaptureMatch,
ParsedImport,
Scope,
ScopeTree,
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
function fakeScope(
kind: Scope['kind'],
@ -73,6 +82,56 @@ describe('csharpImportOwningScope', () => {
});
});
describe('csharpMergeBindings — shadowing precedence', () => {
const scope = fakeScope('Function');
const def = (nodeId: string): SymbolDefinition =>
({ nodeId, filePath: 't.cs', type: 'Function' }) as SymbolDefinition;
const binding = (origin: BindingRef['origin'], nodeId: string): BindingRef =>
({ def: def(nodeId), origin }) as BindingRef;
it('local declaration shadows `using` import', () => {
const local = binding('local', 'L');
const imp = binding('import', 'I');
expect(csharpMergeBindings(scope, [imp, local])).toEqual([local]);
});
it('explicit `using` shadows `using static` (wildcard)', () => {
const imp = binding('import', 'I');
const wc = binding('wildcard', 'W');
expect(csharpMergeBindings(scope, [wc, imp])).toEqual([imp]);
});
it('local shadows both `using` and `using static`', () => {
const local = binding('local', 'L');
const imp = binding('import', 'I');
const wc = binding('wildcard', 'W');
expect(csharpMergeBindings(scope, [wc, imp, local])).toEqual([local]);
});
it('keeps overload siblings at the same tier', () => {
const a = binding('local', 'A');
const b = binding('local', 'B');
expect(csharpMergeBindings(scope, [a, b])).toEqual([a, b]);
});
it('dedupes same-nodeId bindings', () => {
const a = binding('local', 'A');
const a2 = binding('local', 'A');
expect(csharpMergeBindings(scope, [a, a2])).toHaveLength(1);
});
it('namespace and reexport tie with explicit import (same tier)', () => {
const ns = binding('namespace', 'N');
const re = binding('reexport', 'R');
const imp = binding('import', 'I');
expect(csharpMergeBindings(scope, [ns, re, imp])).toHaveLength(3);
});
it('empty in → empty out', () => {
expect(csharpMergeBindings(scope, [])).toEqual([]);
});
});
describe('csharpReceiverBinding', () => {
it('returns the `this` type binding for an instance method scope', () => {
const binding: TypeRef = { rawName: 'User', source: 'self' } as unknown as TypeRef;