Skip to content

Structured decorator representation: PyDecorator on callable, class, attribute, parameter #128

Closed

Description

@rahlk

Part of the epic codellm-devkit/.github#35
(canonical schema v2 consistency). The decorator shape is decided by
.github#36, the canonical projection
contract — this issue implements whatever #36 settles on.
Evidence and recommended shape:
docs/design/specs/decorator-representation-input.md.
Supersedes #127 (folded in) and #129 (answered by qualified_name, no edge family).

Blocked on #36. Do not start until the projection contract fixes the shape.

Problem

decorators is a flat list of ast.unparse output — ["trace('hot')", "functools.lru_cache(maxsize=128)"] — on PyCallable only.

The keystone specifies the opposite, at L1, on type/callable/field, and says so in as many
words: "Structured { name, args[], span } — not flat strings."
codeanalyzer-typescript-v2 already ships that shape (src/schema/schema.ts, TSDecorator) on
classes, callables, parameters, and properties. Python is the divergent analyzer.

The cost of the current shape: @app.route and @blueprint.route are unequal strings even when
they resolve to the same function, arguments are not separable from the callee, and PyClass
carries no decorators at all — so @dataclass is dropped silently (verified on main 6f02581).

Scope boundary

Implements the shape settled by .github#36 in codeanalyzer-python only. Does not close
TypeScript's mirror-image gap — it has the structured data but no Neo4j decorator projection,
which is its own migration item under #35. Does not touch python-sdk
(its own work item, filed when picked up), does not add a decoration edge family (spec D5), does
not address async (#130), does not fix the PyCG call-edge gap (#131), and does not touch
codeanalyzer-java's annotations.

Goals

  • PyDecorator model: name, qualified_name: Optional[str], positional_arguments: List[str], keyword_arguments: Dict[str, str], span: Optional[Span]
  • PyCallable.decorators retyped List[str] → List[PyDecorator]
  • decorators added to PyClass, PyClassAttribute, PyCallableParameter
  • Populated at L1 for all four, at every nesting depth
  • qualified_name resolved via Jedi; None when unresolvable, never raising
  • Neo4j: :PyDecorator gains qualified_name, keeps merging on name (spec D7); PY_DECORATED_BY start labels widened to include PyClass
  • Cache-invalidation guard (core.py:728) rejects caches built with the old decorator shape
  • .claude/SCHEMA_DECISIONS.md gains one line per decision D1–D8, linking the spec

Caveats and known risks

  • No schema_version bump, and none is claimed. Schema v2 is a moving target until Java,
    Python, and TypeScript settle on a stable shape, so there is no frozen contract number for this
    change to amend. python-sdk ships decorators: List[str] today and breaks with no version
    signal — that is a real cost, and the mitigation is the SDK update, not a number.
  • Stale caches are the sharp edge. core.py:728 accepts any cache matching the current
    schema_version literal. Change the shape without moving that guard and a cached analysis
    silently feeds old-shape decorators through a new analyzer.
  • qualified_name is best-effort. Dynamic attribute access, conditional imports, and
    runtime-built decorators will not resolve. None is the normal path there, not an error.
  • PyCallableParameter.decorators will always be empty — Python has no parameter-decorator
    syntax. Carried for cross-language parity; droppable at review if judged dead weight.
  • Monotonicity gate applies: decorators are L1 data and must not be gated behind a level.

Definition of done

  • @functools.lru_cache(maxsize=128) emits name: "lru_cache",
    qualified_name: "functools.lru_cache", keyword_arguments: {"maxsize": "128"}
  • @trace('hot') emits positional_arguments: ["'hot'"] and resolves qualified_name to the
    declaring module's trace
  • @dataclass emits on the class node; a decorated class attribute and a decorated nested
    class both emit
  • @app.route and @blueprint.route bound to the same function produce the same qualified_name
  • A deliberately unresolvable decorator emits qualified_name: null and does not raise
  • A cache written with the old shape is rejected and rebuilt, not round-tripped
  • Every existing test that reads decorators as strings is updated, not deleted
  • analysis.json(-a 1) ⊆ ... ⊆ (-a 4) still holds
  • Neo4j PY_DECORATED_BY traverses from a :PyClass node and qualified_name is queryable

Activity

  1. changed the title [-]Decorators are raw source strings with no resolved identity[/-] [+]Structured decorator representation: PyDecorator on callable, class, attribute, parameter[/+] on Aug 19, 2026
  2. rahlk commented on Aug 20, 2026

    @rahlk
    ContributorAuthor

    Three findings from a verification pass on a0b94ff, plus an unblock.

    Unblocked. The "blocked on .github#36" note is withdrawn. That block assumed the decorator shape was a cross-analyzer contract decision. It is now settled that codeanalyzer-python is the reference implementation and TypeScript/Java migrate to whatever it lands, so the shape is decided here.

    1. Class decorators are unrecoverable, not merely unstored. ClassDef.lineno points at the class keyword, so decorator lines fall outside PyClass.span:

    class span text starts: 'class Point:\n    x: int\n\n    @'
    @dataclass inside span? False
    

    So module.source[span.bytes] — the documented get_method_body contract — cannot recover them either. The field is the only possible home.

    2. The resolution this issue asks for already exists and is discarded. accessed_symbols on a decorated callable already carries Jedi's resolution:

    {'name': 'audit',      'qualified_name': 'app.audit',            'kind': 'function'}
    {'name': 'lru_cache',  'qualified_name': 'functools.lru_cache',  'kind': 'function'}
    decorators field: ['audit', 'lru_cache(maxsize=128)']
    

    qualified_name is computed at build time and simply not attached to the decorator. That makes the qualified_name goal substantially cheaper than this issue implies — it is plumbing, not new resolution.

    3. An internal consumer is already broken by the string shape. Filed separately as #135: access_paths.py:255 string-matches "staticmethod" not in decorators, so @builtins.staticmethod on a method whose first parameter is self/cls gets a receiver it does not have. Narrow trigger, but it demonstrates the cost inside the analyzer, not just downstream.

    Also worth deciding before building: a decorator callee going null → id at L1→L2 would be a second sanctioned monotonicity refinement. .claude/SCHEMA_DECISIONS.md currently names exactly one (call.callee), plus BodyNode.parent at L2→L3. Resolve deliberately rather than at CI.

    Not verified: no test asserts decorator content today — test/test_cli.py:147-172 only counts call-graph edges on the decorator fixture. Stated from a reading of that test, not from a run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions