mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
869aaf7502
commit
35404167d9
@@ -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] = []
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user