diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts index ac8489a13..3745e16de 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -87,16 +87,25 @@ export function resolveCsharpImportTarget( // Namespace path: `System.Collections.Generic` → `System/Collections/Generic`. const pathLike = targetRaw.replace(/\./g, '/'); + // Gate the WHOLE no-csproj path on declared in-repo namespaces — the direct + // path/suffix match INCLUDED — so a BCL using can't resolve to a + // coincidentally path-aligned local file (e.g. `Legacy/System/Threading/ + // Tasks.cs` satisfying `using System.Threading.Tasks;`). Running the gate + // before `resolveDirectMatch` mirrors the legacy leg's gate-first ordering + // (`import-resolvers/configs/csharp.ts`), so the two legs are equivalent + // (#1881 parity, Codex F2). The gate keeps its fail-open for + // undefined/truncated evidence, so legitimate edges in unscanned repos are + // unaffected. + if (!csharpSuffixFallbackAllowed(targetRaw, evidence)) { + return null; + } + // Exact file / nested-suffix / namespace-dir direct-child match. const direct = resolveDirectMatch(ctx.allFilePaths, pathLike); if (direct !== null) return direct; // Progressive prefix stripping — mirrors csproj's root-namespace mapping - // without the csproj. Gated on declared in-repo namespaces so BCL usings - // don't match a coincidentally-named local file (#1881). - if (!csharpSuffixFallbackAllowed(targetRaw, evidence)) { - return null; - } + // without the csproj. return resolveByProgressiveStripping(ctx.allFilePaths, pathLike); } diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs new file mode 100644 index 000000000..66f19b8cf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs @@ -0,0 +1,10 @@ +// On-disk path (Legacy/System/Threading/Tasks.cs) path-aligns with +// `using System.Threading.Tasks;` but declares an UNRELATED in-repo namespace, +// so the only way an IMPORTS edge forms is the coincidental path — which the +// gate must block in the no-csproj path on BOTH legs (#1881, Codex F2). +namespace MyApp.Legacy; + +public class Tasks +{ + public void Run() { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs new file mode 100644 index 000000000..9864db4d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs @@ -0,0 +1,6 @@ +namespace MyApp.Models; + +public class User +{ + public string Name { get; set; } = ""; +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs new file mode 100644 index 000000000..be2217588 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using MyApp.Models; + +namespace MyApp.Services; + +public class OrderService +{ + public Task ProcessAsync() + { + var user = new User(); + return Task.CompletedTask; + } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 5e15ce5fa..db65f8d66 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -2675,3 +2675,86 @@ describe('C# spurious import edges — legacy DAG leg (#1881, #8)', () => { expect(legit).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// #1881 / Codex F2: in the NO-CSPROJ path the registry leg ran an ungated +// direct-match before the gate, so a path-aligned `Legacy/System/Threading/ +// Tasks.cs` satisfied `using System.Threading.Tasks;`. Both legs must now block +// it (gate-first), proving the legs are equivalent. Fixture ships NO .csproj. +// --------------------------------------------------------------------------- + +describe('C# spurious import edges — no-csproj direct-match, registry leg (#1881, Codex F2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + // Pin to the registry leg: only progressive stripping resolves a no-csproj + // namespace import, so the legit-edge assertion below is registry-specific. + // Pinning also keeps this deterministic under the parity matrix's legacy run. + vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '1'); + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'), + () => {}, + ); + }, 60000); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const spurious = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && + e.targetFilePath === 'Legacy/System/Threading/Tasks.cs', + ); + expect(spurious).toBeUndefined(); + }); + + it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBeGreaterThan(0); + const legit = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs', + ); + expect(legit).toBeDefined(); + }); +}); + +describe('C# spurious import edges — no-csproj direct-match, legacy DAG leg (#1881, Codex F2, #8)', () => { + let result: PipelineResult; + + beforeAll(async () => { + vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0'); + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'), + () => {}, + ); + }, 60000); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const spurious = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && + e.targetFilePath === 'Legacy/System/Threading/Tasks.cs', + ); + expect(spurious).toBeUndefined(); + }); + + it('ingested the fixture so the absence of the spurious edge is meaningful (anti-vacuity)', () => { + // The legacy DAG leg cannot resolve a no-csproj namespace import to a file + // (`using MyApp.Models;` targets a directory of types — only the registry + // leg's progressive stripping resolves it without a csproj RootNamespace, a + // known registry-superiority gap). So the anti-vacuity guard here asserts + // the three fixture files were ingested as graph nodes, proving the spurious + // edge is absent because the gate blocked it — not because nothing parsed. + const files = getNodesByLabel(result, 'File'); + expect(files.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts index c0c49bfa9..4b54bbde1 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts @@ -228,6 +228,49 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => { expect(result).toBe(null); }); + it('does not map a BCL using to a coincidentally PATH-ALIGNED local file via direct-match (#1881, Codex F2)', () => { + // The no-csproj direct-match must be gated too: `Legacy/System/Threading/ + // Tasks.cs` path-aligns with `using System.Threading.Tasks;` and would + // satisfy resolveDirectMatch's nested-suffix match — but System.* is not a + // declared in-repo namespace, so the gate (now run FIRST) blocks it. + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Tasks', + importedName: 'System.Threading.Tasks', + targetRaw: 'System.Threading.Tasks', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Legacy/System/Threading/Tasks.cs', 'Models/User.cs'], + new Set(['MyApp.Services', 'MyApp.Legacy', 'MyApp.Models']), + ), + ); + expect(result).toBe(null); + }); + + it('still resolves a legitimate in-repo using via direct-match when evidence is present (Codex F2 guard)', () => { + // Gating the direct-match must NOT over-block a legitimate aligned import: + // `using MyApp.Services;` aligns (exact declared) so the gate passes and the + // namespace-dir direct-child match still resolves. + 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'], + new Set(['MyApp.Services']), + ), + ); + expect(result).toBe('MyApp/Services/UserService.cs'); + }); + it('still resolves in-repo namespace imports via progressive stripping', () => { const parsed: ParsedImport = { kind: 'namespace',