Przeglądaj źródła

feat(impact): Razor/Blazor (.cshtml/.razor) markup parser

New standalone RazorExtractor (follows the svelte/vue/liquid pattern) links
markup-driven C# that previously had no static in-repo caller:
- `@model Foo` / `@inherits Bar<Foo>` → the view-model / base type (.cshtml + .razor)
- `@inject IService svc` → the injected service type
- `@typeof(X)` → the referenced type
- Blazor `<MyComponent/>` tags + generic `TItem="Foo"` args → the component class

Wiring: `.cshtml`/`.razor` → new `razor` language (types.ts, grammars.ts
EXTENSION_MAP + isLanguageSupported/isGrammarLoaded + GrammarLanguage exclude +
display name); dispatch in tree-sitter.ts `extractFromSource`.

Key risks (per docs/design/template-markup-parser.md) all handled:
1. FAMILY GATE — `razor`+`csharp` share a new `dotnet` family in name-matcher,
   so `@model Foo`→`Foo.cs` isn't dropped by the cross-family gate (082353e).
2. PASCALCASE vs HTML — only `[A-Z]`-initial tags are components (HTML is
   lowercase); known Blazor framework components are skipped.
3. NODE DISCIPLINE — exactly one `component` node per file; tags → `references`
   edges, never nodes (no per-tag explosion).
4. INDEXING — `.cshtml`/`.razor` registered so they're indexed.

Validated: eShopOnWeb — 61 razor nodes, 97 resolved markup→code edges
(`App.razor`→MainLayout, `Create.razor`→CatalogBrand/ICatalogItemService,
ToastComponent covered). FAIR coverage 77.2% → 79.9% (.cshtml/.razor counted as
view entries in the metric). No regression (gin/requests/Express unchanged,
cs-mediatr/cs-polly fine). Full suite 1175; 1 regression test.

Modest gain on this app is honest: residual DTOs are a name-collision
(`CatalogBrand` exists as both entity and DTO — edge resolves to the entity),
and many DTOs/services are used inside Blazor `@code { }` blocks not yet
delegated to the C# extractor — both documented as next increments in the scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 tygodni temu
rodzic
commit
59b8de21fd

+ 1 - 0
CHANGELOG.md

