mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
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
|
### 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)
|
- `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
|
### Fixes
|
||||||
|
|||||||
@@ -4005,6 +4005,26 @@ describe('Razor / Blazor markup extraction', () => {
|
|||||||
const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input');
|
const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input');
|
||||||
expect(htmlNodes.length, 'no node for HTML elements').toBe(0);
|
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)', () => {
|
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 { generateNodeId } from './tree-sitter-helpers';
|
||||||
|
import { TreeSitterExtractor } from './tree-sitter';
|
||||||
|
import { isLanguageSupported } from './grammars';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RazorExtractor — extracts code relationships from ASP.NET Razor (`.cshtml`)
|
* RazorExtractor — extracts code relationships from ASP.NET Razor (`.cshtml`)
|
||||||
@@ -49,6 +51,7 @@ export class RazorExtractor {
|
|||||||
private filePath: string;
|
private filePath: string;
|
||||||
private source: string;
|
private source: string;
|
||||||
private nodes: Node[] = [];
|
private nodes: Node[] = [];
|
||||||
|
private edges: Edge[] = [];
|
||||||
private unresolvedReferences: UnresolvedReference[] = [];
|
private unresolvedReferences: UnresolvedReference[] = [];
|
||||||
private errors: ExtractionError[] = [];
|
private errors: ExtractionError[] = [];
|
||||||
|
|
||||||
@@ -67,6 +70,11 @@ export class RazorExtractor {
|
|||||||
if (this.filePath.toLowerCase().endsWith('.razor')) {
|
if (this.filePath.toLowerCase().endsWith('.razor')) {
|
||||||
this.extractComponentTags(componentId);
|
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) {
|
} catch (error) {
|
||||||
this.errors.push({
|
this.errors.push({
|
||||||
message: `Razor extraction error: ${error instanceof Error ? error.message : String(error)}`,
|
message: `Razor extraction error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
@@ -76,7 +84,7 @@ export class RazorExtractor {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
nodes: this.nodes,
|
nodes: this.nodes,
|
||||||
edges: [],
|
edges: this.edges,
|
||||||
unresolvedReferences: this.unresolvedReferences,
|
unresolvedReferences: this.unresolvedReferences,
|
||||||
errors: this.errors,
|
errors: this.errors,
|
||||||
durationMs: Date.now() - startTime,
|
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