diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 89da0e69d..af2ffb299 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -398,12 +398,17 @@ async function collectDeclaredNamespaces( } catch { return 'truncated'; // unreadable source → signal truncation (fail open) } - for (const ns of scanner.result().namespaces) { + const structure = scanner.result(); + for (const ns of structure.namespaces) { declaredNamespaces.add(ns); const dot = ns.indexOf('.'); rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot)); } - return 'ok'; + // A declaration the scanner could not fully capture (Codex F3) means the + // collected namespaces are an incomplete picture of this file — treat it like + // a truncated read so the #1881 gate fails OPEN rather than over-block an + // import whose namespace was dropped. + return structure.incomplete ? 'truncated' : 'ok'; } export async function loadSwiftPackageConfig(repoRoot: string): Promise { diff --git a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts index fdf79a4de..454b5341d 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts @@ -48,19 +48,62 @@ export interface CsharpFileStructure { /** Dotted paths from `using static X.Y.Z;` (including * `global using static` and aliased `using static A = X.Y.Z;`). */ readonly usingStaticPaths: readonly string[]; + /** True when the scanner saw a `namespace` / `using static` declaration it + * could not fully capture (keyword not at line start, split across lines, or + * an unparseable identifier form). Callers feeding the #1881 gate must treat + * this like a truncated scan and fail OPEN, since a dropped namespace would + * otherwise over-block a legitimate import (Codex F3). Absent/false on a + * cleanly-scanned file. */ + readonly incomplete?: boolean; } +// A dotted C# namespace identifier: each segment is an optional verbatim `@` +// followed by a Unicode letter/`_` and Unicode letters/digits/`_`. The `u` flag +// makes the classes Unicode-aware so `namespace Café.Models;` is captured (the +// old ASCII `[A-Za-z…]` truncated it). The `@` markers are stripped from the +// capture so it matches the tree-sitter AST's `name` text. +const CS_NS_IDENT = String.raw`@?[\p{L}_][\p{L}\p{N}_]*(?:\.@?[\p{L}_][\p{L}\p{N}_]*)*`; + // Line-anchored matchers for the worker-path fallback (see // `extractCsharpStructureViaScanner`). Anchored at line start (after // indentation); the scanner additionally tracks block-comment / string // state across lines so a keyword at the start of a line inside one of // those regions is skipped. -const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/; +const CS_NAMESPACE_RE = new RegExp(String.raw`^[ \t]*namespace[ \t]+(${CS_NS_IDENT})`, 'u'); // `global using static`, plain `using static`, and the aliased // `using static Alias = NS.Type;` form (the AST keeps the RHS path, so // the optional `Alias =` is skipped and only the dotted path captured). -const CS_USING_STATIC_RE = - /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:[A-Za-z_@][A-Za-z0-9_]*[ \t]*=[ \t]*)?([A-Za-z_@][A-Za-z0-9_.]*)/; +const CS_USING_STATIC_RE = new RegExp( + String.raw`^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:@?[\p{L}_][\p{L}\p{N}_]*[ \t]*=[ \t]*)?(${CS_NS_IDENT})`, + 'u', +); + +// Incompleteness detectors — used ONLY when the precise matchers above failed, +// to flag a declaration the scanner could not capture (so the file fails the +// #1881 gate OPEN instead of silently dropping the namespace). Kept +// high-precision so ordinary files never trip them (which would wrongly disable +// the gate repo-wide): +// - `…_BARE`: the keyword alone on a line (the name is on the next line). +// - `…_AT_START`: a line-start declaration the precise matcher couldn't parse. +// - `CS_NAMESPACE_AFTER_CODE`: a `namespace` keyword right after a `}`/`;`/`{`/`]` +// (real code, NOT a `//` comment), i.e. not at line start. +const CS_NAMESPACE_BARE = /^[ \t]*namespace[ \t]*\r?$/; +const CS_USING_STATIC_BARE = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]*\r?$/; +const CS_NAMESPACE_AT_START = /^[ \t]*namespace[ \t]+\S/; +const CS_USING_STATIC_AT_START = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+\S/; +const CS_NAMESPACE_AFTER_CODE = /[}\];{][ \t]*namespace[ \t]+@?[\p{L}_]/u; + +/** Whether a `code`-state line declares a namespace / using-static the precise + * matchers could not capture — see the detectors above. */ +function looksLikeUncapturedDeclaration(line: string): boolean { + return ( + CS_NAMESPACE_BARE.test(line) || + CS_USING_STATIC_BARE.test(line) || + CS_NAMESPACE_AT_START.test(line) || + CS_USING_STATIC_AT_START.test(line) || + CS_NAMESPACE_AFTER_CODE.test(line) + ); +} /** Multi-line lexical state carried line-to-line by the scanner. */ type CsScanState = 'code' | 'block' | 'verbatim' | 'raw'; @@ -200,6 +243,7 @@ export interface CsharpStructureLineScanner { export function createCsharpStructureScanner(): CsharpStructureLineScanner { const namespaces: string[] = []; const usingStaticPaths: string[] = []; + let incomplete = false; let state: CsScanState = 'code'; let rawFence = 0; return { @@ -209,16 +253,24 @@ export function createCsharpStructureScanner(): CsharpStructureLineScanner { if (state === 'code') { const ns = CS_NAMESPACE_RE.exec(line); if (ns !== null) { - namespaces.push(ns[1]!); + namespaces.push(ns[1]!.replace(/@/g, '')); } else { const us = CS_USING_STATIC_RE.exec(line); - if (us !== null) usingStaticPaths.push(us[1]!); + if (us !== null) { + usingStaticPaths.push(us[1]!.replace(/@/g, '')); + } else if (looksLikeUncapturedDeclaration(line)) { + // A declaration the precise matchers couldn't capture → mark the + // file incomplete so the #1881 gate fails OPEN (Codex F3). + incomplete = true; + } } } [state, rawFence] = advanceCsScanState(line, state, rawFence); }, result(): CsharpFileStructure { - return { namespaces, usingStaticPaths }; + return incomplete + ? { namespaces, usingStaticPaths, incomplete } + : { namespaces, usingStaticPaths }; }, }; } diff --git a/gitnexus/test/unit/csharp-namespace-extraction.test.ts b/gitnexus/test/unit/csharp-namespace-extraction.test.ts index 7f39a061f..4a52e9e11 100644 --- a/gitnexus/test/unit/csharp-namespace-extraction.test.ts +++ b/gitnexus/test/unit/csharp-namespace-extraction.test.ts @@ -96,4 +96,84 @@ describe('extractCsharpStructureViaScanner', () => { const src = `/* header */ class C {}\nnamespace App.Real;`; expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']); }); + + // --- Unicode / @-verbatim identifiers (Codex F3): these must be CAPTURED, not + // truncated/dropped, so the #1881 gate doesn't over-block legitimate imports. + it('captures a Unicode namespace identifier', () => { + const out = extractCsharpStructureViaScanner('namespace Café.Modèles;'); + expect(out.namespaces).toEqual(['Café.Modèles']); + expect(out.incomplete).toBeFalsy(); + }); + + it('captures a non-Latin (Greek) namespace identifier', () => { + expect(extractCsharpStructureViaScanner('namespace Ωμέγα.Models;').namespaces).toEqual([ + 'Ωμέγα.Models', + ]); + }); + + it('strips a leading @ from a verbatim namespace identifier to match the AST', () => { + expect(extractCsharpStructureViaScanner('namespace @namespace.Models;').namespaces).toEqual([ + 'namespace.Models', + ]); + }); + + it('strips a mid-path @ from a verbatim namespace segment', () => { + expect(extractCsharpStructureViaScanner('namespace App.@class.Models;').namespaces).toEqual([ + 'App.class.Models', + ]); + }); + + // --- Forms the line scanner cannot capture must flag `incomplete` so the + // caller fails the #1881 gate OPEN (Codex F3) instead of dropping the namespace. + it('flags `incomplete` for a namespace declaration split across lines', () => { + const out = extractCsharpStructureViaScanner('namespace\n App.Models;'); + expect(out.namespaces).toEqual([]); + expect(out.incomplete).toBe(true); + }); + + it('flags `incomplete` for a namespace keyword not at line start', () => { + const out = extractCsharpStructureViaScanner('class C {} namespace App.Models;'); + expect(out.incomplete).toBe(true); + }); + + it('flags `incomplete` for an attributed same-line namespace', () => { + const out = extractCsharpStructureViaScanner('[Obsolete] namespace App.Legacy;'); + expect(out.incomplete).toBe(true); + }); + + // --- Guards: ordinary / handled forms must NEVER set `incomplete`, or one + // exotic line would wrongly disable the gate repo-wide. + it('does NOT flag `incomplete` for ordinary handled forms', () => { + for (const src of [ + 'namespace App.Models;', + 'namespace App.Services\n{\n}', + 'namespace A.One {}\nnamespace A.Two {}', + 'using static System.Math;\nnamespace App;', + 'global using static App.Utils.Logger;', + 'using static M = App.Utils.MathUtils;', + 'using System.Collections.Generic;\nusing App.Models;', + '\t\tnamespace App.Indented;', + 'public class Global {}', + '', + ]) { + expect(extractCsharpStructureViaScanner(src).incomplete).toBeFalsy(); + } + }); + + it('does NOT flag `incomplete` for a `// namespace` line comment or a namespace mentioned after `//`', () => { + expect( + extractCsharpStructureViaScanner('// namespace Fake.Comment;\nnamespace App.Real;') + .incomplete, + ).toBeFalsy(); + expect( + extractCsharpStructureViaScanner('public class C {} // namespace Foo').incomplete, + ).toBeFalsy(); + }); + + it('does NOT flag `incomplete` for an identifier that merely starts with "namespace"', () => { + // `namespaceManager` is an ordinary identifier, not the keyword. + expect( + extractCsharpStructureViaScanner('var namespaceManager = Get();').incomplete, + ).toBeFalsy(); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts index 4b54bbde1..dbd87f154 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts @@ -701,4 +701,44 @@ describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () => await fsp.rm(root, { recursive: true, force: true }); } }); + + it('collects a Unicode namespace through the streamed scan, not truncated (Codex F3)', async () => { + // The scanner is now Unicode-aware, so a non-ASCII namespace is captured + // end-to-end instead of being dropped (which would over-block its imports). + const root = await makeTempRepo({ + 'App.csproj': + 'MyApp', + 'Models/Café.cs': 'namespace Café.App;\npublic class Modèle {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(false); + expect(ns.declaredNamespaces!.has('Café.App')).toBe(true); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('marks the scan truncated when a file has an uncaptured namespace form, failing the gate OPEN (Codex F3)', async () => { + // A namespace split across lines is not captured by the line scanner; the + // scan must flag truncated so the dropped namespace fails the #1881 gate + // OPEN rather than over-block an import declared in that file. + const root = await makeTempRepo({ + 'App.csproj': + 'MyApp', + 'Weird.cs': 'namespace\n MyApp.Weird;\npublic class W {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(true); + // A local-looking import under the dropped namespace fails open (and U1's + // external-root denylist still keeps BCL roots blocked under truncation). + expect(csharpSuffixFallbackAllowed('MyApp.Weird.Thing', ns)).toBe(true); + expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', ns)).toBe(false); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); });