mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
* fix(csharp): eliminate O(S·D) BindingRef OOM in namespace siblings Types declared in the C# global (default) namespace are visible from every file, so the previous per-scope augmentation materialized O(scopes × defs) BindingRefs — on large Unity solutions (tens of thousands of global types) this caused severe slowness and OOM. Route global-namespace types through a single workspace-level binding channel (workspaceFqnBindings, consulted by lookupBindingsAt) for O(D) memory. Also fix quadratic costs in the non-global path: append defs in place instead of copying (was O(D²) per bucket), pre-index the first scope per file (was O(S²·D)), and seed de-dup sets instead of repeated .some scans. Add csharp-pipeline-benchmark.test.ts (mirrors the PHP benchmark) with spread and concentrated-global-namespace scenarios to track elapsedMs, peakHeapMB, nodeCount, and edgeCount. Post-fix runs show linear scaling and stable heap. Co-authored-by: Cursor <cursoragent@cursor.com> * perf(csharp): scanner fallback for namespace siblings on the worker path Worker threads can't return tree-sitter Trees across MessageChannels, so the cross-phase tree cache is empty for worker-parsed files. The C# same-namespace pass (populateCsharpNamespaceSiblings -> extractFileStructure) then re-parsed every file with tree-sitter to find namespace / using-static nodes — effectively parsing a large solution a second time during scope resolution. Add a line-scanner fallback (extractCsharpStructureViaScanner) used only when no cached Tree is available, mirroring PHP's fix for issue #1741. It extracts the same namespaces / usingStaticPaths the AST walk produces for the common line-anchored forms (file-scoped + block namespaces, plain / global / aliased `using static`). The AST walk stays authoritative on the sequential / warm-cache path. Micro-benchmark over 3000 synthetic files: scanner is ~188x faster than parse+walk (0.001 vs 0.251 ms/file) with identical output on the parity spot-check; real-world files are larger, so the worker-path saving is bigger. Adds csharp-namespace-extraction.test.ts (12 cases) covering all declaration forms plus negative cases (using var, plain using, comments). Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(csharp): cover global-namespace workspaceFqnBindings path + doc + using-static perf Addresses the production-readiness review of the namespace-siblings OOM fix. - Add a unit test proving global-(default-)namespace C# types route to indexes.workspaceFqnBindings (one entry per simple name) with ZERO bindingAugmentations — pinning the O(D) invariant behind the #1871 Unity-scale OOM fix and guarding against a revert to per-scope O(scopes x defs) augmentation. (The csharp-hooks mock now supplies workspaceFqnBindings, which the global fast path reads directly.) - Correct the workspaceFqnBindings doc comment: it is shared by PHP (backslash-FQN keys) and C# (global-namespace simple-name keys); the two key formats are disjoint. - Pre-index parsedFiles by path before the `using static` member-injection loop, replacing an O(files) find-per-import with an O(1) Map lookup. Verified: tsc --noEmit clean; csharp-hooks + csharp-namespace-extraction suites pass (38 tests); prettier clean; eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(csharp): apply PR-review polish to namespace-siblings (tests, types, docs) Addresses the multi-agent code review of this PR — the concrete, defensible findings. Two items intentionally deferred (below). - namespace-siblings.ts: couple the augmentation bucket + its de-dup set into one nullable lifecycle, removing the seen!/bucketArr! non-null assertions (identical runtime, still lazy). - validate-bindings-immutability.ts: extend the dev-mode immutability validator to the third channel (workspaceFqnBindings) + a test; complete the validator test mock with workspaceFqnBindings. - walkers.ts: document that namesAtScope deliberately excludes the scope-independent workspaceFqnBindings channel (enumerating workspace names at every scope would flood per-scope callers; lookupBindingsAt still consults it when resolving a specific name). - scope-resolution-indexes.ts: reframe the workspaceFqnBindings doc to describe the key-format contract language-neutrally (examples, not language branching). - csharp-hooks.test.ts: assert workspace entries carry origin:'namespace'; add a partial-class test (same simple name, distinct nodeIds across global files → both kept); rename the stale "parses" cache-miss test to "scans". - csharp-pipeline-benchmark.test.ts: clearTimeout the Promise.race budget timer (dangling handle when the pipeline won the race). - csharp.test.ts: correct the #1066 comment — extractFileStructure no longer re-parses on cache miss (line scanner); only emitCsharpScopeCaptures re-parses. Deferred (surfaced, not applied): (1) worker-path scanner mis-reads namespace/using-static inside block comments and verbatim/raw strings — an explicitly documented trade-off mirroring the PHP scanner; hardening it to track comment/string state is a separate decision. (2) workspaceFqnBindings is read via an `as Map` cast; a type-safe mutable handle from finalize-orchestrator is a cross-module contract change. Verified: tsc --noEmit clean; 49 unit tests pass (incl. 3 new); prettier clean; eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(csharp): harden worker-path scanner + localize workspace-map cast Addresses the two deferred PR-review findings plus the remaining test gap. #1 — Worker-path scanner false positives: the line scanner now tracks block- comment and string state across lines (advanceCsScanState), so a `namespace` / `using static` keyword at the start of a line inside a block comment, verbatim string (@"..."), or raw string literal ("""...""") is no longer mistaken for a declaration on the worker cache-miss path. It matches only at code-state line starts. 5 new scanner tests cover the block-comment / raw / verbatim cases. #4 — workspaceFqnBindings type safety: the ReadonlyMap->Map cast is localized to one documented line, and global-namespace writes go through a new getWorkspaceBucket helper (mirroring getAugmentationBucket) rather than an inline `.set()` at the mutation site. #2 — lookupBindingsAt workspace-channel coverage: walkers-augmentations.test.ts now exercises the third (workspace) channel: workspace-only, append-after- finalized/augmented, and dedup-loses-to-finalized/augmented precedence. #5 — OOM CI guard: the deterministic O(D) invariant (zero per-scope augmentation for global types) is already asserted by the always-on csharp-hooks unit tests added earlier; the scale/time benchmark stays appropriately opt-in (skipIf). Verified: tsc --noEmit clean; 69 unit tests (4 suites) + 210 C# integration resolver tests pass; prettier clean; eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(csharp): replace remaining O(A) .some dedup scans with seeded Sets The using-static member-injection loop and the cross-namespace import loop both de-duped via `bucketArr.some((b) => b.def.nodeId === ...)` — O(A) per item. Both now use a per-file `Map<simpleName, Set<nodeId>>`, seeded lazily from the augmentation bucket (capturing entries from earlier passes), matching the global and named-namespace paths. Same dedup semantics, O(1) amortized. Verified: tsc --noEmit clean; csharp-hooks unit (27) + C# integration resolver (210) tests pass; prettier + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(csharp): gate suffix-fallback import resolution to declared namespaces (#1881) 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 <cursoragent@cursor.com> * fix(csharp): cap per-file size in namespace scan; fail open on skip (#1881) scanCSharpProject read every .cs/.csproj in full with no size guard and issued per-directory reads with no concurrency bound, an OOM/FD-exhaustion vector on large or generated repos. Add an fs.stat size guard before each read, reusing getMaxFileSizeBytes() (the same 512KB cap the Phase-1 walker uses). An oversized or unreadable .cs now signals truncation so the #1881 suffix-fallback gate fails OPEN rather than wrongly suppressing an import whose declaring namespace lived in the skipped file (previously a silent return left the scan looking complete). Adds a size-cap scan test. * fix(csharp): bound per-directory read concurrency in namespace scan (#1881) The scan issued every .cs/.csproj read in a directory at once via Promise.all, so in-flight file descriptors scaled with the largest directory's file count. Issue reads in bounded windows (32, mirroring the Phase-1 filesystem-walker) via Promise.allSettled; an unexpected read/scan rejection now trips truncation (fail open) instead of rejecting the whole scan. Behavior-preserving for namespace collection (C# scope-resolution parity passes on both legs). * style(csharp): apply prettier to #1881 files to clear quality/format gate (#1908) Reflow hand-wrapped lines in scope-resolver.ts and the csharp integration test that prettier collapses under printWidth 100. Formatting only, no behavioral change; clears the failing quality/format CI gate. * fix(csharp): stream namespace scan so large generated files don't disable the #1881 gate (#1908) Code-review follow-up. The scan read each .cs fully into a string behind a 512KB size cap (the tree-sitter parse budget); a single larger generated file (*.g.cs, EF/gRPC output) tripped `truncated`, making the #1881 suffix-fallback gate fail open repo-wide and silently undoing the fix on real repos. Stream each .cs line-by-line via createReadStream + readline into a new incremental scanner (createCsharpStructureScanner) instead of buffering the whole file. Memory is now constant regardless of file size, so the per-file size cap is dropped for the namespace line-scan and large generated files are fully collected. extractCsharpStructureViaScanner is reimplemented on the same incremental scanner (byte-identical; C# parity 2/2). collectDeclaredNamespaces returns 'ok' | 'truncated' (truncation now only from an unreadable file) and the truncation warn lists its real causes. csproj reads keep their size guard. Prior art: ripgrep/ctags/Node readline stream rather than cap for line scans; GitHub (384KB) and Sourcegraph (1MB) cap only their full-content indexes. * fix(csharp): cap .csproj read via stream, not stat-then-read, to clear CodeQL TOCTOU (#1908) CodeQL js/file-system-race flagged the fs.stat + fs.readFile size guard in readCsprojConfig as a check-then-use filesystem race. Replace it with a length-capped createReadStream (readFileTextCapped) — same memory bound on untrusted input, no stat-then-read race, and consistent with the streamed .cs scan. Behavior is unchanged for real .csproj files (parity 2/2). * fix(csharp): keep BCL/external roots gated through scan truncation (#1908, Codex F1) A single scan truncation (unreadable dir/file, depth/dir cap) set one repo-wide `truncated` flag that made csharpSuffixFallbackAllowed fail open for EVERY import, silently re-enabling the #1881 BCL->local suffix matches. Add a CSHARP_EXTERNAL_ROOTS denylist (System/Microsoft/...): an external-rooted using that does not align with an in-repo declared namespace stays BLOCKED even under truncation, while genuinely local-looking usings still fail open. A repo that declares the root is allowed via the alignment escape hatch. Shared predicate, so both legs inherit it. * fix(csharp): gate the registry no-csproj direct-match path (#1908, Codex F2) In the no-csproj branch of resolveCsharpImportTarget, resolveDirectMatch ran BEFORE the gate, so a path-aligned Legacy/System/Threading/Tasks.cs satisfied 'using System.Threading.Tasks;' even though System.* is not a declared in-repo namespace — while the legacy leg (gate-first) blocked it, so the legs were not equivalent. Run csharpSuffixFallbackAllowed first (return null on fail), then direct-match, then progressive stripping — mirroring the legacy ordering. Adds a no-csproj fixture with a deep path-aligned Tasks.cs and dual-leg integration describes (registry + forced-legacy), plus a path-aligned unit case. Parity 2/2. * 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. * fix(csharp): stream the .csproj RootNamespace read, no byte cap (#1908, Codex F4) 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. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
790 lines
30 KiB
TypeScript
790 lines
30 KiB
TypeScript
/**
|
|
* Unit 2 coverage for the C# import interpreter + target resolver.
|
|
*
|
|
* Asserts the ParsedImport shape for every `using` flavor and checks
|
|
* the resolver adapter's single-target behavior against a small set
|
|
* of fake file paths.
|
|
*/
|
|
|
|
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 { getMaxFileSizeBytes } from '../../../../src/core/ingestion/utils/max-file-size.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[] {
|
|
const matches = emitCsharpScopeCaptures(src, 'test.cs');
|
|
return matches
|
|
.filter((m) => m['@import.statement'] !== undefined)
|
|
.map((m) => interpretCsharpImport(m))
|
|
.filter((p): p is ParsedImport => p !== null);
|
|
}
|
|
|
|
describe('interpretCsharpImport — using flavors', () => {
|
|
it('interprets `using System;` as a namespace import', () => {
|
|
const [imp, ...rest] = importsFor('using System;\nclass A {}');
|
|
expect(rest).toHaveLength(0);
|
|
expect(imp).toEqual({
|
|
kind: 'namespace',
|
|
localName: 'System',
|
|
importedName: 'System',
|
|
targetRaw: 'System',
|
|
});
|
|
});
|
|
|
|
it('interprets multi-segment namespace — localName is the last segment', () => {
|
|
const [imp] = importsFor('using System.Collections.Generic;\nclass A {}');
|
|
expect(imp).toEqual({
|
|
kind: 'namespace',
|
|
localName: 'Generic',
|
|
importedName: 'System.Collections.Generic',
|
|
targetRaw: 'System.Collections.Generic',
|
|
});
|
|
});
|
|
|
|
it('interprets `using Alias = Path;` as an alias import with generics stripped', () => {
|
|
const [imp] = importsFor(
|
|
'using Dict = System.Collections.Generic.Dictionary<string, int>;\nclass A {}',
|
|
);
|
|
expect(imp).toEqual({
|
|
kind: 'alias',
|
|
localName: 'Dict',
|
|
importedName: 'Dictionary',
|
|
alias: 'Dict',
|
|
targetRaw: 'System.Collections.Generic.Dictionary',
|
|
});
|
|
});
|
|
|
|
it('interprets `using static X.Y;` as a namespace import targeting the type', () => {
|
|
// `using static` brings static members into unqualified scope.
|
|
// Initially this was mapped to `kind: 'wildcard'` but that
|
|
// requires `expandsWildcardTo` to materialize any IMPORTS edge;
|
|
// we map to `namespace` so the File→File edge still emits and
|
|
// the namespace-siblings pass (which walks known namespaces)
|
|
// picks up the target file's classes. Unqualified static-member
|
|
// access is a deferred limitation — see csharp/index.ts.
|
|
const [imp] = importsFor('using static System.Math;\nclass A {}');
|
|
expect(imp).toEqual({
|
|
kind: 'namespace',
|
|
localName: 'Math',
|
|
importedName: 'System.Math',
|
|
targetRaw: 'System.Math',
|
|
});
|
|
});
|
|
|
|
it('strips `global::` qualifier — `using global::X.Y;` → namespace X.Y', () => {
|
|
const [imp] = importsFor('using global::System.IO;\nclass A {}');
|
|
expect(imp).toEqual({
|
|
kind: 'namespace',
|
|
localName: 'IO',
|
|
importedName: 'System.IO',
|
|
targetRaw: 'System.IO',
|
|
});
|
|
});
|
|
|
|
it('treats `global using X;` as a file-scoped namespace import', () => {
|
|
// Plan decision: defer first-class global-using support; treat as
|
|
// same-file namespace using for this PR. Unit 7 parity gate flags
|
|
// any regression.
|
|
const [imp] = importsFor('global using System;\nclass A {}');
|
|
expect(imp?.kind).toBe('namespace');
|
|
expect(imp?.targetRaw).toBe('System');
|
|
});
|
|
|
|
it('emits exactly one ParsedImport per using directive', () => {
|
|
const src = `
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using Dict = System.Collections.Generic.Dictionary<string, int>;
|
|
using static System.Math;
|
|
`;
|
|
const imps = importsFor(src);
|
|
expect(imps).toHaveLength(4);
|
|
expect(imps.map((p) => p.kind)).toEqual(['namespace', 'namespace', 'alias', 'namespace']);
|
|
});
|
|
});
|
|
|
|
describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|
function ctx(
|
|
fromFile: string,
|
|
paths: string[],
|
|
declaredNamespaces?: ReadonlySet<string>,
|
|
extra?: {
|
|
rootNamespaces?: ReadonlySet<string>;
|
|
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', () => {
|
|
const parsed: ParsedImport = {
|
|
kind: 'namespace',
|
|
localName: 'Services',
|
|
importedName: 'MyApp.Services',
|
|
targetRaw: 'MyApp.Services',
|
|
};
|
|
const result = resolveCsharpImportTarget(
|
|
parsed,
|
|
ctx('MyApp/Program.cs', [
|
|
'MyApp/Program.cs',
|
|
'MyApp/Services/UserService.cs',
|
|
'MyApp/Services/Nested/Inner.cs',
|
|
]),
|
|
);
|
|
expect(result).toBe('MyApp/Services/UserService.cs');
|
|
});
|
|
|
|
it('resolves via suffix when namespace dir is nested under a project root', () => {
|
|
const parsed: ParsedImport = {
|
|
kind: 'namespace',
|
|
localName: 'Models',
|
|
importedName: 'MyApp.Models',
|
|
targetRaw: 'MyApp.Models',
|
|
};
|
|
const result = resolveCsharpImportTarget(
|
|
parsed,
|
|
ctx('src/Program.cs', ['src/Program.cs', 'src/MyApp/Models/User.cs']),
|
|
);
|
|
expect(result).toBe('src/MyApp/Models/User.cs');
|
|
});
|
|
|
|
it('returns null when no matching .cs file exists', () => {
|
|
const parsed: ParsedImport = {
|
|
kind: 'namespace',
|
|
localName: 'Nothing',
|
|
importedName: 'Not.Here',
|
|
targetRaw: 'Not.Here',
|
|
};
|
|
const result = resolveCsharpImportTarget(
|
|
parsed,
|
|
ctx('a.cs', ['a.cs', 'b.cs', 'some/Other/Thing.cs']),
|
|
);
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it('returns null for dynamic-unresolved imports', () => {
|
|
const parsed: ParsedImport = { kind: 'dynamic-unresolved', localName: '', targetRaw: null };
|
|
const result = resolveCsharpImportTarget(parsed, ctx('a.cs', ['a.cs']));
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it('returns null when WorkspaceIndex has the wrong shape', () => {
|
|
const parsed: ParsedImport = {
|
|
kind: 'namespace',
|
|
localName: 'X',
|
|
importedName: 'X',
|
|
targetRaw: 'X',
|
|
};
|
|
// Intentionally missing `allFilePaths`.
|
|
const result = resolveCsharpImportTarget(parsed, {
|
|
fromFile: 'a.cs',
|
|
} 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('does not map a BCL using to a coincidentally PATH-ALIGNED local file via direct-match (#1881, Codex F2)', () => {
|
|
// The no-csproj direct-match must be gated too: `Legacy/System/Threading/
|
|
// Tasks.cs` path-aligns with `using System.Threading.Tasks;` and would
|
|
// satisfy resolveDirectMatch's nested-suffix match — but System.* is not a
|
|
// declared in-repo namespace, so the gate (now run FIRST) blocks it.
|
|
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', 'Legacy/System/Threading/Tasks.cs', 'Models/User.cs'],
|
|
new Set(['MyApp.Services', 'MyApp.Legacy', 'MyApp.Models']),
|
|
),
|
|
);
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it('still resolves a legitimate in-repo using via direct-match when evidence is present (Codex F2 guard)', () => {
|
|
// Gating the direct-match must NOT over-block a legitimate aligned import:
|
|
// `using MyApp.Services;` aligns (exact declared) so the gate passes and the
|
|
// namespace-dir direct-child match still resolves.
|
|
const parsed: ParsedImport = {
|
|
kind: 'namespace',
|
|
localName: 'Services',
|
|
importedName: 'MyApp.Services',
|
|
targetRaw: 'MyApp.Services',
|
|
};
|
|
const result = resolveCsharpImportTarget(
|
|
parsed,
|
|
ctx(
|
|
'MyApp/Program.cs',
|
|
['MyApp/Program.cs', 'MyApp/Services/UserService.cs'],
|
|
new Set(['MyApp.Services']),
|
|
),
|
|
);
|
|
expect(result).toBe('MyApp/Services/UserService.cs');
|
|
});
|
|
|
|
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('keeps a clearly-external BCL root BLOCKED even when the scan was truncated (#1881, Codex F1)', () => {
|
|
// A single truncation must NOT silently re-enable BCL→local suffix matches
|
|
// repo-wide: System.* stays gated through truncation when the repo does not
|
|
// declare it. (This reverses the prior blanket-fail-open for external roots.)
|
|
expect(
|
|
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
|
declaredNamespaces: declared,
|
|
rootNamespaces: roots,
|
|
truncated: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('fails OPEN for a genuinely local-looking import when the scan was truncated (#6)', () => {
|
|
// Non-external roots still fail open under truncation so an incomplete
|
|
// (capped/unreadable) scan does not silently drop a legitimate in-repo edge.
|
|
expect(
|
|
csharpSuffixFallbackAllowed('MyApp.Internal.Widget', {
|
|
declaredNamespaces: declared,
|
|
rootNamespaces: roots,
|
|
truncated: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('lets an external root fail OPEN through truncation when the repo declares it (escape hatch)', () => {
|
|
// If the repo actually declares the (normally-external) root, the alignment
|
|
// escape hatch allows the import even under truncation.
|
|
expect(
|
|
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
|
declaredNamespaces: new Set(['System.Threading']),
|
|
rootNamespaces: new Set(['System']),
|
|
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<string, string>): Promise<string> {
|
|
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':
|
|
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
|
'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':
|
|
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
|
'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 });
|
|
}
|
|
});
|
|
|
|
it('streams a large .cs file end-to-end, collecting namespaces past the old size cap (#1881)', async () => {
|
|
// The namespace scan streams each file, so a `.cs` far larger than the old
|
|
// per-file size cap is read end-to-end in constant memory instead of being
|
|
// skipped. A namespace at the START and one at the very END (well past the
|
|
// old cap boundary) must BOTH be collected, and `truncated` must stay false
|
|
// — a big generated file no longer disables the #1881 gate repo-wide.
|
|
const cap = getMaxFileSizeBytes();
|
|
const padLine = '// pad pad pad pad pad pad\n';
|
|
const padding = padLine.repeat(Math.ceil((cap * 3) / padLine.length));
|
|
const huge = `namespace Generated.Head;\n${padding}namespace Generated.Tail { }\n`;
|
|
const root = await makeTempRepo({
|
|
'Hand.cs': 'namespace Hand.Written;',
|
|
'Generated.cs': huge,
|
|
});
|
|
try {
|
|
const config = await loadCsharpResolutionConfig(root);
|
|
const ns = config.namespaces!;
|
|
expect(ns.truncated).toBe(false);
|
|
expect(ns.declaredNamespaces!.has('Hand.Written')).toBe(true);
|
|
expect(ns.declaredNamespaces!.has('Generated.Head')).toBe(true);
|
|
expect(ns.declaredNamespaces!.has('Generated.Tail')).toBe(true);
|
|
} finally {
|
|
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 });
|
|
}
|
|
});
|
|
|
|
it('recovers <RootNamespace> past the old read cap via streaming (Codex F4)', async () => {
|
|
// A big leading <ItemGroup> pushes <RootNamespace> 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 = ' <Compile Include="src/Generated/F.cs" />\n';
|
|
const bigItemGroup =
|
|
' <ItemGroup>\n' +
|
|
itemLine.repeat(Math.ceil((cap * 2) / itemLine.length)) +
|
|
' </ItemGroup>\n';
|
|
const csproj =
|
|
'<Project Sdk="Microsoft.NET.Sdk">\n' +
|
|
bigItemGroup +
|
|
' <PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup>\n' +
|
|
'</Project>\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 <RootNamespace> 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':
|
|
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>',
|
|
'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 });
|
|
}
|
|
});
|
|
});
|