diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 361aa5815..aa1e53529 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3581,6 +3581,41 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # node orphaned (#1050). Treat decorated_definition as a transparent # wrapper so parent_class_nid propagates to the real function node. if t == "decorated_definition": + # Applying a decorator emitted no edge to the decorator symbol, so + # `affected ` reported nothing for the functions it wraps + # (#2154). Emit the same shape TS/JS already emits in + # `_ts_emit_decorator_edges`: a `references` edge (context= + # "decorator") from the decorated function/class to each decorator, + # resolved via ensure_named_node so an imported decorator becomes a + # sourceless stub the corpus rewire collapses onto its definition. + # The owner ids mirror the definition branches below/above verbatim, + # so the edge lands on the node the walk is about to create. + if config.ts_module == "tree_sitter_python": + inner = node.child_by_field_name("definition") + inner_name = None + if inner is not None: + name_node = inner.child_by_field_name("name") + inner_name = _read_text(name_node, source) if name_node else None + # A name that normalizes to nothing is skipped by the definition + # branches (#1899), so an edge to it would dangle. + if inner_name and normalize_id(inner_name): + if inner.type in config.class_types: + owner_nid = _make_id(stem, ".".join(namespace_stack), inner_name) + elif parent_class_nid: + owner_nid = _make_id(parent_class_nid, inner_name) + else: + owner_nid = _make_id(stem, inner_name) + for child in node.children: + if child.type != "decorator": + continue + deco_name = _python_decorator_name(child, source) + if not deco_name: + continue + deco_line = child.start_point[0] + 1 + target = ensure_named_node(deco_name, deco_line) + if target != owner_nid: + add_edge(owner_nid, target, "references", deco_line, + context="decorator") for child in node.children: walk(child, parent_class_nid=parent_class_nid) return @@ -4480,6 +4515,28 @@ def _scan_js_module_dispatch(n) -> None: result["csharp_type_table"] = {"path": str_path, "table": cs_table} return result +def _python_decorator_name(deco_node, source: bytes) -> str | None: + """Return the head symbol of a Python `decorator` node. + + The Python twin of `_ts_decorator_name`, differing only in grammar node + names: `@traced` -> the identifier; `@retry(times=3)` -> the `function` of + the `call`; `@app.route("/")` / `@mod.deco` -> the `attribute` (the symbol + itself, not the module alias it is reached through). + """ + for child in deco_node.children: + if not child.is_named: + continue + target = child + if target.type == "call": + target = target.child_by_field_name("function") or target + if target.type == "attribute": + attr = target.child_by_field_name("attribute") + return _read_text(attr, source) if attr else None + if target.type == "identifier": + return _read_text(target, source) + return None + return None + def _ts_decorator_name(deco_node, source: bytes) -> str | None: """Return the head symbol of a TS `decorator` node. diff --git a/tests/test_python_decorators.py b/tests/test_python_decorators.py new file mode 100644 index 000000000..76307e8f1 --- /dev/null +++ b/tests/test_python_decorators.py @@ -0,0 +1,168 @@ +"""Regression tests: Python decorator references (#2154). + +Applying a Python decorator emitted no edge to the decorator symbol, so +`affected ` answered "No affected nodes found" for every function it +wraps — a silent false negative on reverse-impact queries. + +TS/JS already emitted these edges (`_ts_emit_decorator_edges`); the Python +`decorated_definition` branch walked its children only to propagate the parent +class id (#1050) and never looked at the `decorator` children. Python now emits +the same shape: `references` edges with context="decorator" from the decorated +function/class to the decorator symbol, resolved through the same +sourceless-stub path as type references so an imported decorator 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 _stem(file: str) -> str: + return _file_stem(Path(file)) + + +def _func_nid(file: str, func: str) -> str: + return _make_id(_stem(file), func) + + +def _class_nid(file: str, cls: str) -> str: + return _make_id(_stem(file), cls) + + +def _method_nid(file: str, cls: str, method: str) -> str: + return _make_id(_class_nid(file, cls), method) + + +def _deco_edges(result: dict, owner_nid: str) -> set[str]: + """Decorator-reference edge targets emitted from owner_nid.""" + return { + e["target"] + for e in result["edges"] + if e["source"] == owner_nid + and e["relation"] == "references" + and e.get("context") == "decorator" + } + + +def test_module_level_function_decorator(tmp_path): + # The issue repro: decorator imported from another module, applied to a + # module-level function. Target is the sourceless stub the rewire collapses. + f = _write(tmp_path / "pkg" / "consumer.py", + "from deco import my_decorator\n" + "\n" + "@my_decorator\n" + "def business_logic():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("my_decorator") in _deco_edges( + r, _func_nid("pkg/consumer.py", "business_logic")) + + +def test_same_file_decorator_resolves_to_local_definition(tmp_path): + # Decorator defined above its use in the same file: the edge must point at + # the real local node, not a stub. + f = _write(tmp_path / "pkg" / "local.py", + "def my_decorator(fn):\n" + " return fn\n" + "\n" + "@my_decorator\n" + "def business_logic():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _func_nid("pkg/local.py", "my_decorator") in _deco_edges( + r, _func_nid("pkg/local.py", "business_logic")) + + +def test_decorator_with_arguments(tmp_path): + # `@deco(arg)` is a `call` node; the head symbol is its `function` field. + f = _write(tmp_path / "pkg" / "args.py", + "from deco import retry\n" + "\n" + "@retry(times=3)\n" + "def flaky():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("retry") in _deco_edges(r, _func_nid("pkg/args.py", "flaky")) + + +def test_attribute_decorator_targets_the_symbol_not_the_module(tmp_path): + # `@app.route("/")` is an `attribute` under a `call`; the target is `route`, + # matching _ts_decorator_name's member_expression handling. + f = _write(tmp_path / "pkg" / "web.py", + "import app\n" + "\n" + "@app.route(\"/\")\n" + "def index():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + targets = _deco_edges(r, _func_nid("pkg/web.py", "index")) + assert _make_id("route") in targets + assert _make_id("app") not in targets + + +def test_stacked_decorators_all_emit(tmp_path): + f = _write(tmp_path / "pkg" / "stack.py", + "from deco import a, b, c\n" + "\n" + "@a\n" + "@b\n" + "@c\n" + "def target():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + targets = _deco_edges(r, _func_nid("pkg/stack.py", "target")) + assert {_make_id("a"), _make_id("b"), _make_id("c")} <= targets + + +def test_decorated_method_owner_is_class_qualified(tmp_path): + # Guards the #1050 interaction: the owner must be the class-qualified method + # id, not a bare module-level id. + f = _write(tmp_path / "pkg" / "svc.py", + "from deco import traced\n" + "\n" + "class Service:\n" + " @traced\n" + " def handle(self):\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("traced") in _deco_edges( + r, _method_nid("pkg/svc.py", "Service", "handle")) + + +def test_property_still_class_qualified(tmp_path): + # #1050 regression guard: @property/@staticmethod must not change the + # method node's class-qualified id. + f = _write(tmp_path / "pkg" / "prop.py", + "class Config:\n" + " @property\n" + " def name(self):\n" + " return 1\n") + r = extract([f], cache_root=tmp_path) + assert any(n["id"] == _method_nid("pkg/prop.py", "Config", "name") + for n in r["nodes"]) + + +def test_decorated_class(tmp_path): + f = _write(tmp_path / "pkg" / "model.py", + "from dataclasses import dataclass\n" + "\n" + "@dataclass\n" + "class Point:\n" + " x: int\n") + r = extract([f], cache_root=tmp_path) + assert _make_id("dataclass") in _deco_edges( + r, _class_nid("pkg/model.py", "Point")) + + +def test_undecorated_function_emits_no_decorator_edge(tmp_path): + f = _write(tmp_path / "pkg" / "plain.py", + "def plain():\n" + " pass\n") + r = extract([f], cache_root=tmp_path) + assert _deco_edges(r, _func_nid("pkg/plain.py", "plain")) == set()