diff --git a/codegraph/parsers/typescript.py b/codegraph/parsers/typescript.py index 8ebc187..c99de55 100644 --- a/codegraph/parsers/typescript.py +++ b/codegraph/parsers/typescript.py @@ -840,6 +840,8 @@ def _handle_class( if c.type == "class_body": body = c break + self._emit_decorator_calls(node, rel, class_id, src, edges) + if body is not None: for child in body.children: if child.type == "method_definition": @@ -900,6 +902,8 @@ def _handle_method( file=rel, line=node.start_point[0] + 1, )) + self._emit_decorator_calls(node, rel, method_id, src, edges) + body = node.child_by_field_name("body") if body is not None: self._collect_calls(body, rel, method_id, src, edges) @@ -1075,6 +1079,103 @@ def _handle_lexical_decl( {"arrow_function", "function_expression", "function"} ) + def _emit_decorator_calls( + self, + def_node: tree_sitter.Node, + rel: str, + scope_id: str, + src: bytes, + edges: list[Edge], + ) -> None: + """Emit a CALLS edge for each invoked decorator on a TS class or method. + + Mirrors the Python parser's ``_emit_decorator_calls`` convention: + ``@Controller('/users')`` and ``@Get(':id')`` are calls — they invoke + the decorator factory at definition time. Without these edges, + NestJS-style decorated classes/methods appear unreferenced. + + For class nodes the decorator siblings may live on the enclosing + ``export_statement`` (exported) or on the ``class_declaration`` itself + (non-exported). For method nodes they appear as preceding siblings + inside ``class_body``. + + Only invoked decorators (``@Foo(...)``) emit edges; bare references + (``@Foo`` without parentheses) are NOT calls and are skipped. + """ + # Collect the decorator nodes that belong to this definition. + decorator_nodes: list[tree_sitter.Node] = [] + node_type = def_node.type + + if node_type in ("class_declaration", "abstract_class_declaration"): + # Class decorators may be children of the class node itself + # (non-exported) or of the parent export_statement (exported). + for child in def_node.children: + if child.type == "decorator": + decorator_nodes.append(child) + parent = def_node.parent + if parent is not None and parent.type == "export_statement": + for child in parent.children: + if child.type == "decorator": + decorator_nodes.append(child) + + elif node_type == "method_definition": + # Method decorators are siblings in class_body that immediately + # precede this method_definition. Walk backward from def_node's + # position; stop on any non-decorator sibling. + # Note: tree-sitter wraps nodes in new Python objects on each + # access so ``is`` identity does not work; use start_byte instead. + parent = def_node.parent + if parent is not None and parent.type == "class_body": + siblings = parent.children + idx = next( + ( + i for i, s in enumerate(siblings) + if s.start_byte == def_node.start_byte + ), + -1, + ) + if idx > 0: + j = idx - 1 + while j >= 0 and siblings[j].type == "decorator": + decorator_nodes.insert(0, siblings[j]) + j -= 1 + + for dec_node in decorator_nodes: + for sub in dec_node.children: + if sub.type != "call_expression": + continue + func_child = sub.child_by_field_name("function") + if func_child is None and sub.children: + func_child = sub.children[0] + if func_child is None: + continue + name = node_text(func_child, src) + args_node = sub.child_by_field_name("arguments") + if args_node is None: + for c in sub.children: + if c.type == "arguments": + args_node = c + break + if args_node is not None: + call_args, call_kwargs = _split_call_arguments( + args_node, src + ) + else: + call_args, call_kwargs = [], {} + edges.append(Edge( + src=scope_id, + dst=f"unresolved::{name}", + kind=EdgeKind.CALLS, + file=rel, + line=sub.start_point[0] + 1, + metadata={ + "target_name": name, + "args": call_args, + "kwargs": call_kwargs, + "decorator": True, + }, + )) + def _collect_calls( self, node: tree_sitter.Node, diff --git a/codegraph/resolve/calls.py b/codegraph/resolve/calls.py index 343393a..cbaf47d 100644 --- a/codegraph/resolve/calls.py +++ b/codegraph/resolve/calls.py @@ -58,6 +58,38 @@ def _strip_unresolved(dst: str) -> str: return dst[len(prefix):] if dst.startswith(prefix) else dst +def _fresh_instance_class_name(raw_target: str) -> str | None: + """Return the class/constructor name when *raw_target* is a fresh-instance + chain like ``new Builder().method`` or ``Builder().method``. + + Returns ``None`` when the target does not match that pattern. + + A fresh-instance target has the shape ``(...).method`` where the + opening ``(`` appears before the final ``.method`` segment. Both + ``new ClassName(...)`` and bare ``factory(...)`` are detected; the + caller is responsible for checking that the resolved name maps to a CLASS + node before emitting the extra edge. + """ + cleaned = raw_target.strip() + if cleaned.startswith("await "): + cleaned = cleaned[len("await "):] + has_new = cleaned.startswith("new ") + if has_new: + cleaned = cleaned[len("new "):] + # Must contain '(' before the final '.method' part. + paren_pos = cleaned.find("(") + if paren_pos < 0: + return None + dot_after_parens = cleaned.find(".", paren_pos) + if dot_after_parens < 0: + return None + # The class/factory name is everything before the first '('. + ctor_name = cleaned[:paren_pos].strip() + if not ctor_name: + return None + return ctor_name + + def _normalize_target(name: str) -> str: """Strip call-syntax noise so "foo.bar()" / "foo()" become "foo.bar". @@ -544,6 +576,38 @@ def resolve_unresolved_edges( ) stats.resolved += 1 + # Fresh-instance binding: when the call was ``new Foo().method`` or + # ``foo().method`` AND the method resolved successfully, also emit a + # CALLS edge to the constructor/class if ``Foo`` resolves to a CLASS + # node. This lets callers see that the class was instantiated, not + # just that one of its methods was called. + if edge.kind == EdgeKind.CALLS: + ctor_name = _fresh_instance_class_name(target) + if ctor_name is not None: + ctor_node = _resolve_target( + ctor_name, src_node, index, bindings + ) + if ( + ctor_node is not None + and ctor_node.kind == NodeKind.CLASS + and ctor_node.id != edge.src + and ctor_node.id != resolved.id + ): + new_edges.append( + Edge( + src=edge.src, + dst=ctor_node.id, + kind=EdgeKind.CALLS, + file=edge.file, + line=edge.line, + metadata={ + "target_name": ctor_name, + "fresh_instance": True, + "resolved_from": edge.dst, + }, + ) + ) + for src, dst, kind in deletions: store.delete_edge(src, dst, kind) if new_edges: diff --git a/tests/fixtures/resolver_r2/ts_decorators.ts b/tests/fixtures/resolver_r2/ts_decorators.ts new file mode 100644 index 0000000..f19480b --- /dev/null +++ b/tests/fixtures/resolver_r2/ts_decorators.ts @@ -0,0 +1,19 @@ +// Fixture: TS decorator CALLS edges. +// @Injectable() on class and @Get(':id') on method must each emit +// CALLS edges with decorator:true metadata. + +export function Injectable(): ClassDecorator { + return (target) => target; +} + +export function Get(path: string): MethodDecorator { + return (_target, _key, _desc) => _desc; +} + +@Injectable() +export class UserController { + @Get(":id") + getUser(id: string): string { + return id; + } +} diff --git a/tests/fixtures/resolver_r2/ts_fresh_instance.ts b/tests/fixtures/resolver_r2/ts_fresh_instance.ts new file mode 100644 index 0000000..09156f0 --- /dev/null +++ b/tests/fixtures/resolver_r2/ts_fresh_instance.ts @@ -0,0 +1,13 @@ +// Fixture: fresh-instance binding for TS +// new UserService().getUser(id) must emit CALLS edges to BOTH +// UserService (class) and UserService.getUser (method). + +export class UserService { + getUser(id: string): string { + return id; + } +} + +export function run(id: string): string { + return new UserService().getUser(id); +} diff --git a/tests/fixtures/resolver_r2/ts_fresh_instance_factory.ts b/tests/fixtures/resolver_r2/ts_fresh_instance_factory.ts new file mode 100644 index 0000000..f9ec035 --- /dev/null +++ b/tests/fixtures/resolver_r2/ts_fresh_instance_factory.ts @@ -0,0 +1,11 @@ +// Fixture: fresh-instance via factory function (NOT a class). +// factory().run() must NOT produce a class CALLS edge since factory +// is a function, not a class. + +export function factory(): { run(): string } { + return { run: () => "ok" }; +} + +export function main(): string { + return factory().run(); +} diff --git a/tests/test_resolve_r2.py b/tests/test_resolve_r2.py index fac436a..ce224b0 100644 --- a/tests/test_resolve_r2.py +++ b/tests/test_resolve_r2.py @@ -100,5 +100,104 @@ def test_r2_instance_chain_method_call(tmp_path: Path) -> None: ) +def test_ts_fresh_instance_both_edges(tmp_path: Path) -> None: + """``new UserService().getUser(id)`` emits CALLS to both UserService + (class, fresh_instance=True) and UserService.getUser (method). + """ + store = _build(tmp_path, "ts_fresh_instance.ts") + cls = _find_one(store, kind=NodeKind.CLASS, suffix=".UserService") + method = _find_one(store, kind=NodeKind.METHOD, suffix=".UserService.getUser") + method_calls = _calls_to(store, method.id) + assert method_calls, ( + "expected CALLS edge into UserService.getUser from new UserService().getUser()" + ) + class_calls = _calls_to(store, cls.id) + fresh_calls = [e for e in class_calls if e.metadata.get("fresh_instance")] + assert fresh_calls, ( + "expected CALLS edge with fresh_instance=True into UserService class" + ) + + +def test_ts_fresh_instance_factory_no_class_edge(tmp_path: Path) -> None: + """``factory().run()`` must NOT produce a fresh_instance CALLS edge when + ``factory`` is a plain function, not a class. + """ + store = _build(tmp_path, "ts_fresh_instance_factory.ts") + # factory is a FUNCTION; there should be no fresh_instance edge to it. + factory_nodes = [ + n for n in store.iter_nodes(kind=NodeKind.FUNCTION) + if n.qualname.endswith(".factory") + ] + assert factory_nodes, "expected a 'factory' function node in the graph" + factory_id = factory_nodes[0].id + calls_to_factory = _calls_to(store, factory_id) + fresh_class_calls = [ + e for e in calls_to_factory if e.metadata.get("fresh_instance") + ] + assert not fresh_class_calls, ( + "factory() is a function, not a class — no fresh_instance edge expected" + ) + + +def test_ts_decorator_calls_edges(tmp_path: Path) -> None: + """@Injectable() on a class and @Get(':id') on a method each emit a + CALLS edge with decorator=True metadata. + """ + store = _build(tmp_path, "ts_decorators.ts") + injectable = _find_one(store, kind=NodeKind.FUNCTION, suffix=".Injectable") + get_fn = _find_one(store, kind=NodeKind.FUNCTION, suffix=".Get") + cls = _find_one(store, kind=NodeKind.CLASS, suffix=".UserController") + method = _find_one(store, kind=NodeKind.METHOD, suffix=".UserController.getUser") + + # @Injectable() -> CALLS edge to Injectable from the class node. + injectable_calls = [ + e for e in _calls_to(store, injectable.id) + if e.src == cls.id and e.metadata.get("decorator") + ] + assert injectable_calls, ( + "expected decorator CALLS edge from UserController class to Injectable" + ) + + # @Get(':id') -> CALLS edge to Get from the method node. + get_calls = [ + e for e in _calls_to(store, get_fn.id) + if e.src == method.id and e.metadata.get("decorator") + ] + assert get_calls, ( + "expected decorator CALLS edge from getUser method to Get" + ) + + +def test_ts_decorator_unresolved_stays_unresolved(tmp_path: Path) -> None: + """When the decorator is defined outside the repo, the CALLS edge stays + unresolved (no crash, decorator metadata still present on the raw edge). + """ + from codegraph.graph.store_sqlite import SQLiteGraphStore as _Store + repo = tmp_path / "repo2" + repo.mkdir() + # Only the controller; decorators come from an external package. + src = repo / "ctrl.ts" + src.write_text( + "import { Controller, Get } from '@nestjs/common';\n" + "@Controller('users')\n" + "export class UsersController {\n" + " @Get(':id')\n" + " getUser(id: string): string { return id; }\n" + "}\n" + ) + db = tmp_path / "g2.db" + st = _Store(db) + from codegraph.graph.builder import GraphBuilder + GraphBuilder(repo, st).build(incremental=False) + # Decorator edges should have been emitted with decorator=True but remain + # unresolved (since nestjs is not in-repo). The graph must not crash. + all_calls = list(st.iter_edges(kind=EdgeKind.CALLS)) + decorator_calls = [e for e in all_calls if e.metadata.get("decorator")] + # There should be at least two decorator edges (Controller, Get). + assert len(decorator_calls) >= 2, ( + f"expected >=2 unresolved decorator CALLS edges, got {len(decorator_calls)}" + ) + + if __name__ == "__main__": # pragma: no cover pytest.main([__file__, "-v"])