/**
* 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' | 'concentrated-named';
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 #1954
// OOM-prone path. "concentrated-named": every file in ONE named namespace
// (`namespace App;`) — the #1871 named-namespace twin, which the global-only
// fix did not cover. Both concentrated shapes put all N type defs in a single
// bucket; "concentrated-named" exercises the per-namespace channel rather than
// the flat workspace channel.
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 if (shape === 'concentrated-named') {
namespaces.push('App'); // single named namespace — all files share it
} 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;',
' }',
'',
// Unique method name per file. Each method's return-type binding is
// hoisted to the file's MODULE scope keyed by the method name, so unique
// names give the global ('') namespace a DISTINCT module-typeBinding key
// per file. A shared name (e.g. plain `Process`) collapses every file's
// key to one, which made the per-file typeBindings propagation skip all
// copies and HID the O(files²) #1871 blow-up. Unique names exercise the
// real concentrated-global path that OOM'd large no-namespace solutions.
` public ${siblingClass} Process${f}()`,
' {',
` 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 = [
'',
' ',
' net8.0',
' enable',
' ',
'',
'',
].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 {
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 | undefined;
try {
const start = Date.now();
const result = await Promise.race([
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
new Promise((_, 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.
//
// Scales reach 2000 deliberately: with the #1871 regression present
// (per-file global typeBindings copy), the 1000→2000 step measured ~3.06×
// for a 2× file increase — failing the <3 sub-quadratic assertion below.
// The workspaceTypeBindings fast-path keeps it ~linear (~1.2×).
const scales = [500, 1000, 2000];
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);
it('scales with file count — all types in one (named) namespace bucket', async () => {
// Regression guard for the #1871 named-namespace twin: every file under one
// `namespace App;`, so the `App` bucket holds every type def. The global-only
// fix (#1954, workspaceTypeBindings) did NOT cover this — the per-file
// namespace-siblings copy (both the BindingRef augmentation and the
// typeBindings mirror) was still O(files²) for a concentrated named
// namespace. The per-namespace channels (namespaceFqnBindings /
// namespaceTypeBindings) keep it sub-quadratic; with the fix reverted the
// 1000→2000 step goes quadratic and trips the <3 assertion below.
const scales = [500, 1000, 2000];
const results: BenchResult[] = [];
for (const fileCount of scales) {
const result = await runBenchmark(fileCount, 1, 'concentrated-named', 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 Named Namespace', results);
// The fixture must actually exercise cross-file resolution (not just
// parsing), or the scaling assertion would pass vacuously.
expect(results[results.length - 1].edgeCount).toBeGreaterThan(0);
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);
});