fix(csharp): stop spurious IMPORTS edges from ungated using-resolution (#1881) (#1908)

* fix(csharp): eliminate O(S·D) BindingRef OOM in namespace siblings

Types declared in the C# global (default) namespace are visible from
every file, so the previous per-scope augmentation materialized
O(scopes × defs) BindingRefs — on large Unity solutions (tens of
thousands of global types) this caused severe slowness and OOM.

Route global-namespace types through a single workspace-level binding
channel (workspaceFqnBindings, consulted by lookupBindingsAt) for O(D)
memory. Also fix quadratic costs in the non-global path: append defs in
place instead of copying (was O(D²) per bucket), pre-index the first
scope per file (was O(S²·D)), and seed de-dup sets instead of repeated
.some scans.

Add csharp-pipeline-benchmark.test.ts (mirrors the PHP benchmark) with
spread and concentrated-global-namespace scenarios to track elapsedMs,
peakHeapMB, nodeCount, and edgeCount. Post-fix runs show linear scaling
and stable heap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(csharp): scanner fallback for namespace siblings on the worker path

Worker threads can't return tree-sitter Trees across MessageChannels, so
the cross-phase tree cache is empty for worker-parsed files. The C#
same-namespace pass (populateCsharpNamespaceSiblings -> extractFileStructure)
then re-parsed every file with tree-sitter to find namespace / using-static
nodes — effectively parsing a large solution a second time during scope
resolution.

Add a line-scanner fallback (extractCsharpStructureViaScanner) used only
when no cached Tree is available, mirroring PHP's fix for issue #1741. It
extracts the same namespaces / usingStaticPaths the AST walk produces for
the common line-anchored forms (file-scoped + block namespaces, plain /
global / aliased `using static`). The AST walk stays authoritative on the
sequential / warm-cache path.

Micro-benchmark over 3000 synthetic files: scanner is ~188x faster than
parse+walk (0.001 vs 0.251 ms/file) with identical output on the parity
spot-check; real-world files are larger, so the worker-path saving is
bigger. Adds csharp-namespace-extraction.test.ts (12 cases) covering all
declaration forms plus negative cases (using var, plain using, comments).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(csharp): cover global-namespace workspaceFqnBindings path + doc + using-static perf

Addresses the production-readiness review of the namespace-siblings OOM fix.

- Add a unit test proving global-(default-)namespace C# types route to
  indexes.workspaceFqnBindings (one entry per simple name) with ZERO
  bindingAugmentations — pinning the O(D) invariant behind the #1871
  Unity-scale OOM fix and guarding against a revert to per-scope
  O(scopes x defs) augmentation. (The csharp-hooks mock now supplies
  workspaceFqnBindings, which the global fast path reads directly.)
- Correct the workspaceFqnBindings doc comment: it is shared by PHP
  (backslash-FQN keys) and C# (global-namespace simple-name keys); the two
  key formats are disjoint.
- Pre-index parsedFiles by path before the `using static` member-injection
  loop, replacing an O(files) find-per-import with an O(1) Map lookup.

Verified: tsc --noEmit clean; csharp-hooks + csharp-namespace-extraction
suites pass (38 tests); prettier clean; eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(csharp): apply PR-review polish to namespace-siblings (tests, types, docs)

Addresses the multi-agent code review of this PR — the concrete, defensible
findings. Two items intentionally deferred (below).

- namespace-siblings.ts: couple the augmentation bucket + its de-dup set into
  one nullable lifecycle, removing the seen!/bucketArr! non-null assertions
  (identical runtime, still lazy).
- validate-bindings-immutability.ts: extend the dev-mode immutability validator
  to the third channel (workspaceFqnBindings) + a test; complete the validator
  test mock with workspaceFqnBindings.
- walkers.ts: document that namesAtScope deliberately excludes the
  scope-independent workspaceFqnBindings channel (enumerating workspace names at
  every scope would flood per-scope callers; lookupBindingsAt still consults it
  when resolving a specific name).
- scope-resolution-indexes.ts: reframe the workspaceFqnBindings doc to describe
  the key-format contract language-neutrally (examples, not language branching).
- csharp-hooks.test.ts: assert workspace entries carry origin:'namespace'; add a
  partial-class test (same simple name, distinct nodeIds across global files →
  both kept); rename the stale "parses" cache-miss test to "scans".
- csharp-pipeline-benchmark.test.ts: clearTimeout the Promise.race budget timer
  (dangling handle when the pipeline won the race).
- csharp.test.ts: correct the #1066 comment — extractFileStructure no longer
  re-parses on cache miss (line scanner); only emitCsharpScopeCaptures re-parses.

Deferred (surfaced, not applied): (1) worker-path scanner mis-reads
namespace/using-static inside block comments and verbatim/raw strings — an
explicitly documented trade-off mirroring the PHP scanner; hardening it to track
comment/string state is a separate decision. (2) workspaceFqnBindings is read
via an `as Map` cast; a type-safe mutable handle from finalize-orchestrator is a
cross-module contract change.

Verified: tsc --noEmit clean; 49 unit tests pass (incl. 3 new); prettier clean;
eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(csharp): harden worker-path scanner + localize workspace-map cast

Addresses the two deferred PR-review findings plus the remaining test gap.

#1 — Worker-path scanner false positives: the line scanner now tracks block-
comment and string state across lines (advanceCsScanState), so a `namespace` /
`using static` keyword at the start of a line inside a block comment, verbatim
string (@"..."), or raw string literal ("""...""") is no longer mistaken for a
declaration on the worker cache-miss path. It matches only at code-state line
starts. 5 new scanner tests cover the block-comment / raw / verbatim cases.

#4 — workspaceFqnBindings type safety: the ReadonlyMap->Map cast is localized
to one documented line, and global-namespace writes go through a new
getWorkspaceBucket helper (mirroring getAugmentationBucket) rather than an
inline `.set()` at the mutation site.

#2 — lookupBindingsAt workspace-channel coverage: walkers-augmentations.test.ts
now exercises the third (workspace) channel: workspace-only, append-after-
finalized/augmented, and dedup-loses-to-finalized/augmented precedence.

#5 — OOM CI guard: the deterministic O(D) invariant (zero per-scope
augmentation for global types) is already asserted by the always-on
csharp-hooks unit tests added earlier; the scale/time benchmark stays
appropriately opt-in (skipIf).

Verified: tsc --noEmit clean; 69 unit tests (4 suites) + 210 C# integration
resolver tests pass; prettier clean; eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(csharp): replace remaining O(A) .some dedup scans with seeded Sets

The using-static member-injection loop and the cross-namespace import loop both
de-duped via `bucketArr.some((b) => b.def.nodeId === ...)` — O(A) per item. Both
now use a per-file `Map<simpleName, Set<nodeId>>`, seeded lazily from the
augmentation bucket (capturing entries from earlier passes), matching the
global and named-namespace paths. Same dedup semantics, O(1) amortized.

