feat(csharp-scope): Unit 2 — import interpret + target resolver

Adds the three files Unit 2 of the C# scope-resolution plan calls for:

- `import-decomposer.ts` — inspects each `using_directive` node and
  synthesizes `@import.kind/source/name/alias` markers. Kinds:
    `namespace`  — `using X;` / `using X.Y.Z;`
    `alias`      — `using Alias = X.Y.Z;`         (generics stripped)
    `static`     — `using static X.Y;`
  `global using` maps to namespace (plan's deferred decision); the
  `global::` qualifier is stripped before emitting.
- `interpret.ts` — reads the markers and builds `ParsedImport`. Static
  using maps to `kind: 'wildcard'` since it brings members into
  unqualified scope; Unit 4's merge-bindings tiers wildcards lowest.
  Also provides `interpretCsharpTypeBinding` with nullable/single-arg
  generic/qualifier stripping so receiver-typed resolution sees the
  concrete class name.
- `import-target.ts` — suffix-match adapter returning a single primary
  file. Cross-file partial-class aggregation runs later at graph-bridge
  time (Unit 6). The csproj-based `resolveCSharpImportInternal` stays
  on the legacy path until Unit 7's parity gate surfaces a gap.
- `captures.ts` routes `@import.statement` matches through the
  decomposer so the interpreter sees the markers it needs.

Tests cover every using flavor + resolution edge cases. 38/38 scope-
resolution C# unit tests pass; tsc clean.
This commit is contained in:
Gergo Magyar 2026-04-21 17:04:24 +01:00
parent a58a478c06
commit a3a2c65cc7
5 changed files with 516 additions and 7 deletions

View file

@ -1,19 +1,24 @@
/**
* `emitScopeCaptures` for C#.
*
* Drives the C# scope query against tree-sitter-c-sharp and groups
* raw matches into `CaptureMatch[]` for the central extractor.
* Drives the C# scope query against tree-sitter-c-sharp and groups raw
* matches into `CaptureMatch[]` for the central extractor. Layers one
* synthesized stream on top today:
*
* Unit 1 shape: pure pass-through each tree-sitter match becomes
* one grouped `CaptureMatch`. Import decomposition (Unit 2),
* receiver-type-binding synthesis (Unit 3), and arity metadata
* synthesis (Unit 5) layer on top later.
* 1. **Decomposed using directives** each `using_directive` is
* re-emitted with `@import.kind/source/name/alias` markers so
* `interpretCsharpImport` can recover the ParsedImport shape
* without re-parsing raw text (see `import-decomposer.ts`).
*
* Receiver-binding synthesis (`this` / `base` type anchors) and arity
* metadata synthesis (Unit 5) layer on top later.
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture } from '../../utils/ast-helpers.js';
import { findNodeAtRange, nodeToCapture } from '../../utils/ast-helpers.js';
import { splitUsingDirective } from './import-decomposer.js';
import { getCsharpParser, getCsharpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
@ -47,6 +52,26 @@ export function emitCsharpScopeCaptures(
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Decompose each `using_directive` so `interpretCsharpImport` sees
// the kind/source/name/alias markers it consumes. Raw query match
// only carries the @import.statement anchor.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'using_directive');
if (stmtNode !== null) {
const decomposed = splitUsingDirective(stmtNode);
if (decomposed !== null) {
out.push(decomposed);
continue;
}
}
// Defensive fallback: emit the raw match so the extractor at
// least sees an anchor, even without markers.
out.push(grouped);
continue;
}
out.push(grouped);
}

View file

@ -0,0 +1,115 @@
/**
* Decompose a C# `using_directive` into a `CaptureMatch` carrying the
* synthesized markers `@import.kind` / `@import.source` / `@import.name`
* / `@import.alias` that `interpretCsharpImport` consumes.
*
* Unlike Python's decomposer this is 1:1 each `using` produces exactly
* one import. The split layer exists to expose the kind (namespace vs
* alias vs static) without pushing raw-text parsing into `interpret.ts`.
*
* using System; namespace
* using System.Collections.Generic; namespace
* using Foo = System.Bar; alias
* using static System.Math; static
* global using System.IO; namespace (treated as file-scoped)
* using global::System.IO; namespace (global:: alias stripped)
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
type ImportKind = 'namespace' | 'alias' | 'static';
interface ImportSpec {
readonly kind: ImportKind;
/** Full dotted path (generics stripped): `System.Collections.Generic`. */
readonly source: string;
/** Local binding name last source segment for namespace/static,
* the alias for alias imports. */
readonly name: string;
/** Present iff `kind === 'alias'`. */
readonly alias?: string;
/** Node to anchor the synthesized captures (range-wise). */
readonly atNode: SyntaxNode;
}
export function splitUsingDirective(stmtNode: SyntaxNode): CaptureMatch | null {
if (stmtNode.type !== 'using_directive') return null;
const spec = parseUsingDirective(stmtNode);
if (spec === null) return null;
return buildImportMatch(stmtNode, spec);
}
function parseUsingDirective(node: SyntaxNode): ImportSpec | null {
// tree-sitter-c-sharp's using_directive exposes named children
// corresponding to the parts of the directive but omits keyword tokens
// (`using`, `static`, `global`) from the named-child list. We inspect
// the raw source text to detect the flavor — the grammar doesn't give
// us a cleaner signal.
const raw = node.text;
// Named child layout:
// namespace form: [pathNode]
// alias form: [aliasIdNode, pathNode] (name field = aliasId)
// static form: [pathNode] (same as namespace)
// global using: [pathNode] (same as namespace)
const aliasField = node.childForFieldName('name');
const children = node.namedChildren;
if (children.length === 0) return null;
// Alias form — the `name:` field is the alias identifier; the
// remaining named child is the type/namespace path.
if (aliasField !== null) {
const pathNode = children.find((c) => c !== null && c.startIndex !== aliasField.startIndex) as
| SyntaxNode
| undefined;
if (pathNode === undefined) return null;
return {
kind: 'alias',
source: stripGenericArgs(unwrapGlobalAlias(pathNode.text)),
name: aliasField.text,
alias: aliasField.text,
atNode: node,
};
}
const pathNode = children[0];
if (pathNode === null) return null;
const source = stripGenericArgs(unwrapGlobalAlias(pathNode.text));
if (source === '') return null;
const lastSegment = source.split('.').pop() ?? source;
// `using static X.Y;` — detect by scanning the raw text before the path.
// `global using` behaves semantically as a file-scoped using for our
// purposes, so it isn't a separate kind here.
if (/^\s*(?:global\s+)?using\s+static\s/.test(raw)) {
return { kind: 'static', source, name: '*', atNode: node };
}
return { kind: 'namespace', source, name: lastSegment, atNode: node };
}
/** Strip `global::` prefix — `global::System.IO` → `System.IO`. */
function unwrapGlobalAlias(text: string): string {
return text.replace(/^global::/, '');
}
/** Strip generic type arguments — `Dictionary<string, int>` → `Dictionary`. */
function stripGenericArgs(text: string): string {
const lt = text.indexOf('<');
if (lt === -1) return text;
return text.slice(0, lt);
}
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
if (spec.alias !== undefined) {
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
}
return m;
}

