Skip to content

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

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 main6f02581).

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

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