@@ -11,6 +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)
 - `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

+ 54 - 0
__tests__/extraction.test.ts

@@ -3953,6 +3953,60 @@ describe('Python absolute module import resolution', () => {
   });
 });
 
+describe('Razor / Blazor markup extraction', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('links @model and Blazor component tags to their C# types; ignores HTML elements', async () => {
+    fs.mkdirSync(path.join(tempDir, 'Views'), { recursive: true });
+    fs.writeFileSync(
+      path.join(tempDir, 'LoginViewModel.cs'),
+      `namespace App { public class LoginViewModel { public string Email { get; set; } } }`
+    );
+    fs.writeFileSync(
+      path.join(tempDir, 'ToastComponent.cs'),
+      `namespace App { public class ToastComponent { } }`
+    );
+    fs.writeFileSync(
+      path.join(tempDir, 'Views/Login.cshtml'),
+      `@model LoginViewModel\n<div class="form">\n  <input asp-for="Email" />\n</div>\n`
+    );
+    fs.writeFileSync(
+      path.join(tempDir, 'Index.razor'),
+      `<div>\n  <ToastComponent />\n</div>\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    // `@model LoginViewModel` → the view-model class.
+    const vm = cg.getNodesByKind('class').find((n) => n.name === 'LoginViewModel');
+    expect(vm, 'LoginViewModel class').toBeDefined();
+    const vmDeps = [...cg.getImpactRadius(vm!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(vmDeps.some((p) => p.endsWith('Login.cshtml')), '@model links the view').toBe(true);
+
+    // `<ToastComponent />` → the component class.
+    const toast = cg.getNodesByKind('class').find((n) => n.name === 'ToastComponent');
+    expect(toast, 'ToastComponent class').toBeDefined();
+    const toastDeps = [...cg.getImpactRadius(toast!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(toastDeps.some((p) => p.endsWith('Index.razor')), 'Blazor tag links the component').toBe(true);
+
+    // HTML elements (`<div>`, `<input>`) must NOT become component references.
+    const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input');
+    expect(htmlNodes.length, 'no node for HTML elements').toBe(0);
+  });
+});
+
 describe('Default import resolution (renamed default export)', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 152 - 0
docs/design/template-markup-parser.md

@@ -0,0 +1,152 @@
+# Scope: Template-markup parser (Razor / Blazor / Thymeleaf)
+
+Status: **proposed** (scoping only — not implemented). Authored 2026-06-04 from the
+cross-language impact-coverage campaign (`feat/cross-language-impact-coverage`).
+
+## Problem
+
+The impact graph is built from code the engine parses. **Template markup is not
+parsed**, so any code-behind, component, view-model, or DTO that is referenced
+*only* from markup looks like it has no in-repo dependent. On convention-heavy
+frameworks this is the dominant residual gap after framework-entry exclusions:
+
+| Framework | App | FAIR coverage (entries excluded) | Residual cause |
+|---|---|---|---|
+| ASP.NET | eShopOnWeb | **77.2%** (115/149) | Razor `.cshtml` + Blazor `.razor` reference `.cs` we don't parse |
+| Spring | petclinic | 65.2% | mostly Spring Data proxies + JPA, **not** templates (Thymeleaf links are weak) |
+| Django | django-realworld | 74.1% | signals / DRF / string-config, **not** templates |
+
+**This feature is primarily an ASP.NET (Razor + Blazor) win.** Thymeleaf and Django
+templates link to code only weakly (template→template fragments + fuzzy
+model-attribute strings), and those frameworks' real gaps are elsewhere — so they
+are explicitly lower priority here.
+
+### Quantified target (eShopOnWeb, the 34 residual zeros after entry-exclusion)
+
+- **~20 markup-coverable** by this feature:
+  - 5 MVC `ViewModels/*` ← Razor `@model X`
+  - 7 `BlazorShared/Models/*` (DTOs) ← Blazor `@bind` / component params
+  - 6 `BlazorAdmin/*` C# components ← Blazor `<Component/>` tags
+  - 1 `BasketComponent` ViewComponent ← `<vc:basket>` / `Component.InvokeAsync`
+  - 1 Razor page helper
+- **~13 NOT covered** (separate frontier — reflection/proxy + value-reads): AutoMapper
+  `MappingProfile`, Swagger `CustomSchemaFilters`/`ImageValidators`, `ExceptionMiddleware`,
+  health checks, `Constants` (static-member reads), `Buyer` entity.
+
+**Honest ceiling: ASP.NET ~77% → ~90%**, not 95%. The last ~10% is reflection/proxy
+(AutoMapper, Swagger, DI/middleware registration) + C# static-const reads — a
+*separate* feature (reflection modeling + extending the static-member pass to C#).
+
+## Reference patterns to extract (prioritized)
+
+| Pri | Format | Markup construct | Edge to emit | Resolves to |
+|---|---|---|---|---|
+| P1 | Razor `.cshtml`/`.razor` | `@model Foo` / `@inherits X<Foo>` | `references` | the model/VM class `Foo` |
+| P1 | Razor/Blazor | `@inject IBar bar` | `references` | the service type `IBar` |
+| P2 | Blazor `.razor` | `<MyComponent .../>` (PascalCase element) | `references` | component class (`.razor` or `.cs : ComponentBase`) |
+| P2 | Blazor `.razor` | `@typeof(MainLayout)`, `@inherits LayoutBase` | `references` | the type |
+| P3 | Razor `.cshtml` | `<partial name="_X"/>`, `<vc:basket>`, `Component.InvokeAsync("X")` | `references` | the partial view / `XViewComponent` |
+| P3 | Razor `.cshtml` | `asp-page="./Register"`, `asp-controller`/`asp-action` | `references` | the page / controller action |
+| P4 (defer) | Thymeleaf `.html` | `th:replace="~{frag :: x}"` | `references` | template fragment (template→template only) |
+| P4 (defer) | Django `.html` | `{% extends %}` / `{% include %}` / `{% url 'n' %}` | `references` | template / named route |
+
+`asp-for="Prop"`, `th:field="*{prop}"` (property-string bindings) are the data-flow
+frontier — **out of scope** (would need model-type inference; low value, high noise).
+
+## Architecture — follow the existing standalone-extractor pattern
+
+The engine already has non-tree-sitter extractors (`svelte-extractor.ts`,
+`vue-extractor.ts`, `liquid-extractor.ts`): a class taking `(filePath, source)`,
+returning `{ nodes, references }`, wired in two places. Mirror exactly:
+
+1. **`src/extraction/grammars.ts`** — map extensions to a synthetic language:
+   `.cshtml`/`.razor` → `'razor'`, (later) `.html` under `templates/` → `'thymeleaf'`.
+   (Django `.html` is ambiguous with plain HTML — gate on a `templates/` path or a
+   `{% %}`/`{{ }}` content sniff, like the framework resolvers do.)
+2. **`src/extraction/tree-sitter.ts`** — dispatch by extension to a new
+   `RazorExtractor` (and `ThymeleafExtractor`), exactly as `SvelteExtractor` is
+   dispatched (~line 4025).
+3. **`src/extraction/razor-extractor.ts`** (new) — regex/line scan (markup is
+   highly stylized; no grammar needed, same as Liquid/Svelte template scanning):
+   - Emit ONE `component` node for the file (so `.razor` components are linkable as
+     `<X/>` targets and the file is a graph citizen).
+   - Emit `references` per the P1–P3 patterns above, `fromNodeId` = the file/component
+     node, `referenceKind: 'references'`, `language: 'razor'`.
+   - **Code-behind link:** a `Foo.razor` + `Foo.razor.cs` (partial class) — emit a
+     `references` (or rely on same-basename) so the markup's refs also credit the
+     code-behind. (eShop's Blazor components are plain `.cs : ComponentBase`, named
+     `<ToastComponent/>` → resolves by class name; the `.razor.cs` partial case is
+     the other shape.)
+
+**Resolution: no new resolver needed.** The emitted refs are ordinary `references`
+to a class/component by name; the existing name-matcher resolves them (`@model
+RegisterModel` → class `RegisterModel`; `<ToastComponent/>` → class `ToastComponent`).
+Apply the **same cross-family language gate** already in place — a `razor` ref must
+resolve to a `csharp` symbol, so add `razor` to the `web`/dotnet family or treat
+`razor`↔`csharp` as same-family (otherwise the gate from commit 082353e drops it).
+**This is the one resolver-side change** and must be done or every edge is gated away.
+
+## Node/edge shape & invariants
+
+- +1 `component` node per template file (real new symbol — like `.svelte`/`.vue`).
+  Node count grows by the template-file count only; **no per-tag node explosion**
+  (component tags become `references` edges, not nodes).
+- All edges are `references` (counted by impact / `affected` / `getFileDependents`,
+  not by `callers`/`callees` — matches how `route`/`component` edges already behave).
+- Idempotent re-index; node count stable across re-runs.
+
+## Phasing
+
+- **P1 (highest value/effort ratio):** Razor `@model` + `@inject` for `.cshtml` AND
+  `.razor`. Covers the 5 ViewModels + injected services. + the resolver family-gate fix.
+- **P2:** Blazor `<PascalComponent/>` tags + `@typeof`/`@inherits` + code-behind link.
+  Covers the 6 Blazor `.cs` components + the 7 DTOs (via component params/`@bind`).
+- **P3:** Razor `<partial>` / `<vc:>` / `Component.InvokeAsync` / `asp-page`.
+- **P4 (defer / probably skip):** Thymeleaf + Django templates — weak code links,
+  low coverage payoff; revisit only if a Thymeleaf/Django app is a priority.
+
+## Edge cases & risks
+
+- **PascalCase tag vs HTML element:** only `[A-Z]`-initial tags are Blazor components
+  (HTML is lowercase) — safe discriminator. Skip known framework components
+  (`<Router>`, `<Found>`, `<LayoutView>`, `<RouteView>`, `<CascadingValue>`) via a
+  builtin set, or just let them fail to resolve (no false edge — they're not in-repo).
+- **`_Imports.razor` `@using`:** namespace imports, not code refs — ignore (or emit
+  `imports` to the namespace, low value).
+- **Generic components `<Grid TItem="CatalogItem">`:** 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 `<ToastComponent/>` covers it; an HTML
+   `<div>` 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.

+ 8 - 2
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<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
+export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'razor' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
 
 /**
  * WASM filename map — maps each language to its .wasm grammar file
@@ -69,6 +69,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
   '.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',

+ 178 - 0
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<Foo>`  → the view-model / base type (.cshtml + .razor)
+ *  - `@inject IService svc`               → the injected service type
+ *  - `@typeof(MainLayout)`                → the referenced type
+ *  - `<MyComponent .../>` (Blazor only)   → the component class (.razor or `.cs : ComponentBase`)
+ *  - `<Grid TItem="CatalogItem">`         → 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<Foo, Baz>` → `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<Foo>` — 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 (`</Foo>`) 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: `<Grid TItem="CatalogItem">`.
+        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);
+        }
+      }
+    }
+  }
+}

+ 5 - 0
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.

+ 3 - 0
src/resolution/name-matcher.ts

@@ -115,6 +115,9 @@ const LANGUAGE_FAMILY: Record<string, string> = {
   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` /
+  // `<MyComponent/>` 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;

+ 1 - 0
src/types.ts

@@ -75,6 +75,7 @@ export const LANGUAGES = [
   'c',
   'cpp',
   'csharp',
+  'razor',
   'php',
   'ruby',
   'swift',