From ae5ea1b848e5b059e0e5776e41504cd64134ec03 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 30 May 2026 07:51:45 +0000 Subject: [PATCH] fix(csharp): stream the .csproj RootNamespace read, no byte cap (#1908, Codex F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readCsprojConfig read only the first 512KB of a .csproj and, on a match-miss, couldn't tell 'no RootNamespace' from 'RootNamespace past the cap' — both synthesized a filename root. A wrong authoritative root makes imports under the real root resolve to nothing AND suppresses the fallback. Replace the capped read with a streamed early-stop search (findCsprojRootNamespace) that reads until the tag or EOF: filename fallback ONLY on genuine read-to-EOF absence; on a soft-budget cap-hit or unreadable file, OMIT the config so the no-csproj fallback stays reachable. Removes the now-unused readFileTextCapped + getMaxFileSizeBytes cap from the scan. Parity 2/2. --- .../src/core/ingestion/language-config.ts | 93 ++++++++++++------- .../csharp/csharp-imports.test.ts | 46 +++++++++ 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index af2ffb299..16ad25e43 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -6,7 +6,6 @@ import type { ImportConfigs } from './import-resolvers/types.js'; import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js'; import { isDev } from './utils/env.js'; -import { getMaxFileSizeBytes } from './utils/max-file-size.js'; import { logger } from '../logger.js'; // ============================================================================ @@ -234,10 +233,6 @@ export async function scanCSharpProject(repoRoot: string): Promise 0) { if (dirsScanned >= CSHARP_SCAN_MAX_DIRS) { @@ -284,9 +279,7 @@ export async function scanCSharpProject(repoRoot: string): Promise - readCsprojConfig(path.join(dir, name), name, repoRoot, dir, maxFileSizeBytes), - ), + batch.map((name) => readCsprojConfig(path.join(dir, name), name, repoRoot, dir)), ); for (const r of settled) { const config = r.status === 'fulfilled' ? r.value : null; @@ -324,17 +317,51 @@ export async function scanCSharpProject(repoRoot: string): Promise { - const parts: string[] = []; - const stream = createReadStream(filePath, { encoding: 'utf-8', end: maxBytes }); - for await (const part of stream) { - parts.push(part as string); +// Generous soft budget for locating ``: a real .csproj declares +// it in the first PropertyGroup near the top, so this is only reached by a +// pathological project file with a huge leading ItemGroup and no early +// RootNamespace. On hit we OMIT the config rather than guess a root (Codex F4). +const CSPROJ_ROOT_SCAN_MAX_BYTES = 4 * 1024 * 1024; +// Overlap kept across stream chunks so a `` tag straddling a +// chunk boundary is still matched (the tag + a short namespace value fit well +// within this window). +const CSPROJ_TAG_OVERLAP = 512; + +/** + * Stream a `.csproj` just far enough to find ``, in constant + * memory and without a stat-then-read filesystem race. Returns the namespace + * when found; otherwise `rootNamespace: null` with `capHit` distinguishing a + * genuine read-to-EOF absence (`false`) from "not found within the soft budget" + * (`true`) — so the caller never synthesizes a wrong filename root for a late + * tag (Codex F4). + */ +async function findCsprojRootNamespace( + csprojPath: string, +): Promise<{ rootNamespace: string | null; capHit: boolean }> { + const stream = createReadStream(csprojPath, { encoding: 'utf-8' }); + let window = ''; + let bytesRead = 0; + try { + for await (const chunk of stream) { + const text = chunk as string; + bytesRead += text.length; + window = + (window.length > CSPROJ_TAG_OVERLAP ? window.slice(-CSPROJ_TAG_OVERLAP) : window) + text; + const match = window.match(CSHARP_ROOT_NAMESPACE_RE); + if (match) { + stream.destroy(); + return { rootNamespace: match[1]!.trim(), capHit: false }; + } + if (bytesRead >= CSPROJ_ROOT_SCAN_MAX_BYTES) { + stream.destroy(); + return { rootNamespace: null, capHit: true }; + } + } + } catch { + // Unreadable .csproj: don't guess a filename root either — omit the config. + return { rootNamespace: null, capHit: true }; } - return parts.join(''); + return { rootNamespace: null, capHit: false }; // read to EOF, tag genuinely absent } async function readCsprojConfig( @@ -342,24 +369,22 @@ async function readCsprojConfig( fileName: string, repoRoot: string, dir: string, - maxFileSizeBytes: number, ): Promise { - try { - // `` sits in the first PropertyGroup near the top, so a - // capped read captures any real .csproj while bounding pathological input. - const content = await readFileTextCapped(csprojPath, maxFileSizeBytes); - 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 + const { rootNamespace: found, capHit } = await findCsprojRootNamespace(csprojPath); + // A late `` we couldn't reach (capHit) or an unreadable file + // must NOT synthesize a filename root — a wrong authoritative root would make + // imports under the real root resolve to nothing and suppress the fallback + // (Codex F4). Omit the config so the no-csproj fallback stays available. Only + // fall back to the filename on a genuine read-to-EOF absence of the tag. + if (capHit) return null; + const rootNamespace = found ?? fileName.replace(/\.csproj$/, ''); + const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/'); + if (isDev) { + logger.info( + `📦 Loaded C# project: ${fileName} (namespace: ${rootNamespace}, dir: ${projectDir})`, + ); } + return { rootNamespace, projectDir }; } /** 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 dbd87f154..d7b7084dd 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts @@ -741,4 +741,50 @@ describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () => await fsp.rm(root, { recursive: true, force: true }); } }); + + it('recovers past the old read cap via streaming (Codex F4)', async () => { + // A big leading pushes past the old 512KB read + // cap; the streamed scan reads on until it finds the tag, so the correct + // root is recovered (pre-fix the capped read synthesized the filename 'App'). + const cap = getMaxFileSizeBytes(); + const itemLine = ' \n'; + const bigItemGroup = + ' \n' + + itemLine.repeat(Math.ceil((cap * 2) / itemLine.length)) + + ' \n'; + const csproj = + '\n' + + bigItemGroup + + ' MyApp\n' + + '\n'; + const root = await makeTempRepo({ + 'App.csproj': csproj, + 'Models/User.cs': 'namespace MyApp.Models;\npublic class User {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + expect(config.csharpConfigs).toHaveLength(1); + expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp'); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('falls back to the filename root only when is genuinely absent (Codex F4 control)', async () => { + // A genuine read-to-EOF absence still synthesizes the filename root, so a + // .csproj without RootNamespace is unchanged — the fix only avoids guessing + // when the tag was unreachable. + const root = await makeTempRepo({ + 'App.csproj': + 'net8.0', + 'Models/User.cs': 'namespace App.Models;\npublic class User {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + expect(config.csharpConfigs).toHaveLength(1); + expect(config.csharpConfigs[0]!.rootNamespace).toBe('App'); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); });