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:
co-authored by
Claude Opus 4.7
Colby McHenry
parent
2dc4bc3968
commit
8eed24327c
@@ -3079,3 +3079,101 @@ describe('Directory Exclusion', () => {
|
|||||||
expect(files.every((f) => !f.includes('vendor'))).toBe(true);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -606,5 +606,109 @@ function main(): void {
|
|||||||
// Should have attempted resolution
|
// Should have attempted resolution
|
||||||
expect(result.stats.total).toBeGreaterThanOrEqual(0);
|
expect(result.stats.total).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('promotes calls→instantiates when target resolves to a class (Python)', async () => {
|
||||||
|
// Python has no `new` keyword — `Foo()` is the standard
|
||||||
|
// instantiation syntax. Extraction can't tell that apart from
|
||||||
|
// a function call without symbol info, so it emits a `calls`
|
||||||
|
// ref. Resolution promotes it to `instantiates` once the
|
||||||
|
// target is known to be a class.
|
||||||
|
const srcDir = path.join(tempDir, 'src');
|
||||||
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(srcDir, 'app.py'),
|
||||||
|
`class UserService:
|
||||||
|
def __init__(self):
|
||||||
|
self.db = None
|
||||||
|
|
||||||
|
def bootstrap():
|
||||||
|
return UserService()
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
cg = await CodeGraph.init(tempDir, { index: true });
|
||||||
|
cg.resolveReferences();
|
||||||
|
|
||||||
|
const bootstrap = cg
|
||||||
|
.getNodesByKind('function')
|
||||||
|
.find((n) => n.name === 'bootstrap');
|
||||||
|
expect(bootstrap).toBeDefined();
|
||||||
|
|
||||||
|
const outgoing = cg.getOutgoingEdges(bootstrap!.id);
|
||||||
|
const instantiates = outgoing.find((e) => e.kind === 'instantiates');
|
||||||
|
expect(instantiates).toBeDefined();
|
||||||
|
// Same edge must NOT also appear as a `calls` edge — promotion
|
||||||
|
// replaces the kind, doesn't duplicate.
|
||||||
|
const callsToUserService = outgoing.filter(
|
||||||
|
(e) => e.kind === 'calls' && e.target === instantiates!.target
|
||||||
|
);
|
||||||
|
expect(callsToUserService).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Name Matcher: kind bias for new ref kinds', () => {
|
||||||
|
const baseContext = (candidates: Node[]): ResolutionContext => ({
|
||||||
|
getNodesInFile: () => [],
|
||||||
|
getNodesByName: (name) => candidates.filter((c) => c.name === name),
|
||||||
|
getNodesByQualifiedName: () => [],
|
||||||
|
getNodesByKind: () => [],
|
||||||
|
fileExists: () => true,
|
||||||
|
readFile: () => null,
|
||||||
|
getProjectRoot: () => '/test',
|
||||||
|
getAllFiles: () => [],
|
||||||
|
getNodesByLowerName: () => [],
|
||||||
|
getImportMappings: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a class candidate over a function for `instantiates` refs', () => {
|
||||||
|
// A class and a function share a name across the codebase.
|
||||||
|
// Without the kind bias, the function (which gets the +25 `calls`
|
||||||
|
// bonus historically applied to all candidates of that kind) would
|
||||||
|
// win. Now the instantiates branch reverses it.
|
||||||
|
const fn: Node = {
|
||||||
|
id: 'func:utils.ts:Logger:5', kind: 'function', name: 'Logger',
|
||||||
|
qualifiedName: 'utils.ts::Logger', filePath: 'utils.ts', language: 'typescript',
|
||||||
|
startLine: 5, endLine: 7, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const cls: Node = {
|
||||||
|
id: 'class:logger.ts:Logger:10', kind: 'class', name: 'Logger',
|
||||||
|
qualifiedName: 'logger.ts::Logger', filePath: 'logger.ts', language: 'typescript',
|
||||||
|
startLine: 10, endLine: 30, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ref = {
|
||||||
|
fromNodeId: 'func:main.ts:bootstrap:1',
|
||||||
|
referenceName: 'Logger',
|
||||||
|
referenceKind: 'instantiates' as const,
|
||||||
|
line: 5, column: 0, filePath: 'main.ts', language: 'typescript' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = matchReference(ref, baseContext([fn, cls]));
|
||||||
|
expect(result?.targetNodeId).toBe('class:logger.ts:Logger:10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a function candidate over a non-function for `decorates` refs', () => {
|
||||||
|
const variable: Node = {
|
||||||
|
id: 'var:config.ts:Inject:5', kind: 'variable', name: 'Inject',
|
||||||
|
qualifiedName: 'config.ts::Inject', filePath: 'config.ts', language: 'typescript',
|
||||||
|
startLine: 5, endLine: 5, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const decorator: Node = {
|
||||||
|
id: 'func:di.ts:Inject:10', kind: 'function', name: 'Inject',
|
||||||
|
qualifiedName: 'di.ts::Inject', filePath: 'di.ts', language: 'typescript',
|
||||||
|
startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ref = {
|
||||||
|
fromNodeId: 'class:svc.ts:UserService:1',
|
||||||
|
referenceName: 'Inject',
|
||||||
|
referenceKind: 'decorates' as const,
|
||||||
|
line: 5, column: 0, filePath: 'svc.ts', language: 'typescript' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = matchReference(ref, baseContext([variable, decorator]));
|
||||||
|
expect(result?.targetNodeId).toBe('func:di.ts:Inject:10');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,17 @@ function extractName(node: SyntaxNode, source: string, extractor: LanguageExtrac
|
|||||||
return '<anonymous>';
|
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
|
* TreeSitterExtractor - Main extraction class
|
||||||
*/
|
*/
|
||||||
@@ -334,6 +345,17 @@ export class TreeSitterExtractor {
|
|||||||
else if (this.extractor.callTypes.includes(nodeType)) {
|
else if (this.extractor.callTypes.includes(nodeType)) {
|
||||||
this.extractCall(node);
|
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
|
// Rust: `impl Trait for Type { ... }` — creates implements edge from Type to Trait
|
||||||
else if (nodeType === 'impl_item') {
|
else if (nodeType === 'impl_item') {
|
||||||
this.extractRustImplItem(node);
|
this.extractRustImplItem(node);
|
||||||
@@ -531,6 +553,11 @@ export class TreeSitterExtractor {
|
|||||||
// Extract type annotations (parameter types and return type)
|
// Extract type annotations (parameter types and return type)
|
||||||
this.extractTypeAnnotations(node, funcNode.id);
|
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
|
// Push to stack and visit body
|
||||||
this.nodeStack.push(funcNode.id);
|
this.nodeStack.push(funcNode.id);
|
||||||
const body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
|
const body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
|
||||||
@@ -562,6 +589,9 @@ export class TreeSitterExtractor {
|
|||||||
// Extract extends/implements
|
// Extract extends/implements
|
||||||
this.extractInheritance(node, classNode.id);
|
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
|
// Push to stack and visit body
|
||||||
this.nodeStack.push(classNode.id);
|
this.nodeStack.push(classNode.id);
|
||||||
let body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
|
let body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
|
||||||
@@ -655,6 +685,9 @@ export class TreeSitterExtractor {
|
|||||||
// Extract type annotations (parameter types and return type)
|
// Extract type annotations (parameter types and return type)
|
||||||
this.extractTypeAnnotations(node, methodNode.id);
|
this.extractTypeAnnotations(node, methodNode.id);
|
||||||
|
|
||||||
|
// Extract decorators (`@Get('/list') list() {}`).
|
||||||
|
this.extractDecoratorsFor(node, methodNode.id);
|
||||||
|
|
||||||
// Push to stack and visit body
|
// Push to stack and visit body
|
||||||
this.nodeStack.push(methodNode.id);
|
this.nodeStack.push(methodNode.id);
|
||||||
const body = this.extractor.resolveBody?.(node, this.extractor.bodyField)
|
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 typeText = typeNode ? getNodeText(typeNode, this.source) : undefined;
|
||||||
const signature = typeText ? `${typeText} ${name}` : name;
|
const signature = typeText ? `${typeText} ${name}` : name;
|
||||||
|
|
||||||
this.createNode('property', name, node, {
|
const propNode = this.createNode('property', name, node, {
|
||||||
docstring,
|
docstring,
|
||||||
signature,
|
signature,
|
||||||
visibility,
|
visibility,
|
||||||
isStatic,
|
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;
|
if (!nameNode) continue;
|
||||||
const name = getNodeText(nameNode, this.source);
|
const name = getNodeText(nameNode, this.source);
|
||||||
const signature = typeText ? `${typeText} ${name}` : name;
|
const signature = typeText ? `${typeText} ${name}` : name;
|
||||||
this.createNode('field', name, decl, {
|
const fieldNode = this.createNode('field', name, decl, {
|
||||||
docstring,
|
docstring,
|
||||||
signature,
|
signature,
|
||||||
visibility,
|
visibility,
|
||||||
isStatic,
|
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 {
|
} else {
|
||||||
// Fallback: try to find an identifier child directly
|
// 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).
|
* Visit function body and extract calls (and structural nodes).
|
||||||
*
|
*
|
||||||
@@ -1466,6 +1664,12 @@ export class TreeSitterExtractor {
|
|||||||
|
|
||||||
if (this.extractor!.callTypes.includes(nodeType)) {
|
if (this.extractor!.callTypes.includes(nodeType)) {
|
||||||
this.extractCall(node);
|
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) {
|
} else if (this.extractor!.extractBareCall) {
|
||||||
const calleeName = this.extractor!.extractBareCall(node, this.source);
|
const calleeName = this.extractor!.extractBareCall(node, this.source);
|
||||||
if (calleeName && this.nodeStack.length > 0) {
|
if (calleeName && this.nodeStack.length > 0) {
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
source: ref.original.fromNodeId,
|
source: ref.original.fromNodeId,
|
||||||
target: ref.targetNodeId,
|
target: ref.targetNodeId,
|
||||||
|
|||||||
@@ -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
|
// Exported bonus
|
||||||
if (candidate.isExported) {
|
if (candidate.isExported) {
|
||||||
score += 10;
|
score += 10;
|
||||||
|
|||||||
Reference in New Issue
Block a user