View file

@ -0,0 +1,87 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Unit 2 shape: suffix-match against the repo's `.cs` files. Each
* `using System.Collections.Generic;` could legally expand to multiple
* files (every `.cs` that declares `namespace System.Collections.Generic`
* partial classes, assembly-wide namespaces). The scope-resolver
* contract returns a single primary target, so we pick the first
* match. Cross-file partial-class aggregation runs at graph-bridge
* time (Unit 6) via `populateOwners`.
*
* The legacy csproj-based `resolveCSharpImportInternal` needs config
* objects the scope-resolver doesn't carry; the Unit 7 parity gate
* will surface cases where the suffix-match diverges from the
* namespace-based resolver and we'll adjust the contract if needed.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
export interface CsharpResolveContext {
readonly fromFile: string;
readonly allFilePaths: Set<string>;
}
export function resolveCsharpImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = workspaceIndex as CsharpResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
// Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
const suffix = `/${pathLike}`;
// Exact file match: `System/Collections/Generic.cs` (rare but legal).
// Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`.
// Directory match: first `.cs` file directly inside the namespace dir
// (e.g. `System/Collections/Generic/List.cs` matches namespace Generic).
let exactFile: string | null = null;
let suffixFile: string | null = null;
let directoryChild: string | null = null;
const dirPrefix = `${pathLike}/`;
const suffixDirPrefix = `/${dirPrefix}`;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
if (f === `${pathLike}.cs`) {
exactFile = raw;
break;
}
if (suffixFile === null && f.endsWith(`${suffix}.cs`)) {
suffixFile = raw;
}
if (directoryChild === null) {
// Namespace-to-directory match: pick the first `.cs` directly in
// the namespace dir (not nested deeper). Legacy resolver emits
// all of them; we take one so the scope-resolver contract stays
// single-target.
const atRoot = f.startsWith(dirPrefix);
const atNested = f.includes(suffixDirPrefix);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
const after = f.slice(idx + dirPrefix.length);
if (after.length > 0 && !after.includes('/')) {
directoryChild = raw;
}
}
}
}
if (exactFile !== null) return exactFile;
if (suffixFile !== null) return suffixFile;
return directoryChild;
}

