mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
35404167d9
`@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>
154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
"""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"
|
|
)
|