Skip to content

feat(schema)!: structured decorators on callables and classes - #136

Merged
rahlk merged 1 commit into
mainfrom
feat/issue-128-structured-decorators
Aug 20, 2026
Merged

feat(schema)!: structured decorators on callables and classes#136
rahlk merged 1 commit into
mainfrom
feat/issue-128-structured-decorators

Conversation

@rahlk

Copy link
Copy Markdown
Contributor

Closes#128.

Problem

decorators was List[str] of raw ast.unparse output, on callables only
(codeanalyzer/schema/py_schema.py:274, written at
codeanalyzer/syntactic_analysis/symbol_table_builder.py:334). Callee, arguments and location
were fused into one opaque string, so @app.route and @blueprint.route bound to the same
function were unequal, and @lru_cache and @lru_cache(maxsize=128) were unrelated.

PyClass had no field at all, and it was not recoverable elsewhere: ClassDef.lineno points at
the class keyword, so decorator lines fall outside PyClass.span and module.source[span.bytes]
cannot reach them. @dataclass was simply gone.

Change

PyDecoratorname, qualified_name, positional_arguments, keyword_arguments,
expression, span — carried on PyCallable and PyClass. expression keeps the full unparsed
source, so decorators too complex to decompose lose nothing.

qualified_name is plumbing, not new analysis. Jedi already resolved these and the result was
discarded — accessed_symbols on a decorated callable already carried functools.lru_cache.
Resolution infers at the last identifier of the callee, so @a.b.c resolves c rather than
a, and is best-effort: dynamic and conditional decorators stay None and never abort the build.

Neo4j: :PyDecorator merges on the resolved qualified_name where there is one, so the two
lru_cache spellings collapse to one node. Per-application facts move onto PY_DECORATED_BY
:PyDecorator is project-shared and never pruned (neo4j/bolt.py:32), so anything
application-specific on the node accumulates across every project in the database.
PY_DECORATED_BY now accepts PyClass as a start label.

Verification

Point dataclass -> dataclasses.dataclass {"frozen": "True"}
risky audit -> app.audit
lru_cache -> functools.lru_cache {"maxsize": "128"}
make builtins.staticmethod -> builtins.staticmethod

7 tests in test/test_decorators_structured.py: class decorator with arguments, callee/argument
separation, local and library resolution, dotted spelling, span byte-slicing, [] rather than a
missing key, and unresolvable decorators yielding None without raising.

Three deviations from the issue

  • No cache-guard change. The issue asked for one. Tested instead: an old-shape cache fails
    Pydantic validation (2 validation errors for PyCallable) and core.py already catches that and
    rebuilds. The shape change is self-invalidating.
  • No plumbing for PyClassAttribute / PyCallableParameter. They get the field for
    cross-language parity, but Python has no decorator syntax for either, so the [] default is the
    entire implementation.
  • qualified_name resolved at L1, not L2. This avoids introducing a second sanctioned
    monotonicity refinement — nothing goes null → id across a level boundary.

docs/handoff/ is deliberately untouched: its README pins it as a frozen bundle for
codeanalyzer-python==1.0.1.

Caveats

  • Breaking.decorators elements change str → object; consumers reading decorators[0] as a
    string must read .name or .qualified_name. python-sdk models this field and needs a
    lockstep update. No schema_version bump — v2 is a moving target until Java/Python/TypeScript
    converge, so there is no stable number to move.
  • Unblocks Receiver binding misses non-bare staticmethod spellings (@builtins.staticmethod) #135: receiver binding can now test qualified_name == "builtins.staticmethod" instead
    of string-matching the spelling.
  • Re-keying :PyDecorator strands nodes in an existing database; a load against a pre-existing
    graph leaves the old string-keyed nodes behind.
  • Suite result below is from before the rebase onto the merged fix(pycg): canonicalize the builtins module spelling on PyCG edges #134; the post-rebase run is in
    progress and will be posted.

`decorators` was a list of raw `ast.unparse` output on callables only. The
callee, its arguments and its location were fused into one opaque string, and
`PyClass` had no field at all -- so `@dataclass` was dropped outright. It was
not recoverable from anything else either: `ClassDef.lineno` points at the
`class` keyword, so decorator lines fall outside `PyClass.span` and
`module.source[span.bytes]` cannot reach them.
Adds `PyDecorator` -- `name`, `qualified_name`, `positional_arguments`,
`keyword_arguments`, `expression`, `span` -- and carries it on `PyCallable` and
`PyClass`. `expression` keeps the full unparsed source so decorators too complex
to decompose lose nothing.
`qualified_name` is plumbing, not new analysis: Jedi already resolved these and
the result was being discarded (`accessed_symbols` on a decorated callable
already carried `functools.lru_cache`). Resolution infers at the LAST identifier
of the callee so `@a.b.c` resolves `c` rather than `a`, and is best-effort --
dynamic and conditional decorators stay `None`, and a failure never aborts the
symbol table.
Neo4j: `:PyDecorator` merges on the resolved `qualified_name` where there is one,
so `@lru_cache` and `@lru_cache(maxsize=128)` stop being two unrelated nodes.
Per-application facts (arguments, expression) move onto `PY_DECORATED_BY`, since
`:PyDecorator` is project-shared and never pruned -- anything application-
specific on the node would accumulate across every project in the database.
`PY_DECORATED_BY` now accepts `PyClass` as a start label.
`PyClassAttribute` and `PyCallableParameter` get the field for cross-language
parity but no plumbing: Python has no decorator syntax for either, so there is
nothing to populate and the `[]` default is the whole implementation.
No cache-guard change is needed, contrary to what the issue assumed: an
old-shape cache fails Pydantic validation (`2 validation errors for PyCallable`)
and core.py already catches that and rebuilds. The shape change is
self-invalidating.
BREAKING CHANGE: `decorators` elements change from `str` to an object. Consumers
reading `decorators[0]` as a string must read `.name` or `.qualified_name`.
`docs/handoff/` is deliberately untouched -- it is a frozen bundle pinned to
1.0.1.
@rahlk
rahlk merged commit 173b93e into mainAug 20, 2026
@rahlkrahlk added enhancement New feature or request bug Something isn't working labels Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugSomething isn't workingenhancementNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@rahlk