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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user