GitNexus/gitnexus/test/integration/csharp-pipeline-benchmark.test.ts
Gergő Magyar 26894d835b
fix(csharp): eliminate namespace-siblings OOM and worker-path re-parse (#1905)
* 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>

---------

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>
2026-05-29 20:24:58 +01:00

253 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* C# ingestion pipeline benchmark.
*
* Generates synthetic C# codebases at increasing scales and measures
* wall-clock time and peak heap through the full pipeline — parsing,
* scope extraction, C# namespace-siblings (same-namespace cross-file
* visibility, using-static, cross-namespace imports), and call
* resolution.
*
* Mirrors test/integration/php-pipeline-benchmark.test.ts. Two shapes:
* 1. "spread" — files distributed across many namespaces (the common
* case; each namespace bucket stays small).
* 2. "concentrated" — every file in the SAME (or global/no) namespace,
* so a single namespace bucket holds all type defs. This is the
* shape that drove the Unity-solution OOM: `populateCsharpNamespaceSiblings`
* materialises O(scopes × defs) BindingRefs into that one bucket.
* The concentrated test is the regression guard for that path.
*
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/csharp-pipeline-benchmark.test.ts
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
interface BenchResult {
fileCount: number;
classCount: number;
namespaceCount: number;
elapsedMs: number;
peakHeapMB: number;
nodeCount: number;
edgeCount: number;
}
type FixtureShape = 'spread' | 'concentrated';
function generateCsharpFixture(
fileCount: number,
namespacesPerLevel: number,
shape: FixtureShape,
): { dir: string; classCount: number; namespaceCount: number } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `csharp-bench-${shape}-${fileCount}-`));
// "spread": square grid of namespaces. "concentrated": a single
// global (no-namespace) bucket so every type lands in the `''` bucket
// — the OOM-prone path.
const namespaces: string[] = [];
if (shape === 'spread') {
for (let i = 0; i < namespacesPerLevel; i++) {
for (let j = 0; j < namespacesPerLevel; j++) {
namespaces.push(`App.Module${i}.Sub${j}`);
}
}
} else {
namespaces.push(''); // global / no namespace declaration
}
const classCount = fileCount;
const namespaceCount = namespaces.length;
for (let f = 0; f < fileCount; f++) {
const ns = namespaces[f % namespaces.length]!;
const className = `Class${f}`;
// Concentrated files share a flat directory; spread files mirror the
// namespace as a directory tree (matches typical C# project layout).
const targetDir = ns === '' ? dir : path.join(dir, ns.replace(/\./g, '/'));
fs.mkdirSync(targetDir, { recursive: true });
const siblingIdx = (f + 1) % fileCount;
const siblingClass = `Class${siblingIdx}`;
const crossNsIdx = (f + Math.floor(fileCount / 3)) % fileCount;
const crossNs = namespaces[crossNsIdx % namespaces.length]!;
const crossClass = `Class${crossNsIdx}`;
const usesCross = ns !== '' && ns !== crossNs;
const body = [
ns !== '' ? `namespace ${ns};` : '',
usesCross ? `using ${crossNs};` : '',
'',
`public class ${className}`,
'{',
' private int id;',
' private string name;',
'',
' public int GetId()',
' {',
' return this.id;',
' }',
'',
` public ${siblingClass} Process()`,
' {',
` var sibling = new ${siblingClass}();`,
' return sibling;',
' }',
usesCross
? [
'',
` public ${crossClass} CrossCall()`,
' {',
` var cross = new ${crossClass}();`,
' cross.GetId();',
' return cross;',
' }',
].join('\n')
: '',
'}',
'',
]
.filter(Boolean)
.join('\n');
fs.writeFileSync(path.join(targetDir, `${className}.cs`), body);
}
// Minimal SDK-style csproj so the C# project-loading phase engages
// (matches the real-world Unity/.NET solution path).
const csproj = [
'<Project Sdk="Microsoft.NET.Sdk">',
' <PropertyGroup>',
' <TargetFramework>net8.0</TargetFramework>',
' <Nullable>enable</Nullable>',
' </PropertyGroup>',
'</Project>',
'',
].join('\n');
fs.writeFileSync(path.join(dir, 'Bench.csproj'), csproj);
return { dir, classCount, namespaceCount };
}
async function runBenchmark(
fileCount: number,
nsLevels: number,
shape: FixtureShape,
budgetMs: number,
): Promise<BenchResult> {
const { dir, classCount, namespaceCount } = generateCsharpFixture(fileCount, nsLevels, shape);
let peakHeapMB = 0;
const heapSampler = setInterval(() => {
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
if (heap > peakHeapMB) peakHeapMB = heap;
}, 50);
let budgetTimer: ReturnType<typeof setTimeout> | undefined;
try {
const start = Date.now();
const result = await Promise.race([
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
new Promise<never>((_, reject) => {
budgetTimer = setTimeout(
() =>
reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files (${shape})`)),
budgetMs,
);
}),
]);
const elapsedMs = Date.now() - start;
return {
fileCount,
classCount,
namespaceCount,
elapsedMs,
peakHeapMB: Math.round(peakHeapMB),
nodeCount: result.graph.nodeCount,
edgeCount: result.graph.relationshipCount,
};
} finally {
clearInterval(heapSampler);
clearTimeout(budgetTimer);
fs.rmSync(dir, { recursive: true, force: true });
}
}
function printResults(label: string, results: BenchResult[]) {
console.log(`\n${label}`);
console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐');
console.log('│ Files │ Classes │ NS Count │ Time (ms) │ Heap MB │ Nodes │ Edges │');
console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤');
for (const r of results) {
console.log(
`│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).padStart(7)} │ ${String(r.namespaceCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`,
);
}
console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘');
if (results.length >= 2) {
console.log('\nScaling ratios (time_ratio / file_ratio):');
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
const scaling = timeRatio / fileRatio;
console.log(
` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
);
}
}
}
describe.skipIf(!BENCH_ENABLED)('C# pipeline benchmark', () => {
it('scales with file count — namespaces spread across the solution', async () => {
const scales = [100, 250, 500];
const results: BenchResult[] = [];
for (const fileCount of scales) {
const nsLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4)));
const result = await runBenchmark(fileCount, nsLevels, 'spread', 180_000);
results.push(result);
console.log(
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
);
}
printResults('C# Pipeline — Namespaces Spread', results);
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
expect(timeRatio / fileRatio).toBeLessThan(3);
}
}, 600_000);
it('scales with file count — all types in one (global) namespace bucket', async () => {
// Regression guard for the Unity-solution OOM: a single namespace
// bucket holds every type def, so naive per-scope binding
// materialisation is O(files²). Time must stay sub-quadratic and the
// run must not OOM.
const scales = [100, 250, 500];
const results: BenchResult[] = [];
for (const fileCount of scales) {
const result = await runBenchmark(fileCount, 1, 'concentrated', 180_000);
results.push(result);
console.log(
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
);
}
printResults('C# Pipeline — Concentrated Global Namespace', results);
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
expect(timeRatio / fileRatio).toBeLessThan(3);
}
}, 600_000);
});