Ver código fonte

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>
Colby McHenry 2 semanas atrás
pai
commit
90c5f39
3 arquivos alterados com 125 adições e 3 exclusões
  1. 1 1
      CHANGELOG.md
  2. 20 0
      __tests__/extraction.test.ts
  3. 104 2
      src/extraction/razor-extractor.ts

+ 1 - 1
CHANGELOG.md

@@ -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 - 0
__tests__/extraction.test.ts

@@ -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
src/extraction/razor-extractor.ts

@@ -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',
+        });
+      }
+    }
+  }
 }