Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions codegraph/parsers/typescript.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions codegraph/resolve/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<ctor>(...).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".

Expand Down Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/resolver_r2/ts_decorators.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions tests/fixtures/resolver_r2/ts_fresh_instance.ts
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 11 additions & 0 deletions tests/fixtures/resolver_r2/ts_fresh_instance_factory.ts
Original file line number Diff line number Diff line change
@@ -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();
}
99 changes: 99 additions & 0 deletions tests/test_resolve_r2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading