feat(csharp-scope): parity Unit 5a — IMPORTS edge + static-using mapping

Closes 1 parity failure (21 → 20). Fixes cross-file IMPORTS edge
emission for C#:

- `languages/csharp/interpret.ts`: map `using static X.Y;` to
  `kind: 'namespace'` rather than `'wildcard'`. The File→File
  IMPORTS edge needs a non-wildcard kind to survive finalize's
  Phase 4 (wildcard-expanded edges drop to empty when the provider
  doesn't implement `expandsWildcardTo`). Unqualified static-member
  access is a deferred limitation — covered by the namespace-siblings
  cross-namespace pass for type lookups, and documented under the
  module's Known Limitations.
- `languages/csharp/import-target.ts`: progressive prefix stripping.
  `using CrossFile.Models;` in a repo laid out `Models/User.cs` (no
  `CrossFile/` directory) works because the legacy resolver consults
  csproj; the scope-resolver tries each suffix of the dotted path
  against `.cs` files. Also handles `using static NS.Type;` by
  stripping leading segments until a direct match lands.
- `test/unit/scope-resolution/csharp/csharp-imports.test.ts`: update
  the `using static` test to the new namespace-kind shape.

376/376 scope-resolution unit tests pass; legacy 175/175 green;
20 parity failures remain.
This commit is contained in:
Gergo Magyar 2026-04-21 19:49:56 +01:00
parent 38b203599f
commit ea6ee46ce2
3 changed files with 68 additions and 11 deletions

View file

@ -83,5 +83,44 @@ export function resolveCsharpImportTarget(
if (exactFile !== null) return exactFile;
if (suffixFile !== null) return suffixFile;
return directoryChild;
if (directoryChild !== null) return directoryChild;
// Progressive prefix stripping — mirrors csproj's root-namespace
// mapping without the csproj. `using CrossFile.Models;` in a repo
// laid out `Models/User.cs` (no `CrossFile/` prefix) works because
// the legacy resolver consults csproj; the scope-resolver layer
// doesn't have csproj, so we try each suffix of the namespace path
// against `.cs` files and directories.
//
// Also handles `using static CrossFile.Models.UserFactory;` —
// strip the leading segment, try `Models/UserFactory.cs`; strip
// two, try `UserFactory.cs`.
const segments = pathLike.split('/').filter(Boolean);
for (let skip = 1; skip < segments.length; skip++) {
const tail = segments.slice(skip).join('/');
if (tail === '') continue;
const tailFile = `${tail}.cs`;
const tailSuffix = `/${tailFile}`;
const tailDir = `${tail}/`;
const tailSuffixDir = `/${tailDir}`;
let tailDirectChild: string | null = null;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
if (f === tailFile) return raw;
if (f.endsWith(tailSuffix)) return raw;
if (tailDirectChild === null) {
const atRoot = f.startsWith(tailDir);
const atNested = f.includes(tailSuffixDir);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
const after = f.slice(idx + tailDir.length);
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
}
}
}
if (tailDirectChild !== null) return tailDirectChild;
}
return null;
}

View file

@ -51,10 +51,19 @@ export function interpretCsharpImport(captures: CaptureMatch): ParsedImport | nu
}
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 };
// unqualified scope. Semantically closest to a wildcard, but we
// map to `namespace` here so finalize emits the File→File IMPORTS
// edge without requiring `expandsWildcardTo` (which would list
// every exported member). Static-member unqualified-access is a
// deferred limitation; the usual cross-file lookup via
// namespace-siblings covers `Target.Member` calls.
const lastSegment = sourceCap.text.split('.').pop() ?? sourceCap.text;
return {
kind: 'namespace',
localName: lastSegment,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
default:
return null;

View file

@ -55,12 +55,21 @@ describe('interpretCsharpImport — using flavors', () => {
});
});
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.
it('interprets `using static X.Y;` as a namespace import targeting the type', () => {
// `using static` brings static members into unqualified scope.
// Initially this was mapped to `kind: 'wildcard'` but that
// requires `expandsWildcardTo` to materialize any IMPORTS edge;
// we map to `namespace` so the File→File edge still emits and
// the namespace-siblings pass (which walks known namespaces)
// picks up the target file's classes. Unqualified static-member
// access is a deferred limitation — see csharp/index.ts.
const [imp] = importsFor('using static System.Math;\nclass A {}');
expect(imp).toEqual({ kind: 'wildcard', targetRaw: 'System.Math' });
expect(imp).toEqual({
kind: 'namespace',
localName: 'Math',
importedName: 'System.Math',
targetRaw: 'System.Math',
});
});
it('strips `global::` qualifier — `using global::X.Y;` → namespace X.Y', () => {
@ -91,7 +100,7 @@ describe('interpretCsharpImport — using flavors', () => {
`;
const imps = importsFor(src);
expect(imps).toHaveLength(4);
expect(imps.map((p) => p.kind)).toEqual(['namespace', 'namespace', 'alias', 'wildcard']);
expect(imps.map((p) => p.kind)).toEqual(['namespace', 'namespace', 'alias', 'namespace']);
});
});