View file

@ -0,0 +1,114 @@
/**
* Capture-match semantic-shape interpreters for C#.
*
* - `interpretCsharpImport` `ParsedImport`
* - `interpretCsharpTypeBinding` `ParsedTypeBinding`
*
* The using-directive matches arrive pre-decomposed by
* `emitCsharpScopeCaptures` (one import per match, with synthesized
* `@import.kind/source/name/alias` markers). Type-binding matches arrive
* from the raw query captures each `@type-binding.*` anchor carries
* `@type-binding.name` + `@type-binding.type`.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretCsharpImport(captures: CaptureMatch): ParsedImport | null {
const kindCap = captures['@import.kind'];
const sourceCap = captures['@import.source'];
const nameCap = captures['@import.name'];
const aliasCap = captures['@import.alias'];
const kind = kindCap?.text;
if (kind === undefined || sourceCap === undefined) return null;
switch (kind) {
case 'namespace': {
// `using System;` / `using System.Collections.Generic;`
// Bind the last segment as the local name so `Generic.Foo`-style
// qualifier references resolve. Full path is the resolution target.
return {
kind: 'namespace',
localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'alias': {
// `using Dict = System.Collections.Generic.Dictionary<string, int>;`
// The decomposer already stripped generic args from source.
if (aliasCap === undefined) return null;
const importedName = sourceCap.text.split('.').pop() ?? sourceCap.text;
return {
kind: 'alias',
localName: aliasCap.text,
importedName,
alias: aliasCap.text,
targetRaw: sourceCap.text,
};
}
case 'static': {
// `using static System.Math;` — brings static members of Math into
// unqualified scope. Semantically closest to a wildcard: any name
// can resolve to a static member of the target type. Merge-bindings
// (Unit 4) ranks wildcards lowest so locals still shadow.
return { kind: 'wildcard', targetRaw: sourceCap.text };
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Strip nullable suffix (`User?` → `User`), single-arg generic wrapper
// (`List<User>` → `User`), and qualifier (`System.User` → `User`) so
// receiver-typed resolution treats these identically.
const rawType = stripQualifier(stripGeneric(stripNullable(typeCap.text.trim())));
// Anchor captures distinguish the source of the binding.
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
/** `User?` → `User`. */
function stripNullable(text: string): string {
if (text.endsWith('?')) return text.slice(0, -1).trim();
return text;
}
/**
* Unwrap a single-arg generic collection wrapper `List<User>`,
* `IEnumerable<User>`, `Task<User>` to its element type. Mirrors
* Python's `stripGeneric` behavior so for-loop and chain propagation
* work on the element type.
*
* Multi-arg generics (`Dictionary<string, User>`, `Func<int, User>`)
* are left alone element semantics aren't unambiguous.
*/
function stripGeneric(text: string): string {
const single = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|IList|IEnumerable|ICollection|IReadOnlyList|IReadOnlyCollection|HashSet|ISet|Task|ValueTask|Nullable|IAsyncEnumerable)<([^,<>]+)>$/,
);
if (single !== null) return single[1].trim();
return text;
}
/** `System.Collections.User` → `User`. */
function stripQualifier(text: string): string {
const lastDot = text.lastIndexOf('.');
if (lastDot === -1) return text;
return text.slice(lastDot + 1);
}

View file

