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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user