Verified: tsc --noEmit clean; csharp-hooks unit (27) + C# integration resolver
(210) tests pass; prettier + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(csharp): gate suffix-fallback import resolution to declared namespaces (#1881)

C# `using` directives were resolving via an ungated suffix match, so a BCL
using like `System.Threading.Tasks` matched a coincidental local `Tasks.cs`
and emitted spurious IMPORTS edges. Add a declared-namespace gate that only
permits suffix-fallback when the import plausibly refers to an in-repo
namespace (exact, immediate-parent-declared, or ancestor-of a declared
namespace anchored at an in-repo root). Both resolution legs — the legacy
DAG and the registry-primary scope resolver — thread the same evidence to
the gate, including the no-csproj path.

Declared namespaces are collected with #1905's comment/string-aware scanner
(extractCsharpStructureViaScanner, lazily imported) instead of a regex, so
`namespace` tokens in comments/strings can't seed phantom namespaces. Scan
truncation or unreadable subtrees fail OPEN (gate disabled) and are logged.

Stacked on #1905 (fix/csharp-namespace-scope-oom).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(csharp): cap per-file size in namespace scan; fail open on skip (#1881)

scanCSharpProject read every .cs/.csproj in full with no size guard and
issued per-directory reads with no concurrency bound, an OOM/FD-exhaustion
vector on large or generated repos. Add an fs.stat size guard before each
read, reusing getMaxFileSizeBytes() (the same 512KB cap the Phase-1 walker
uses). An oversized or unreadable .cs now signals truncation so the #1881
suffix-fallback gate fails OPEN rather than wrongly suppressing an import
whose declaring namespace lived in the skipped file (previously a silent
return left the scan looking complete). Adds a size-cap scan test.

* fix(csharp): bound per-directory read concurrency in namespace scan (#1881)

The scan issued every .cs/.csproj read in a directory at once via
Promise.all, so in-flight file descriptors scaled with the largest
directory's file count. Issue reads in bounded windows (32, mirroring
the Phase-1 filesystem-walker) via Promise.allSettled; an unexpected
read/scan rejection now trips truncation (fail open) instead of
rejecting the whole scan. Behavior-preserving for namespace collection
(C# scope-resolution parity passes on both legs).

* style(csharp): apply prettier to #1881 files to clear quality/format gate (#1908)

Reflow hand-wrapped lines in scope-resolver.ts and the csharp integration
test that prettier collapses under printWidth 100. Formatting only, no
behavioral change; clears the failing quality/format CI gate.

* fix(csharp): stream namespace scan so large generated files don't disable the #1881 gate (#1908)

Code-review follow-up. The scan read each .cs fully into a string behind a
512KB size cap (the tree-sitter parse budget); a single larger generated file
(*.g.cs, EF/gRPC output) tripped `truncated`, making the #1881 suffix-fallback
gate fail open repo-wide and silently undoing the fix on real repos.

Stream each .cs line-by-line via createReadStream + readline into a new
incremental scanner (createCsharpStructureScanner) instead of buffering the
whole file. Memory is now constant regardless of file size, so the per-file
size cap is dropped for the namespace line-scan and large generated files are
fully collected. extractCsharpStructureViaScanner is reimplemented on the same
incremental scanner (byte-identical; C# parity 2/2). collectDeclaredNamespaces
returns 'ok' | 'truncated' (truncation now only from an unreadable file) and the
truncation warn lists its real causes. csproj reads keep their size guard.

Prior art: ripgrep/ctags/Node readline stream rather than cap for line scans;
GitHub (384KB) and Sourcegraph (1MB) cap only their full-content indexes.

* fix(csharp): cap .csproj read via stream, not stat-then-read, to clear CodeQL TOCTOU (#1908)

CodeQL js/file-system-race flagged the fs.stat + fs.readFile size guard in
readCsprojConfig as a check-then-use filesystem race. Replace it with a
length-capped createReadStream (readFileTextCapped) — same memory bound on
untrusted input, no stat-then-read race, and consistent with the streamed
.cs scan. Behavior is unchanged for real .csproj files (parity 2/2).

* fix(csharp): keep BCL/external roots gated through scan truncation (#1908, Codex F1)

A single scan truncation (unreadable dir/file, depth/dir cap) set one
repo-wide `truncated` flag that made csharpSuffixFallbackAllowed fail
open for EVERY import, silently re-enabling the #1881 BCL->local suffix
matches. Add a CSHARP_EXTERNAL_ROOTS denylist (System/Microsoft/...): an
external-rooted using that does not align with an in-repo declared
namespace stays BLOCKED even under truncation, while genuinely
local-looking usings still fail open. A repo that declares the root is
allowed via the alignment escape hatch. Shared predicate, so both legs
inherit it.

* 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.

* fix(csharp): flag scanner-uncaptured namespaces incomplete; Unicode/@ matchers (#1908, Codex F3)

The line scanner treated its output as complete even when it missed valid
C# namespace forms, so the gate failed CLOSED and over-blocked legit
imports. Make CS_NAMESPACE_RE/CS_USING_STATIC_RE Unicode-aware (\p{L}\p{N}
+ u flag) and strip leading/segment @ so verbatim/Unicode identifiers are
captured to match the AST. For forms the regex still can't capture (split
across lines, not at line start, attributed), set a per-file 'incomplete'
flag; collectDeclaredNamespaces returns 'truncated' for such files so the
#1881 gate fails OPEN instead of dropping the namespace. High-precision
detectors + guard tests keep ordinary forms (incl. // namespace comments)
from tripping incomplete.

* fix(csharp): stream the .csproj RootNamespace read, no byte cap (#1908, Codex F4)

readCsprojConfig read only the first 512KB of a .csproj and, on a
match-miss, couldn't tell 'no RootNamespace' from 'RootNamespace past
the cap' — both synthesized a filename root. A wrong authoritative root
makes imports under the real root resolve to nothing AND suppresses the
fallback. Replace the capped read with a streamed early-stop search
(findCsprojRootNamespace) that reads until the tag or EOF: filename
fallback ONLY on genuine read-to-EOF absence; on a soft-budget cap-hit or
unreadable file, OMIT the config so the no-csproj fallback stays
reachable. Removes the now-unused readFileTextCapped + getMaxFileSizeBytes
cap from the scan. Parity 2/2.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-05-30 09:56:26 +01:00 committed by GitHub
parent f18ff521fc
commit 4b787be835
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1739 additions and 158 deletions

View file

@ -0,0 +1,154 @@
/**
* Pure predicates gating C# `using` suffix-fallback resolution so BCL usings
* (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
* file (#1881).
*
* Lives in the shared `ingestion/` layer NOT under `languages/csharp/` so
* BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`)
* and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it
* without an `import-resolvers/ -> languages/` dependency inversion (#5).
*/
import type { CSharpNamespaceEvidence } from './language-config.js';
/**
* Top-level namespace segments that clearly belong to the BCL / runtime / a
* ubiquitous third-party package i.e. roots a normal repo does NOT declare.
* These stay gated even when the namespace scan is truncated, so a single
* unreadable file / capped subtree can't silently re-enable BCLlocal suffix
* matches repo-wide (#1881). A repo that legitimately declares one of these
* roots is still allowed via the alignment escape hatch below.
*/
const CSHARP_EXTERNAL_ROOTS: ReadonlySet<string> = new Set([
// .NET BCL / runtime
'System',
'Microsoft',
'Windows',
'Mono',
// ubiquitous third-party NuGet roots
'Newtonsoft',
'Serilog',
'AutoMapper',
'MediatR',
'Polly',
'FluentValidation',
'Grpc',
'Google',
'Azure',
'Amazon',
'AWSSDK',
// common test frameworks
'Xunit',
'NUnit',
'Moq',
'FluentAssertions',
'NSubstitute',
'Shouldly',
]);
/** Whether `targetRaw`'s top-level segment is a clearly-external root. */
function isExternalRoot(targetRaw: string): boolean {
const dot = targetRaw.indexOf('.');
const top = dot === -1 ? targetRaw : targetRaw.slice(0, dot);
return CSHARP_EXTERNAL_ROOTS.has(top);
}
/**
* Whether the unanchored suffix fallback may run for `targetRaw`.
*
* Fails OPEN when the namespace scan was truncated (large repos must not
* silently lose legitimate edges, #1881 #11) and when no evidence was
* threaded at all (preserves legacy permissive behavior). The truncation
* fail-open is carved out for clearly-external roots (BCL / well-known
* packages) that the repo does not declare, so one incomplete scan can't
* re-open the #1881 hole repo-wide. Otherwise defers to
* {@link importAlignsWithDeclaredNamespaces}.
*/
export function csharpSuffixFallbackAllowed(
targetRaw: string,
evidence: CSharpNamespaceEvidence | undefined,
): boolean {
if (evidence === undefined) return true;
if (evidence.truncated) {
// Keep clearly-external roots blocked through truncation UNLESS the repo
// actually declares an aligning namespace (the alignment check is the
// escape hatch — a repo that declares `namespace System;` still resolves).
if (
isExternalRoot(targetRaw) &&
!importAlignsWithDeclaredNamespaces(
targetRaw,
evidence.declaredNamespaces,
evidence.rootNamespaces,
)
) {
return false;
}
return true;
}
return importAlignsWithDeclaredNamespaces(
targetRaw,
evidence.declaredNamespaces,
evidence.rootNamespaces,
);
}
/** True when `targetRaw` plausibly refers to a namespace declared in-repo. */
export function importAlignsWithDeclaredNamespaces(
targetRaw: string,
declaredNamespaces: ReadonlySet<string> | undefined,
rootNamespaces?: ReadonlySet<string>,
): boolean {
if (declaredNamespaces === undefined || declaredNamespaces.size === 0) return false;
// Exact: the import IS a declared in-repo namespace.
if (declaredNamespaces.has(targetRaw)) return true;
// Child-of: the import's IMMEDIATE parent namespace is declared in-repo.
// Anchoring on the direct parent — not "any declared prefix" — is what stops
// a declared BCL prefix from green-lighting an unrelated BCL using: a repo
// that declares `namespace System;` must NOT make `using
// System.Threading.Tasks;` resolve to a coincidental local `Tasks.cs`,
// because the import's parent `System.Threading` is not itself declared
// (#1881). The case this still allows is a type / `using static` import under
// a declared namespace laid out without its full path on disk, e.g.
// `using static MyApp.Utils.Logger;` when `MyApp.Utils` is declared.
const lastDot = targetRaw.lastIndexOf('.');
if (lastDot > 0 && declaredNamespaces.has(targetRaw.slice(0, lastDot))) return true;
// Ancestor-of: the import is a strict prefix of some declared namespace
// (e.g. `using MyApp;` when `MyApp.Models` is declared). Only honored when
// the import also sits at or above an in-repo root namespace, so a BCL prefix
// can't qualify merely because a file declares something deeper under it
// (e.g. `System.Threading.Tasks.Extensions`) (#1881).
const childPrefix = targetRaw + '.';
for (const ns of declaredNamespaces) {
if (ns.startsWith(childPrefix)) {
return isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces);
}
}
return false;
}
function isAtOrAboveInRepoRoot(
targetRaw: string,
declaredNamespaces: ReadonlySet<string>,
rootNamespaces: ReadonlySet<string> | undefined,
): boolean {
const descendantPrefix = targetRaw + '.';
if (rootNamespaces !== undefined && rootNamespaces.size > 0) {
for (const root of rootNamespaces) {
// targetRaw equals a root, or is an ancestor of one (e.g. `using MyApp;`
// for csproj RootNamespace `MyApp.Core`).
if (root === targetRaw || root.startsWith(descendantPrefix)) return true;
}
return false;
}
// No explicit roots (e.g. no csproj): treat the top-level segment of each
// declared namespace as the implied root.
for (const ns of declaredNamespaces) {
const dot = ns.indexOf('.');
const top = dot === -1 ? ns : ns.slice(0, dot);
if (top === targetRaw) return true;
}
return false;
}

View file

@ -7,27 +7,45 @@ import { SupportedLanguages } from 'gitnexus-shared';
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
import { createStandardStrategy } from '../standard.js';
import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js';
import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
/** C# namespace-based resolution strategy via .csproj configs. */
export const csharpNamespaceStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
const csharpConfigs = ctx.configs.csharpConfigs;
if (csharpConfigs.length > 0) {
const resolvedFiles = resolveCSharpImportInternal(
rawImportPath,
csharpConfigs,
ctx.normalizedFileList,
ctx.allFileList,
ctx.index,
);
if (resolvedFiles.length > 1) {
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
if (dirSuffix) {
return { kind: 'package', files: resolvedFiles, dirSuffix };
}
const evidence = ctx.configs.csharpNamespaces;
if (csharpConfigs.length === 0) {
// No csproj → there's no namespace→directory mapping to apply, so the
// generic strategy would normally take over. But that generic suffix match
// is UNGATED: it re-introduces the BCL→local spurious match the #1881 gate
// exists to stop. Mirror the registry leg's no-csproj path — defer to the
// generic strategy ONLY for imports that align with an in-repo declared
// namespace; for everything else (BCL usings) return an authoritative empty
// result that STOPS the chain (#2 parity). With no evidence threaded the
// gate fails open, so behavior is unchanged when the scan didn't run.
if (!csharpSuffixFallbackAllowed(rawImportPath, evidence)) {
return { kind: 'files', files: [] };
}
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
return null;
}
return null;
const resolvedFiles = resolveCSharpImportInternal(
rawImportPath,
csharpConfigs,
ctx.normalizedFileList,
ctx.allFileList,
ctx.index,
evidence,
);
if (resolvedFiles.length > 1) {
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
if (dirSuffix) {
return { kind: 'package', files: resolvedFiles, dirSuffix };
}
}
// Authoritative once csproj configs exist: return even an empty result to
// STOP the chain, so the generic suffix fallback can't re-introduce the
// gated BCL→local match this resolver just suppressed (#1881).
return { kind: 'files', files: resolvedFiles };
};
export const csharpImportConfig: ImportResolutionConfig = {

View file

@ -7,11 +7,16 @@
import type { SuffixIndex } from './utils.js';
import { suffixResolve } from './utils.js';
import type { CSharpProjectConfig } from '../language-config.js';
import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js';
import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js';
/**
* Resolve a C# using-directive import path to matching .cs files (low-level helper).
* Tries single-file match first, then directory match for namespace imports.
*
* The final unanchored suffix fallback is gated on `evidence` so BCL usings
* (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
* file (#1881). When `evidence` is omitted the fallback stays permissive.
*/
export function resolveCSharpImportInternal(
importPath: string,
@ -19,6 +24,7 @@ export function resolveCSharpImportInternal(
normalizedFileList: string[],
allFileList: string[],
index?: SuffixIndex,
evidence?: CSharpNamespaceEvidence,
): string[] {
const namespacePath = importPath.replace(/\./g, '/');
const results: string[] = [];
@ -86,7 +92,11 @@ export function resolveCSharpImportInternal(
}
}
// Fallback: suffix matching without namespace stripping (single file)
// Fallback: suffix matching without namespace stripping (single file).
// Gated on in-repo declared-namespace evidence (#1881).
if (!csharpSuffixFallbackAllowed(importPath, evidence)) {
return [];
}
const pathParts = namespacePath.split('/').filter(Boolean);
const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index);
return fallback ? [fallback] : [];

View file

@ -8,6 +8,7 @@ import type {
TsconfigPaths,
GoModuleConfig,
CSharpProjectConfig,
CSharpNamespaceEvidence,
ComposerConfig,
} from '../language-config.js';
import type { SwiftPackageConfig } from '../language-config.js';
@ -32,6 +33,8 @@ export interface ImportConfigs {
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
/** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
csharpNamespaces?: CSharpNamespaceEvidence;
}
/** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */

View file

@ -1,6 +1,9 @@
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import { createInterface } from 'readline';
import path from 'path';
import type { ImportConfigs } from './import-resolvers/types.js';
import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js';
import { isDev } from './utils/env.js';
@ -40,6 +43,44 @@ export interface CSharpProjectConfig {
projectDir: string;
}
/**
* Declared-namespace evidence used to gate C# suffix-fallback resolution so
* BCL usings (e.g. `System.Threading.Tasks`) can't match a coincidentally-
* named local file (#1881).
*/
export interface CSharpNamespaceEvidence {
/** Every `namespace X.Y` declared in-repo (scan may be capped — see `truncated`). */
readonly declaredNamespaces?: ReadonlySet<string>;
/** csproj RootNamespace values plus the top-level segment of each declared
* namespace the anchor set for the parent-namespace gate direction. */
readonly rootNamespaces?: ReadonlySet<string>;
/** True when the BFS hit its dir/depth cap, so the namespace set may be
* incomplete; the gate fails open (allows) in that case. */
readonly truncated?: boolean;
}
/** Result of a single BFS over a repo collecting both csproj configs and
* declared `.cs` namespaces (one disk traversal see `scanCSharpProject`). */
export interface CSharpProjectScan {
readonly configs: CSharpProjectConfig[];
readonly declaredNamespaces: ReadonlySet<string>;
readonly rootNamespaces: ReadonlySet<string>;
readonly truncated: boolean;
}
/** Project the one-pass {@link CSharpProjectScan} into the
* {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
* #1881 gate one shape, two carriers (`ImportConfigs.csharpNamespaces` for
* the legacy DAG, `CsharpResolutionConfig.namespaces` for the scope resolver).
* Keeps the field mapping in one place so the two carriers can't drift. */
export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEvidence {
return {
declaredNamespaces: scan.declaredNamespaces,
rootNamespaces: scan.rootNamespaces,
truncated: scan.truncated,
};
}
/** Swift Package Manager module config */
export interface SwiftPackageConfig {
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
@ -141,58 +182,258 @@ export async function loadComposerConfig(repoRoot: string): Promise<ComposerConf
}
}
/**
* Parse .csproj files to extract RootNamespace.
* Scans the repo root for .csproj files and returns configs for each.
*/
export async function loadCSharpProjectConfig(repoRoot: string): Promise<CSharpProjectConfig[]> {
const configs: CSharpProjectConfig[] = [];
// BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
const maxDepth = 5;
const maxDirs = 100;
let dirsScanned = 0;
// BFS bounds shared by the C# project/namespace scan. Sized to comfortably
// exceed normal C# repos so `truncated` stays the rare exception it was meant
// to be: a too-low cap trips `truncated=true` on ordinary repos, which makes
// `csharpSuffixFallbackAllowed` fail OPEN for every import and silently
// disables the #1881 gate. Truncation remains the safety valve for genuinely
// pathological trees (deep generated output, huge monorepos).
const CSHARP_SCAN_MAX_DEPTH = 24;
const CSHARP_SCAN_MAX_DIRS = 20000;
// Bound on in-flight file reads per directory so a directory with thousands of
// `.cs` files can't exhaust file descriptors / spike memory. Mirrors the
// Phase-1 walker's `READ_CONCURRENCY` (see `filesystem-walker.ts`).
const CSHARP_SCAN_READ_CONCURRENCY = 32;
const CSHARP_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'bin', 'obj']);
const CSHARP_ROOT_NAMESPACE_RE = /<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/;
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
// Declared `namespace` names are extracted with the comment/string-aware
// scanner shared with the scope-resolution namespace-siblings pass
// (`extractCsharpStructureViaScanner`), not a bare regex: a regex matches
// `namespace` inside comments and string literals, seeding the #1881 gate
// with phantom namespaces. Imported lazily (and memoized) so the always-on
// `loadImportConfigs` path — every repo, every language — doesn't eagerly
// pull tree-sitter-c-sharp in via `namespace-siblings.ts` → `query.ts`.
let csharpScannerFactoryPromise: Promise<() => CsharpStructureLineScanner> | undefined;
function getCsharpStructureScannerFactory(): Promise<() => CsharpStructureLineScanner> {
if (csharpScannerFactoryPromise === undefined) {
csharpScannerFactoryPromise = import('./languages/csharp/namespace-siblings.js').then(
(mod) => mod.createCsharpStructureScanner,
);
}
return csharpScannerFactoryPromise;
}
/**
* Single BFS over a repo that collects BOTH .csproj configs and the set of
* `namespace` declarations from `.cs` files.
*
* The csproj walk is cheap (a handful of project files); the namespace scan
* is NOT it opens and reads every `.cs` file in the repo to collect its
* `namespace` declarations. That `.cs` read cost is the price of the #1881
* gate, not a saving: collapsing the csproj and namespace walks into one BFS
* avoids a second directory traversal, but the per-file `.cs` reads are new
* work this scan introduces. Reads within a directory are issued in bounded
* windows (see below); directories are still visited breadth-first.
*/
export async function scanCSharpProject(repoRoot: string): Promise<CSharpProjectScan> {
const configs: CSharpProjectConfig[] = [];
const declaredNamespaces = new Set<string>();
const rootNamespaces = new Set<string>();
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
let dirsScanned = 0;
let truncated = false;
while (scanQueue.length > 0) {
if (dirsScanned >= CSHARP_SCAN_MAX_DIRS) {
truncated = true;
break;
}
const { dir, depth } = scanQueue.shift()!;
dirsScanned++;
let entries: import('fs').Dirent[];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && depth < maxDepth) {
// Skip common non-project directories
if (
entry.name === 'node_modules' ||
entry.name === '.git' ||
entry.name === 'bin' ||
entry.name === 'obj'
)
continue;
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
// Unreadable directory → its `.cs` namespaces are missed, so the scan is
// incomplete. Mark truncated so the #1881 gate fails OPEN (allows the
// suffix fallback) rather than wrongly blocking an import whose declaring
// namespace lived in the unread subtree (#5).
truncated = true;
continue;
}
// Collect read targets, then issue them in bounded windows (rather than all
// at once) so a directory with thousands of `.cs` files can't exhaust file
// descriptors / spike memory. csproj reads keep entry order (config
// precedence matters); `.cs` namespace results land in shared Sets where
// order is irrelevant.
const csprojNames: string[] = [];
const csNames: string[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
if (CSHARP_SCAN_SKIP_DIRS.has(entry.name)) continue;
if (depth < CSHARP_SCAN_MAX_DEPTH) {
scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
} else {
truncated = true; // a real subtree was pruned at the depth cap
}
if (entry.isFile() && entry.name.endsWith('.csproj')) {
try {
const csprojPath = path.join(dir, entry.name);
const content = await fs.readFile(csprojPath, 'utf-8');
const nsMatch = content.match(/<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/);
const rootNamespace = nsMatch ? nsMatch[1].trim() : entry.name.replace(/\.csproj$/, '');
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
configs.push({ rootNamespace, projectDir });
if (isDev) {
logger.info(
`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`,
);
}
} catch {
// Can't read .csproj
}
continue;
}
if (!entry.isFile()) continue;
if (entry.name.endsWith('.csproj')) {
csprojNames.push(entry.name);
} else if (entry.name.endsWith('.cs')) {
csNames.push(entry.name);
}
}
for (let i = 0; i < csprojNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
const batch = csprojNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
const settled = await Promise.allSettled(
batch.map((name) => readCsprojConfig(path.join(dir, name), name, repoRoot, dir)),
);
for (const r of settled) {
const config = r.status === 'fulfilled' ? r.value : null;
if (config) {
configs.push(config);
rootNamespaces.add(config.rootNamespace);
}
}
} catch {
// Can't read directory
}
for (let i = 0; i < csNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
const batch = csNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
const settled = await Promise.allSettled(
batch.map((name) =>
collectDeclaredNamespaces(path.join(dir, name), declaredNamespaces, rootNamespaces),
),
);
// A `.cs` that was unreadable (or whose read/scan unexpectedly rejected)
// leaves its namespaces uncollected → mark truncated to fail the #1881
// gate OPEN rather than wrongly suppress an import. The scan streams each
// file, so file size no longer trips truncation.
for (const r of settled) {
if (r.status !== 'fulfilled' || r.value === 'truncated') truncated = true;
}
}
}
return configs;
if (truncated) {
// Surface the fail-open so an incomplete scan (dir/depth cap, or an
// unreadable directory or `.cs` file) silently disabling the #1881 gate
// repo-wide is observable (#4) rather than a mystery edge regression.
logger.warn(
`[csharp] namespace scan of ${repoRoot} truncated (dir cap ${CSHARP_SCAN_MAX_DIRS}, depth cap ${CSHARP_SCAN_MAX_DEPTH}, an unreadable directory, or an unreadable .cs file); the #1881 suffix-fallback gate fails open for unmatched usings`,
);
}
return { configs, declaredNamespaces, rootNamespaces, truncated };
}
// Generous soft budget for locating `<RootNamespace>`: a real .csproj declares
// it in the first PropertyGroup near the top, so this is only reached by a
// pathological project file with a huge leading ItemGroup and no early
// RootNamespace. On hit we OMIT the config rather than guess a root (Codex F4).
const CSPROJ_ROOT_SCAN_MAX_BYTES = 4 * 1024 * 1024;
// Overlap kept across stream chunks so a `<RootNamespace>` tag straddling a
// chunk boundary is still matched (the tag + a short namespace value fit well
// within this window).
const CSPROJ_TAG_OVERLAP = 512;
/**
* Stream a `.csproj` just far enough to find `<RootNamespace>`, in constant
* memory and without a stat-then-read filesystem race. Returns the namespace
* when found; otherwise `rootNamespace: null` with `capHit` distinguishing a
* genuine read-to-EOF absence (`false`) from "not found within the soft budget"
* (`true`) so the caller never synthesizes a wrong filename root for a late
* tag (Codex F4).
*/
async function findCsprojRootNamespace(
csprojPath: string,
): Promise<{ rootNamespace: string | null; capHit: boolean }> {
const stream = createReadStream(csprojPath, { encoding: 'utf-8' });
let window = '';
let bytesRead = 0;
try {
for await (const chunk of stream) {
const text = chunk as string;
bytesRead += text.length;
window =
(window.length > CSPROJ_TAG_OVERLAP ? window.slice(-CSPROJ_TAG_OVERLAP) : window) + text;
const match = window.match(CSHARP_ROOT_NAMESPACE_RE);
if (match) {
stream.destroy();
return { rootNamespace: match[1]!.trim(), capHit: false };
}
if (bytesRead >= CSPROJ_ROOT_SCAN_MAX_BYTES) {
stream.destroy();
return { rootNamespace: null, capHit: true };
}
}
} catch {
// Unreadable .csproj: don't guess a filename root either — omit the config.
return { rootNamespace: null, capHit: true };
}
return { rootNamespace: null, capHit: false }; // read to EOF, tag genuinely absent
}
async function readCsprojConfig(
csprojPath: string,
fileName: string,
repoRoot: string,
dir: string,
): Promise<CSharpProjectConfig | null> {
const { rootNamespace: found, capHit } = await findCsprojRootNamespace(csprojPath);
// A late `<RootNamespace>` we couldn't reach (capHit) or an unreadable file
// must NOT synthesize a filename root — a wrong authoritative root would make
// imports under the real root resolve to nothing and suppress the fallback
// (Codex F4). Omit the config so the no-csproj fallback stays available. Only
// fall back to the filename on a genuine read-to-EOF absence of the tag.
if (capHit) return null;
const rootNamespace = found ?? fileName.replace(/\.csproj$/, '');
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
if (isDev) {
logger.info(
`📦 Loaded C# project: ${fileName} (namespace: ${rootNamespace}, dir: ${projectDir})`,
);
}
return { rootNamespace, projectDir };
}
/**
* Stream one `.cs` file line-by-line and collect its declared `namespace` names
* into the shared Sets.
*
* Streaming (rather than reading the whole file into a string) keeps memory
* constant regardless of file size, so a large generated `.cs` (`*.g.cs`, EF /
* gRPC output) is fully scanned instead of skipped by a per-file size cap
* which would otherwise trip `truncated` and disable the #1881 gate repo-wide.
* Only the cheap line scan streams here; the tree-sitter PARSE path keeps its
* own size cap.
*
* Returns `'truncated'` when the file could not be read, so the caller marks the
* scan truncated and the #1881 gate fails OPEN rather than wrongly suppress an
* import declared in the unread file. Returns `'ok'` on a complete read.
*/
async function collectDeclaredNamespaces(
filePath: string,
declaredNamespaces: Set<string>,
rootNamespaces: Set<string>,
): Promise<'ok' | 'truncated'> {
const createScanner = await getCsharpStructureScannerFactory();
const scanner = createScanner();
try {
// `crlfDelay: Infinity` treats every `\r\n` as a single break; the line
// scanner is terminator-agnostic, so a streamed scan yields the same
// namespaces as scanning the whole file content at once.
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity,
});
for await (const line of lines) {
scanner.pushLine(line);
}
} catch {
return 'truncated'; // unreadable source → signal truncation (fail open)
}
const structure = scanner.result();
for (const ns of structure.namespaces) {
declaredNamespaces.add(ns);
const dot = ns.indexOf('.');
rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot));
}
// A declaration the scanner could not fully capture (Codex F3) means the
// collected namespaces are an incomplete picture of this file — treat it like
// a truncated read so the #1881 gate fails OPEN rather than over-block an
// import whose namespace was dropped.
return structure.incomplete ? 'truncated' : 'ok';
}
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
@ -231,11 +472,13 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
/** Load all language-specific configs once for an ingestion run. */
export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs> {
const csharpScan = await scanCSharpProject(repoRoot);
return {
tsconfigPaths: await loadTsconfigPaths(repoRoot),
goModule: await loadGoModulePath(repoRoot),
composerConfig: await loadComposerConfig(repoRoot),
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
csharpConfigs: await loadCSharpProjectConfig(repoRoot),
csharpConfigs: csharpScan.configs,
csharpNamespaces: csharpScanToEvidence(csharpScan),
};
}

View file

@ -9,30 +9,112 @@
* 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.
* When `.csproj` configs are available, consults the legacy
* namespace-directory resolver first. Both that resolver's suffix
* fallback and the progressive prefix stripping below are gated on
* declared in-repo namespaces so BCL usings like `System.Threading.Tasks`
* cannot spuriously match a local `Tasks.cs` (#1881).
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../../language-config.js';
import { resolveCSharpImportInternal } from '../../import-resolvers/csharp.js';
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
export interface CsharpResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
readonly csharpConfigs?: readonly CSharpProjectConfig[];
readonly namespaces?: CSharpNamespaceEvidence;
}
/** Normalized file list + suffix index, built once per workspace `allFilePaths`. */
interface WorkspaceFileIndex {
readonly normalized: string[];
readonly all: string[];
readonly index: SuffixIndex;
}
// Memoize on Set identity: the orchestrator passes the SAME `allFilePaths`
// Set through every `resolveImportTarget` call in a pass, so this rebuilds
// the normalized list + suffix index once instead of once per import (#1881 #2).
const workspaceFileIndexCache = new WeakMap<ReadonlySet<string>, WorkspaceFileIndex>();
function getWorkspaceFileIndex(allFilePaths: ReadonlySet<string>): WorkspaceFileIndex {
const cached = workspaceFileIndexCache.get(allFilePaths);
if (cached) return cached;
const all = [...allFilePaths];
const normalized = all.map((f) => f.replace(/\\/g, '/'));
const built: WorkspaceFileIndex = { normalized, all, index: buildSuffixIndex(normalized, all) };
workspaceFileIndexCache.set(allFilePaths, built);
return built;
}
export function resolveCsharpImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
// WorkspaceIndex is `unknown` in the shared contract (Ring 1
// placeholder). The scope-resolution orchestrator hands us a
// CsharpResolveContext-shaped object; narrow structurally rather
// than via a cast chain so unexpected shapes return null cleanly.
const ctx = narrowContext(workspaceIndex);
if (ctx === null) return null;
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
const targetRaw = parsedImport.targetRaw;
const evidence = ctx.namespaces;
const csharpConfigs = ctx.csharpConfigs ?? [];
if (csharpConfigs.length > 0) {
const { normalized, all, index } = getWorkspaceFileIndex(ctx.allFilePaths);
const fromCsproj = resolveCSharpImportInternal(
targetRaw,
[...csharpConfigs],
normalized,
all,
index,
evidence,
);
if (fromCsproj.length > 0) return fromCsproj[0]!;
// csproj configs are authoritative: mirror legacy `configs/csharp.ts`,
// which returns an empty result to STOP the chain. Falling through to the
// ungated `resolveDirectMatch` would re-introduce the BCL→local match the
// internal resolver's gate just suppressed (#1881 parity, #2).
return null;
}
// 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.
return resolveByProgressiveStripping(ctx.allFilePaths, pathLike);
}
/**
* `WorkspaceIndex` is an opaque `unknown` placeholder in the shared contract;
* the orchestrator hands us a `CsharpResolveContext`-shaped object. Narrow
* structurally rather than via a cast chain so unexpected shapes fail cleanly.
*/
function narrowContext(workspaceIndex: WorkspaceIndex): CsharpResolveContext | null {
const ctx = workspaceIndex as CsharpResolveContext | undefined;
if (
ctx === undefined ||
@ -41,90 +123,78 @@ export function resolveCsharpImportTarget(
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
return ctx;
}
// 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;
/**
* First-pass resolution against the full namespace path:
* exact whole-path file > nested suffix file > first `.cs` directly inside
* the namespace directory.
*/
function resolveDirectMatch(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
const exactName = `${pathLike}.cs`;
const nestedSuffix = `/${exactName}`;
let suffixFile: string | null = null;
let directoryChild: string | null = null;
const dirPrefix = `${pathLike}/`;
const suffixDirPrefix = `/${dirPrefix}`;
for (const raw of ctx.allFilePaths) {
for (const raw of 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 (f === exactName) return raw; // exact whole-path match wins
if (suffixFile === null && f.endsWith(nestedSuffix)) suffixFile = raw;
}
if (exactFile !== null) return exactFile;
if (suffixFile !== null) return suffixFile;
if (directoryChild !== null) return directoryChild;
return findDirectChild(allFilePaths, pathLike);
}
// 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`.
/**
* First `.cs` file that lives directly inside the namespace directory
* `dirSegment` (at repo root or nested under a project prefix), not deeper.
* The legacy resolver emits all of them; the scope-resolver contract is
* single-target so we take one.
*/
function findDirectChild(allFilePaths: ReadonlySet<string>, dirSegment: string): string | null {
const dirPrefix = `${dirSegment}/`;
const nestedDirPrefix = `/${dirPrefix}`;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.cs')) continue;
const atRoot = f.startsWith(dirPrefix);
const atNested = f.includes(nestedDirPrefix);
if (!atRoot && !atNested) continue;
const idx = atRoot ? 0 : f.indexOf(nestedDirPrefix) + 1;
const after = f.slice(idx + dirPrefix.length);
if (after.length > 0 && !after.includes('/')) return raw;
}
return null;
}
/**
* Try each suffix of the namespace path against `.cs` files and directories,
* stripping leading segments one at a time. Models `using CrossFile.Models;`
* resolving to `Models/User.cs` in a repo laid out without the `CrossFile/`
* prefix (the scope-resolver layer has no csproj to consult).
*/
function resolveByProgressiveStripping(
allFilePaths: ReadonlySet<string>,
pathLike: string,
): string | null {
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) {
let tailFileMatch: string | null = null;
for (const raw of 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 (f === tailFile || f.endsWith(tailSuffix)) {
tailFileMatch = raw;
break;
}
}
if (tailDirectChild !== null) return tailDirectChild;
if (tailFileMatch !== null) return tailFileMatch;
const child = findDirectChild(allFilePaths, tail);
if (child !== null) return child;
}
return null;
}

View file

@ -48,19 +48,62 @@ export interface CsharpFileStructure {
/** Dotted paths from `using static X.Y.Z;` (including
* `global using static` and aliased `using static A = X.Y.Z;`). */
readonly usingStaticPaths: readonly string[];
/** True when the scanner saw a `namespace` / `using static` declaration it
* could not fully capture (keyword not at line start, split across lines, or
* an unparseable identifier form). Callers feeding the #1881 gate must treat
* this like a truncated scan and fail OPEN, since a dropped namespace would
* otherwise over-block a legitimate import (Codex F3). Absent/false on a
* cleanly-scanned file. */
readonly incomplete?: boolean;
}
// A dotted C# namespace identifier: each segment is an optional verbatim `@`
// followed by a Unicode letter/`_` and Unicode letters/digits/`_`. The `u` flag
// makes the classes Unicode-aware so `namespace Café.Models;` is captured (the
// old ASCII `[A-Za-z…]` truncated it). The `@` markers are stripped from the
// capture so it matches the tree-sitter AST's `name` text.
const CS_NS_IDENT = String.raw`@?[\p{L}_][\p{L}\p{N}_]*(?:\.@?[\p{L}_][\p{L}\p{N}_]*)*`;
// Line-anchored matchers for the worker-path fallback (see
// `extractCsharpStructureViaScanner`). Anchored at line start (after
// indentation); the scanner additionally tracks block-comment / string
// state across lines so a keyword at the start of a line inside one of
// those regions is skipped.
const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/;
const CS_NAMESPACE_RE = new RegExp(String.raw`^[ \t]*namespace[ \t]+(${CS_NS_IDENT})`, 'u');
// `global using static`, plain `using static`, and the aliased
// `using static Alias = NS.Type;` form (the AST keeps the RHS path, so
// the optional `Alias =` is skipped and only the dotted path captured).
const CS_USING_STATIC_RE =
/^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:[A-Za-z_@][A-Za-z0-9_]*[ \t]*=[ \t]*)?([A-Za-z_@][A-Za-z0-9_.]*)/;
const CS_USING_STATIC_RE = new RegExp(
String.raw`^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:@?[\p{L}_][\p{L}\p{N}_]*[ \t]*=[ \t]*)?(${CS_NS_IDENT})`,
'u',
);
// Incompleteness detectors — used ONLY when the precise matchers above failed,
// to flag a declaration the scanner could not capture (so the file fails the
// #1881 gate OPEN instead of silently dropping the namespace). Kept
// high-precision so ordinary files never trip them (which would wrongly disable
// the gate repo-wide):
// - `…_BARE`: the keyword alone on a line (the name is on the next line).
// - `…_AT_START`: a line-start declaration the precise matcher couldn't parse.
// - `CS_NAMESPACE_AFTER_CODE`: a `namespace` keyword right after a `}`/`;`/`{`/`]`
// (real code, NOT a `//` comment), i.e. not at line start.
const CS_NAMESPACE_BARE = /^[ \t]*namespace[ \t]*\r?$/;
const CS_USING_STATIC_BARE = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]*\r?$/;
const CS_NAMESPACE_AT_START = /^[ \t]*namespace[ \t]+\S/;
const CS_USING_STATIC_AT_START = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+\S/;
const CS_NAMESPACE_AFTER_CODE = /[}\];{][ \t]*namespace[ \t]+@?[\p{L}_]/u;
/** Whether a `code`-state line declares a namespace / using-static the precise
* matchers could not capture see the detectors above. */
function looksLikeUncapturedDeclaration(line: string): boolean {
return (
CS_NAMESPACE_BARE.test(line) ||
CS_USING_STATIC_BARE.test(line) ||
CS_NAMESPACE_AT_START.test(line) ||
CS_USING_STATIC_AT_START.test(line) ||
CS_NAMESPACE_AFTER_CODE.test(line)
);
}
/** Multi-line lexical state carried line-to-line by the scanner. */
type CsScanState = 'code' | 'block' | 'verbatim' | 'raw';
@ -182,26 +225,60 @@ function advanceCsScanState(
* AST is a declaration whose keyword is not at the start of a code line
* (split across lines, or sharing a line with a comment/string closer).
* Mirrors PHP's `extractNamespaceViaScanner` (issue #1741). */
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
/** Incremental form of {@link extractCsharpStructureViaScanner}: feed lines one
* at a time via `pushLine` (in source order), then read the accumulated
* structure with `result()`. Lets a caller stream a file off disk
* (`createReadStream` + `readline`) and scan it for `namespace` / `using
* static` declarations in CONSTANT memory rather than buffering the whole file
* into a string the line splitting and per-line matching are identical, so a
* streamed scan yields the same result as scanning the full content. The line
* terminator must be stripped (as `readline` does, or `String.split('\n')`); a
* trailing `\r` on a CRLF line is inert to both the matchers and the lexer. */
export interface CsharpStructureLineScanner {
pushLine(line: string): void;
result(): CsharpFileStructure;
}
/** Create a fresh stateful line scanner — see {@link CsharpStructureLineScanner}. */
export function createCsharpStructureScanner(): CsharpStructureLineScanner {
const namespaces: string[] = [];
const usingStaticPaths: string[] = [];
let incomplete = false;
let state: CsScanState = 'code';
let rawFence = 0;
for (const line of content.split('\n')) {
// Only match when the line START is real code — keywords reached while
// inside a block comment / multi-line string are skipped.
if (state === 'code') {
const ns = CS_NAMESPACE_RE.exec(line);
if (ns !== null) {
namespaces.push(ns[1]!);
} else {
const us = CS_USING_STATIC_RE.exec(line);
if (us !== null) usingStaticPaths.push(us[1]!);
return {
pushLine(line: string): void {
// Only match when the line START is real code — keywords reached while
// inside a block comment / multi-line string are skipped.
if (state === 'code') {
const ns = CS_NAMESPACE_RE.exec(line);
if (ns !== null) {
namespaces.push(ns[1]!.replace(/@/g, ''));
} else {
const us = CS_USING_STATIC_RE.exec(line);
if (us !== null) {
usingStaticPaths.push(us[1]!.replace(/@/g, ''));
} else if (looksLikeUncapturedDeclaration(line)) {
// A declaration the precise matchers couldn't capture → mark the
// file incomplete so the #1881 gate fails OPEN (Codex F3).
incomplete = true;
}
}
}
}
[state, rawFence] = advanceCsScanState(line, state, rawFence);
}
return { namespaces, usingStaticPaths };
[state, rawFence] = advanceCsScanState(line, state, rawFence);
},
result(): CsharpFileStructure {
return incomplete
? { namespaces, usingStaticPaths, incomplete }
: { namespaces, usingStaticPaths };
},
};
}
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
const scanner = createCsharpStructureScanner();
for (const line of content.split('\n')) scanner.pushLine(line);
return scanner.result();
}
/** Build a structural view of a C# file. Prefers `cachedTree` (handed in

View file

@ -0,0 +1,30 @@
/**
* Per-workspace config for C# scope-resolution import targeting.
*
* Loaded once per analyze pass via `csharpScopeResolver.loadResolutionConfig`
* and threaded into `resolveCsharpImportTarget`. The pure gate predicates live
* in `../../csharp-namespace-gate.ts` (shared with the legacy DAG resolver).
*/
import {
scanCSharpProject,
csharpScanToEvidence,
type CSharpProjectConfig,
type CSharpNamespaceEvidence,
} from '../../language-config.js';
export interface CsharpResolutionConfig {
readonly csharpConfigs: readonly CSharpProjectConfig[];
/** In-repo declared-namespace evidence gating suffix-fallback resolution (#1881). */
readonly namespaces?: CSharpNamespaceEvidence;
}
export async function loadCsharpResolutionConfig(
repoRoot: string,
): Promise<CsharpResolutionConfig> {
const scan = await scanCSharpProject(repoRoot);
return {
csharpConfigs: scan.configs,
namespaces: csharpScanToEvidence(scan),
};
}

View file

@ -19,6 +19,7 @@ import {
type CsharpResolveContext,
} from './index.js';
import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js';
import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js';
const csharpScopeResolver: ScopeResolver = {
@ -26,8 +27,16 @@ const csharpScopeResolver: ScopeResolver = {
languageProvider: csharpProvider,
importEdgeReason: 'csharp-scope: using',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: CsharpResolveContext = { fromFile, allFilePaths };
loadResolutionConfig: (repoPath) => loadCsharpResolutionConfig(repoPath),
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
const config = resolutionConfig as CsharpResolutionConfig | undefined;
const ws: CsharpResolveContext = {
fromFile,
allFilePaths,
csharpConfigs: config?.csharpConfigs,
namespaces: config?.namespaces,
};
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
// shared contract, so `ws` passes structurally without a cast.
return resolveCsharpImportTarget(

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

@ -0,0 +1,6 @@
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

@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>MyApp</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -1,7 +1,7 @@
/**
* C#: heritage resolution via base_list + ambiguous namespace-import refusal
*/
import { describe, expect, beforeAll } from 'vitest';
import { describe, expect, beforeAll, afterAll, vi } from 'vitest';
import path from 'path';
import {
FIXTURES,
@ -2603,3 +2603,158 @@ describe('C# namespace-as-root with no trailing newline (issue #1086)', () => {
expect(edge!.rel.reason).toBe('csharp-scope: using');
});
});
// ---------------------------------------------------------------------------
// Spurious IMPORTS: BCL usings must not match coincidentally-named local files
// (#1881)
// ---------------------------------------------------------------------------
describe('C# spurious import edges (#1881)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {});
}, 60000);
it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => {
const imports = getRelationships(result, 'IMPORTS');
const spurious = imports.find(
(e) =>
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs',
);
expect(spurious).toBeUndefined();
});
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
// Guards against the negative above passing vacuously: the fixture's
// `using MyApp.Models;` must resolve to a real IMPORTS edge.
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();
});
});
// ---------------------------------------------------------------------------
// #1881 on the LEGACY DAG leg, forced in-process so it runs under `npm test`
// (not only the CI parity matrix). `isRegistryPrimary` reads `process.env`
// per call with no caching, so stubbing the flag before the pipeline run
// routes C# import resolution through `csharpNamespaceStrategy` (#8).
// ---------------------------------------------------------------------------
describe('C# spurious import edges — legacy DAG leg (#1881, #8)', () => {
let result: PipelineResult;
beforeAll(async () => {
vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0');
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {});
}, 60000);
afterAll(() => {
vi.unstubAllEnvs();
});
it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => {
const imports = getRelationships(result, 'IMPORTS');
const spurious = imports.find(
(e) =>
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs',
);
expect(spurious).toBeUndefined();
});
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
const imports = getRelationships(result, 'IMPORTS');
const legit = imports.find(
(e) =>
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs',
);
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

