mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +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> --------- 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>
253 lines
9.4 KiB
TypeScript
253 lines
9.4 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|