feat(extraction): instantiates + decorates graph edges (#134)

* feat(extraction): instantiates + decorates graph edges

Two new structural edges that fill gaps in the call graph for
modern JS/TS / Java / C# / Python / Kotlin codebases.

1) `instantiates` edges from `new Foo(...)`:

The bulk-extraction and visitFunctionBody dispatchers only
recognised `call_expression`; `new_expression` (and the equivalent
`object_creation_expression` / `instance_creation_expression` in
other grammars) was silently ignored. Adds INSTANTIATION_KINDS,
extractInstantiation(), and dispatch from BOTH the top-level
visitNode and the per-function-body walker. Children are still
descended so nested calls inside constructor args (`new Foo(bar())`)
get their own `calls` refs.

Output: a `bootstrap` function that does `new UserService(); new
UserController(svc)` now produces two `instantiates` edges to those
class nodes — previously zero edges.

2) `decorates` edges from `@Decorator` annotations:

Tree-sitter places decorator nodes BEFORE the symbol they apply to
in the AST, so the original walk-time dispatch saw the wrong
nodeStack head (file/class instead of class/method). Replaced with
extractDecoratorsFor(declNode, decoratedId) that runs from inside
extractClass / extractFunction / extractMethod after the symbol's
node id is known.

Looks for decorator nodes in two places:
  - Direct named children of the declaration (method/property style)
  - Preceding siblings in the parent (TypeScript class style:
    @Foo class X {} parses as parent { decorator, class_decl })

Sibling check uses startIndex comparison rather than reference
identity — tree-sitter web bindings return fresh JS wrappers from
parent/namedChild navigation, so `===` is unreliable. Took a debug
session to spot this; flagging in the comment so the next reader
doesn't re-introduce the bug.

Output: a `@Controller` class decorator + `@Get` method decorator
on a NestJS-style controller now produce two `decorates` edges
(class→Controller, method→Get) with the correct source nodes.

Verified live on a synthetic NestJS-shape fixture; all 380
existing tests pass.

* fix(extraction): address reviewer findings — decorator boundary, generic constructors, property/field decorators, marker_annotation, tests

Five fixes from independent semantic review:

- extractDecoratorsFor sibling walk now iterates BACKWARD from the
  declaration and stops at the first non-decorator/annotation
  separator. Previous version walked forward up to declStart and
  consumed every decorator-typed sibling — so two adjacent
  decorated classes (`@A class Foo {} @B class Bar {}`) had `@A`
  spuriously attributed to `Bar`.

- extractInstantiation strips the type-argument suffix from the
  constructor field text. `new Map<K, V>()` was producing
  referenceName 'Map<K, V>' (the constructor field is a generic_type
  node) and resolution always failed.

- extractProperty and extractField now call extractDecoratorsFor
  after their createNode calls. NestJS-style `@Inject() private
  svc: Foo` and Java field annotations were being silently dropped.

- consider() in extractDecoratorsFor recognises 'marker_annotation'
  in addition to 'decorator'/'annotation'. Java's tree-sitter grammar
  emits marker_annotation for arg-less annotations like @Override
  and @Deprecated; without this every Java marker annotation was
  silently skipped.

- 6 new extraction tests covering: instantiates ref for new Foo(),
  generic-type stripping (`new Container<string>()` -> 'Container'),
  qualified-new keeps trailing identifier (`new ns.Foo()` -> 'Foo'),
  decorates ref for @Foo class X {}, regression for adjacent
  decorated classes (each gets its OWN decorator), decorates ref
  for @Foo method().

Full test suite: 386 passed (was 380, +6 new extraction tests).

* feat(resolution): kind-aware scoring + Python instantiation promotion

Two follow-ups to the new instantiates/decorates ref kinds, surfaced
during review:

1) name-matcher previously only had a kind bonus for `calls`
   (preferring function/method). When a class and a function share a
   name across modules, an `instantiates` ref would tie or pick the
   wrong candidate. Adds:
     - `instantiates` → +25 for class/struct/interface
     - `decorates`    → +25 for function/method, +15 for class
       (Python class decorators, Java annotation interfaces)

