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:
co-authored by
Claude Opus 4.8
parent
bfa84d32b8
commit
07af3db6c7
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
+945
-14
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user