@ -96,4 +96,84 @@ describe('extractCsharpStructureViaScanner', () => {
const src = `/* header */ class C {}\nnamespace App.Real;`;
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
});
// --- Unicode / @-verbatim identifiers (Codex F3): these must be CAPTURED, not
// truncated/dropped, so the #1881 gate doesn't over-block legitimate imports.
it('captures a Unicode namespace identifier', () => {
const out = extractCsharpStructureViaScanner('namespace Café.Modèles;');
expect(out.namespaces).toEqual(['Café.Modèles']);
expect(out.incomplete).toBeFalsy();
});
it('captures a non-Latin (Greek) namespace identifier', () => {
expect(extractCsharpStructureViaScanner('namespace Ωμέγα.Models;').namespaces).toEqual([
'Ωμέγα.Models',
]);
});
it('strips a leading @ from a verbatim namespace identifier to match the AST', () => {
expect(extractCsharpStructureViaScanner('namespace @namespace.Models;').namespaces).toEqual([
'namespace.Models',
]);
});
it('strips a mid-path @ from a verbatim namespace segment', () => {
expect(extractCsharpStructureViaScanner('namespace App.@class.Models;').namespaces).toEqual([
'App.class.Models',
]);
});
// --- Forms the line scanner cannot capture must flag `incomplete` so the
// caller fails the #1881 gate OPEN (Codex F3) instead of dropping the namespace.
it('flags `incomplete` for a namespace declaration split across lines', () => {
const out = extractCsharpStructureViaScanner('namespace\n App.Models;');
expect(out.namespaces).toEqual([]);
expect(out.incomplete).toBe(true);
});
it('flags `incomplete` for a namespace keyword not at line start', () => {
const out = extractCsharpStructureViaScanner('class C {} namespace App.Models;');
expect(out.incomplete).toBe(true);
});
it('flags `incomplete` for an attributed same-line namespace', () => {
const out = extractCsharpStructureViaScanner('[Obsolete] namespace App.Legacy;');
expect(out.incomplete).toBe(true);
});
// --- Guards: ordinary / handled forms must NEVER set `incomplete`, or one
// exotic line would wrongly disable the gate repo-wide.
it('does NOT flag `incomplete` for ordinary handled forms', () => {
for (const src of [
'namespace App.Models;',
'namespace App.Services\n{\n}',
'namespace A.One {}\nnamespace A.Two {}',
'using static System.Math;\nnamespace App;',
'global using static App.Utils.Logger;',
'using static M = App.Utils.MathUtils;',
'using System.Collections.Generic;\nusing App.Models;',
'\t\tnamespace App.Indented;',
'public class Global {}',
'',
]) {
expect(extractCsharpStructureViaScanner(src).incomplete).toBeFalsy();
}
});
it('does NOT flag `incomplete` for a `// namespace` line comment or a namespace mentioned after `//`', () => {
expect(
extractCsharpStructureViaScanner('// namespace Fake.Comment;\nnamespace App.Real;')
.incomplete,
).toBeFalsy();
expect(
extractCsharpStructureViaScanner('public class C {} // namespace Foo').incomplete,
).toBeFalsy();
});
it('does NOT flag `incomplete` for an identifier that merely starts with "namespace"', () => {
// `namespaceManager` is an ordinary identifier, not the keyword.
expect(
extractCsharpStructureViaScanner('var namespaceManager = Get();').incomplete,
).toBeFalsy();
});
});

