gitnexus 1.6.6-rc.95 → 1.6.6-rc.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pure predicates gating C# `using` suffix-fallback resolution so BCL usings
3
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
4
+ * file (#1881).
5
+ *
6
+ * Lives in the shared `ingestion/` layer — NOT under `languages/csharp/` — so
7
+ * BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`)
8
+ * and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it
9
+ * without an `import-resolvers/ -> languages/` dependency inversion (#5).
10
+ */
11
+ import type { CSharpNamespaceEvidence } from './language-config.js';
12
+ /**
13
+ * Whether the unanchored suffix fallback may run for `targetRaw`.
14
+ *
15
+ * Fails OPEN when the namespace scan was truncated (large repos must not
16
+ * silently lose legitimate edges, #1881 #11) and when no evidence was
17
+ * threaded at all (preserves legacy permissive behavior). The truncation
18
+ * fail-open is carved out for clearly-external roots (BCL / well-known
19
+ * packages) that the repo does not declare, so one incomplete scan can't
20
+ * re-open the #1881 hole repo-wide. Otherwise defers to
21
+ * {@link importAlignsWithDeclaredNamespaces}.
22
+ */
23
+ export declare function csharpSuffixFallbackAllowed(targetRaw: string, evidence: CSharpNamespaceEvidence | undefined): boolean;
24
+ /** True when `targetRaw` plausibly refers to a namespace declared in-repo. */
25
+ export declare function importAlignsWithDeclaredNamespaces(targetRaw: string, declaredNamespaces: ReadonlySet<string> | undefined, rootNamespaces?: ReadonlySet<string>): boolean;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Pure predicates gating C# `using` suffix-fallback resolution so BCL usings
3
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
4
+ * file (#1881).
5
+ *
6
+ * Lives in the shared `ingestion/` layer — NOT under `languages/csharp/` — so
7
+ * BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`)
8
+ * and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it
9
+ * without an `import-resolvers/ -> languages/` dependency inversion (#5).
10
+ */
11
+ /**
12
+ * Top-level namespace segments that clearly belong to the BCL / runtime / a
13
+ * ubiquitous third-party package — i.e. roots a normal repo does NOT declare.
14
+ * These stay gated even when the namespace scan is truncated, so a single
15
+ * unreadable file / capped subtree can't silently re-enable BCL→local suffix
16
+ * matches repo-wide (#1881). A repo that legitimately declares one of these
17
+ * roots is still allowed via the alignment escape hatch below.
18
+ */
19
+ const CSHARP_EXTERNAL_ROOTS = new Set([
20
+ // .NET BCL / runtime
21
+ 'System',
22
+ 'Microsoft',
23
+ 'Windows',
24
+ 'Mono',
25
+ // ubiquitous third-party NuGet roots
26
+ 'Newtonsoft',
27
+ 'Serilog',
28
+ 'AutoMapper',
29
+ 'MediatR',
30
+ 'Polly',
31
+ 'FluentValidation',
32
+ 'Grpc',
33
+ 'Google',
34
+ 'Azure',
35
+ 'Amazon',
36
+ 'AWSSDK',
37
+ // common test frameworks
38
+ 'Xunit',
39
+ 'NUnit',
40
+ 'Moq',
41
+ 'FluentAssertions',
42
+ 'NSubstitute',
43
+ 'Shouldly',
44
+ ]);
45
+ /** Whether `targetRaw`'s top-level segment is a clearly-external root. */
46
+ function isExternalRoot(targetRaw) {
47
+ const dot = targetRaw.indexOf('.');
48
+ const top = dot === -1 ? targetRaw : targetRaw.slice(0, dot);
49
+ return CSHARP_EXTERNAL_ROOTS.has(top);
50
+ }
51
+ /**
52
+ * Whether the unanchored suffix fallback may run for `targetRaw`.
53
+ *
54
+ * Fails OPEN when the namespace scan was truncated (large repos must not
55
+ * silently lose legitimate edges, #1881 #11) and when no evidence was
56
+ * threaded at all (preserves legacy permissive behavior). The truncation
57
+ * fail-open is carved out for clearly-external roots (BCL / well-known
58
+ * packages) that the repo does not declare, so one incomplete scan can't
59
+ * re-open the #1881 hole repo-wide. Otherwise defers to
60
+ * {@link importAlignsWithDeclaredNamespaces}.
61
+ */
62
+ export function csharpSuffixFallbackAllowed(targetRaw, evidence) {
63
+ if (evidence === undefined)
64
+ return true;
65
+ if (evidence.truncated) {
66
+ // Keep clearly-external roots blocked through truncation UNLESS the repo
67
+ // actually declares an aligning namespace (the alignment check is the
68
+ // escape hatch — a repo that declares `namespace System;` still resolves).
69
+ if (isExternalRoot(targetRaw) &&
70
+ !importAlignsWithDeclaredNamespaces(targetRaw, evidence.declaredNamespaces, evidence.rootNamespaces)) {
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+ return importAlignsWithDeclaredNamespaces(targetRaw, evidence.declaredNamespaces, evidence.rootNamespaces);
76
+ }
77
+ /** True when `targetRaw` plausibly refers to a namespace declared in-repo. */
78
+ export function importAlignsWithDeclaredNamespaces(targetRaw, declaredNamespaces, rootNamespaces) {
79
+ if (declaredNamespaces === undefined || declaredNamespaces.size === 0)
80
+ return false;
81
+ // Exact: the import IS a declared in-repo namespace.
82
+ if (declaredNamespaces.has(targetRaw))
83
+ return true;
84
+ // Child-of: the import's IMMEDIATE parent namespace is declared in-repo.
85
+ // Anchoring on the direct parent — not "any declared prefix" — is what stops
86
+ // a declared BCL prefix from green-lighting an unrelated BCL using: a repo
87
+ // that declares `namespace System;` must NOT make `using
88
+ // System.Threading.Tasks;` resolve to a coincidental local `Tasks.cs`,
89
+ // because the import's parent `System.Threading` is not itself declared
90
+ // (#1881). The case this still allows is a type / `using static` import under
91
+ // a declared namespace laid out without its full path on disk, e.g.
92
+ // `using static MyApp.Utils.Logger;` when `MyApp.Utils` is declared.
93
+ const lastDot = targetRaw.lastIndexOf('.');
94
+ if (lastDot > 0 && declaredNamespaces.has(targetRaw.slice(0, lastDot)))
95
+ return true;
96
+ // Ancestor-of: the import is a strict prefix of some declared namespace
97
+ // (e.g. `using MyApp;` when `MyApp.Models` is declared). Only honored when
98
+ // the import also sits at or above an in-repo root namespace, so a BCL prefix
99
+ // can't qualify merely because a file declares something deeper under it
100
+ // (e.g. `System.Threading.Tasks.Extensions`) (#1881).
101
+ const childPrefix = targetRaw + '.';
102
+ for (const ns of declaredNamespaces) {
103
+ if (ns.startsWith(childPrefix)) {
104
+ return isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces);
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+ function isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces) {
110
+ const descendantPrefix = targetRaw + '.';
111
+ if (rootNamespaces !== undefined && rootNamespaces.size > 0) {
112
+ for (const root of rootNamespaces) {
113
+ // targetRaw equals a root, or is an ancestor of one (e.g. `using MyApp;`
114
+ // for csproj RootNamespace `MyApp.Core`).
115
+ if (root === targetRaw || root.startsWith(descendantPrefix))
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+ // No explicit roots (e.g. no csproj): treat the top-level segment of each
121
+ // declared namespace as the implied root.
122
+ for (const ns of declaredNamespaces) {
123
+ const dot = ns.indexOf('.');
124
+ const top = dot === -1 ? ns : ns.slice(0, dot);
125
+ if (top === targetRaw)
126
+ return true;
127
+ }
128
+ return false;
129
+ }
@@ -5,21 +5,36 @@
5
5
  import { SupportedLanguages } from '../../../../_shared/index.js';
6
6
  import { createStandardStrategy } from '../standard.js';
7
7
  import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js';
8
+ import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
8
9
  /** C# namespace-based resolution strategy via .csproj configs. */
9
10
  export const csharpNamespaceStrategy = (rawImportPath, _filePath, ctx) => {
10
11
  const csharpConfigs = ctx.configs.csharpConfigs;
11
- if (csharpConfigs.length > 0) {
12
- const resolvedFiles = resolveCSharpImportInternal(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index);
13
- if (resolvedFiles.length > 1) {
14
- const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
15
- if (dirSuffix) {
16
- return { kind: 'package', files: resolvedFiles, dirSuffix };
17
- }
12
+ const evidence = ctx.configs.csharpNamespaces;
13
+ if (csharpConfigs.length === 0) {
14
+ // No csproj there's no namespace→directory mapping to apply, so the
15
+ // generic strategy would normally take over. But that generic suffix match
16
+ // is UNGATED: it re-introduces the BCL→local spurious match the #1881 gate
17
+ // exists to stop. Mirror the registry leg's no-csproj path defer to the
18
+ // generic strategy ONLY for imports that align with an in-repo declared
19
+ // namespace; for everything else (BCL usings) return an authoritative empty
20
+ // result that STOPS the chain (#2 parity). With no evidence threaded the
21
+ // gate fails open, so behavior is unchanged when the scan didn't run.
22
+ if (!csharpSuffixFallbackAllowed(rawImportPath, evidence)) {
23
+ return { kind: 'files', files: [] };
18
24
  }
19
- if (resolvedFiles.length > 0)
20
- return { kind: 'files', files: resolvedFiles };
25
+ return null;
21
26
  }
22
- return null;
27
+ const resolvedFiles = resolveCSharpImportInternal(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index, evidence);
28
+ if (resolvedFiles.length > 1) {
29
+ const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
30
+ if (dirSuffix) {
31
+ return { kind: 'package', files: resolvedFiles, dirSuffix };
32
+ }
33
+ }
34
+ // Authoritative once csproj configs exist: return even an empty result to
35
+ // STOP the chain, so the generic suffix fallback can't re-introduce the
36
+ // gated BCL→local match this resolver just suppressed (#1881).
37
+ return { kind: 'files', files: resolvedFiles };
23
38
  };
24
39
  export const csharpImportConfig = {
25
40
  language: SupportedLanguages.CSharp,
@@ -5,12 +5,16 @@
5
5
  * This file contains shared helpers for namespace-based resolution.
6
6
  */
7
7
  import type { SuffixIndex } from './utils.js';
8
- import type { CSharpProjectConfig } from '../language-config.js';
8
+ import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js';
9
9
  /**
10
10
  * Resolve a C# using-directive import path to matching .cs files (low-level helper).
11
11
  * Tries single-file match first, then directory match for namespace imports.
12
+ *
13
+ * The final unanchored suffix fallback is gated on `evidence` so BCL usings
14
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
15
+ * file (#1881). When `evidence` is omitted the fallback stays permissive.
12
16
  */
13
- export declare function resolveCSharpImportInternal(importPath: string, csharpConfigs: CSharpProjectConfig[], normalizedFileList: string[], allFileList: string[], index?: SuffixIndex): string[];
17
+ export declare function resolveCSharpImportInternal(importPath: string, csharpConfigs: CSharpProjectConfig[], normalizedFileList: string[], allFileList: string[], index?: SuffixIndex, evidence?: CSharpNamespaceEvidence): string[];
14
18
  /**
15
19
  * Compute the directory suffix for a C# namespace import (for PackageMap).
16
20
  * Returns a suffix like "/ProjectDir/Models/" or null if no config matches.
@@ -5,11 +5,16 @@
5
5
  * This file contains shared helpers for namespace-based resolution.
6
6
  */
7
7
  import { suffixResolve } from './utils.js';
8
+ import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js';
8
9
  /**
9
10
  * Resolve a C# using-directive import path to matching .cs files (low-level helper).
10
11
  * Tries single-file match first, then directory match for namespace imports.
12
+ *
13
+ * The final unanchored suffix fallback is gated on `evidence` so BCL usings
14
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
15
+ * file (#1881). When `evidence` is omitted the fallback stays permissive.
11
16
  */
12
- export function resolveCSharpImportInternal(importPath, csharpConfigs, normalizedFileList, allFileList, index) {
17
+ export function resolveCSharpImportInternal(importPath, csharpConfigs, normalizedFileList, allFileList, index, evidence) {
13
18
  const namespacePath = importPath.replace(/\./g, '/');
14
19
  const results = [];
15
20
  for (const config of csharpConfigs) {
@@ -79,7 +84,11 @@ export function resolveCSharpImportInternal(importPath, csharpConfigs, normalize
79
84
  return results;
80
85
  }
81
86
  }
82
- // Fallback: suffix matching without namespace stripping (single file)
87
+ // Fallback: suffix matching without namespace stripping (single file).
88
+ // Gated on in-repo declared-namespace evidence (#1881).
89
+ if (!csharpSuffixFallbackAllowed(importPath, evidence)) {
90
+ return [];
91
+ }
83
92
  const pathParts = namespacePath.split('/').filter(Boolean);
84
93
  const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index);
85
94
  return fallback ? [fallback] : [];
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Extracted from import-resolution.ts to co-locate types with their consumers.
5
5
  */
6
- import type { TsconfigPaths, GoModuleConfig, CSharpProjectConfig, ComposerConfig } from '../language-config.js';
6
+ import type { TsconfigPaths, GoModuleConfig, CSharpProjectConfig, CSharpNamespaceEvidence, ComposerConfig } from '../language-config.js';
7
7
  import type { SwiftPackageConfig } from '../language-config.js';
8
8
  import type { SuffixIndex } from './utils.js';
9
9
  import type { SupportedLanguages } from '../../../_shared/index.js';
@@ -28,6 +28,8 @@ export interface ImportConfigs {
28
28
  composerConfig: ComposerConfig | null;
29
29
  swiftPackageConfig: SwiftPackageConfig | null;
30
30
  csharpConfigs: CSharpProjectConfig[];
31
+ /** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
32
+ csharpNamespaces?: CSharpNamespaceEvidence;
31
33
  }
32
34
  /** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */
33
35
  export interface ImportResolutionContext {
@@ -26,6 +26,35 @@ export interface CSharpProjectConfig {
26
26
  /** Directory containing the .csproj file */
27
27
  projectDir: string;
28
28
  }
29
+ /**
30
+ * Declared-namespace evidence used to gate C# suffix-fallback resolution so
31
+ * BCL usings (e.g. `System.Threading.Tasks`) can't match a coincidentally-
32
+ * named local file (#1881).
33
+ */
34
+ export interface CSharpNamespaceEvidence {
35
+ /** Every `namespace X.Y` declared in-repo (scan may be capped — see `truncated`). */
36
+ readonly declaredNamespaces?: ReadonlySet<string>;
37
+ /** csproj RootNamespace values plus the top-level segment of each declared
38
+ * namespace — the anchor set for the parent-namespace gate direction. */
39
+ readonly rootNamespaces?: ReadonlySet<string>;
40
+ /** True when the BFS hit its dir/depth cap, so the namespace set may be
41
+ * incomplete; the gate fails open (allows) in that case. */
42
+ readonly truncated?: boolean;
43
+ }
44
+ /** Result of a single BFS over a repo collecting both csproj configs and
45
+ * declared `.cs` namespaces (one disk traversal — see `scanCSharpProject`). */
46
+ export interface CSharpProjectScan {
47
+ readonly configs: CSharpProjectConfig[];
48
+ readonly declaredNamespaces: ReadonlySet<string>;
49
+ readonly rootNamespaces: ReadonlySet<string>;
50
+ readonly truncated: boolean;
51
+ }
52
+ /** Project the one-pass {@link CSharpProjectScan} into the
53
+ * {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
54
+ * #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for
55
+ * the legacy DAG, `CsharpResolutionConfig.namespaces` for the scope resolver).
56
+ * Keeps the field mapping in one place so the two carriers can't drift. */
57
+ export declare function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEvidence;
29
58
  /** Swift Package Manager module config */
30
59
  export interface SwiftPackageConfig {
31
60
  /** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
@@ -43,10 +72,18 @@ export declare function loadGoModulePath(repoRoot: string): Promise<GoModuleConf
43
72
  /** Parse composer.json to extract PSR-4 autoload mappings (including autoload-dev). */
44
73
  export declare function loadComposerConfig(repoRoot: string): Promise<ComposerConfig | null>;
45
74
  /**
46
- * Parse .csproj files to extract RootNamespace.
47
- * Scans the repo root for .csproj files and returns configs for each.
75
+ * Single BFS over a repo that collects BOTH .csproj configs and the set of
76
+ * `namespace` declarations from `.cs` files.
77
+ *
78
+ * The csproj walk is cheap (a handful of project files); the namespace scan
79
+ * is NOT — it opens and reads every `.cs` file in the repo to collect its
80
+ * `namespace` declarations. That `.cs` read cost is the price of the #1881
81
+ * gate, not a saving: collapsing the csproj and namespace walks into one BFS
82
+ * avoids a second directory traversal, but the per-file `.cs` reads are new
83
+ * work this scan introduces. Reads within a directory are issued in bounded
84
+ * windows (see below); directories are still visited breadth-first.
48
85
  */
49
- export declare function loadCSharpProjectConfig(repoRoot: string): Promise<CSharpProjectConfig[]>;
86
+ export declare function scanCSharpProject(repoRoot: string): Promise<CSharpProjectScan>;
50
87
  export declare function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null>;
51
88
  /** Load all language-specific configs once for an ingestion run. */
52
89
  export declare function loadImportConfigs(repoRoot: string): Promise<ImportConfigs>;
@@ -1,7 +1,21 @@
1
1
  import fs from 'fs/promises';
2
+ import { createReadStream } from 'fs';
3
+ import { createInterface } from 'readline';
2
4
  import path from 'path';
3
5
  import { isDev } from './utils/env.js';
4
6
  import { logger } from '../logger.js';
7
+ /** Project the one-pass {@link CSharpProjectScan} into the
8
+ * {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
9
+ * #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for
10
+ * the legacy DAG, `CsharpResolutionConfig.namespaces` for the scope resolver).
11
+ * Keeps the field mapping in one place so the two carriers can't drift. */
12
+ export function csharpScanToEvidence(scan) {
13
+ return {
14
+ declaredNamespaces: scan.declaredNamespaces,
15
+ rootNamespaces: scan.rootNamespaces,
16
+ truncated: scan.truncated,
17
+ };
18
+ }
5
19
  // ============================================================================
6
20
  // LANGUAGE-SPECIFIC CONFIG LOADERS
7
21
  // ============================================================================
@@ -89,55 +103,236 @@ export async function loadComposerConfig(repoRoot) {
89
103
  return null;
90
104
  }
91
105
  }
106
+ // BFS bounds shared by the C# project/namespace scan. Sized to comfortably
107
+ // exceed normal C# repos so `truncated` stays the rare exception it was meant
108
+ // to be: a too-low cap trips `truncated=true` on ordinary repos, which makes
109
+ // `csharpSuffixFallbackAllowed` fail OPEN for every import and silently
110
+ // disables the #1881 gate. Truncation remains the safety valve for genuinely
111
+ // pathological trees (deep generated output, huge monorepos).
112
+ const CSHARP_SCAN_MAX_DEPTH = 24;
113
+ const CSHARP_SCAN_MAX_DIRS = 20000;
114
+ // Bound on in-flight file reads per directory so a directory with thousands of
115
+ // `.cs` files can't exhaust file descriptors / spike memory. Mirrors the
116
+ // Phase-1 walker's `READ_CONCURRENCY` (see `filesystem-walker.ts`).
117
+ const CSHARP_SCAN_READ_CONCURRENCY = 32;
118
+ const CSHARP_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'bin', 'obj']);
119
+ const CSHARP_ROOT_NAMESPACE_RE = /<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/;
120
+ // Declared `namespace` names are extracted with the comment/string-aware
121
+ // scanner shared with the scope-resolution namespace-siblings pass
122
+ // (`extractCsharpStructureViaScanner`), not a bare regex: a regex matches
123
+ // `namespace` inside comments and string literals, seeding the #1881 gate
124
+ // with phantom namespaces. Imported lazily (and memoized) so the always-on
125
+ // `loadImportConfigs` path — every repo, every language — doesn't eagerly
126
+ // pull tree-sitter-c-sharp in via `namespace-siblings.ts` → `query.ts`.
127
+ let csharpScannerFactoryPromise;
128
+ function getCsharpStructureScannerFactory() {
129
+ if (csharpScannerFactoryPromise === undefined) {
130
+ csharpScannerFactoryPromise = import('./languages/csharp/namespace-siblings.js').then((mod) => mod.createCsharpStructureScanner);
131
+ }
132
+ return csharpScannerFactoryPromise;
133
+ }
92
134
  /**
93
- * Parse .csproj files to extract RootNamespace.
94
- * Scans the repo root for .csproj files and returns configs for each.
135
+ * Single BFS over a repo that collects BOTH .csproj configs and the set of
136
+ * `namespace` declarations from `.cs` files.
137
+ *
138
+ * The csproj walk is cheap (a handful of project files); the namespace scan
139
+ * is NOT — it opens and reads every `.cs` file in the repo to collect its
140
+ * `namespace` declarations. That `.cs` read cost is the price of the #1881
141
+ * gate, not a saving: collapsing the csproj and namespace walks into one BFS
142
+ * avoids a second directory traversal, but the per-file `.cs` reads are new
143
+ * work this scan introduces. Reads within a directory are issued in bounded
144
+ * windows (see below); directories are still visited breadth-first.
95
145
  */
96
- export async function loadCSharpProjectConfig(repoRoot) {
146
+ export async function scanCSharpProject(repoRoot) {
97
147
  const configs = [];
98
- // BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning
148
+ const declaredNamespaces = new Set();
149
+ const rootNamespaces = new Set();
99
150
  const scanQueue = [{ dir: repoRoot, depth: 0 }];
100
- const maxDepth = 5;
101
- const maxDirs = 100;
102
151
  let dirsScanned = 0;
103
- while (scanQueue.length > 0 && dirsScanned < maxDirs) {
152
+ let truncated = false;
153
+ while (scanQueue.length > 0) {
154
+ if (dirsScanned >= CSHARP_SCAN_MAX_DIRS) {
155
+ truncated = true;
156
+ break;
157
+ }
104
158
  const { dir, depth } = scanQueue.shift();
105
159
  dirsScanned++;
160
+ let entries;
106
161
  try {
107
- const entries = await fs.readdir(dir, { withFileTypes: true });
108
- for (const entry of entries) {
109
- if (entry.isDirectory() && depth < maxDepth) {
110
- // Skip common non-project directories
111
- if (entry.name === 'node_modules' ||
112
- entry.name === '.git' ||
113
- entry.name === 'bin' ||
114
- entry.name === 'obj')
115
- continue;
162
+ entries = await fs.readdir(dir, { withFileTypes: true });
163
+ }
164
+ catch {
165
+ // Unreadable directory its `.cs` namespaces are missed, so the scan is
166
+ // incomplete. Mark truncated so the #1881 gate fails OPEN (allows the
167
+ // suffix fallback) rather than wrongly blocking an import whose declaring
168
+ // namespace lived in the unread subtree (#5).
169
+ truncated = true;
170
+ continue;
171
+ }
172
+ // Collect read targets, then issue them in bounded windows (rather than all
173
+ // at once) so a directory with thousands of `.cs` files can't exhaust file
174
+ // descriptors / spike memory. csproj reads keep entry order (config
175
+ // precedence matters); `.cs` namespace results land in shared Sets where
176
+ // order is irrelevant.
177
+ const csprojNames = [];
178
+ const csNames = [];
179
+ for (const entry of entries) {
180
+ if (entry.isDirectory()) {
181
+ if (CSHARP_SCAN_SKIP_DIRS.has(entry.name))
182
+ continue;
183
+ if (depth < CSHARP_SCAN_MAX_DEPTH) {
116
184
  scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
117
185
  }
118
- if (entry.isFile() && entry.name.endsWith('.csproj')) {
119
- try {
120
- const csprojPath = path.join(dir, entry.name);
121
- const content = await fs.readFile(csprojPath, 'utf-8');
122
- const nsMatch = content.match(/<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/);
123
- const rootNamespace = nsMatch ? nsMatch[1].trim() : entry.name.replace(/\.csproj$/, '');
124
- const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
125
- configs.push({ rootNamespace, projectDir });
126
- if (isDev) {
127
- logger.info(`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`);
128
- }
129
- }
130
- catch {
131
- // Can't read .csproj
132
- }
186
+ else {
187
+ truncated = true; // a real subtree was pruned at the depth cap
133
188
  }
189
+ continue;
190
+ }
191
+ if (!entry.isFile())
192
+ continue;
193
+ if (entry.name.endsWith('.csproj')) {
194
+ csprojNames.push(entry.name);
195
+ }
196
+ else if (entry.name.endsWith('.cs')) {
197
+ csNames.push(entry.name);
134
198
  }
135
199
  }
136
- catch {
137
- // Can't read directory
200
+ for (let i = 0; i < csprojNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
201
+ const batch = csprojNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
202
+ const settled = await Promise.allSettled(batch.map((name) => readCsprojConfig(path.join(dir, name), name, repoRoot, dir)));
203
+ for (const r of settled) {
204
+ const config = r.status === 'fulfilled' ? r.value : null;
205
+ if (config) {
206
+ configs.push(config);
207
+ rootNamespaces.add(config.rootNamespace);
208
+ }
209
+ }
210
+ }
211
+ for (let i = 0; i < csNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
212
+ const batch = csNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
213
+ const settled = await Promise.allSettled(batch.map((name) => collectDeclaredNamespaces(path.join(dir, name), declaredNamespaces, rootNamespaces)));
214
+ // A `.cs` that was unreadable (or whose read/scan unexpectedly rejected)
215
+ // leaves its namespaces uncollected → mark truncated to fail the #1881
216
+ // gate OPEN rather than wrongly suppress an import. The scan streams each
217
+ // file, so file size no longer trips truncation.
218
+ for (const r of settled) {
219
+ if (r.status !== 'fulfilled' || r.value === 'truncated')
220
+ truncated = true;
221
+ }
138
222
  }
139
223
  }
140
- return configs;
224
+ if (truncated) {
225
+ // Surface the fail-open so an incomplete scan (dir/depth cap, or an
226
+ // unreadable directory or `.cs` file) silently disabling the #1881 gate
227
+ // repo-wide is observable (#4) rather than a mystery edge regression.
228
+ 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`);
229
+ }
230
+ return { configs, declaredNamespaces, rootNamespaces, truncated };
231
+ }
232
+ // Generous soft budget for locating `<RootNamespace>`: a real .csproj declares
233
+ // it in the first PropertyGroup near the top, so this is only reached by a
234
+ // pathological project file with a huge leading ItemGroup and no early
235
+ // RootNamespace. On hit we OMIT the config rather than guess a root (Codex F4).
236
+ const CSPROJ_ROOT_SCAN_MAX_BYTES = 4 * 1024 * 1024;
237
+ // Overlap kept across stream chunks so a `<RootNamespace>` tag straddling a
238
+ // chunk boundary is still matched (the tag + a short namespace value fit well
239
+ // within this window).
240
+ const CSPROJ_TAG_OVERLAP = 512;
241
+ /**
242
+ * Stream a `.csproj` just far enough to find `<RootNamespace>`, in constant
243
+ * memory and without a stat-then-read filesystem race. Returns the namespace
244
+ * when found; otherwise `rootNamespace: null` with `capHit` distinguishing a
245
+ * genuine read-to-EOF absence (`false`) from "not found within the soft budget"
246
+ * (`true`) — so the caller never synthesizes a wrong filename root for a late
247
+ * tag (Codex F4).
248
+ */
249
+ async function findCsprojRootNamespace(csprojPath) {
250
+ const stream = createReadStream(csprojPath, { encoding: 'utf-8' });
251
+ let window = '';
252
+ let bytesRead = 0;
253
+ try {
254
+ for await (const chunk of stream) {
255
+ const text = chunk;
256
+ bytesRead += text.length;
257
+ window =
258
+ (window.length > CSPROJ_TAG_OVERLAP ? window.slice(-CSPROJ_TAG_OVERLAP) : window) + text;
259
+ const match = window.match(CSHARP_ROOT_NAMESPACE_RE);
260
+ if (match) {
261
+ stream.destroy();
262
+ return { rootNamespace: match[1].trim(), capHit: false };
263
+ }
264
+ if (bytesRead >= CSPROJ_ROOT_SCAN_MAX_BYTES) {
265
+ stream.destroy();
266
+ return { rootNamespace: null, capHit: true };
267
+ }
268
+ }
269
+ }
270
+ catch {
271
+ // Unreadable .csproj: don't guess a filename root either — omit the config.
272
+ return { rootNamespace: null, capHit: true };
273
+ }
274
+ return { rootNamespace: null, capHit: false }; // read to EOF, tag genuinely absent
275
+ }
276
+ async function readCsprojConfig(csprojPath, fileName, repoRoot, dir) {
277
+ const { rootNamespace: found, capHit } = await findCsprojRootNamespace(csprojPath);
278
+ // A late `<RootNamespace>` we couldn't reach (capHit) or an unreadable file
279
+ // must NOT synthesize a filename root — a wrong authoritative root would make
280
+ // imports under the real root resolve to nothing and suppress the fallback
281
+ // (Codex F4). Omit the config so the no-csproj fallback stays available. Only
282
+ // fall back to the filename on a genuine read-to-EOF absence of the tag.
283
+ if (capHit)
284
+ return null;
285
+ const rootNamespace = found ?? fileName.replace(/\.csproj$/, '');
286
+ const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
287
+ if (isDev) {
288
+ logger.info(`📦 Loaded C# project: ${fileName} (namespace: ${rootNamespace}, dir: ${projectDir})`);
289
+ }
290
+ return { rootNamespace, projectDir };
291
+ }
292
+ /**
293
+ * Stream one `.cs` file line-by-line and collect its declared `namespace` names
294
+ * into the shared Sets.
295
+ *
296
+ * Streaming (rather than reading the whole file into a string) keeps memory
297
+ * constant regardless of file size, so a large generated `.cs` (`*.g.cs`, EF /
298
+ * gRPC output) is fully scanned instead of skipped by a per-file size cap —
299
+ * which would otherwise trip `truncated` and disable the #1881 gate repo-wide.
300
+ * Only the cheap line scan streams here; the tree-sitter PARSE path keeps its
301
+ * own size cap.
302
+ *
303
+ * Returns `'truncated'` when the file could not be read, so the caller marks the
304
+ * scan truncated and the #1881 gate fails OPEN rather than wrongly suppress an
305
+ * import declared in the unread file. Returns `'ok'` on a complete read.
306
+ */
307
+ async function collectDeclaredNamespaces(filePath, declaredNamespaces, rootNamespaces) {
308
+ const createScanner = await getCsharpStructureScannerFactory();
309
+ const scanner = createScanner();
310
+ try {
311
+ // `crlfDelay: Infinity` treats every `\r\n` as a single break; the line
312
+ // scanner is terminator-agnostic, so a streamed scan yields the same
313
+ // namespaces as scanning the whole file content at once.
314
+ const lines = createInterface({
315
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
316
+ crlfDelay: Infinity,
317
+ });
318
+ for await (const line of lines) {
319
+ scanner.pushLine(line);
320
+ }
321
+ }
322
+ catch {
323
+ return 'truncated'; // unreadable source → signal truncation (fail open)
324
+ }
325
+ const structure = scanner.result();
326
+ for (const ns of structure.namespaces) {
327
+ declaredNamespaces.add(ns);
328
+ const dot = ns.indexOf('.');
329
+ rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot));
330
+ }
331
+ // A declaration the scanner could not fully capture (Codex F3) means the
332
+ // collected namespaces are an incomplete picture of this file — treat it like
333
+ // a truncated read so the #1881 gate fails OPEN rather than over-block an
334
+ // import whose namespace was dropped.
335
+ return structure.incomplete ? 'truncated' : 'ok';
141
336
  }
142
337
  export async function loadSwiftPackageConfig(repoRoot) {
143
338
  // Swift imports are module-name based (e.g., `import SiuperModel`)
@@ -172,11 +367,13 @@ export async function loadSwiftPackageConfig(repoRoot) {
172
367
  // ============================================================================
173
368
  /** Load all language-specific configs once for an ingestion run. */
174
369
  export async function loadImportConfigs(repoRoot) {
370
+ const csharpScan = await scanCSharpProject(repoRoot);
175
371
  return {
176
372
  tsconfigPaths: await loadTsconfigPaths(repoRoot),
177
373
  goModule: await loadGoModulePath(repoRoot),
178
374
  composerConfig: await loadComposerConfig(repoRoot),
179
375
  swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
180
- csharpConfigs: await loadCSharpProjectConfig(repoRoot),
376
+ csharpConfigs: csharpScan.configs,
377
+ csharpNamespaces: csharpScanToEvidence(csharpScan),
181
378
  };
182
379
  }
@@ -9,17 +9,21 @@
9
9
  * match. Cross-file partial-class aggregation runs at graph-bridge
10
10
  * time (Unit 6) via `populateOwners`.
11
11
  *
12
- * The legacy csproj-based `resolveCSharpImportInternal` needs config
13
- * objects the scope-resolver doesn't carry; the Unit 7 parity gate
14
- * will surface cases where the suffix-match diverges from the
15
- * namespace-based resolver and we'll adjust the contract if needed.
12
+ * When `.csproj` configs are available, consults the legacy
13
+ * namespace-directory resolver first. Both that resolver's suffix
14
+ * fallback and the progressive prefix stripping below are gated on
15
+ * declared in-repo namespaces so BCL usings like `System.Threading.Tasks`
16
+ * cannot spuriously match a local `Tasks.cs` (#1881).
16
17
  *
17
18
  * Returning `null` lets the finalize algorithm mark the edge as
18
19
  * `linkStatus: 'unresolved'`.
19
20
  */
20
21
  import type { ParsedImport, WorkspaceIndex } from '../../../../_shared/index.js';
22
+ import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../../language-config.js';
21
23
  export interface CsharpResolveContext {
22
24
  readonly fromFile: string;
23
25
  readonly allFilePaths: ReadonlySet<string>;
26
+ readonly csharpConfigs?: readonly CSharpProjectConfig[];
27
+ readonly namespaces?: CSharpNamespaceEvidence;
24
28
  }
25
29
  export declare function resolveCsharpImportTarget(parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex): string | null;
@@ -9,84 +9,143 @@
9
9
  * match. Cross-file partial-class aggregation runs at graph-bridge
10
10
  * time (Unit 6) via `populateOwners`.
11
11
  *
12
- * The legacy csproj-based `resolveCSharpImportInternal` needs config
13
- * objects the scope-resolver doesn't carry; the Unit 7 parity gate
14
- * will surface cases where the suffix-match diverges from the
15
- * namespace-based resolver and we'll adjust the contract if needed.
12
+ * When `.csproj` configs are available, consults the legacy
13
+ * namespace-directory resolver first. Both that resolver's suffix
14
+ * fallback and the progressive prefix stripping below are gated on
15
+ * declared in-repo namespaces so BCL usings like `System.Threading.Tasks`
16
+ * cannot spuriously match a local `Tasks.cs` (#1881).
16
17
  *
17
18
  * Returning `null` lets the finalize algorithm mark the edge as
18
19
  * `linkStatus: 'unresolved'`.
19
20
  */
21
+ import { resolveCSharpImportInternal } from '../../import-resolvers/csharp.js';
22
+ import { buildSuffixIndex } from '../../import-resolvers/utils.js';
23
+ import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
24
+ // Memoize on Set identity: the orchestrator passes the SAME `allFilePaths`
25
+ // Set through every `resolveImportTarget` call in a pass, so this rebuilds
26
+ // the normalized list + suffix index once instead of once per import (#1881 #2).
27
+ const workspaceFileIndexCache = new WeakMap();
28
+ function getWorkspaceFileIndex(allFilePaths) {
29
+ const cached = workspaceFileIndexCache.get(allFilePaths);
30
+ if (cached)
31
+ return cached;
32
+ const all = [...allFilePaths];
33
+ const normalized = all.map((f) => f.replace(/\\/g, '/'));
34
+ const built = { normalized, all, index: buildSuffixIndex(normalized, all) };
35
+ workspaceFileIndexCache.set(allFilePaths, built);
36
+ return built;
37
+ }
20
38
  export function resolveCsharpImportTarget(parsedImport, workspaceIndex) {
21
- // WorkspaceIndex is `unknown` in the shared contract (Ring 1
22
- // placeholder). The scope-resolution orchestrator hands us a
23
- // CsharpResolveContext-shaped object; narrow structurally rather
24
- // than via a cast chain so unexpected shapes return null cleanly.
25
- const ctx = workspaceIndex;
26
- if (ctx === undefined ||
27
- typeof ctx.fromFile !== 'string' ||
28
- !(ctx.allFilePaths instanceof Set)) {
39
+ const ctx = narrowContext(workspaceIndex);
40
+ if (ctx === null)
29
41
  return null;
30
- }
31
42
  if (parsedImport.kind === 'dynamic-unresolved')
32
43
  return null;
33
44
  if (parsedImport.targetRaw === null || parsedImport.targetRaw === '')
34
45
  return null;
46
+ const targetRaw = parsedImport.targetRaw;
47
+ const evidence = ctx.namespaces;
48
+ const csharpConfigs = ctx.csharpConfigs ?? [];
49
+ if (csharpConfigs.length > 0) {
50
+ const { normalized, all, index } = getWorkspaceFileIndex(ctx.allFilePaths);
51
+ const fromCsproj = resolveCSharpImportInternal(targetRaw, [...csharpConfigs], normalized, all, index, evidence);
52
+ if (fromCsproj.length > 0)
53
+ return fromCsproj[0];
54
+ // csproj configs are authoritative: mirror legacy `configs/csharp.ts`,
55
+ // which returns an empty result to STOP the chain. Falling through to the
56
+ // ungated `resolveDirectMatch` would re-introduce the BCL→local match the
57
+ // internal resolver's gate just suppressed (#1881 parity, #2).
58
+ return null;
59
+ }
35
60
  // Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
36
- const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
37
- const suffix = `/${pathLike}`;
38
- // Exact file match: `System/Collections/Generic.cs` (rare but legal).
39
- // Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`.
40
- // Directory match: first `.cs` file directly inside the namespace dir
41
- // (e.g. `System/Collections/Generic/List.cs` matches namespace Generic).
42
- let exactFile = null;
61
+ const pathLike = targetRaw.replace(/\./g, '/');
62
+ // Gate the WHOLE no-csproj path on declared in-repo namespaces — the direct
63
+ // path/suffix match INCLUDED so a BCL using can't resolve to a
64
+ // coincidentally path-aligned local file (e.g. `Legacy/System/Threading/
65
+ // Tasks.cs` satisfying `using System.Threading.Tasks;`). Running the gate
66
+ // before `resolveDirectMatch` mirrors the legacy leg's gate-first ordering
67
+ // (`import-resolvers/configs/csharp.ts`), so the two legs are equivalent
68
+ // (#1881 parity, Codex F2). The gate keeps its fail-open for
69
+ // undefined/truncated evidence, so legitimate edges in unscanned repos are
70
+ // unaffected.
71
+ if (!csharpSuffixFallbackAllowed(targetRaw, evidence)) {
72
+ return null;
73
+ }
74
+ // Exact file / nested-suffix / namespace-dir direct-child match.
75
+ const direct = resolveDirectMatch(ctx.allFilePaths, pathLike);
76
+ if (direct !== null)
77
+ return direct;
78
+ // Progressive prefix stripping — mirrors csproj's root-namespace mapping
79
+ // without the csproj.
80
+ return resolveByProgressiveStripping(ctx.allFilePaths, pathLike);
81
+ }
82
+ /**
83
+ * `WorkspaceIndex` is an opaque `unknown` placeholder in the shared contract;
84
+ * the orchestrator hands us a `CsharpResolveContext`-shaped object. Narrow
85
+ * structurally rather than via a cast chain so unexpected shapes fail cleanly.
86
+ */
87
+ function narrowContext(workspaceIndex) {
88
+ const ctx = workspaceIndex;
89
+ if (ctx === undefined ||
90
+ typeof ctx.fromFile !== 'string' ||
91
+ !(ctx.allFilePaths instanceof Set)) {
92
+ return null;
93
+ }
94
+ return ctx;
95
+ }
96
+ /**
97
+ * First-pass resolution against the full namespace path:
98
+ * exact whole-path file > nested suffix file > first `.cs` directly inside
99
+ * the namespace directory.
100
+ */
101
+ function resolveDirectMatch(allFilePaths, pathLike) {
102
+ const exactName = `${pathLike}.cs`;
103
+ const nestedSuffix = `/${exactName}`;
43
104
  let suffixFile = null;
44
- let directoryChild = null;
45
- const dirPrefix = `${pathLike}/`;
46
- const suffixDirPrefix = `/${dirPrefix}`;
47
- for (const raw of ctx.allFilePaths) {
105
+ for (const raw of allFilePaths) {
48
106
  const f = raw.replace(/\\/g, '/');
49
107
  if (!f.endsWith('.cs'))
50
108
  continue;
51
- if (f === `${pathLike}.cs`) {
52
- exactFile = raw;
53
- break;
54
- }
55
- if (suffixFile === null && f.endsWith(`${suffix}.cs`)) {
109
+ if (f === exactName)
110
+ return raw; // exact whole-path match wins
111
+ if (suffixFile === null && f.endsWith(nestedSuffix))
56
112
  suffixFile = raw;
57
- }
58
- if (directoryChild === null) {
59
- // Namespace-to-directory match: pick the first `.cs` directly in
60
- // the namespace dir (not nested deeper). Legacy resolver emits
61
- // all of them; we take one so the scope-resolver contract stays
62
- // single-target.
63
- const atRoot = f.startsWith(dirPrefix);
64
- const atNested = f.includes(suffixDirPrefix);
65
- if (atRoot || atNested) {
66
- const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
67
- const after = f.slice(idx + dirPrefix.length);
68
- if (after.length > 0 && !after.includes('/')) {
69
- directoryChild = raw;
70
- }
71
- }
72
- }
73
113
  }
74
- if (exactFile !== null)
75
- return exactFile;
76
114
  if (suffixFile !== null)
77
115
  return suffixFile;
78
- if (directoryChild !== null)
79
- return directoryChild;
80
- // Progressive prefix stripping — mirrors csproj's root-namespace
81
- // mapping without the csproj. `using CrossFile.Models;` in a repo
82
- // laid out `Models/User.cs` (no `CrossFile/` prefix) works because
83
- // the legacy resolver consults csproj; the scope-resolver layer
84
- // doesn't have csproj, so we try each suffix of the namespace path
85
- // against `.cs` files and directories.
86
- //
87
- // Also handles `using static CrossFile.Models.UserFactory;` —
88
- // strip the leading segment, try `Models/UserFactory.cs`; strip
89
- // two, try `UserFactory.cs`.
116
+ return findDirectChild(allFilePaths, pathLike);
117
+ }
118
+ /**
119
+ * First `.cs` file that lives directly inside the namespace directory
120
+ * `dirSegment` (at repo root or nested under a project prefix), not deeper.
121
+ * The legacy resolver emits all of them; the scope-resolver contract is
122
+ * single-target so we take one.
123
+ */
124
+ function findDirectChild(allFilePaths, dirSegment) {
125
+ const dirPrefix = `${dirSegment}/`;
126
+ const nestedDirPrefix = `/${dirPrefix}`;
127
+ for (const raw of allFilePaths) {
128
+ const f = raw.replace(/\\/g, '/');
129
+ if (!f.endsWith('.cs'))
130
+ continue;
131
+ const atRoot = f.startsWith(dirPrefix);
132
+ const atNested = f.includes(nestedDirPrefix);
133
+ if (!atRoot && !atNested)
134
+ continue;
135
+ const idx = atRoot ? 0 : f.indexOf(nestedDirPrefix) + 1;
136
+ const after = f.slice(idx + dirPrefix.length);
137
+ if (after.length > 0 && !after.includes('/'))
138
+ return raw;
139
+ }
140
+ return null;
141
+ }
142
+ /**
143
+ * Try each suffix of the namespace path against `.cs` files and directories,
144
+ * stripping leading segments one at a time. Models `using CrossFile.Models;`
145
+ * resolving to `Models/User.cs` in a repo laid out without the `CrossFile/`
146
+ * prefix (the scope-resolver layer has no csproj to consult).
147
+ */
148
+ function resolveByProgressiveStripping(allFilePaths, pathLike) {
90
149
  const segments = pathLike.split('/').filter(Boolean);
91
150
  for (let skip = 1; skip < segments.length; skip++) {
92
151
  const tail = segments.slice(skip).join('/');
@@ -94,30 +153,21 @@ export function resolveCsharpImportTarget(parsedImport, workspaceIndex) {
94
153
  continue;
95
154
  const tailFile = `${tail}.cs`;
96
155
  const tailSuffix = `/${tailFile}`;
97
- const tailDir = `${tail}/`;
98
- const tailSuffixDir = `/${tailDir}`;
99
- let tailDirectChild = null;
100
- for (const raw of ctx.allFilePaths) {
156
+ let tailFileMatch = null;
157
+ for (const raw of allFilePaths) {
101
158
  const f = raw.replace(/\\/g, '/');
102
159
  if (!f.endsWith('.cs'))
103
160
  continue;
104
- if (f === tailFile)
105
- return raw;
106
- if (f.endsWith(tailSuffix))
107
- return raw;
108
- if (tailDirectChild === null) {
109
- const atRoot = f.startsWith(tailDir);
110
- const atNested = f.includes(tailSuffixDir);
111
- if (atRoot || atNested) {
112
- const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
113
- const after = f.slice(idx + tailDir.length);
114
- if (after.length > 0 && !after.includes('/'))
115
- tailDirectChild = raw;
116
- }
161
+ if (f === tailFile || f.endsWith(tailSuffix)) {
162
+ tailFileMatch = raw;
163
+ break;
117
164
  }
118
165
  }
119
- if (tailDirectChild !== null)
120
- return tailDirectChild;
166
+ if (tailFileMatch !== null)
167
+ return tailFileMatch;
168
+ const child = findDirectChild(allFilePaths, tail);
169
+ if (child !== null)
170
+ return child;
121
171
  }
122
172
  return null;
123
173
  }
@@ -44,6 +44,13 @@ export interface CsharpFileStructure {
44
44
  /** Dotted paths from `using static X.Y.Z;` (including
45
45
  * `global using static` and aliased `using static A = X.Y.Z;`). */
46
46
  readonly usingStaticPaths: readonly string[];
47
+ /** True when the scanner saw a `namespace` / `using static` declaration it
48
+ * could not fully capture (keyword not at line start, split across lines, or
49
+ * an unparseable identifier form). Callers feeding the #1881 gate must treat
50
+ * this like a truncated scan and fail OPEN, since a dropped namespace would
51
+ * otherwise over-block a legitimate import (Codex F3). Absent/false on a
52
+ * cleanly-scanned file. */
53
+ readonly incomplete?: boolean;
47
54
  }
48
55
  /** Line-scanner used when no cached tree is available (worker-parsed files
49
56
  * can't transfer native tree-sitter Trees across MessageChannels, so
@@ -59,6 +66,21 @@ export interface CsharpFileStructure {
59
66
  * AST is a declaration whose keyword is not at the start of a code line
60
67
  * (split across lines, or sharing a line with a comment/string closer).
61
68
  * Mirrors PHP's `extractNamespaceViaScanner` (issue #1741). */
69
+ /** Incremental form of {@link extractCsharpStructureViaScanner}: feed lines one
70
+ * at a time via `pushLine` (in source order), then read the accumulated
71
+ * structure with `result()`. Lets a caller stream a file off disk
72
+ * (`createReadStream` + `readline`) and scan it for `namespace` / `using
73
+ * static` declarations in CONSTANT memory rather than buffering the whole file
74
+ * into a string — the line splitting and per-line matching are identical, so a
75
+ * streamed scan yields the same result as scanning the full content. The line
76
+ * terminator must be stripped (as `readline` does, or `String.split('\n')`); a
77
+ * trailing `\r` on a CRLF line is inert to both the matchers and the lexer. */
78
+ export interface CsharpStructureLineScanner {
79
+ pushLine(line: string): void;
80
+ result(): CsharpFileStructure;
81
+ }
82
+ /** Create a fresh stateful line scanner — see {@link CsharpStructureLineScanner}. */
83
+ export declare function createCsharpStructureScanner(): CsharpStructureLineScanner;
62
84
  export declare function extractCsharpStructureViaScanner(content: string): CsharpFileStructure;
63
85
  /** Content + (optional) pre-parsed tree-sitter trees keyed by filePath.
64
86
  * The orchestrator builds `fileContents` from the pipeline's file list;
@@ -34,16 +34,45 @@
34
34
  * re-parsing every file from scratch (that re-parse dominated worker-mode
35
35
  * scope-resolution time). See `extractCsharpStructureViaScanner`.
36
36
  */
37
+ // A dotted C# namespace identifier: each segment is an optional verbatim `@`
38
+ // followed by a Unicode letter/`_` and Unicode letters/digits/`_`. The `u` flag
39
+ // makes the classes Unicode-aware so `namespace Café.Models;` is captured (the
40
+ // old ASCII `[A-Za-z…]` truncated it). The `@` markers are stripped from the
41
+ // capture so it matches the tree-sitter AST's `name` text.
42
+ const CS_NS_IDENT = String.raw `@?[\p{L}_][\p{L}\p{N}_]*(?:\.@?[\p{L}_][\p{L}\p{N}_]*)*`;
37
43
  // Line-anchored matchers for the worker-path fallback (see
38
44
  // `extractCsharpStructureViaScanner`). Anchored at line start (after
39
45
  // indentation); the scanner additionally tracks block-comment / string
40
46
  // state across lines so a keyword at the start of a line inside one of
41
47
  // those regions is skipped.
42
- const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/;
48
+ const CS_NAMESPACE_RE = new RegExp(String.raw `^[ \t]*namespace[ \t]+(${CS_NS_IDENT})`, 'u');
43
49
  // `global using static`, plain `using static`, and the aliased
44
50
  // `using static Alias = NS.Type;` form (the AST keeps the RHS path, so
45
51
  // the optional `Alias =` is skipped and only the dotted path captured).
46
- 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_.]*)/;
52
+ 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');
53
+ // Incompleteness detectors — used ONLY when the precise matchers above failed,
54
+ // to flag a declaration the scanner could not capture (so the file fails the
55
+ // #1881 gate OPEN instead of silently dropping the namespace). Kept
56
+ // high-precision so ordinary files never trip them (which would wrongly disable
57
+ // the gate repo-wide):
58
+ // - `…_BARE`: the keyword alone on a line (the name is on the next line).
59
+ // - `…_AT_START`: a line-start declaration the precise matcher couldn't parse.
60
+ // - `CS_NAMESPACE_AFTER_CODE`: a `namespace` keyword right after a `}`/`;`/`{`/`]`
61
+ // (real code, NOT a `//` comment), i.e. not at line start.
62
+ const CS_NAMESPACE_BARE = /^[ \t]*namespace[ \t]*\r?$/;
63
+ const CS_USING_STATIC_BARE = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]*\r?$/;
64
+ const CS_NAMESPACE_AT_START = /^[ \t]*namespace[ \t]+\S/;
65
+ const CS_USING_STATIC_AT_START = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+\S/;
66
+ const CS_NAMESPACE_AFTER_CODE = /[}\];{][ \t]*namespace[ \t]+@?[\p{L}_]/u;
67
+ /** Whether a `code`-state line declares a namespace / using-static the precise
68
+ * matchers could not capture — see the detectors above. */
69
+ function looksLikeUncapturedDeclaration(line) {
70
+ return (CS_NAMESPACE_BARE.test(line) ||
71
+ CS_USING_STATIC_BARE.test(line) ||
72
+ CS_NAMESPACE_AT_START.test(line) ||
73
+ CS_USING_STATIC_AT_START.test(line) ||
74
+ CS_NAMESPACE_AFTER_CODE.test(line));
75
+ }
47
76
  /** Advance the scanner's lexical state across one line, consuming block
48
77
  * comments (slash-star), line comments (`//`), single-line regular /
49
78
  * interpolated strings, verbatim strings (`@"…"`), and raw string literals
@@ -160,42 +189,48 @@ function advanceCsScanState(line, state, rawFence) {
160
189
  }
161
190
  return [state, rawFence];
162
191
  }
163
- /** Line-scanner used when no cached tree is available (worker-parsed files
164
- * can't transfer native tree-sitter Trees across MessageChannels, so
165
- * `treeCache` is empty for them). Re-parsing every C# file here with
166
- * tree-sitter was the dominant scope-resolution cost on large worker-mode
167
- * runs — for a multi-thousand-file solution this loop alone re-parsed the
168
- * whole repo a second time. The scanner extracts the same `namespaces` /
169
- * `usingStaticPaths` the AST walk produces for line-anchored declarations,
170
- * while tracking block-comment and string state across lines (via
171
- * `advanceCsScanState`) so a `namespace` / `using static` keyword at the
172
- * start of a line inside a block comment, verbatim string, or raw string
173
- * literal is NOT mistaken for a declaration. The remaining trade-off vs the
174
- * AST is a declaration whose keyword is not at the start of a code line
175
- * (split across lines, or sharing a line with a comment/string closer).
176
- * Mirrors PHP's `extractNamespaceViaScanner` (issue #1741). */
177
- export function extractCsharpStructureViaScanner(content) {
192
+ /** Create a fresh stateful line scanner see {@link CsharpStructureLineScanner}. */
193
+ export function createCsharpStructureScanner() {
178
194
  const namespaces = [];
179
195
  const usingStaticPaths = [];
196
+ let incomplete = false;
180
197
  let state = 'code';
181
198
  let rawFence = 0;
182
- for (const line of content.split('\n')) {
183
- // Only match when the line START is real code — keywords reached while
184
- // inside a block comment / multi-line string are skipped.
185
- if (state === 'code') {
186
- const ns = CS_NAMESPACE_RE.exec(line);
187
- if (ns !== null) {
188
- namespaces.push(ns[1]);
189
- }
190
- else {
191
- const us = CS_USING_STATIC_RE.exec(line);
192
- if (us !== null)
193
- usingStaticPaths.push(us[1]);
199
+ return {
200
+ pushLine(line) {
201
+ // Only match when the line START is real code — keywords reached while
202
+ // inside a block comment / multi-line string are skipped.
203
+ if (state === 'code') {
204
+ const ns = CS_NAMESPACE_RE.exec(line);
205
+ if (ns !== null) {
206
+ namespaces.push(ns[1].replace(/@/g, ''));
207
+ }
208
+ else {
209
+ const us = CS_USING_STATIC_RE.exec(line);
210
+ if (us !== null) {
211
+ usingStaticPaths.push(us[1].replace(/@/g, ''));
212
+ }
213
+ else if (looksLikeUncapturedDeclaration(line)) {
214
+ // A declaration the precise matchers couldn't capture → mark the
215
+ // file incomplete so the #1881 gate fails OPEN (Codex F3).
216
+ incomplete = true;
217
+ }
218
+ }
194
219
  }
195
- }
196
- [state, rawFence] = advanceCsScanState(line, state, rawFence);
197
- }
198
- return { namespaces, usingStaticPaths };
220
+ [state, rawFence] = advanceCsScanState(line, state, rawFence);
221
+ },
222
+ result() {
223
+ return incomplete
224
+ ? { namespaces, usingStaticPaths, incomplete }
225
+ : { namespaces, usingStaticPaths };
226
+ },
227
+ };
228
+ }
229
+ export function extractCsharpStructureViaScanner(content) {
230
+ const scanner = createCsharpStructureScanner();
231
+ for (const line of content.split('\n'))
232
+ scanner.pushLine(line);
233
+ return scanner.result();
199
234
  }
200
235
  /** Build a structural view of a C# file. Prefers `cachedTree` (handed in
201
236
  * via `treeCache`) and walks the tree-sitter AST — the authoritative
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Per-workspace config for C# scope-resolution import targeting.
3
+ *
4
+ * Loaded once per analyze pass via `csharpScopeResolver.loadResolutionConfig`
5
+ * and threaded into `resolveCsharpImportTarget`. The pure gate predicates live
6
+ * in `../../csharp-namespace-gate.ts` (shared with the legacy DAG resolver).
7
+ */
8
+ import { type CSharpProjectConfig, type CSharpNamespaceEvidence } from '../../language-config.js';
9
+ export interface CsharpResolutionConfig {
10
+ readonly csharpConfigs: readonly CSharpProjectConfig[];
11
+ /** In-repo declared-namespace evidence gating suffix-fallback resolution (#1881). */
12
+ readonly namespaces?: CSharpNamespaceEvidence;
13
+ }
14
+ export declare function loadCsharpResolutionConfig(repoRoot: string): Promise<CsharpResolutionConfig>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Per-workspace config for C# scope-resolution import targeting.
3
+ *
4
+ * Loaded once per analyze pass via `csharpScopeResolver.loadResolutionConfig`
5
+ * and threaded into `resolveCsharpImportTarget`. The pure gate predicates live
6
+ * in `../../csharp-namespace-gate.ts` (shared with the legacy DAG resolver).
7
+ */
8
+ import { scanCSharpProject, csharpScanToEvidence, } from '../../language-config.js';
9
+ export async function loadCsharpResolutionConfig(repoRoot) {
10
+ const scan = await scanCSharpProject(repoRoot);
11
+ return {
12
+ csharpConfigs: scan.configs,
13
+ namespaces: csharpScanToEvidence(scan),
14
+ };
15
+ }
@@ -11,13 +11,21 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
11
11
  import { csharpProvider } from '../csharp.js';
12
12
  import { csharpArityCompatibility, csharpMergeBindings, resolveCsharpImportTarget, } from './index.js';
13
13
  import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
14
+ import { loadCsharpResolutionConfig } from './resolution-config.js';
14
15
  import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js';
15
16
  const csharpScopeResolver = {
16
17
  language: SupportedLanguages.CSharp,
17
18
  languageProvider: csharpProvider,
18
19
  importEdgeReason: 'csharp-scope: using',
19
- resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
20
- const ws = { fromFile, allFilePaths };
20
+ loadResolutionConfig: (repoPath) => loadCsharpResolutionConfig(repoPath),
21
+ resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
22
+ const config = resolutionConfig;
23
+ const ws = {
24
+ fromFile,
25
+ allFilePaths,
26
+ csharpConfigs: config?.csharpConfigs,
27
+ namespaces: config?.namespaces,
28
+ };
21
29
  // `WorkspaceIndex` is an opaque `unknown` placeholder in the
22
30
  // shared contract, so `ws` passes structurally without a cast.
23
31
  return resolveCsharpImportTarget({ kind: 'namespace', localName: '_', importedName: '_', targetRaw }, ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.95",
3
+ "version": "1.6.6-rc.96",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",