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
+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;
}