GitNexus/gitnexus/test/integration/csharp-razor-view-components-benchmark.test.ts
Gergő Magyar 43a842724d
fix: bind Razor ViewComponent names to in-repo classes (#3104)
* fix: bind Razor ViewComponent names to in-repo classes

Index Component.InvokeAsync("Name") and in-repo ViewComponent("Name")
as CALLS to workspace ViewComponent classes so impact sees real callers
instead of an empty graph. SDK types stay unresolved.

Fixes #2991

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: scan Razor and C# ViewComponent names without regex holes

Use string-aware lexers so combined Name= aliases, code-block calls,
this/base helpers, and escaped @@ markup match ASP.NET instead of
emitting false or missing CALLS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* perf: skip Razor scans without ViewComponent tokens

Preserve the lexer correctness fixes while avoiding per-character work for
the common view that cannot contain a supported invocation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: gate Razor ViewComponent extractor scaling in CI

Wire mixed-corpus tripwire + GITNEXUS_BENCH loader/scaling checks into the dedicated ci-tests benchmarks job so the #2991 lexer cannot regress without a wall-clock gate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: read Razor views through one file handle

CodeQL js/file-system-race: the size gate stat'd the path and the read
re-resolved it, so a template swapped in between could be read past the
size ceiling. Both now go through the same handle.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-30 22:41:13 +00:00

179 lines
6.2 KiB
TypeScript

/**
* C# Razor ViewComponent extractor + loader scaling guards (#2991).
*
* The coverage job always runs the tripwire (direct extractors, no worker).
* GITNEXUS_BENCH=1 additionally checks sub-quadratic scaling and that the
* production loader retains invocation names instead of view source.
*
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/csharp-razor-view-components-benchmark.test.ts
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
extractCsharpViewComponentInvocations,
extractRazorViewComponentInvocations,
loadRazorViewComponentConfig,
} from '../../src/core/ingestion/languages/csharp/razor-view-components.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
const PAD = 'x'.repeat(4_000);
function csharpMixed(i: number): string {
if (i % 10 === 0) {
return (
'using Microsoft.AspNetCore.Mvc;\n' +
`class C${i} {\n` +
' async Task T() {\n' +
' await Component.InvokeAsync("Cart");\n' +
' }\n' +
'}\n' +
`// ${PAD}\n`
);
}
if (i % 10 === 1) {
return `// ViewComponent decoy\nclass C${i} { string s = "InvokeAsync"; }\n// ${PAD}\n`;
}
return `class C${i} { int X => ${i}; }\n// ${PAD}\n`;
}
function razorMixed(i: number): string {
if (i % 10 === 0) {
return `@await Component.InvokeAsync("Cart")\n<!-- ${PAD} -->\n`;
}
if (i % 10 === 1) {
return (
'@* @await Component.InvokeAsync("Dead") *@\n' +
'@@await Component.InvokeAsync("Escaped")\n' +
'@* <vc:login /> *@\n' +
`<!-- ${PAD} -->\n`
);
}
return `<p>hello ${i}</p>\n<!-- ${PAD} -->\n`;
}
function expectedHits(fileCount: number): number {
return fileCount > 0 ? Math.floor((fileCount - 1) / 10) + 1 : 0;
}
function scanCsharp(fileCount: number): { hits: number; elapsedMs: number } {
const sources = Array.from({ length: fileCount }, (_, i) => csharpMixed(i));
const started = performance.now();
let hits = 0;
for (const source of sources) {
hits += extractCsharpViewComponentInvocations(source).length;
}
return { hits, elapsedMs: performance.now() - started };
}
function scanRazor(fileCount: number): { hits: number; elapsedMs: number } {
const sources = Array.from({ length: fileCount }, (_, i) => razorMixed(i));
const started = performance.now();
let hits = 0;
for (const source of sources) {
hits += extractRazorViewComponentInvocations(source).length;
}
return { hits, elapsedMs: performance.now() - started };
}
/**
* Direct extractor tripwire for the coverage job. Coarse budget: far above the
* measured linear path, far below a full-corpus character-by-character scan of
* every padded view without the token prefilter.
*/
describe('C# Razor ViewComponent extractor tripwire', () => {
it('scans a 400-file mixed corpus well under the O(n^2) budget', () => {
const FILE_COUNT = 400;
const BUDGET_MS = 2_000;
scanCsharp(40);
scanRazor(40);
const csharp = scanCsharp(FILE_COUNT);
const razor = scanRazor(FILE_COUNT);
expect(csharp.hits).toBe(expectedHits(FILE_COUNT));
expect(razor.hits).toBe(expectedHits(FILE_COUNT));
expect(csharp.elapsedMs + razor.elapsedMs).toBeLessThan(BUDGET_MS);
}, 15_000);
});
describe.skipIf(!BENCH_ENABLED)('C# Razor ViewComponent extractor benchmark', () => {
it('scales sub-quadratically as mixed C# and Razor corpora grow', () => {
scanCsharp(50);
scanRazor(50);
const csharpSmall = scanCsharp(500);
const csharpLarge = scanCsharp(2_000);
const razorSmall = scanRazor(500);
const razorLarge = scanRazor(2_000);
const ratio = 2_000 / 500;
console.log('\nC# Razor ViewComponent extractor benchmark');
console.log(
` csharp files=500 wall=${csharpSmall.elapsedMs.toFixed(2)}ms hits=${csharpSmall.hits}`,
);
console.log(
` csharp files=2000 wall=${csharpLarge.elapsedMs.toFixed(2)}ms hits=${csharpLarge.hits}`,
);
console.log(
` razor files=500 wall=${razorSmall.elapsedMs.toFixed(2)}ms hits=${razorSmall.hits}`,
);
console.log(
` razor files=2000 wall=${razorLarge.elapsedMs.toFixed(2)}ms hits=${razorLarge.hits}`,
);
expect(csharpSmall.hits).toBe(expectedHits(500));
expect(csharpLarge.hits).toBe(expectedHits(2_000));
expect(razorSmall.hits).toBe(expectedHits(500));
expect(razorLarge.hits).toBe(expectedHits(2_000));
if (csharpSmall.elapsedMs >= 5) {
expect(csharpLarge.elapsedMs / csharpSmall.elapsedMs).toBeLessThan(Math.pow(ratio, 1.5));
}
if (razorSmall.elapsedMs >= 5) {
expect(razorLarge.elapsedMs / razorSmall.elapsedMs).toBeLessThan(Math.pow(ratio, 1.5));
}
expect(csharpLarge.elapsedMs).toBeLessThan(5_000);
expect(razorLarge.elapsedMs).toBeLessThan(5_000);
}, 60_000);
it('loader retains invocation names instead of view source', async () => {
const FILE_COUNT = 2_000;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-razor-vc-bench-'));
try {
fs.mkdirSync(path.join(dir, 'Views'), { recursive: true });
for (let i = 0; i < FILE_COUNT; i++) {
fs.writeFileSync(path.join(dir, 'Views', `v${i}.cshtml`), razorMixed(i));
}
await loadRazorViewComponentConfig(dir);
const started = performance.now();
const config = await loadRazorViewComponentConfig(dir);
const elapsedMs = performance.now() - started;
let sourceBytes = 0;
let retainedChars = 0;
let hits = 0;
for (const names of config.views.values()) {
hits += names.length;
for (const name of names) retainedChars += name.length;
}
for (let i = 0; i < FILE_COUNT; i++) {
sourceBytes += Buffer.byteLength(razorMixed(i));
}
console.log(
` loader files=${FILE_COUNT} wall=${elapsedMs.toFixed(2)}ms ` +
`source=${(sourceBytes / 1024 / 1024).toFixed(2)}MB retainedChars=${retainedChars}`,
);
expect(config.views.size).toBe(FILE_COUNT);
expect(hits).toBe(expectedHits(FILE_COUNT));
expect(retainedChars).toBeLessThan(sourceBytes / 20);
expect(elapsedMs).toBeLessThan(15_000);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});