`:** capture the type-arg as a
+ `references` to `CatalogItem` (bonus DTO coverage).
+- **Name collisions:** component/model names are usually unique; rely on the
+ name-matcher's existing proximity scoring. Same-named class in another language is
+ blocked by the family gate.
+- **Razor `@{ ... }` C# blocks:** contain real C# (calls, `new`) — P-future; regex
+ scanning the C# inside markup is noisy. Defer (the directives above are the wins).
+- **`.razor` is NOT `.cs`:** must add to `grammars.ts` + the indexer's include globs
+ (verify `.razor`/`.cshtml` aren't in a default-exclude).
+
+## Validation (per the engine's methodology)
+
+1. Build `RazorExtractor`; unit tests in `__tests__/extraction.test.ts` (a `.cshtml`
+ with `@model X` covers `X`; a `.razor` with `` covers it; an HTML
+ `` does NOT create an edge).
+2. Re-measure eShopOnWeb FAIR coverage before/after (`/tmp/faircov.cjs`): target
+ 77% → ~90%; **node count stable** (only +template-file component nodes); residual
+ zeros are the reflection/value-read set only.
+3. No regression on a non-.NET control (gin/requests) and on the Razor-free C#
+ repos (cs-mediatr/cs-polly unchanged).
+4. Record in this doc + the coverage handoff.
+
+## Effort
+
+- P1: ~0.5 day (extractor skeleton + `@model`/`@inject` scan + family-gate fix + tests).
+- P2: ~1 day (Blazor tags + code-behind + generic type-args).
+- P3: ~0.5 day. P4 (Thymeleaf/Django): ~1–2 days, low ROI — defer.
+- **Total for the ASP.NET win (P1+P2+P3): ~2 days → ASP.NET ~90%.**
+
+## Non-goals (and what's still needed for 95% on convention apps)
+
+This feature does NOT close: reflection/proxy registration (Spring Data repository
+proxies, AutoMapper profiles, Swagger filters, DI container / middleware), property-
+string data bindings (`asp-for`/`th:field`), or C# static-const value reads
+(`Constants.X`). Convention apps reaching literal 95% additionally need a **reflection/
+DI-registration modeling** pass and **extending the static-member pass to C#/TS** —
+tracked separately. Markup parsing is the single biggest, most self-contained step.
diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts
index 576845e20..1eba365a2 100644
--- a/src/extraction/grammars.ts
+++ b/src/extraction/grammars.ts
@@ -10,7 +10,7 @@ import * as path from 'path';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { Language } from '../types';
-export type GrammarLanguage = Exclude;
+export type GrammarLanguage = Exclude;
/**
* WASM filename map — maps each language to its .wasm grammar file
@@ -69,6 +69,10 @@ export const EXTENSION_MAP: Record = {
'.hpp': 'cpp',
'.hxx': 'cpp',
'.cs': 'csharp',
+ // ASP.NET Razor / Blazor markup — custom RazorExtractor (links @model/@inject/
+ // component tags to their C# types; markup isn't a tree-sitter grammar).
+ '.cshtml': 'razor',
+ '.razor': 'razor',
'.php': 'php',
// Drupal-specific PHP file extensions
'.module': 'php',
@@ -278,6 +282,7 @@ export function isLanguageSupported(language: Language): boolean {
if (language === 'svelte') return true; // custom extractor (script block delegation)
if (language === 'vue') return true; // custom extractor (script block delegation)
if (language === 'liquid') return true; // custom regex extractor
+ if (language === 'razor') return true; // custom RazorExtractor (.cshtml/.razor markup)
if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver
if (language === 'twig') return true; // file-level tracking only
if (language === 'xml') return true; // MyBatis mapper extractor
@@ -290,7 +295,7 @@ export function isLanguageSupported(language: Language): boolean {
* Check if a grammar has been loaded and is ready for parsing.
*/
export function isGrammarLoaded(language: Language): boolean {
- if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
+ if (language === 'svelte' || language === 'vue' || language === 'liquid' || language === 'razor') return true;
if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed
if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed
return languageCache.has(language);
@@ -371,6 +376,7 @@ export function getLanguageDisplayName(language: Language): string {
c: 'C',
cpp: 'C++',
csharp: 'C#',
+ razor: 'Razor/Blazor',
php: 'PHP',
ruby: 'Ruby',
swift: 'Swift',
diff --git a/src/extraction/razor-extractor.ts b/src/extraction/razor-extractor.ts
new file mode 100644
index 000000000..ffa11f8b0
--- /dev/null
+++ b/src/extraction/razor-extractor.ts
@@ -0,0 +1,178 @@
+import { Node, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
+import { generateNodeId } from './tree-sitter-helpers';
+
+/**
+ * RazorExtractor — extracts code relationships from ASP.NET Razor (`.cshtml`)
+ * and Blazor (`.razor`) markup.
+ *
+ * Markup-driven code-behind, view-models, components, and DTOs are referenced
+ * only from markup the engine otherwise doesn't parse, so they look like nothing
+ * depends on them. This extractor links the markup → the C# types it names:
+ *
+ * - `@model Foo` / `@inherits Bar` → the view-model / base type (.cshtml + .razor)
+ * - `@inject IService svc` → the injected service type
+ * - `@typeof(MainLayout)` → the referenced type
+ * - `` (Blazor only) → the component class (.razor or `.cs : ComponentBase`)
+ * - `` → the generic type argument
+ *
+ * Risk mitigations (see docs/design/template-markup-parser.md):
+ * - Only PascalCase (`[A-Z]`-initial) tags are treated as components — HTML
+ * elements are lowercase, so they never match. Known Blazor framework
+ * components are skipped (they aren't in-repo, so a ref would just dangle).
+ * - Exactly ONE `component` node per file; component tags become `references`
+ * EDGES, never nodes — no per-tag node explosion.
+ * - Emitted refs are ordinary by-name `references` resolved by the name-matcher;
+ * `razor` shares the `dotnet` language family with `csharp` (name-matcher.ts)
+ * so the cross-family gate doesn't drop them.
+ * - `.cshtml`/`.razor` are registered in grammars.ts so they're indexed.
+ *
+ * Out of scope (data-flow / low-value): `asp-for`/`th:field` property-string
+ * bindings; the C# inside `@code { }` / `@{ }` blocks (noisy regex on embedded C#).
+ */
+
+/**
+ * Blazor framework-provided components — invoked by the runtime, not defined
+ * in-repo, so a reference to them would never resolve. Skip to avoid dangling refs.
+ */
+const BLAZOR_BUILTIN_COMPONENTS = new Set([
+ 'Router', 'Found', 'NotFound', 'RouteView', 'AuthorizeRouteView', 'LayoutView',
+ 'CascadingValue', 'CascadingAuthenticationState', 'AuthorizeView', 'Authorized',
+ 'NotAuthorized', 'Authorizing', 'EditForm', 'DataAnnotationsValidator',
+ 'ValidationSummary', 'ValidationMessage', 'InputText', 'InputNumber',
+ 'InputCheckbox', 'InputSelect', 'InputDate', 'InputTextArea', 'InputRadio',
+ 'InputRadioGroup', 'InputFile', 'PageTitle', 'HeadContent', 'HeadOutlet',
+ 'Virtualize', 'DynamicComponent', 'ErrorBoundary', 'SectionContent',
+ 'SectionOutlet', 'FocusOnNavigate', 'NavLink', 'Microsoft',
+]);
+
+export class RazorExtractor {
+ private filePath: string;
+ private source: string;
+ private nodes: Node[] = [];
+ private unresolvedReferences: UnresolvedReference[] = [];
+ private errors: ExtractionError[] = [];
+
+ constructor(filePath: string, source: string) {
+ this.filePath = filePath;
+ this.source = source;
+ }
+
+ extract(): ExtractionResult {
+ const startTime = Date.now();
+ try {
+ const componentId = this.createComponentNode().id;
+ this.extractDirectives(componentId);
+ // Blazor component tags only — `.cshtml` uses HTML + tag helpers, not
+ // PascalCase component elements.
+ if (this.filePath.toLowerCase().endsWith('.razor')) {
+ this.extractComponentTags(componentId);
+ }
+ } catch (error) {
+ this.errors.push({
+ message: `Razor extraction error: ${error instanceof Error ? error.message : String(error)}`,
+ severity: 'error',
+ code: 'parse_error',
+ });
+ }
+ return {
+ nodes: this.nodes,
+ edges: [],
+ unresolvedReferences: this.unresolvedReferences,
+ errors: this.errors,
+ durationMs: Date.now() - startTime,
+ };
+ }
+
+ private createComponentNode(): Node {
+ const lines = this.source.split('\n');
+ const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
+ const componentName = fileName.replace(/\.(razor|cshtml)$/i, '');
+ const node: Node = {
+ id: generateNodeId(this.filePath, 'component', componentName, 1),
+ kind: 'component',
+ name: componentName,
+ qualifiedName: `${this.filePath}::${componentName}`,
+ filePath: this.filePath,
+ language: 'razor',
+ startLine: 1,
+ endLine: lines.length,
+ startColumn: 0,
+ endColumn: lines[lines.length - 1]?.length || 0,
+ isExported: true,
+ updatedAt: Date.now(),
+ };
+ this.nodes.push(node);
+ return node;
+ }
+
+ /** Last `.`-segment (`App.ViewModels.RegisterModel` → `RegisterModel`). */
+ private lastSegment(s: string): string {
+ const i = s.lastIndexOf('.');
+ return i >= 0 ? s.slice(i + 1) : s;
+ }
+
+ /**
+ * Split a type expression into the capitalized type names it contains — base
+ * type plus any generic arguments (`Bar` → `Bar`, `Foo`, `Baz`),
+ * each reduced to its last namespace segment. Lowercase/keyword tokens drop out.
+ */
+ private typeNames(expr: string): string[] {
+ const out: string[] = [];
+ for (const raw of expr.split(/[<>,\s]+/)) {
+ const seg = this.lastSegment(raw.trim());
+ if (/^[A-Z][A-Za-z0-9_]*$/.test(seg)) out.push(seg);
+ }
+ return out;
+ }
+
+ private pushRef(componentId: string, name: string, line: number, column: number): void {
+ this.unresolvedReferences.push({
+ fromNodeId: componentId,
+ referenceName: name,
+ referenceKind: 'references',
+ line,
+ column,
+ filePath: this.filePath,
+ language: 'razor',
+ });
+ }
+
+ private extractDirectives(componentId: string): void {
+ const lines = this.source.split('\n');
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i]!;
+ // `@model Foo` / `@inherits Bar` — directive followed by a type.
+ const dir = line.match(/^\s*@(?:model|inherits)\s+([A-Za-z_][\w.]*(?:\s*<[^>]+>)?)/);
+ if (dir) for (const t of this.typeNames(dir[1]!)) this.pushRef(componentId, t, i + 1, 0);
+ // `@inject IService name` — the type is the first token, a name follows.
+ const inj = line.match(/^\s*@inject\s+([A-Za-z_][\w.]*(?:\s*<[^>]+>)?)\s+[A-Za-z_]/);
+ if (inj) for (const t of this.typeNames(inj[1]!)) this.pushRef(componentId, t, i + 1, 0);
+ // `@typeof(X)` anywhere on the line.
+ for (const m of line.matchAll(/@typeof\(\s*([A-Za-z_][\w.]*)\s*\)/g)) {
+ const seg = this.lastSegment(m[1]!);
+ if (/^[A-Z]/.test(seg)) this.pushRef(componentId, seg, i + 1, m.index ?? 0);
+ }
+ }
+ }
+
+ private extractComponentTags(componentId: string): void {
+ const lines = this.source.split('\n');
+ // PascalCase opening / self-closing tags. Closing tags (``) start with
+ // `` and are skipped. HTML elements are lowercase → never match.
+ const tagRe = /<([A-Z][A-Za-z0-9_]*)\b([^>]*)>/g;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i]!;
+ let m: RegExpExecArray | null;
+ while ((m = tagRe.exec(line)) !== null) {
+ const name = m[1]!;
+ if (BLAZOR_BUILTIN_COMPONENTS.has(name)) continue;
+ this.pushRef(componentId, name, i + 1, m.index + 1);
+ // Generic component type arg: ``.
+ for (const t of (m[2] || '').matchAll(/\bT[A-Za-z]*\s*=\s*"([A-Za-z_][\w.]*)"/g)) {
+ const seg = this.lastSegment(t[1]!);
+ if (/^[A-Z]/.test(seg)) this.pushRef(componentId, seg, i + 1, 0);
+ }
+ }
+ }
+ }
+}
diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts
index bb675836a..1b83326e5 100644
--- a/src/extraction/tree-sitter.ts
+++ b/src/extraction/tree-sitter.ts
@@ -20,6 +20,7 @@ import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring } f
import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
import { EXTRACTORS } from './languages';
import { LiquidExtractor } from './liquid-extractor';
+import { RazorExtractor } from './razor-extractor';
import { SvelteExtractor } from './svelte-extractor';
import { DfmExtractor } from './dfm-extractor';
import { VueExtractor } from './vue-extractor';
@@ -4032,6 +4033,10 @@ export function extractFromSource(
// Use custom extractor for Liquid
const extractor = new LiquidExtractor(filePath, source);
result = extractor.extract();
+ } else if (detectedLanguage === 'razor') {
+ // Use custom extractor for ASP.NET Razor (.cshtml) / Blazor (.razor) markup
+ const extractor = new RazorExtractor(filePath, source);
+ result = extractor.extract();
} else if (detectedLanguage === 'xml') {
// Custom extractor for MyBatis mapper XML. Non-mapper XML returns just a
// file node so the watcher tracks it without emitting symbols.
diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts
index bd2049168..d6bce5659 100644
--- a/src/resolution/name-matcher.ts
+++ b/src/resolution/name-matcher.ts
@@ -115,6 +115,9 @@ const LANGUAGE_FAMILY: Record = {
swift: 'apple', objc: 'apple',
typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web',
c: 'c', cpp: 'c',
+ // Razor/Blazor markup names C# types — same family so `@model Foo` /
+ // `` resolve to their `.cs` class through the cross-family gate.
+ csharp: 'dotnet', razor: 'dotnet',
};
export function sameLanguageFamily(a: string, b: string): boolean {
if (a === b) return true;
diff --git a/src/types.ts b/src/types.ts
index e710e31a1..01aadae02 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -75,6 +75,7 @@ export const LANGUAGES = [
'c',
'cpp',
'csharp',
+ 'razor',
'php',
'ruby',
'swift',