fix(csharp): flag scanner-uncaptured namespaces incomplete; Unicode/@ matchers (#1908, Codex F3)

The line scanner treated its output as complete even when it missed valid
C# namespace forms, so the gate failed CLOSED and over-blocked legit
imports. Make CS_NAMESPACE_RE/CS_USING_STATIC_RE Unicode-aware (\p{L}\p{N}
+ u flag) and strip leading/segment @ so verbatim/Unicode identifiers are
captured to match the AST. For forms the regex still can't capture (split
across lines, not at line start, attributed), set a per-file 'incomplete'
flag; collectDeclaredNamespaces returns 'truncated' for such files so the
#1881 gate fails OPEN instead of dropping the namespace. High-precision
detectors + guard tests keep ordinary forms (incl. // namespace comments)
from tripping incomplete.
This commit is contained in:
Gergo Magyar 2026-05-30 07:44:45 +00:00
parent 5c84f734ae
commit 8fd1ef9bfe
4 changed files with 185 additions and 8 deletions

View file

@ -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<SwiftPackageConfig | null> {

View file

@ -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 };
},
};
}

View file

@ -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();
});
});

View file

@ -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':
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
'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':
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
'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 });
}
});
});