From cc0b6999da47df1e7ab04555accffdc6e98947b7 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 29 May 2026 19:07:48 +0000 Subject: [PATCH] fix(csharp): gate suffix-fallback import resolution to declared namespaces (#1881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/ingestion/csharp-namespace-gate.ts | 94 +++++ .../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 | 229 ++++++++-- .../languages/csharp/import-target.ts | 211 +++++---- .../languages/csharp/resolution-config.ts | 30 ++ .../languages/csharp/scope-resolver.ts | 16 +- .../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 | 81 +++- .../test/unit/import-resolver-factory.test.ts | 59 +++ .../csharp/csharp-imports.test.ts | 399 +++++++++++++++++- 15 files changed, 1072 insertions(+), 143 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/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..12b697e82 --- /dev/null +++ b/gitnexus/src/core/ingestion/csharp-namespace-gate.ts @@ -0,0 +1,94 @@ +/** + * 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'; + +/** + * 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). Otherwise defers + * to {@link importAlignsWithDeclaredNamespaces}. + */ +export function csharpSuffixFallbackAllowed( + targetRaw: string, + evidence: CSharpNamespaceEvidence | undefined, +): boolean { + if (evidence === undefined) return true; + if (evidence.truncated) 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..c5d6642fe 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -1,6 +1,7 @@ import fs from 'fs/promises'; import path from 'path'; import type { ImportConfigs } from './import-resolvers/types.js'; +import type { CsharpFileStructure } from './languages/csharp/namespace-siblings.js'; import { isDev } from './utils/env.js'; @@ -40,6 +41,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 +180,154 @@ 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; +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 csharpScannerPromise: Promise<(content: string) => CsharpFileStructure> | undefined; +function getCsharpStructureScanner(): Promise<(content: string) => CsharpFileStructure> { + if (csharpScannerPromise === undefined) { + csharpScannerPromise = import('./languages/csharp/namespace-siblings.js').then( + (mod) => mod.extractCsharpStructureViaScanner, + ); + } + return csharpScannerPromise; +} + +/** + * 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 parallel + * (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; - scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); - } - 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 - } - } - } + entries = await fs.readdir(dir, { withFileTypes: true }); } catch { - // Can't read directory + // 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; } + // Issue all file reads in this directory concurrently; csproj results stay + // in entry order (config precedence matters) while `.cs` namespace results + // land in shared Sets where order is irrelevant. + const csprojReads: Promise[] = []; + const csReads: Promise[] = []; + 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 + } + continue; + } + if (!entry.isFile()) continue; + const filePath = path.join(dir, entry.name); + if (entry.name.endsWith('.csproj')) { + csprojReads.push(readCsprojConfig(filePath, entry.name, repoRoot, dir)); + } else if (entry.name.endsWith('.cs')) { + csReads.push(collectDeclaredNamespaces(filePath, declaredNamespaces, rootNamespaces)); + } + } + for (const config of await Promise.all(csprojReads)) { + if (config) { + configs.push(config); + rootNamespaces.add(config.rootNamespace); + } + } + await Promise.all(csReads); + } + + if (truncated) { + // Surface the fail-open so a too-small cap (or an unreadable subtree) + // 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}, or an unreadable directory); the #1881 suffix-fallback gate fails open for unmatched usings`, + ); + } + return { configs, declaredNamespaces, rootNamespaces, truncated }; +} + +async function readCsprojConfig( + csprojPath: string, + fileName: string, + repoRoot: string, + dir: string, +): Promise { + try { + const content = await fs.readFile(csprojPath, 'utf-8'); + const nsMatch = content.match(CSHARP_ROOT_NAMESPACE_RE); + const rootNamespace = nsMatch ? nsMatch[1].trim() : 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 }; + } catch { + return null; // can't read .csproj + } +} + +async function collectDeclaredNamespaces( + filePath: string, + declaredNamespaces: Set, + rootNamespaces: Set, +): Promise { + let content: string; + try { + content = await fs.readFile(filePath, 'utf-8'); + } catch { + return; // unreadable source + } + const scan = await getCsharpStructureScanner(); + for (const ns of scan(content).namespaces) { + declaredNamespaces.add(ns); + const dot = ns.indexOf('.'); + rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot)); } - return configs; } export async function loadSwiftPackageConfig(repoRoot: string): Promise { @@ -231,11 +366,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..ac8489a13 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -9,30 +9,103 @@ * 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, '/'); + + // Exact file / nested-suffix / namespace-dir direct-child match. + const direct = resolveDirectMatch(ctx.allFilePaths, pathLike); + if (direct !== null) return direct; + + // Progressive prefix stripping — mirrors csproj's root-namespace mapping + // without the csproj. Gated on declared in-repo namespaces so BCL usings + // don't match a coincidentally-named local file (#1881). + if (!csharpSuffixFallbackAllowed(targetRaw, evidence)) { + return null; + } + 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 +114,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/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..178f4ec92 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -19,6 +19,10 @@ 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 +30,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/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..1f2fabacb 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,82 @@ 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(); + }); +}); 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..769e4fecb 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,19 @@ */ 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 { + 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 +115,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 +208,365 @@ 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('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('fails OPEN (allows) when the namespace scan was truncated (#6)', () => { + // Same non-aligned import again: an incomplete (capped/unreadable) scan + // must not silently drop a legitimate edge, so truncation re-opens the + // fallback for every import. + expect( + csharpSuffixFallbackAllowed('System.Threading.Tasks', { + declaredNamespaces: declared, + rootNamespaces: roots, + 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 }); + } + }); });