GitNexus/gitnexus/test/integration/csharp-razor-view-components.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

127 lines
4.4 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './resolvers/helpers.js';
describe('C# Razor ViewComponent conventions', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-razor-vc-'));
let result: PipelineResult;
beforeAll(() => vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000'));
beforeAll(async () => {
writeFixtureRepo(root, {
'Components/SessionSummaryBarViewComponent.cs': `
namespace Demo.Components;
public class SessionSummaryBarViewComponent : ViewComponent
{
public object Invoke() => new object();
}
`,
'Components/MenuViewComponent.cs': `
namespace Demo.Components;
[ApiController, ViewComponent(Name = "AccountMenu")]
public class MenuViewComponent : ViewComponent
{
public object Invoke() => new object();
}
`,
'One/DuplicateViewComponent.cs': `
namespace Demo.One;
public class DuplicateViewComponent : ViewComponent {}
`,
'Two/DuplicateViewComponent.cs': `
namespace Demo.Two;
public class DuplicateViewComponent : ViewComponent {}
`,
'Views/Home/Index.cshtml': `
@await Component.InvokeAsync("SessionSummaryBar", new { id = 1 })
<vc:account-menu />
@{
await Component.InvokeAsync("SessionSummaryBar");
}
@@await Component.InvokeAsync("SessionSummaryBar")
<!-- @await Component.InvokeAsync("AccountMenu") -->
`,
'Views/Shared/Alias.cshtml': `@await Component.InvokeAsync("AccountMenu")`,
'Views/Shared/Ambiguous.cshtml': `@await Component.InvokeAsync("Duplicate")`,
'Views/Shared/Commented.cshtml': `
@* @await Component.InvokeAsync("SessionSummaryBar") *@
`,
'Views/Shared/Suffix.cshtml': `@await Component.InvokeAsync("Menu")`,
'Controllers/HomeController.cs': `
using Microsoft.AspNetCore.Mvc;
namespace Demo.Controllers;
public class HomeController : Controller
{
public IViewComponentResult Widget() => ViewComponent("SessionSummaryBar");
public IViewComponentResult FromBase() => base.ViewComponent("SessionSummaryBar");
public IViewComponentResult FromThis() => this.ViewComponent("SessionSummaryBar");
}
`,
});
result = await runPipelineFromRepo(root, () => {}, { skipGraphPhases: true });
}, 120000);
afterAll(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it('emits File-to-Class CALLS for literal and tag-helper invocations', () => {
const calls = getRelationships(result, 'CALLS').filter(
(edge) => edge.rel.reason === 'aspnet-razor-view-component',
);
expect(
calls
.map((edge) => ({
source: edge.sourceFilePath,
target: edge.target,
targetLabel: edge.targetLabel,
}))
.sort((a, b) => `${a.source}:${a.target}`.localeCompare(`${b.source}:${b.target}`)),
).toEqual([
{
source: 'Controllers/HomeController.cs',
target: 'SessionSummaryBarViewComponent',
targetLabel: 'Class',
},
{
source: 'Views/Home/Index.cshtml',
target: 'MenuViewComponent',
targetLabel: 'Class',
},
{
source: 'Views/Home/Index.cshtml',
target: 'SessionSummaryBarViewComponent',
targetLabel: 'Class',
},
{
source: 'Views/Shared/Alias.cshtml',
target: 'MenuViewComponent',
targetLabel: 'Class',
},
]);
expect(calls.some((edge) => edge.target === 'ViewComponent')).toBe(false);
expect(calls.some((edge) => edge.target === 'InvokeAsync')).toBe(false);
expect(calls.some((edge) => edge.sourceFilePath === 'Components/MenuViewComponent.cs')).toBe(
false,
);
});
it('fails closed for ambiguous names, Razor comments, and replaced suffixes', () => {
const razorSources = getRelationships(result, 'CALLS')
.filter((edge) => edge.rel.reason === 'aspnet-razor-view-component')
.map((edge) => edge.sourceFilePath);
expect(razorSources).not.toContain('Views/Shared/Ambiguous.cshtml');
expect(razorSources).not.toContain('Views/Shared/Commented.cshtml');
expect(razorSources).not.toContain('Views/Shared/Suffix.cshtml');
});
});