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
+98
View File
@@ -3079,3 +3079,101 @@ describe('Directory Exclusion', () => {
expect(files.every((f) => !f.includes('vendor'))).toBe(true);
});
});
describe('Instantiates + Decorates edge extraction', () => {
it('emits an instantiates ref for `new Foo()`', () => {
const code = `
class Foo {}
function bootstrap() { return new Foo(); }
`;
const result = extractFromSource('app.ts', code);
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'instantiates' && r.referenceName === 'Foo'
);
expect(ref).toBeDefined();
});
it('strips type-argument suffix from generic constructors', () => {
const code = `
class Container<T> { constructor(_: T) {} }
function go() { return new Container<string>('x'); }
`;
const result = extractFromSource('app.ts', code);
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'instantiates'
);
expect(ref).toBeDefined();
// Container<string> must be normalised to "Container" — otherwise
// resolution can never match the class node.
expect(ref!.referenceName).toBe('Container');
});
it('keeps trailing identifier from qualified `new ns.Foo()`', () => {
const code = `
const ns = { Foo: class {} };
function go() { return new ns.Foo(); }
`;
const result = extractFromSource('app.ts', code);
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'instantiates'
);
// We can't always resolve which Foo, but the name should be the
// simple identifier so name-matching has a chance.
expect(ref?.referenceName).toBe('Foo');
});
it('emits a decorates ref for `@Foo class X {}`', () => {
const code = `
function Foo(_arg: string) { return (cls: any) => cls; }
@Foo('x')
class X {}
`;
const result = extractFromSource('app.ts', code);
const decorClass = result.unresolvedReferences.find(
(r) => r.referenceKind === 'decorates' && r.referenceName === 'Foo'
);
expect(decorClass).toBeDefined();
});
it('does NOT attribute a prior class\'s decorator to the next class', () => {
// Regression: the sibling-walk must stop at the first non-
// decorator separator. `@A class Foo {} @B class Bar {}` must
// produce `decorates(Foo, A)` and `decorates(Bar, B)` — never
// `decorates(Bar, A)`.
const code = `
function A(cls: any) { return cls; }
function B(cls: any) { return cls; }
@A
class Foo {}
@B
class Bar {}
`;
const result = extractFromSource('app.ts', code);
const decoratesEdges = result.unresolvedReferences.filter(
(r) => r.referenceKind === 'decorates'
);
// Exactly one decorates ref per decorated class, no cross-attribution.
const fromBar = decoratesEdges.filter((r) =>
result.nodes.find((n) => n.id === r.fromNodeId && n.name === 'Bar')
);
expect(fromBar.length).toBe(1);
expect(fromBar[0]!.referenceName).toBe('B');
});
it('emits a decorates ref for `@Foo method() {}`', () => {
const code = `
function Get(p: string) { return (t: any, k: string) => t; }
class Svc {
@Get('/x') method() { return 1; }
}
`;
const result = extractFromSource('app.ts', code);
const decorMethod = result.unresolvedReferences.find(
(r) => r.referenceKind === 'decorates' && r.referenceName === 'Get'
);
expect(decorMethod).toBeDefined();
// The decorated symbol must be `method`, not the constructor or class.
const decoratedNode = result.nodes.find((n) => n.id === decorMethod!.fromNodeId);
expect(decoratedNode?.name).toBe('method');
});
});