2) Python (and Ruby) have no `new` keyword — `Foo()` is the standard
   instantiation syntax, indistinguishable from a function call at
   extraction time. Resolution can tell the difference once the
   target is known: when a `calls` ref resolves to a class/struct,
   promote it to `instantiates`. Mirrors the existing extends→
   implements promotion in createEdges.

Verified: 386 → 389 passing (+3 tests covering the kind biases and
the Python promotion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
andreinknv
2026-05-07 20:08:55 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 Colby McHenry
parent 2dc4bc3968
commit 8eed24327c
5 changed files with 444 additions and 2 deletions
+206 -2
View File
@@ -95,6 +95,17 @@ function extractName(node: SyntaxNode, source: string, extractor: LanguageExtrac
return '<anonymous>';
}
/**
* Tree-sitter node kinds that represent constructor invocations
* (`new Foo()` and friends). Used by extractInstantiation to emit
* an `instantiates` reference targeting the class name.
*/
const INSTANTIATION_KINDS: ReadonlySet<string> = new Set([
'new_expression', // typescript / javascript / tsx / jsx
'object_creation_expression', // java / c#
'instance_creation_expression', // some grammars
]);
/**
* TreeSitterExtractor - Main extraction class
*/
@@ -334,6 +345,17 @@ export class TreeSitterExtractor {
else if (this.extractor.callTypes.includes(nodeType)) {
this.extractCall(node);
}
// `new Foo(...)` / `Foo::new(...)` / object_creation_expression —
// produce an `instantiates` reference. Children still walked so
// nested calls inside the constructor args (`new Foo(bar())`) get
// their own `calls` refs.
else if (INSTANTIATION_KINDS.has(nodeType)) {
this.extractInstantiation(node);
}
// (Decorator handling lives inside the symbol-creating extractors
// — extractClass / extractFunction / extractProperty — because the
// decorator node sits BEFORE the symbol in the AST and the walker
// would otherwise see the wrong nodeStack head.)
// Rust: `impl Trait for Type { ... }` — creates implements edge from Type to Trait
else if (nodeType === 'impl_item') {
this.extractRustImplItem(node);
@@ -531,6 +553,11 @@ export class TreeSitterExtractor {
// Extract type annotations (parameter types and return type)
this.extractTypeAnnotations(node, funcNode.id);
// Extract decorators applied to the function (rare in JS/TS but
// present in Python `@decorator def f():` and Java/Kotlin
// annotations on free functions).
this.extractDecoratorsFor(node, funcNode.id);
// Push to stack and visit body
this.nodeStack.push(funcNode.id);
const body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
@@ -562,6 +589,9 @@ export class TreeSitterExtractor {
// Extract extends/implements
this.extractInheritance(node, classNode.id);
// Extract decorators applied to the class (`@Foo class X {}`).
this.extractDecoratorsFor(node, classNode.id);
// Push to stack and visit body
this.nodeStack.push(classNode.id);
let body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
@@ -655,6 +685,9 @@ export class TreeSitterExtractor {
// Extract type annotations (parameter types and return type)
this.extractTypeAnnotations(node, methodNode.id);
// Extract decorators (`@Get('/list') list() {}`).
this.extractDecoratorsFor(node, methodNode.id);
// Push to stack and visit body
this.nodeStack.push(methodNode.id);
const body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
@@ -834,12 +867,18 @@ export class TreeSitterExtractor {
const typeText = typeNode ? getNodeText(typeNode, this.source) : undefined;
const signature = typeText ? `${typeText} ${name}` : name;
this.createNode('property', name, node, {
const propNode = this.createNode('property', name, node, {
docstring,
signature,
visibility,
isStatic,
});
// `@Inject() private svc: Foo` and similar — capture the
// decorator->target relationship for class properties too.
if (propNode) {
this.extractDecoratorsFor(node, propNode.id);
}
}
/**
@@ -913,12 +952,15 @@ export class TreeSitterExtractor {
if (!nameNode) continue;
const name = getNodeText(nameNode, this.source);
const signature = typeText ? `${typeText} ${name}` : name;
this.createNode('field', name, decl, {
const fieldNode = this.createNode('field', name, decl, {
docstring,
signature,
visibility,
isStatic,
});
// Java/Kotlin annotations / TS field decorators sit on the
// outer field_declaration, not on the individual declarator.
if (fieldNode) this.extractDecoratorsFor(node, fieldNode.id);
}
} else {
// Fallback: try to find an identifier child directly
@@ -1448,6 +1490,162 @@ export class TreeSitterExtractor {
}
}
/**
* `new Foo(...)` / `Foo::new(...)` / object_creation_expression —
* emit an `instantiates` reference to the class name. The resolver
* then links it to the class node, producing the `instantiates`
* edge that powers "what creates instances of X" queries.
*
* Children are still walked so nested calls inside the constructor
* arguments (`new Foo(bar())`) get their own `calls` references.
*/
private extractInstantiation(node: SyntaxNode): void {
if (this.nodeStack.length === 0) return;
const fromId = this.nodeStack[this.nodeStack.length - 1];
if (!fromId) return;
// The class name is in the `constructor`/`type`/first-named-child
// depending on grammar.
const ctor =
getChildByField(node, 'constructor') ||
getChildByField(node, 'type') ||
getChildByField(node, 'name') ||
node.namedChild(0);
if (!ctor) return;
let className = getNodeText(ctor, this.source);
// Strip type-argument suffix first: `new Map<K, V>()` would
// otherwise produce className 'Map<K, V>' (the constructor
// field is a `generic_type` node) and resolution would fail
// because no class is named with the angle-bracket suffix.
const ltIdx = className.indexOf('<');
if (ltIdx > 0) className = className.slice(0, ltIdx);
// For namespaced/qualified constructors (`new ns.Foo()`,
// `new ns::Foo()`) keep the trailing identifier — that's what
// matches a class node in the index.
const lastDot = Math.max(
className.lastIndexOf('.'),
className.lastIndexOf('::')
);
if (lastDot >= 0) className = className.slice(lastDot + 1).replace(/^[:.]/, '');
className = className.trim();
if (className) {
this.unresolvedReferences.push({
fromNodeId: fromId,
referenceName: className,
referenceKind: 'instantiates',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
}
/**
* Scan `declNode` and its preceding siblings (within the parent's
* named children) for decorator nodes, emitting a `decorates`
* reference from `decoratedId` to each decorator's function name.
*
* Why preceding siblings: in TypeScript, `@Foo class Bar {}` parses
* as an `export_statement` (or top-level wrapper) with the
* `decorator` as a child *before* the `class_declaration` — so the
* decorator isn't a child of the class itself. For methods/
* properties, the decorator IS a direct child of the declaration,
* so we also scan declNode.namedChildren.
*
* Idempotent across grammars: if neither location yields decorators
* (most non-decorator-using languages), the function is a no-op.
*/
private extractDecoratorsFor(declNode: SyntaxNode, decoratedId: string): void {
const consider = (n: SyntaxNode | null): void => {
if (!n) return;
// `marker_annotation` is Java's grammar for arg-less annotations
// (`@Override`, `@Deprecated`); without including it, every
// such Java annotation would be silently skipped.
if (
n.type !== 'decorator' &&
n.type !== 'annotation' &&
n.type !== 'marker_annotation'
) {
return;
}
// Find the leading identifier: skip the `@` punct, unwrap
// a call_expression if the decorator is invoked with args.
let target: SyntaxNode | null = null;
for (let i = 0; i < n.namedChildCount; i++) {
const child = n.namedChild(i);
if (!child) continue;
if (child.type === 'call_expression') {
const fn = getChildByField(child, 'function') ?? child.namedChild(0);
if (fn) target = fn;
if (target) break;
}
if (
child.type === 'identifier' ||
child.type === 'member_expression' ||
child.type === 'scoped_identifier' ||
child.type === 'navigation_expression'
) {
target = child;
break;
}
}
if (!target) return;
let name = getNodeText(target, this.source);
const lastDot = Math.max(name.lastIndexOf('.'), name.lastIndexOf('::'));
if (lastDot >= 0) name = name.slice(lastDot + 1).replace(/^[:.]/, '');
if (!name) return;
this.unresolvedReferences.push({
fromNodeId: decoratedId,
referenceName: name,
referenceKind: 'decorates',
line: n.startPosition.row + 1,
column: n.startPosition.column,
});
};
// 1. Decorators that are direct children of the declaration
// (method/property style, also some grammars for class).
for (let i = 0; i < declNode.namedChildCount; i++) {
consider(declNode.namedChild(i));
}
// 2. Decorators that are PRECEDING siblings of the declaration
// inside the parent's children (TypeScript class style).
// Walk BACKWARDS from the declaration and stop at the first
// non-decorator sibling — without that stop, decorators
// belonging to an EARLIER unrelated declaration leak in
// (e.g. `@A class Foo {} @B class Bar {}` would otherwise
// attribute @A to Bar).
//
// Note on identity: tree-sitter web bindings return fresh JS
// wrapper objects from `parent`/`namedChild` navigation, so
// `sibling === declNode` is unreliable — `startIndex` does
// the matching instead.
const parent = declNode.parent;
if (parent) {
const declStart = declNode.startIndex;
let declIdx = -1;
for (let i = 0; i < parent.namedChildCount; i++) {
const sibling = parent.namedChild(i);
if (sibling && sibling.startIndex === declStart) {
declIdx = i;
break;
}
}
if (declIdx > 0) {
for (let j = declIdx - 1; j >= 0; j--) {
const sibling = parent.namedChild(j);
if (!sibling) continue;
if (sibling.type !== 'decorator' && sibling.type !== 'annotation' && sibling.type !== 'marker_annotation') {
break; // non-decorator separator → stop consuming
}
consider(sibling);
}
}
}
}
/**
* Visit function body and extract calls (and structural nodes).
*
@@ -1466,6 +1664,12 @@ export class TreeSitterExtractor {
if (this.extractor!.callTypes.includes(nodeType)) {
this.extractCall(node);
} else if (INSTANTIATION_KINDS.has(nodeType)) {
// `new Foo()` inside a function body — emit an `instantiates`
// reference. Without this branch the body walker only knew
// about `call_expression`, so constructor invocations
// produced no graph edges at all.
this.extractInstantiation(node);
} else if (this.extractor!.extractBareCall) {
const calleeName = this.extractor!.extractBareCall(node, this.source);
if (calleeName && this.nodeStack.length > 0) {
+12
View File
@@ -443,6 +443,18 @@ export class ReferenceResolver {
}
}
// Promote "calls" to "instantiates" when the resolved target is a
// class/struct. Languages without a `new` keyword (Python, Ruby)
// express instantiation as `Foo()` — extraction can't tell that
// apart from a function call without symbol info, but resolution
// can: if `Foo` resolves to a class, the call IS an instantiation.
if (kind === 'calls') {
const targetNode = this.queries.getNodeById(ref.targetNodeId);
if (targetNode && (targetNode.kind === 'class' || targetNode.kind === 'struct')) {
kind = 'instantiates';
}
}
return {
source: ref.original.fromNodeId,
target: ref.targetNodeId,
+24
View File
@@ -352,6 +352,30 @@ function findBestMatch(
}
}
// For instantiation references (`new Foo()`), prefer class-like
// targets — without this, a function named `Foo` in another module
// could outscore the actual class.
if (ref.referenceKind === 'instantiates') {
if (
candidate.kind === 'class' ||
candidate.kind === 'struct' ||
candidate.kind === 'interface'
) {
score += 25;
}
}
// For decorator references (`@Foo`), prefer functions. Class
// decorators (Python `@SomeClass`, Java annotation interfaces)
// also resolve here, hence the smaller class bonus.
if (ref.referenceKind === 'decorates') {
if (candidate.kind === 'function' || candidate.kind === 'method') {
score += 25;
} else if (candidate.kind === 'class' || candidate.kind === 'interface') {
score += 15;
}
}
// Exported bonus
if (candidate.isExported) {
score += 10;