@ -0,0 +1,168 @@
/**
* Unit 2 coverage for the C# import interpreter + target resolver.
*
* Asserts the ParsedImport shape for every `using` flavor and checks
* the resolver adapter's single-target behavior against a small set
* of fake file paths.
*/
import { describe, it, expect } from 'vitest';
import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/captures.js';
import { interpretCsharpImport } from '../../../../src/core/ingestion/languages/csharp/interpret.js';
import { resolveCsharpImportTarget } from '../../../../src/core/ingestion/languages/csharp/import-target.js';
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
function importsFor(src: string): ParsedImport[] {
const matches = emitCsharpScopeCaptures(src, 'test.cs');
return matches
.filter((m) => m['@import.statement'] !== undefined)
.map((m) => interpretCsharpImport(m))
.filter((p): p is ParsedImport => p !== null);
}
describe('interpretCsharpImport — using flavors', () => {
it('interprets `using System;` as a namespace import', () => {
const [imp, ...rest] = importsFor('using System;\nclass A {}');
expect(rest).toHaveLength(0);
expect(imp).toEqual({
kind: 'namespace',
localName: 'System',
importedName: 'System',
targetRaw: 'System',
});
});
it('interprets multi-segment namespace — localName is the last segment', () => {
const [imp] = importsFor('using System.Collections.Generic;\nclass A {}');
expect(imp).toEqual({
kind: 'namespace',
localName: 'Generic',
importedName: 'System.Collections.Generic',
targetRaw: 'System.Collections.Generic',
});
});
it('interprets `using Alias = Path;` as an alias import with generics stripped', () => {
const [imp] = importsFor(
'using Dict = System.Collections.Generic.Dictionary<string, int>;\nclass A {}',
);
expect(imp).toEqual({
kind: 'alias',
localName: 'Dict',
importedName: 'Dictionary',
alias: 'Dict',
targetRaw: 'System.Collections.Generic.Dictionary',
});
});
it('interprets `using static X.Y;` as a wildcard import', () => {
// `using static` brings static members of the target type into
// unqualified scope. Merge-bindings (Unit 4) ranks wildcards
// lowest so locals still shadow them.
const [imp] = importsFor('using static System.Math;\nclass A {}');
expect(imp).toEqual({ kind: 'wildcard', targetRaw: 'System.Math' });
});
it('strips `global::` qualifier — `using global::X.Y;` → namespace X.Y', () => {
const [imp] = importsFor('using global::System.IO;\nclass A {}');
expect(imp).toEqual({
kind: 'namespace',
localName: 'IO',
importedName: 'System.IO',
targetRaw: 'System.IO',
});
});
it('treats `global using X;` as a file-scoped namespace import', () => {
// Plan decision: defer first-class global-using support; treat as
// same-file namespace using for this PR. Unit 7 parity gate flags
// any regression.
const [imp] = importsFor('global using System;\nclass A {}');
expect(imp?.kind).toBe('namespace');
expect(imp?.targetRaw).toBe('System');
});
it('emits exactly one ParsedImport per using directive', () => {
const src = `
using System;
using System.Collections.Generic;
using Dict = System.Collections.Generic.Dictionary<string, int>;
using static System.Math;
`;
const imps = importsFor(src);
expect(imps).toHaveLength(4);
expect(imps.map((p) => p.kind)).toEqual(['namespace', 'namespace', 'alias', 'wildcard']);
});
});
describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
function ctx(fromFile: string, paths: string[]): WorkspaceIndex {
return { fromFile, allFilePaths: new Set(paths) } as unknown as WorkspaceIndex;
}
it('resolves `MyApp.Services` to `MyApp/Services/...cs` when a direct child exists', () => {
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'Services',
importedName: 'MyApp.Services',
targetRaw: 'MyApp.Services',
};
const result = resolveCsharpImportTarget(
parsed,
ctx('MyApp/Program.cs', [
'MyApp/Program.cs',
'MyApp/Services/UserService.cs',
'MyApp/Services/Nested/Inner.cs',
]),
);
expect(result).toBe('MyApp/Services/UserService.cs');
});
it('resolves via suffix when namespace dir is nested under a project root', () => {
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'Models',
importedName: 'MyApp.Models',
targetRaw: 'MyApp.Models',
};
const result = resolveCsharpImportTarget(
parsed,
ctx('src/Program.cs', ['src/Program.cs', 'src/MyApp/Models/User.cs']),
);
expect(result).toBe('src/MyApp/Models/User.cs');
});
it('returns null when no matching .cs file exists', () => {
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'Nothing',
importedName: 'Not.Here',
targetRaw: 'Not.Here',
};
const result = resolveCsharpImportTarget(
parsed,
ctx('a.cs', ['a.cs', 'b.cs', 'some/Other/Thing.cs']),
);
expect(result).toBe(null);
});
it('returns null for dynamic-unresolved imports', () => {
const parsed: ParsedImport = { kind: 'dynamic-unresolved', localName: '', targetRaw: null };
const result = resolveCsharpImportTarget(parsed, ctx('a.cs', ['a.cs']));
expect(result).toBe(null);
});
it('returns null when WorkspaceIndex has the wrong shape', () => {
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'X',
importedName: 'X',
targetRaw: 'X',
};
// Intentionally missing `allFilePaths`.
const result = resolveCsharpImportTarget(parsed, {
fromFile: 'a.cs',
} as unknown as WorkspaceIndex);
expect(result).toBe(null);
});
});