From 794cf9ebb6a348c477de2e7cc0ca7c98a182fc95 Mon Sep 17 00:00:00 2001 From: Rishet Mehra Date: Sat, 25 Jul 2026 06:11:34 +0530 Subject: [PATCH] fix(extract): emit references edges for Python decorators (#2154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 emit these edges via `_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: a `references` edge with context="decorator" from the decorated function/class to each decorator symbol. Owner ids reuse the definition branches' own formulas, so the edge lands on the node the walk creates. Targets go through `ensure_named_node`, so an imported decorator becomes a sourceless stub the corpus rewire collapses onto its real definition. Stacked, called (`@retry(3)`) and attribute (`@app.route`) decorators are covered; the attribute form targets the symbol, not the module alias, matching `_ts_decorator_name`. --- graphify/extractors/engine.py | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 361aa58..aa1e535 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3581,6 +3581,41 @@ def _extract_generic( # 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 _extract_generic( 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.