feat(impact): delegate Blazor @code / Razor @{} blocks to the C# extractor

Extends the Razor/Blazor markup parser to analyze the C# inside `@code { }`,
`@functions { }`, and `@{ }` blocks — the Blazor analog of Svelte's <script>
delegation. Blocks are brace-matched (skipping strings/comments), wrapped in a
synthetic class so tree-sitter parses the component's fields/methods in a class
context, then their external references (service/DTO calls, `new X()`, type uses)
are attributed to the component. No per-member nodes are added — coverage only
needs the dependency edges to external types, so node count stays flat.

So a service/DTO used only in component logic (`@code { _svc.Load(); }`) is now
linked. Validated: eShopOnWeb 79.9% -> 81.2%; the test proves a `@code`
`new CatalogService()` covers CatalogService. Full suite 1176; 1 regression test;
no node explosion (entry-excluded count unchanged).

Residual ASP.NET gap is now the DTO name-collision (`CatalogBrand` exists as both
a domain entity and a DTO — the markup/@code ref resolves to the entity) — the
next increment is `@using` namespace disambiguation, tracked in the scope doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-04 19:03:56 -04:00
parent 59b8de21fd
commit 90c5f39568
3 changed files with 125 additions and 3 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
- ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `<MyComponent/>` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class. So a view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor)
- ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `<MyComponent/>` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class; and the C# inside `@code { }` / `@functions { }` / `@{ }` blocks is analyzed too, so services and types used in component logic are linked. A view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor)
- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)
### Fixes
+20
View File
@@ -4005,6 +4005,26 @@ describe('Razor / Blazor markup extraction', () => {
const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input');
expect(htmlNodes.length, 'no node for HTML elements').toBe(0);
});
it('delegates Blazor @code block C# to cover types used in component logic', async () => {
fs.writeFileSync(
path.join(tempDir, 'CatalogService.cs'),
`namespace App { public class CatalogService { public void Load() { } } }`
);
fs.writeFileSync(
path.join(tempDir, 'List.razor'),
`<h1>Catalog</h1>\n\n@code {\n private CatalogService _svc = new CatalogService();\n void Refresh() { _svc.Load(); }\n}\n`
);
cg = CodeGraph.initSync(tempDir);
await cg.indexAll();
cg.resolveReferences();
const svc = cg.getNodesByKind('class').find((n) => n.name === 'CatalogService');
expect(svc, 'CatalogService class').toBeDefined();
const deps = [...cg.getImpactRadius(svc!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
expect(deps.some((p) => p.endsWith('List.razor')), '@code usage links the component to the service').toBe(true);
});
});
describe('Default import resolution (renamed default export)', () => {
+104 -2
View File
@@ -1,5 +1,7 @@
import { Node, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
/**
* RazorExtractor extracts code relationships from ASP.NET Razor (`.cshtml`)
@@ -49,6 +51,7 @@ export class RazorExtractor {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
@@ -67,6 +70,11 @@ export class RazorExtractor {
if (this.filePath.toLowerCase().endsWith('.razor')) {
this.extractComponentTags(componentId);
}
// Delegate the C# in `@code { }` / `@functions { }` / `@{ }` blocks to the
// C# tree-sitter extractor (the Blazor analog of Svelte's <script> block) —
// this is where component logic uses services/DTOs, so it covers the types
// referenced only from component code.
this.processCodeBlocks(componentId);
} catch (error) {
this.errors.push({
message: `Razor extraction error: ${error instanceof Error ? error.message : String(error)}`,
@@ -76,7 +84,7 @@ export class RazorExtractor {
}
return {
nodes: this.nodes,
edges: [],
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
@@ -175,4 +183,98 @@ export class RazorExtractor {
}
}
}
/**
* Find the matching `}` for the `{` at `openIdx`, skipping string literals and
* comments so a brace inside `"{"` / `// }` doesn't throw off the count.
* Returns the index of the closing brace, or -1 if unbalanced.
*/
private matchBrace(src: string, openIdx: number): number {
let depth = 0;
for (let i = openIdx; i < src.length; i++) {
const ch = src[i];
if (ch === '"' || ch === "'") {
const quote = ch;
i++;
while (i < src.length && src[i] !== quote) {
if (src[i] === '\\') i++;
i++;
}
continue;
}
if (ch === '/' && src[i + 1] === '/') {
while (i < src.length && src[i] !== '\n') i++;
continue;
}
if (ch === '/' && src[i + 1] === '*') {
i += 2;
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
/** `@code { … }` / `@functions { … }` (Blazor) and `@{ … }` (Razor) C# blocks. */
private extractCodeBlocks(): Array<{ content: string; lineOffset: number }> {
const blocks: Array<{ content: string; lineOffset: number }> = [];
const re = /@(?:code|functions)\b\s*\{|@\{/g;
let m: RegExpExecArray | null;
while ((m = re.exec(this.source)) !== null) {
const openIdx = this.source.indexOf('{', m.index);
if (openIdx < 0) continue;
const close = this.matchBrace(this.source, openIdx);
if (close < 0) continue;
const content = this.source.slice(openIdx + 1, close);
// newlines before the content's first char → 0-indexed line of content start
const lineOffset = (this.source.slice(0, openIdx + 1).match(/\n/g) || []).length;
blocks.push({ content, lineOffset });
re.lastIndex = close;
}
return blocks;
}
/**
* Delegate each `@code`/`@functions`/`@{` block's C# to the tree-sitter C#
* extractor and attribute the block's external references (service/DTO calls,
* `new X()`, type uses) to the component. The block is wrapped in a synthetic
* class so tree-sitter parses the component's fields/methods in a class context
* (a Blazor `@code` body compiles into the component's partial class). We keep
* only the dependency references coverage just needs the edges to external
* types, not per-member nodes. Degrades gracefully if the C# grammar isn't loaded.
*/
private processCodeBlocks(componentId: string): void {
if (!isLanguageSupported('csharp')) return;
for (const block of this.extractCodeBlocks()) {
if (!block.content.trim()) continue;
let result: ExtractionResult;
try {
result = new TreeSitterExtractor(
this.filePath,
`class __RazorCode__ {\n${block.content}\n}`,
'csharp'
).extract();
} catch {
continue; // grammar not loaded / parse failure — skip this block
}
// The synthetic wrapper adds one line before the block content; map ref
// lines back to the .razor file (display only — coverage is line-agnostic).
for (const ref of result.unresolvedReferences) {
this.unresolvedReferences.push({
...ref,
fromNodeId: componentId,
line: ref.line + block.lineOffset - 1,
column: ref.column,
filePath: this.filePath,
language: 'razor',
});
}
}
}
}