fix(extract): emit reference edges for TS/JS decorators

`@Component`, `@Injectable`, `@Input`, `@Inject`, `@Entity`, … produced no
edge — the `decorator` node kind was never walked. This is framework-critical
(Angular, NestJS, Vue class components, TypeORM): the decorators are the
primary signal of what a class is and does.

Decorators occur only on classes, class members, and parameters, so one pass
over each class declaration covers them. `_ts_emit_decorator_edges` emits a
`references` edge (context="decorator") from the decorated entity to the
decorator symbol:

  - class decorators -> the class. Handles both `@Deco class C` (decorator is
    a child of the class) and `@Deco export class C` (decorator sits on the
    wrapping export_statement), plus stacked decorators.
  - method decorators -> the method node. They are siblings preceding the
    `method_definition`; stacked decorators are skipped past to find it.
  - field / accessor decorators -> the class (the field is not a graph node).
  - parameter decorators (`@Inject(T)`) -> the enclosing method/constructor.

The symbol is the head identifier: `@Injectable`, the `function` of
`@Component({...})`, or the `property` of `@ns.Component()`. Targets go
through `ensure_named_node`, so a decorator defined outside the corpus
becomes a sourceless stub, consistent with type references — one per
referencing file, matching the cross-file stub disambiguation introduced
with full-path node IDs in 0.9.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paulo Pinto
2026-07-02 22:38:29 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 869aaf7502
commit 35404167d9
2 changed files with 278 additions and 0 deletions
+125
View File
@@ -3367,6 +3367,12 @@ def _extract_generic(
callable_def_nids.add(class_nid) # a class is callable (constructor)
add_edge(file_nid, class_nid, "contains", line)
# TS/JS decorators on the class and its members (@Component, @Injectable,
# @Input, @Inject, @Entity, …). Decorators live only in class subtrees.
if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
_ts_emit_decorator_edges(node, class_nid, stem, source,
ensure_named_node, add_edge)
if config.ts_module == "tree_sitter_swift" and any(
c.type == "extension" for c in node.children
):
@@ -9913,6 +9919,125 @@ _JS_PRIMITIVE_TYPES = frozenset({
})
def _ts_decorator_name(deco_node, source: bytes) -> str | None:
"""Return the head symbol of a TS `decorator` node.
`@Injectable` -> the identifier; `@Component({...})` / `@Input()` -> the
`function` of the call_expression; `@ng.Component()` / `@core.Injectable` ->
the `property` of the member_expression (the imported symbol, not the
namespace alias).
"""
for child in deco_node.children:
if not child.is_named:
continue
target = child
if target.type == "call_expression":
target = target.child_by_field_name("function") or target
if target.type == "member_expression":
prop = target.child_by_field_name("property")
return _read_text(prop, source) if prop else None
if target.type == "identifier":
return _read_text(target, source)
return None
return None
def _ts_method_name(method_node, source: bytes) -> str | None:
"""Name of a `method_definition`, matching the id the function-types branch
builds (`_make_id(class_nid, name)`)."""
name_node = method_node.child_by_field_name("name")
return _read_text(name_node, source) if name_node else None
def _ts_descendant_decorators(node) -> list:
"""Collect `decorator` nodes under `node` (e.g. parameter decorators inside a
method's formal_parameters, or a field's own decorator), without crossing into
a nested class or a nested method, which own their own decorators."""
out: list = []
def rec(n, top: bool) -> None:
for child in n.children:
ct = child.type
if ct == "decorator":
out.append(child)
elif ct in ("class_declaration", "abstract_class_declaration"):
continue
elif ct == "method_definition" and not top:
continue
else:
rec(child, False)
rec(node, True)
return out
def _ts_emit_decorator_edges(class_node, class_nid: str, stem: str, source: bytes,
ensure_named_node, add_edge) -> None:
"""Emit `references` edges (context="decorator") from a class and its members
to the symbols of the TS decorators applied to them.
Decorators only occur on classes, class members, and parameters, so a single
pass over the class declaration covers them. Members that are graph nodes
(methods, incl. the constructor) own their decorators and their parameter
decorators; members that are not nodes (fields, parameters) attribute to the
enclosing class. Targets go through `ensure_named_node`, so a decorator
imported from another module (the common case `@Component` from
`@angular/core`) becomes a sourceless stub the corpus rewire collapses onto
the real definition.
"""
def emit(deco_node, owner_nid: str) -> None:
name = _ts_decorator_name(deco_node, source)
if not name:
return
line = deco_node.start_point[0] + 1
target = ensure_named_node(name, line)
if target != owner_nid:
add_edge(owner_nid, target, "references", line, context="decorator")
# Class-level decorators: direct children of the class node (`@Deco class C`),
# plus — when exported (`@Deco export class C`) — the decorators that sit on
# the wrapping export_statement, before the class.
for child in class_node.children:
if child.type == "decorator":
emit(child, class_nid)
parent = class_node.parent
if parent is not None and parent.type == "export_statement":
for child in parent.children:
if child.type == "decorator":
emit(child, class_nid)
elif child.type in ("class_declaration", "abstract_class_declaration"):
break
# Member decorators inside the class body.
body = next((c for c in class_node.children if c.type == "class_body"), None)
if body is None:
return
for member in body.children:
mt = member.type
if mt == "decorator":
# A method decorator is a sibling preceding the method; skip past any
# stacked decorators to find it.
owner = class_nid
sib = member.next_named_sibling
while sib is not None and sib.type == "decorator":
sib = sib.next_named_sibling
if sib is not None and sib.type == "method_definition":
mname = _ts_method_name(sib, source)
if mname:
owner = _make_id(class_nid, mname)
emit(member, owner)
elif mt == "method_definition":
mname = _ts_method_name(member, source)
m_nid = _make_id(class_nid, mname) if mname else class_nid
for deco in _ts_descendant_decorators(member):
emit(deco, m_nid)
else:
# Fields / accessors: the member is not a node, so attribute its
# decorators (e.g. `@Input()`, `@Column()`) to the class.
for deco in _ts_descendant_decorators(member):
emit(deco, class_nid)
def _ts_heritage_clause_entries(clause_node, source: bytes) -> list[str]:
"""Return base/interface type names from an extends_clause or implements_clause."""
out: list[str] = []
+153
View File
@@ -0,0 +1,153 @@
"""Regression tests: TypeScript/JavaScript decorator references.
`@Component`, `@Injectable`, `@Input`, `@Inject`, `@Entity`, … emitted no edge
to the decorator symbol — the `decorator` node kind was never walked. This is
framework-critical (Angular, NestJS, Vue class components, TypeORM).
Decorators are emitted as `references` edges with context="decorator" from the
decorated entity: the class for class/field/parameter decorators, the method
for method (and its parameter) decorators. Targets resolve through the same
sourceless-stub path as type references, so a decorator imported from another
module collapses onto its real definition.
"""
from pathlib import Path
from graphify.extract import _file_stem, _make_id, extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def _class_nid(file: str, cls: str) -> str:
return _make_id(_file_stem(Path(file)), cls)
def _method_nid(file: str, cls: str, method: str) -> str:
return _make_id(_class_nid(file, cls), method)
def _has_deco(result: dict, owner_nid: str, deco: str) -> bool:
"""True if owner_nid references the (cross-file, bare-stub) decorator symbol."""
tgt = _make_id(deco)
return any(
e["source"] == owner_nid
and e["target"] == tgt
and e["relation"] == "references"
and e.get("context") == "decorator"
for e in result["edges"]
)
def test_class_decorator_on_exported_class(tmp_path):
# The canonical Angular shape: decorator sits on the wrapping export_statement.
f = _write(tmp_path / "src" / "c.ts",
"@Component({ selector: 'app' })\n"
"export class AppComponent {}\n")
r = extract([f], cache_root=tmp_path)
assert _has_deco(r, _class_nid("src/c.ts", "AppComponent"), "Component")
def test_class_decorator_on_plain_class(tmp_path):
f = _write(tmp_path / "src" / "s.ts",
"@Injectable()\nclass Service {}\n")
r = extract([f], cache_root=tmp_path)
assert _has_deco(r, _class_nid("src/s.ts", "Service"), "Injectable")
def test_stacked_class_decorators(tmp_path):
f = _write(tmp_path / "src" / "s.ts",
"@Injectable()\n@Entity()\nexport class Repo {}\n")
r = extract([f], cache_root=tmp_path)
nid = _class_nid("src/s.ts", "Repo")
assert _has_deco(r, nid, "Injectable")
assert _has_deco(r, nid, "Entity")
def test_method_decorator_attributes_to_method(tmp_path):
f = _write(tmp_path / "src" / "c.ts",
"export class C {\n"
" @HostListener('click') onClick() {}\n"
"}\n")
r = extract([f], cache_root=tmp_path)
assert _has_deco(r, _method_nid("src/c.ts", "C", "onClick"), "HostListener")
# and NOT to the class
assert not _has_deco(r, _class_nid("src/c.ts", "C"), "HostListener")
def test_stacked_method_decorators(tmp_path):
f = _write(tmp_path / "src" / "c.ts",
"export class C {\n"
" @Get('/') @UseGuards(Auth) list() {}\n"
"}\n")
r = extract([f], cache_root=tmp_path)
nid = _method_nid("src/c.ts", "C", "list")
assert _has_deco(r, nid, "Get")
assert _has_deco(r, nid, "UseGuards")
def test_field_decorator_attributes_to_class(tmp_path):
# The field is not a graph node, so its decorator attributes to the class.
f = _write(tmp_path / "src" / "c.ts",
"export class C {\n"
" @Input() name: string;\n"
" @Column() age: number;\n"
"}\n")
r = extract([f], cache_root=tmp_path)
nid = _class_nid("src/c.ts", "C")
assert _has_deco(r, nid, "Input")
assert _has_deco(r, nid, "Column")
def test_parameter_decorator_attributes_to_constructor(tmp_path):
f = _write(tmp_path / "src" / "c.ts",
"export class C {\n"
" constructor(@Inject(TOKEN) private s: Svc) {}\n"
"}\n")
r = extract([f], cache_root=tmp_path)
assert _has_deco(r, _method_nid("src/c.ts", "C", "constructor"), "Inject")
def test_namespaced_decorator_uses_property_name(tmp_path):
f = _write(tmp_path / "src" / "c.ts",
"@core.Component({})\nexport class Widget {}\n")
r = extract([f], cache_root=tmp_path)
assert _has_deco(r, _class_nid("src/c.ts", "Widget"), "Component")
def test_external_decorator_stub_disambiguated_per_file(tmp_path):
"""An external decorator (definition absent from the corpus — the common
framework case) still emits a `references`/`decorator` edge from every class
that applies it — the core behavior of this fix.
Convergence note (v0.9.0+): the edges no longer collapse onto a single shared
bare-name stub. v0.9.0 embeds the full repo-relative path in node IDs and
#1462 disambiguates imported type stubs across files, so the same external
`Injectable` referenced from two files now resolves to two distinct per-file
stubs (`src_a_ts_injectable`, `src_b_ts_injectable`) rather than one hub. (A
single in-corpus reference still keeps the bare `injectable` stub — only
cross-file, unresolved references are split.)"""
a = _write(tmp_path / "src" / "a.ts", "@Injectable()\nexport class A {}\n")
b = _write(tmp_path / "src" / "b.ts", "@Injectable()\nexport class B {}\n")
r = extract([a, b], cache_root=tmp_path)
# Each class emits exactly one decorator-context edge to an `Injectable`
# node (checked by label, since the stub id is now path-qualified).
id_to_label = {n["id"]: n.get("label") for n in r["nodes"]}
deco_edges = [
e for e in r["edges"]
if e["relation"] == "references" and e.get("context") == "decorator"
]
a_targets = [e["target"] for e in deco_edges if e["source"] == _class_nid("src/a.ts", "A")]
b_targets = [e["target"] for e in deco_edges if e["source"] == _class_nid("src/b.ts", "B")]
assert len(a_targets) == 1 and id_to_label.get(a_targets[0]) == "Injectable"
assert len(b_targets) == 1 and id_to_label.get(b_targets[0]) == "Injectable"
# v0.9.0 full-path IDs + #1462 stub disambiguation: external stubs are split
# per file, so the two no longer converge on one shared hub.
assert a_targets[0] != b_targets[0], (
"external decorator stubs are disambiguated per file in v0.9.0+; "
"they no longer converge on a single shared stub"
)