View file

@ -352,6 +352,65 @@ describe('csharpNamespaceStrategy', () => {
expect(result).toBeNull();
});
it('no csproj + non-aligned BCL import: stops the chain instead of the ungated standard strategy (#2)', () => {
// Parity with the registry leg's no-csproj path. Without csproj configs the
// generic strategy would suffix-match `System.Threading.Tasks` onto the
// coincidental local `Legacy/Tasks.cs`. The gate sees the import aligns
// with no declared namespace, so the strategy returns an absorbing sentinel
// (`{ kind: 'files', files: [] }`) that STOPS the chain — the standard
// strategy never runs and no spurious edge is emitted.
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
csharpNamespaces: {
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
});
const result = csharpNamespaceStrategy(
'System.Threading.Tasks',
'Services/OrderService.cs',
ctx,
);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('no csproj + in-repo-aligned import: keeps delegating to the standard strategy (#2)', () => {
// An import that DOES align with a declared namespace must keep returning
// null so the generic strategy resolves it — legitimate no-csproj behavior
// is unchanged; only non-aligned (BCL) imports are stopped.
const ctx = makeCtx(['Services/OrderService.cs', 'Models/User.cs'], {
csharpNamespaces: {
declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
});
const result = csharpNamespaceStrategy('MyApp.Models', 'Services/OrderService.cs', ctx);
expect(result).toBeNull();
});
it('returns an empty files result (chain-stop) for a gated BCL import when csproj configs exist (#1881, #8)', () => {
// Legacy DAG leg of #1881: with csproj configs present, a BCL using like
// `System.Threading.Tasks` must NOT suffix-match the coincidental local
// `Legacy/Tasks.cs`. The strategy returns `{ kind: 'files', files: [] }`
// (absorbing sentinel) to STOP the chain, NOT null — null would let the
// generic suffix fallback re-introduce the spurious edge.
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
csharpNamespaces: {
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
});
const result = csharpNamespaceStrategy(
'System.Threading.Tasks',
'Services/OrderService.cs',
ctx,
);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('csharpImportConfig full chain produces package-kind (strategy-order guard)', () => {
const files = ['src/Services/Auth/AuthService.cs', 'src/Services/Auth/TokenService.cs'];
const ctx = makeCtx(files, {

View file

@ -7,9 +7,20 @@
*/
import { describe, it, expect } from 'vitest';
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';
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 { loadCsharpResolutionConfig } from '../../../../src/core/ingestion/languages/csharp/resolution-config.js';
import { getMaxFileSizeBytes } from '../../../../src/core/ingestion/utils/max-file-size.js';
import {
csharpSuffixFallbackAllowed,
importAlignsWithDeclaredNamespaces,
} from '../../../../src/core/ingestion/csharp-namespace-gate.js';
import { csharpScopeResolver } from '../../../../src/core/ingestion/languages/csharp/scope-resolver.js';
import type { CSharpProjectConfig } from '../../../../src/core/ingestion/language-config.js';
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
function importsFor(src: string): ParsedImport[] {
@ -105,8 +116,32 @@ describe('interpretCsharpImport — using flavors', () => {
});
describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
function ctx(fromFile: string, paths: string[]): WorkspaceIndex {
return { fromFile, allFilePaths: new Set(paths) } as unknown as WorkspaceIndex;
function ctx(
fromFile: string,
paths: string[],
declaredNamespaces?: ReadonlySet<string>,
extra?: {
rootNamespaces?: ReadonlySet<string>;
truncated?: boolean;
csharpConfigs?: readonly CSharpProjectConfig[];
},
): WorkspaceIndex {
const hasEvidence =
declaredNamespaces !== undefined ||
extra?.rootNamespaces !== undefined ||
extra?.truncated !== undefined;
return {
fromFile,
allFilePaths: new Set(paths),
csharpConfigs: extra?.csharpConfigs,
namespaces: hasEvidence
? {
declaredNamespaces,
rootNamespaces: extra?.rootNamespaces,
truncated: extra?.truncated,
}
: undefined,
} as unknown as WorkspaceIndex;
}
it('resolves `MyApp.Services` to `MyApp/Services/...cs` when a direct child exists', () => {
@ -174,4 +209,582 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
} as unknown as WorkspaceIndex);
expect(result).toBe(null);
});
it('does not map BCL usings to coincidentally-named local files (#1881)', () => {
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', 'Tasks.cs', 'Events/OrderCreatedEvent.cs'],
new Set(['MyApp.Services', 'MyApp.Events', 'MyApp.Legacy']),
),
);
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',
localName: 'Models',
importedName: 'MyApp.Models',
targetRaw: 'MyApp.Models',
};
const result = resolveCsharpImportTarget(
parsed,
ctx(
'Services/UserService.cs',
['Services/UserService.cs', 'Models/User.cs'],
new Set(['MyApp.Models', 'MyApp.Services']),
),
);
expect(result).toBe('Models/User.cs');
});
it('drives the csproj-first branch: resolves via the internal resolver when configs exist (#7)', () => {
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'Models',
importedName: 'MyApp.Models',
targetRaw: 'MyApp.Models',
};
const result = resolveCsharpImportTarget(
parsed,
ctx(
'Services/OrderService.cs',
['Services/OrderService.cs', 'Models/User.cs'],
new Set(['MyApp.Services', 'MyApp.Models']),
{
rootNamespaces: new Set(['MyApp']),
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
},
),
);
expect(result).toBe('Models/User.cs');
});
it('mirrors legacy authority: csproj present + internal-resolver-empty returns null, no ungated direct match (#2)', () => {
// `Foo/Bar.cs` is an exact whole-path match that the ungated
// `resolveDirectMatch` would have returned. With csproj configs present
// and `Foo.Bar` outside the declared namespaces, the legacy strategy
// returns an empty result that STOPS the chain — the registry path must
// now do the same (return null) instead of falling through.
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'Bar',
importedName: 'Foo.Bar',
targetRaw: 'Foo.Bar',
};
const result = resolveCsharpImportTarget(
parsed,
ctx(
'Services/OrderService.cs',
['Services/OrderService.cs', 'Foo/Bar.cs'],
new Set(['MyApp.Models']),
{
rootNamespaces: new Set(['MyApp']),
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
},
),
);
expect(result).toBe(null);
});
it('requires the rootNamespaces anchor end-to-end: parent-of import resolves only when anchored (#7)', () => {
// `using MyApp.Core;` is an ancestor of declared `MyApp.Core.Models`.
// The gate opens ONLY when `MyApp.Core` sits at/above an in-repo root, so
// `Core/Thing.cs` resolves with roots {MyApp.Core} but not without them.
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'Core',
importedName: 'MyApp.Core',
targetRaw: 'MyApp.Core',
};
const anchored = resolveCsharpImportTarget(
parsed,
ctx(
'Services/OrderService.cs',
['Services/OrderService.cs', 'Core/Thing.cs'],
new Set(['MyApp.Core.Models']),
{
rootNamespaces: new Set(['MyApp.Core']),
},
),
);
expect(anchored).toBe('Core/Thing.cs');
const unanchored = resolveCsharpImportTarget(
parsed,
ctx(
'Services/OrderService.cs',
['Services/OrderService.cs', 'Core/Thing.cs'],
new Set(['MyApp.Core.Models']),
),
);
expect(unanchored).toBe(null);
});
it('a sibling import outside the declared namespaces does not resolve even with roots (#7)', () => {
// `using MyApp.Other;` is neither a child nor an ancestor of the only
// declared namespace `MyApp.Models`, so the gate stays closed and the
// otherwise-matchable `Other/Thing.cs` is left unresolved.
const parsed: ParsedImport = {
kind: 'namespace',
localName: 'Other',
importedName: 'MyApp.Other',
targetRaw: 'MyApp.Other',
};
const result = resolveCsharpImportTarget(
parsed,
ctx(
'Services/OrderService.cs',
['Services/OrderService.cs', 'Other/Thing.cs'],
new Set(['MyApp.Models']),
{
rootNamespaces: new Set(['MyApp']),
},
),
);
expect(result).toBe(null);
});
});
describe('importAlignsWithDeclaredNamespaces — declared-namespace gate (#1881)', () => {
it('matches an exactly-declared namespace', () => {
expect(importAlignsWithDeclaredNamespaces('MyApp.Models', new Set(['MyApp.Models']))).toBe(
true,
);
});
it('child-of: import nested under a declared ancestor namespace', () => {
// `using MyApp.Models.Detail;` when the repo declares `MyApp.Models`.
expect(
importAlignsWithDeclaredNamespaces('MyApp.Models.Detail', new Set(['MyApp.Models'])),
).toBe(true);
});
it('child-of allows a using-static type under a declared namespace (#1)', () => {
// `using static MyApp.Utils.Logger;` — the parent namespace `MyApp.Utils`
// is declared, so the type import aligns even though `MyApp.Utils.Logger`
// itself is not a declared namespace.
expect(importAlignsWithDeclaredNamespaces('MyApp.Utils.Logger', new Set(['MyApp.Utils']))).toBe(
true,
);
});
it('child-of stays anchored: a declared BCL root does NOT qualify a BCL using (#1)', () => {
// A repo that declares `namespace System;` (a shim) must not green-light
// `using System.Threading.Tasks;` — the import's parent `System.Threading`
// is NOT declared, so the only match would be a coincidental local
// `Tasks.cs`. The old "any declared prefix" rule re-opened #1881 here.
expect(
importAlignsWithDeclaredNamespaces(
'System.Threading.Tasks',
new Set(['System', 'MyApp.Models']),
new Set(['System', 'MyApp']),
),
).toBe(false);
});
it('parent-of: parent-namespace import resolves against a declared child', () => {
// `using MyApp;` when the repo declares `MyApp.Models` — must still open
// the gate (anchored on the in-repo root namespace `MyApp`).
expect(
importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']), new Set(['MyApp'])),
).toBe(true);
});
it('parent-of works without explicit roots via the top-level declared segment', () => {
expect(importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']))).toBe(true);
});
it('parent-of for a multi-segment csproj root (using MyApp; with RootNamespace MyApp.Core)', () => {
expect(
importAlignsWithDeclaredNamespaces(
'MyApp',
new Set(['MyApp.Core.Models']),
new Set(['MyApp.Core', 'MyApp']),
),
).toBe(true);
});
it('parent-of stays anchored: a BCL prefix does NOT qualify via a locally-declared sub-namespace (#5)', () => {
// A file declaring `namespace System.Threading.Tasks.Extensions` must not
// open the gate for `using System.Threading.Tasks;`.
const declared = new Set(['System.Threading.Tasks.Extensions', 'MyApp.Models']);
expect(
importAlignsWithDeclaredNamespaces(
'System.Threading.Tasks',
declared,
new Set(['MyApp', 'System']),
),
).toBe(false);
// Same conclusion without explicit roots (top-level segment fallback).
expect(importAlignsWithDeclaredNamespaces('System.Threading.Tasks', declared)).toBe(false);
});
it('returns false for an unrelated BCL namespace', () => {
expect(
importAlignsWithDeclaredNamespaces(
'System.Linq',
new Set(['MyApp.Services']),
new Set(['MyApp']),
),
).toBe(false);
});
it('returns false for an empty or undefined declared set', () => {
expect(importAlignsWithDeclaredNamespaces('MyApp', new Set())).toBe(false);
expect(importAlignsWithDeclaredNamespaces('MyApp', undefined)).toBe(false);
});
});
describe('csharpSuffixFallbackAllowed — fail-open safety valves (#1881)', () => {
const declared = new Set(['MyApp.Models']);
const roots = new Set(['MyApp']);
it('blocks a non-aligned import when evidence is present and complete', () => {
// Baseline: with complete evidence, a BCL using that aligns with nothing
// declared in-repo is blocked.
expect(
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
declaredNamespaces: declared,
rootNamespaces: roots,
truncated: false,
}),
).toBe(false);
});
it('fails OPEN (allows) when no evidence was threaded (#7)', () => {
// The exact same import the complete-evidence case blocks must be ALLOWED
// when evidence is undefined — preserving the pre-gate permissive behavior
// for callers that never ran the scan.
expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', undefined)).toBe(true);
});
it('keeps a clearly-external BCL root BLOCKED even when the scan was truncated (#1881, Codex F1)', () => {
// A single truncation must NOT silently re-enable BCL→local suffix matches
// repo-wide: System.* stays gated through truncation when the repo does not
// declare it. (This reverses the prior blanket-fail-open for external roots.)
expect(
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
declaredNamespaces: declared,
rootNamespaces: roots,
truncated: true,
}),
).toBe(false);
});
it('fails OPEN for a genuinely local-looking import when the scan was truncated (#6)', () => {
// Non-external roots still fail open under truncation so an incomplete
// (capped/unreadable) scan does not silently drop a legitimate in-repo edge.
expect(
csharpSuffixFallbackAllowed('MyApp.Internal.Widget', {
declaredNamespaces: declared,
rootNamespaces: roots,
truncated: true,
}),
).toBe(true);
});
it('lets an external root fail OPEN through truncation when the repo declares it (escape hatch)', () => {
// If the repo actually declares the (normally-external) root, the alignment
// escape hatch allows the import even under truncation.
expect(
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
declaredNamespaces: new Set(['System.Threading']),
rootNamespaces: new Set(['System']),
truncated: true,
}),
).toBe(true);
});
});
describe('csharpScopeResolver.resolveImportTarget — config→ctx adapter wiring (#9)', () => {
it('threads resolutionConfig.namespaces into the gate so a BCL using is blocked', () => {
// Exercises the adapter (NOT resolveCsharpImportTarget directly): the
// resolutionConfig that loadResolutionConfig returns must reach the gate as
// ctx.namespaces. With a coincidental local `Tasks.cs` present and
// `System.Threading.Tasks` outside the declared namespaces, the wired
// evidence blocks the spurious edge.
const result = csharpScopeResolver.resolveImportTarget(
'System.Threading.Tasks',
'Services/OrderService.cs',
new Set(['Services/OrderService.cs', 'Tasks.cs']),
{
csharpConfigs: [],
namespaces: {
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
},
);
expect(result).toBe(null);
});
it('threads csharpConfigs so a csproj-mapped import resolves through the adapter', () => {
// The other half of the wiring: csharpConfigs must reach ctx.csharpConfigs
// so the csproj root-namespace mapping runs.
const result = csharpScopeResolver.resolveImportTarget(
'MyApp.Models',
'Services/OrderService.cs',
new Set(['Services/OrderService.cs', 'Models/User.cs']),
{
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
namespaces: {
declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
},
);
expect(result).toBe('Models/User.cs');
});
});
describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () => {
async function makeTempRepo(files: Record<string, string>): Promise<string> {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'csharp-scan-'));
for (const [rel, content] of Object.entries(files)) {
const full = path.join(root, rel);
await fsp.mkdir(path.dirname(full), { recursive: true });
await fsp.writeFile(full, content, 'utf-8');
}
return root;
}
it('collects file-scoped, block, and multiple-per-file namespaces; skips bin/obj; reads csproj root', async () => {
const root = await makeTempRepo({
'App.csproj':
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
'Scoped.cs': 'namespace Alpha.Scoped;\npublic class A {}',
'Block.cs': 'namespace Beta.Block\n{\n public class B {}\n}',
'Multi.cs': 'namespace Gamma.One { }\nnamespace Gamma.Two { }',
'bin/Generated.cs': 'namespace Should.Skip;',
'obj/Temp.cs': 'namespace Should.AlsoSkip;',
});
try {
const config = await loadCsharpResolutionConfig(root);
const ns = config.namespaces!;
expect(ns.truncated).toBe(false);
expect([...ns.declaredNamespaces!].sort()).toEqual([
'Alpha.Scoped',
'Beta.Block',
'Gamma.One',
'Gamma.Two',
]);
expect(ns.declaredNamespaces!.has('Should.Skip')).toBe(false);
expect(ns.declaredNamespaces!.has('Should.AlsoSkip')).toBe(false);
// csproj RootNamespace + top-level segment of each declared namespace.
expect(ns.rootNamespaces!.has('MyApp')).toBe(true);
expect([...ns.rootNamespaces!].sort()).toEqual(['Alpha', 'Beta', 'Gamma', 'MyApp']);
expect(config.csharpConfigs).toHaveLength(1);
expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp');
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('keeps truncated=false for a realistic-depth layout so the gate stays engaged (#1)', async () => {
// A repo nested ~8 levels deep is well within the production cap
// (CSHARP_SCAN_MAX_DEPTH=24). Were the cap as low as the old value (5),
// this layout would trip `truncated` and disable the #1881 gate for the
// whole repo. Proving truncated===false here pins the gate ON for repos
// of normal depth.
const root = await makeTempRepo({
'App.csproj':
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
'a/b/c/d/e/f/g/h/Deep.cs': 'namespace MyApp.Deep.Feature;',
});
try {
const config = await loadCsharpResolutionConfig(root);
const ns = config.namespaces!;
expect(ns.truncated).toBe(false);
expect(ns.declaredNamespaces!.has('MyApp.Deep.Feature')).toBe(true);
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('sets the truncation flag when the depth cap prunes a subtree (#11)', async () => {
// repoRoot is depth 0; the chain below nests one level past the depth cap
// (CSHARP_SCAN_MAX_DEPTH=24) so the deepest dir is pruned, its namespace
// is missed, and the flag trips. Built relative to the real cap — do NOT
// lower the production cap for the test.
const deepChain = Array.from({ length: 25 }, (_, i) => `d${i}`).join('/');
const root = await makeTempRepo({
'Shallow.cs': 'namespace Shallow.Ns;',
[`${deepChain}/Deep.cs`]: 'namespace Deep.Ns;',
});
try {
const config = await loadCsharpResolutionConfig(root);
const ns = config.namespaces!;
expect(ns.truncated).toBe(true);
expect(ns.declaredNamespaces!.has('Shallow.Ns')).toBe(true);
expect(ns.declaredNamespaces!.has('Deep.Ns')).toBe(false);
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('streams a large .cs file end-to-end, collecting namespaces past the old size cap (#1881)', async () => {
// The namespace scan streams each file, so a `.cs` far larger than the old
// per-file size cap is read end-to-end in constant memory instead of being
// skipped. A namespace at the START and one at the very END (well past the
// old cap boundary) must BOTH be collected, and `truncated` must stay false
// — a big generated file no longer disables the #1881 gate repo-wide.
const cap = getMaxFileSizeBytes();
const padLine = '// pad pad pad pad pad pad\n';
const padding = padLine.repeat(Math.ceil((cap * 3) / padLine.length));
const huge = `namespace Generated.Head;\n${padding}namespace Generated.Tail { }\n`;
const root = await makeTempRepo({
'Hand.cs': 'namespace Hand.Written;',
'Generated.cs': huge,
});
try {
const config = await loadCsharpResolutionConfig(root);
const ns = config.namespaces!;
expect(ns.truncated).toBe(false);
expect(ns.declaredNamespaces!.has('Hand.Written')).toBe(true);
expect(ns.declaredNamespaces!.has('Generated.Head')).toBe(true);
expect(ns.declaredNamespaces!.has('Generated.Tail')).toBe(true);
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('collects a Unicode namespace through the streamed scan, not truncated (Codex F3)', async () => {
// The scanner is now Unicode-aware, so a non-ASCII namespace is captured
// end-to-end instead of being dropped (which would over-block its imports).
const root = await makeTempRepo({
'App.csproj':
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
'Models/Café.cs': 'namespace Café.App;\npublic class Modèle {}',
});
try {
const config = await loadCsharpResolutionConfig(root);
const ns = config.namespaces!;
expect(ns.truncated).toBe(false);
expect(ns.declaredNamespaces!.has('Café.App')).toBe(true);
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('marks the scan truncated when a file has an uncaptured namespace form, failing the gate OPEN (Codex F3)', async () => {
// A namespace split across lines is not captured by the line scanner; the
// scan must flag truncated so the dropped namespace fails the #1881 gate
// OPEN rather than over-block an import declared in that file.
const root = await makeTempRepo({
'App.csproj':
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
'Weird.cs': 'namespace\n MyApp.Weird;\npublic class W {}',
});
try {
const config = await loadCsharpResolutionConfig(root);
const ns = config.namespaces!;
expect(ns.truncated).toBe(true);
// A local-looking import under the dropped namespace fails open (and U1's
// external-root denylist still keeps BCL roots blocked under truncation).
expect(csharpSuffixFallbackAllowed('MyApp.Weird.Thing', ns)).toBe(true);
expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', ns)).toBe(false);
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('recovers <RootNamespace> past the old read cap via streaming (Codex F4)', async () => {
// A big leading <ItemGroup> pushes <RootNamespace> past the old 512KB read
// cap; the streamed scan reads on until it finds the tag, so the correct
// root is recovered (pre-fix the capped read synthesized the filename 'App').
const cap = getMaxFileSizeBytes();
const itemLine = ' <Compile Include="src/Generated/F.cs" />\n';
const bigItemGroup =
' <ItemGroup>\n' +
itemLine.repeat(Math.ceil((cap * 2) / itemLine.length)) +
' </ItemGroup>\n';
const csproj =
'<Project Sdk="Microsoft.NET.Sdk">\n' +
bigItemGroup +
' <PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup>\n' +
'</Project>\n';
const root = await makeTempRepo({
'App.csproj': csproj,
'Models/User.cs': 'namespace MyApp.Models;\npublic class User {}',
});
try {
const config = await loadCsharpResolutionConfig(root);
expect(config.csharpConfigs).toHaveLength(1);
expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp');
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('falls back to the filename root only when <RootNamespace> is genuinely absent (Codex F4 control)', async () => {
// A genuine read-to-EOF absence still synthesizes the filename root, so a
// .csproj without RootNamespace is unchanged — the fix only avoids guessing
// when the tag was unreachable.
const root = await makeTempRepo({
'App.csproj':
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>',
'Models/User.cs': 'namespace App.Models;\npublic class User {}',
});
try {
const config = await loadCsharpResolutionConfig(root);
expect(config.csharpConfigs).toHaveLength(1);
expect(config.csharpConfigs[0]!.rootNamespace).toBe('App');
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
});