diff --git a/graphify/extract.py b/graphify/extract.py index a6ac4852..bdd8280e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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] = [] diff --git a/tests/test_ts_decorators.py b/tests/test_ts_decorators.py new file mode 100644 index 00000000..95e33789 --- /dev/null +++ b/tests/test_ts_decorators.py @@ -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" + )