From 4b787be83530907550b535bde7cd2d8f0764abe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 30 May 2026 09:56:26 +0100 Subject: [PATCH] fix(csharp): stop spurious IMPORTS edges from ungated using-resolution (#1881) (#1908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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) * 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) * 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) * 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>`, 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) * 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 * 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 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .../core/ingestion/csharp-namespace-gate.ts | 154 +++++ .../import-resolvers/configs/csharp.ts | 48 +- .../core/ingestion/import-resolvers/csharp.ts | 14 +- .../core/ingestion/import-resolvers/types.ts | 3 + .../src/core/ingestion/language-config.ts | 329 ++++++++-- .../languages/csharp/import-target.ts | 220 ++++--- .../languages/csharp/namespace-siblings.ts | 113 +++- .../languages/csharp/resolution-config.ts | 30 + .../languages/csharp/scope-resolver.ts | 13 +- .../Legacy/System/Threading/Tasks.cs | 10 + .../Models/User.cs | 6 + .../Services/OrderService.cs | 13 + .../csharp-spurious-edges/Legacy/Tasks.cs | 6 + .../csharp-spurious-edges/Models/User.cs | 6 + .../Services/OrderService.cs | 13 + .../csharp-spurious-edges/Spurious.csproj | 6 + .../test/integration/resolvers/csharp.test.ts | 157 ++++- .../unit/csharp-namespace-extraction.test.ts | 80 +++ .../test/unit/import-resolver-factory.test.ts | 59 ++ .../csharp/csharp-imports.test.ts | 617 +++++++++++++++++- 20 files changed, 1739 insertions(+), 158 deletions(-) create mode 100644 gitnexus/src/core/ingestion/csharp-namespace-gate.ts create mode 100644 gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj diff --git a/gitnexus/src/core/ingestion/csharp-namespace-gate.ts b/gitnexus/src/core/ingestion/csharp-namespace-gate.ts new file mode 100644 index 000000000..df806bf2b --- /dev/null +++ b/gitnexus/src/core/ingestion/csharp-namespace-gate.ts @@ -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 BCL→local 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 = 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 | undefined, + rootNamespaces?: ReadonlySet, +): 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, + rootNamespaces: ReadonlySet | 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; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts index cb5f77145..d99dbd35b 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts @@ -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 = { diff --git a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts index 79548d7f6..2ce21274f 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts @@ -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] : []; diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 66e23a79b..864206fc3 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -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. */ diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index f51ef57c6..16ad25e43 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -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; + /** csproj RootNamespace values plus the top-level segment of each declared + * namespace — the anchor set for the parent-namespace gate direction. */ + readonly rootNamespaces?: ReadonlySet; + /** 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; + readonly rootNamespaces: ReadonlySet; + 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 { - 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 = /\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 { + const configs: CSharpProjectConfig[] = []; + const declaredNamespaces = new Set(); + const rootNamespaces = new Set(); + 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(/\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 ``: 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 `` 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 ``, 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 { + const { rootNamespace: found, capHit } = await findCsprojRootNamespace(csprojPath); + // A late `` 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, + rootNamespaces: Set, +): 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 { @@ -231,11 +472,13 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { + 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), }; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts index 8183bd2a4..3745e16de 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -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; + 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, WorkspaceFileIndex>(); + +function getWorkspaceFileIndex(allFilePaths: ReadonlySet): 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, 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, 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, + 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; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts index f4d29d7e7..454b5341d 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts b/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts new file mode 100644 index 000000000..9ea232c05 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts @@ -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 { + const scan = await scanCSharpProject(repoRoot); + return { + csharpConfigs: scan.configs, + namespaces: csharpScanToEvidence(scan), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 5fc4f9c63..0642b6f91 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -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( diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs new file mode 100644 index 000000000..66f19b8cf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs @@ -0,0 +1,10 @@ +// On-disk path (Legacy/System/Threading/Tasks.cs) path-aligns with +// `using System.Threading.Tasks;` but declares an UNRELATED in-repo namespace, +// so the only way an IMPORTS edge forms is the coincidental path — which the +// gate must block in the no-csproj path on BOTH legs (#1881, Codex F2). +namespace MyApp.Legacy; + +public class Tasks +{ + public void Run() { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs new file mode 100644 index 000000000..9864db4d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs @@ -0,0 +1,6 @@ +namespace MyApp.Models; + +public class User +{ + public string Name { get; set; } = ""; +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs new file mode 100644 index 000000000..be2217588 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using MyApp.Models; + +namespace MyApp.Services; + +public class OrderService +{ + public Task ProcessAsync() + { + var user = new User(); + return Task.CompletedTask; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs new file mode 100644 index 000000000..aef1989e3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs @@ -0,0 +1,6 @@ +namespace MyApp.Legacy; + +public class Tasks +{ + public void Run() { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs new file mode 100644 index 000000000..9864db4d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs @@ -0,0 +1,6 @@ +namespace MyApp.Models; + +public class User +{ + public string Name { get; set; } = ""; +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs new file mode 100644 index 000000000..be2217588 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using MyApp.Models; + +namespace MyApp.Services; + +public class OrderService +{ + public Task ProcessAsync() + { + var user = new User(); + return Task.CompletedTask; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj new file mode 100644 index 000000000..dc7d27504 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj @@ -0,0 +1,6 @@ + + + net8.0 + MyApp + + diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index d837c32d3..db65f8d66 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/csharp-namespace-extraction.test.ts b/gitnexus/test/unit/csharp-namespace-extraction.test.ts index 7f39a061f..4a52e9e11 100644 --- a/gitnexus/test/unit/csharp-namespace-extraction.test.ts +++ b/gitnexus/test/unit/csharp-namespace-extraction.test.ts @@ -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(); + }); }); diff --git a/gitnexus/test/unit/import-resolver-factory.test.ts b/gitnexus/test/unit/import-resolver-factory.test.ts index 1150f371e..2e22790e0 100644 --- a/gitnexus/test/unit/import-resolver-factory.test.ts +++ b/gitnexus/test/unit/import-resolver-factory.test.ts @@ -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, { diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts index 8c69a0b9d..d7b7084dd 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts @@ -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, + extra?: { + rootNamespaces?: ReadonlySet; + 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): Promise { + 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': + 'MyApp', + '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': + 'MyApp', + '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': + 'MyApp', + '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': + 'MyApp', + '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 past the old read cap via streaming (Codex F4)', async () => { + // A big leading pushes 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 = ' \n'; + const bigItemGroup = + ' \n' + + itemLine.repeat(Math.ceil((cap * 2) / itemLine.length)) + + ' \n'; + const csproj = + '\n' + + bigItemGroup + + ' MyApp\n' + + '\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 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': + 'net8.0', + '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 }); + } + }); });