fix(csharp): gate the registry no-csproj direct-match path (#1908, Codex F2)

In the no-csproj branch of resolveCsharpImportTarget, resolveDirectMatch
ran BEFORE the gate, so a path-aligned Legacy/System/Threading/Tasks.cs
satisfied 'using System.Threading.Tasks;' even though System.* is not a
declared in-repo namespace — while the legacy leg (gate-first) blocked
it, so the legs were not equivalent. Run csharpSuffixFallbackAllowed
first (return null on fail), then direct-match, then progressive
stripping — mirroring the legacy ordering. Adds a no-csproj fixture with
a deep path-aligned Tasks.cs and dual-leg integration describes (registry
+ forced-legacy), plus a path-aligned unit case. Parity 2/2.
This commit is contained in:
Gergo Magyar 2026-05-30 07:37:16 +00:00
parent dbfab7d4f8
commit 5c84f734ae
6 changed files with 169 additions and 5 deletions

View file

@ -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);
}

View file

@ -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() { }
}

View file

@ -0,0 +1,6 @@
namespace MyApp.Models;
public class User
{
public string Name { get; set; } = "";
}

View file

@ -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;
}
}

View file

@ -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);
});
});

View file

@ -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',