feat(impact): cross-language blast-radius coverage (22 languages + 14 frameworks) (#708)

Completes the cross-file dependency graph behind impact / affected / explore across all 22 supported languages and 14 web frameworks, validated on real-world repos (measured fair-coverage table added to the README). Per-language resolution + framework resolvers/synthesizers (Lua/Luau require, Shopify OS 2.0 Liquid sections, Delphi forms, Rust cross-module + Rocket macros, Swift Fluent, SvelteKit/Nuxt loader/component conventions, RN/Expo bridges). 0 cross-family false edges, full suite green (1187 passed). See #708.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-06 11:02:59 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent bfa84d32b8
commit 07af3db6c7
43 changed files with 5344 additions and 716 deletions
+23 -2
View File
@@ -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',
@@ -117,11 +121,23 @@ export const EXTENSION_MAP: Record<string, Language> = {
*/
export function isSourceFile(filePath: string): boolean {
if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless
if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups
const dot = filePath.lastIndexOf('.');
if (dot < 0) return false;
return filePath.slice(dot).toLowerCase() in EXTENSION_MAP;
}
/**
* Shopify OS 2.0 JSON template (`templates/*.json`) or section group
* (`sections/*.json`) — these reference sections by `"type"`, so the Liquid
* extractor links them. (config/ + locales/ JSON have no section refs.)
*/
export function isShopifyLiquidJson(filePath: string): boolean {
// Allow nested template dirs (`templates/customers/login.json`), not just
// top-level (`templates/product.json`).
return /(^|\/)(templates|sections)\/.+\.json$/i.test(filePath);
}
/**
* Play Framework routes file: the extensionless `conf/routes` (and included
* `conf/*.routes`). No grammar — route extraction is done by the Play framework
@@ -242,6 +258,9 @@ export function detectLanguage(filePath: string, source?: string): Language {
// Play framework resolver extracts route nodes from it.
if (isPlayRoutesFile(filePath)) return 'yaml';
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
// Shopify OS 2.0 JSON templates / section groups → the Liquid extractor (it
// links each section `"type"` to its `sections/<type>.liquid`).
if (isShopifyLiquidJson(filePath)) return 'liquid';
const lang = EXTENSION_MAP[ext] || 'unknown';
// .h files could be C, C++, or Objective-C — check source content
@@ -278,6 +297,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 +310,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 +391,7 @@ export function getLanguageDisplayName(language: Language): string {
c: 'C',
cpp: 'C++',
csharp: 'C#',
razor: 'Razor/Blazor',
php: 'PHP',
ruby: 'Ruby',
swift: 'Swift',
+28 -30
View File
@@ -2,49 +2,47 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getChildByField, getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
function extractCppQualifiedMethodName(node: SyntaxNode, source: string): string | undefined {
const declarator = getChildByField(node, 'declarator');
if (!declarator) return undefined;
/**
* Find the function NAME's `qualified_identifier` (`Foo::bar`) inside a
* declarator, skipping the `parameter_list` — a parameter with a qualified type
* (`const std::string& x`) must NOT be mistaken for the method name. Without the
* skip, a plain free function `std::string TableFileName(const std::string&...)`
* was named `string` (from the parameter type), so calls to it never resolved
* and its file looked like nothing depended on it.
*/
function findDeclaratorQualifiedId(declarator: SyntaxNode): SyntaxNode | undefined {
const queue: SyntaxNode[] = [declarator];
while (queue.length > 0) {
const current = queue.shift()!;
if (current.type === 'qualified_identifier') {
const text = getNodeText(current, source).trim();
const parts = text.split('::').filter(Boolean);
return parts[parts.length - 1];
}
if (current.type === 'qualified_identifier') return current;
for (let i = 0; i < current.namedChildCount; i++) {
const child = current.namedChild(i);
if (child) queue.push(child);
// Don't descend into parameters or the trailing return type — their types
// (`const std::string&`, `-> std::string`) aren't the function name.
if (child && child.type !== 'parameter_list' && child.type !== 'trailing_return_type') {
queue.push(child);
}
}
}
return undefined;
}
function extractCppQualifiedMethodName(node: SyntaxNode, source: string): string | undefined {
const declarator = getChildByField(node, 'declarator');
if (!declarator) return undefined;
const qid = findDeclaratorQualifiedId(declarator);
if (!qid) return undefined;
const parts = getNodeText(qid, source).trim().split('::').filter(Boolean);
return parts[parts.length - 1];
}
function extractCppReceiverType(node: SyntaxNode, source: string): string | undefined {
const declarator = getChildByField(node, 'declarator');
if (!declarator) return undefined;
const queue: SyntaxNode[] = [declarator];
while (queue.length > 0) {
const current = queue.shift()!;
if (current.type === 'qualified_identifier') {
const text = getNodeText(current, source).trim();
const parts = text.split('::').filter(Boolean);
if (parts.length > 1) {
return parts.slice(0, -1).join('::');
}
return undefined;
}
for (let i = 0; i < current.namedChildCount; i++) {
const child = current.namedChild(i);
if (child) queue.push(child);
}
}
return undefined;
const qid = findDeclaratorQualifiedId(declarator);
if (!qid) return undefined;
const parts = getNodeText(qid, source).trim().split('::').filter(Boolean);
return parts.length > 1 ? parts.slice(0, -1).join('::') : undefined;
}
export const cExtractor: LanguageExtractor = {
+18 -2
View File
@@ -4,13 +4,29 @@ import type { LanguageExtractor } from '../tree-sitter-types';
export const csharpExtractor: LanguageExtractor = {
functionTypes: [],
classTypes: ['class_declaration'],
// Records are first-class type declarations in modern C# (DTOs, value objects,
// MediatR/CQRS messages). `record` / `record class` parse as record_declaration
// (reference type → class); `record struct` as record_struct_declaration (value
// type → struct). Without these, references to a record never resolve (#237).
classTypes: ['class_declaration', 'record_declaration'],
methodTypes: ['method_declaration', 'constructor_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: ['struct_declaration'],
structTypes: ['struct_declaration', 'record_struct_declaration'],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_member_declaration'],
typeAliasTypes: [],
// Namespaces qualify type names so same-named types in different namespaces are
// distinguishable (e.g. `ApplicationCore.Entities.CatalogBrand` vs
// `BlazorShared.Models.CatalogBrand`). Both block (`namespace Foo { … }`, which
// nests its types) and file-scoped (`namespace Foo;`) forms — extractFilePackage
// pushes the namespace onto the scope so nested/top-level types pick it up.
packageTypes: ['namespace_declaration', 'file_scoped_namespace_declaration'],
extractPackage: (node: SyntaxNode, source: string) => {
const name =
node.childForFieldName('name') ??
node.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name' || c.type === 'identifier');
return name ? getNodeText(name, source) : null;
},
importTypes: ['using_directive'],
callTypes: ['invocation_expression'],
variableTypes: ['local_declaration_statement'],
+5 -1
View File
@@ -6,7 +6,11 @@ export const javaExtractor: LanguageExtractor = {
functionTypes: [],
classTypes: ['class_declaration'],
methodTypes: ['method_declaration', 'constructor_declaration'],
interfaceTypes: ['interface_declaration'],
// `annotation_type_declaration` is `@interface Foo { … }` — an annotation
// definition. Without it, annotation types (`@SerializedName`, `@GetMapping`,
// JPA/Spring annotations) aren't nodes, so the `@Foo` usages that DO get
// extracted can't resolve and the annotation file shows zero dependents.
interfaceTypes: ['interface_declaration', 'annotation_type_declaration'],
structTypes: [],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_constant'],
+23
View File
@@ -227,6 +227,29 @@ export const kotlinExtractor: LanguageExtractor = {
}
return false;
},
extractModifiers: (node) => {
// Kotlin Multiplatform `expect`/`actual` markers live in
// modifiers > platform_modifier > (expect | actual)
// Capturing them lets the resolver link an `expect` declaration in a
// common source set to its `actual` implementations in platform source
// sets (those impls otherwise have zero dependents — the caller resolves
// to the `expect`). Match the AST node, not raw text, so an annotation
// argument or identifier named "actual" can't false-positive.
const mods: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type !== 'modifiers') continue;
for (let j = 0; j < child.childCount; j++) {
const pm = child.child(j);
if (pm?.type !== 'platform_modifier') continue;
for (let k = 0; k < pm.childCount; k++) {
const kw = pm.child(k);
if (kw && (kw.type === 'expect' || kw.type === 'actual')) mods.push(kw.type);
}
}
}
return mods.length > 0 ? mods : undefined;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
const identifier = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
+12
View File
@@ -78,6 +78,18 @@ export const phpExtractor: LanguageExtractor = {
return false;
},
// PHP `namespace Foo\Bar;` is file-level (like a Java/Kotlin package). Capturing
// it scopes every class under an `Foo\Bar::` qualified name, which is what makes
// `use` imports and same-named types (Laravel has 7+ `Factory` interfaces across
// namespaces) resolvable to the RIGHT definition instead of an arbitrary match.
packageTypes: ['namespace_definition'],
extractPackage: (node, source) => {
const nsName = node.namedChildren.find((c: SyntaxNode) => c.type === 'namespace_name');
// Skip braced `namespace Foo { … }` (has a body) — file-level only.
const hasBody = node.namedChildren.some((c: SyntaxNode) => c.type === 'compound_statement' || c.type === 'declaration_list');
if (!nsName || hasBody) return null;
return getNodeText(nsName, source);
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
+36
View File
@@ -17,6 +17,42 @@ export const rubyExtractor: LanguageExtractor = {
bodyField: 'body',
paramsField: 'parameters',
visitNode: (node, ctx) => {
// Ruby mixins: `include Mod`, `extend Mod`, `prepend Mod[, Other]` — the
// primary composition mechanism (ActiveSupport concerns, Comparable, …).
// These parse as a bare `call` to `include`/`extend`/`prepend` with the
// module(s) as constant arguments, so without special handling they'd be
// mis-extracted as a call to a method named "include" and the module would
// record no dependent — even though it's mixed into a class. Emit an
// `implements` edge (enclosing class/module → mixed-in module), so editing a
// concern surfaces every class that includes it.
if (node.type === 'call' && !node.childForFieldName('receiver')) {
const method = node.childForFieldName('method');
const mname = method?.text;
if (mname === 'include' || mname === 'extend' || mname === 'prepend') {
const parentId = ctx.nodeStack.length > 0 ? ctx.nodeStack[ctx.nodeStack.length - 1] : undefined;
const args = node.childForFieldName('arguments')
?? node.namedChildren.find((c: SyntaxNode) => c.type === 'argument_list');
if (parentId && args) {
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
// `Mod` is `constant`, `Foo::Bar` is `scope_resolution`. Skip
// `extend self` / dynamic args (`include foo()`).
if (arg && (arg.type === 'constant' || arg.type === 'scope_resolution')) {
ctx.addUnresolvedReference({
fromNodeId: parentId,
referenceName: getNodeText(arg, ctx.source),
referenceKind: 'implements',
filePath: ctx.filePath,
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
}
return true; // handled — don't also extract as a call to "include"
}
}
}
if (node.type !== 'module') return false;
const nameNode = node.childForFieldName('name');
+6 -2
View File
@@ -3,9 +3,13 @@ import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
export const rustExtractor: LanguageExtractor = {
functionTypes: ['function_item'],
// `function_signature_item` is a trait method DECLARATION (`fn render(&self);`,
// no body). Extracting it makes a trait's method set first-class, which
// impl-navigation and trait-dispatch synthesis need (a struct's method set is
// matched against the trait's).
functionTypes: ['function_item', 'function_signature_item'],
classTypes: [], // Rust has impl blocks
methodTypes: ['function_item'], // Methods are functions in impl blocks
methodTypes: ['function_item', 'function_signature_item'],
interfaceTypes: ['trait_item'],
structTypes: ['struct_item'],
enumTypes: ['enum_item'],
+36 -1
View File
@@ -10,6 +10,40 @@ function getValVarName(node: SyntaxNode, source: string): string | null {
return identChild ? getNodeText(identChild, source) : null;
}
// Capitalized Scala primitives/ubiquitous aliases that shouldn't create refs.
const SCALA_BUILTIN_TYPES = new Set([
'Int', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Boolean', 'Char', 'Unit',
'String', 'Any', 'AnyRef', 'AnyVal', 'Nothing', 'Null',
]);
/**
* Emit `references` edges for every type identifier in a Scala type subtree
* (a `val`/`var` type annotation), unwrapping `generic_type` etc. Mirrors the
* generic type-annotation extraction the core extractor runs for method
* parameter/return types, but Scala `val`s are created here in visitNode so
* their type is walked here too. A trait used only as a field type (the common
* `implicit val x: Monoid[Int]` instance pattern) thus gains a dependent.
*/
function emitScalaTypeRefs(typeNode: SyntaxNode, fromId: string, ctx: { addUnresolvedReference: (r: { fromNodeId: string; referenceName: string; referenceKind: 'references'; line: number; column: number }) => void }, source: string): void {
if (typeNode.type === 'type_identifier') {
const name = source.substring(typeNode.startIndex, typeNode.endIndex);
if (name && !SCALA_BUILTIN_TYPES.has(name)) {
ctx.addUnresolvedReference({
fromNodeId: fromId,
referenceName: name,
referenceKind: 'references',
line: typeNode.startPosition.row + 1,
column: typeNode.startPosition.column,
});
}
return;
}
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child) emitScalaTypeRefs(child, fromId, ctx, source);
}
}
function extractVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
@@ -96,7 +130,8 @@ export const scalaExtractor: LanguageExtractor = {
? `${t === 'val_definition' ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}`
: undefined;
ctx.createNode(kind, name, node, { signature: sig, visibility: extractVisibility(node) });
const created = ctx.createNode(kind, name, node, { signature: sig, visibility: extractVisibility(node) });
if (created && typeNode) emitScalaTypeRefs(typeNode, created.id, ctx, ctx.source);
return true;
}
+46 -8
View File
@@ -33,17 +33,25 @@ export class LiquidExtractor {
// Create file node
const fileNode = this.createFileNode();
// Extract render/include statements (snippet references)
this.extractSnippetReferences(fileNode.id);
// Shopify OS 2.0 JSON template / section group: link each section `type`
// to its `sections/<type>.liquid` file. (No symbol nodes are emitted — the
// JSON file just carries the references — so it stays out of any
// symbol-bearing-file metric while its sections still get their dependents.)
if (this.filePath.endsWith('.json')) {
this.extractShopifyJsonSections(fileNode.id);
} else {
// Extract render/include statements (snippet references)
this.extractSnippetReferences(fileNode.id);
// Extract section references
this.extractSectionReferences(fileNode.id);
// Extract section references
this.extractSectionReferences(fileNode.id);
// Extract schema block
this.extractSchema(fileNode.id);
// Extract schema block
this.extractSchema(fileNode.id);
// Extract assign statements as variables
this.extractAssignments(fileNode.id);
// Extract assign statements as variables
this.extractAssignments(fileNode.id);
}
} catch (error) {
this.errors.push({
message: `Liquid extraction error: ${error instanceof Error ? error.message : String(error)}`,
@@ -86,6 +94,36 @@ export class LiquidExtractor {
return fileNode;
}
/**
* Shopify OS 2.0 JSON template / section group. Both have a `sections` object
* mapping an id → `{ "type": "<section-name>", ... }`; the `type` names a
* `sections/<type>.liquid` file. Emit a `references` edge to each, so a section
* used only from a JSON template (the OS 2.0 norm) is no longer orphaned.
*/
private extractShopifyJsonSections(fromNodeId: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(this.source);
} catch {
return; // not valid JSON (or a partial) — nothing to link
}
const sections = (parsed as { sections?: Record<string, { type?: unknown }> })?.sections;
if (!sections || typeof sections !== 'object') return;
const seen = new Set<string>();
for (const key of Object.keys(sections)) {
const type = sections[key]?.type;
if (typeof type !== 'string' || seen.has(type)) continue;
seen.add(type);
this.unresolvedReferences.push({
fromNodeId,
referenceName: `sections/${type}.liquid`,
referenceKind: 'references',
line: 1,
column: 0,
});
}
}
/**
* Extract {% render 'snippet' %} and {% include 'snippet' %} references
*/
+280
View File
@@ -0,0 +1,280 @@
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`)
* 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 edges: Edge[] = [];
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);
}
// 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)}`,
severity: 'error',
code: 'parse_error',
});
}
return {
nodes: this.nodes,
edges: this.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);
}
}
}
}
/**
* 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',
});
}
}
}
}
+8
View File
@@ -138,6 +138,14 @@ export interface LanguageExtractor {
isStatic?: (node: SyntaxNode) => boolean;
/** Check if variable declaration is a constant (const vs let/var) */
isConst?: (node: SyntaxNode) => boolean;
/**
* Extract extra symbol-level modifier keywords to persist on the node's
* `decorators` list (e.g. Kotlin `expect`/`actual` multiplatform markers).
* Called generically for every created node; return undefined/[] when none.
* Used by the resolver to link `expect` declarations to their `actual`
* implementations across source sets.
*/
extractModifiers?: (node: SyntaxNode) => string[] | undefined;
// --- New config properties ---
File diff suppressed because it is too large Load Diff