Skip to content

feat(ast): decorator calls produce no graph edge — the decorated_definition arm in _walk_for_calls is an unfilled seam #372

Description

@cdeust

The gap

Calls made in a decorator are invisible to the code graph.

@app.route("/api")
@retry(times=3)
def handler():
    work()
extract_calls_per_function -> {'handler': ['work']}

route and retry are call expressions in the source and produce no edge anywhere. For a graph whose purpose is answering "what calls what", decorator calls are not decoration: @app.route(...), @celery.task(...), @pytest.fixture(...) and @click.command(...) are how routes, tasks, fixtures and CLI entry points get registered. A consumer asking "what registers this route" gets nothing.

Why this is filed rather than fixed in passing

Found while pinning the extractor walkers (#369). _walk_for_calls has a decorated_definition arm:

elif ntype == "decorated_definition":
    stack.extend((c, scope) for c in reversed(child.children))

which is byte-identical to the else arm that follows it, so mutating it is undetectable and mutation reports its mutants as survivors. I initially removed it as dead code. That was wrong, and the maintainer caught it: the arm mirrors _extract_python_children's dispatch, where the equivalent branch is load-bearing — that function handles exactly function_definition, class_definition and decorated_definition with no catch-all, so without it a decorated function disappears from definitions entirely.

The arm in the calls path is therefore a seam that was never filled in, not dead weight. It has been kept, and this issue is the missing behaviour.

Design question to settle first

Where does a decorator call belong? Three defensible answers, and the choice is observable to every consumer:

  1. Attribute to the decorated functionhandler gains route and retry. Simple, and matches "this function's definition involves these calls". Conflates what the function calls at runtime with what runs at import time.
  2. Attribute to module scope — needs a module-level pseudo-qname, which extract_calls_per_function has no concept of today.
  3. A distinct edge kind — truest, most work, and requires the consumers (ingest_codebase, wiki reference pages) to understand a new relation.

Option 1 is the cheapest and probably right for a first pass, but it changes extract_calls_per_function output for every decorated function in every indexed repository, so it needs a deliberate decision rather than a drive-by.

Acceptance criteria

  1. A decision recorded for the question above (an ADR if option 3).
  2. Decorator calls captured per that decision, with @a.b(...) resolving to the same basename convention _callee_basename already uses.
  3. Tests: bare decorator (@property, no call), single call decorator, stacked decorators, decorator on a method inside a class, decorator on a class.
  4. Mutation: the decorated_definition arm's mutants become killable, since the arm stops being behaviourally identical to the catch-all. Verify with the scoped runner over mcp_server/core/ast_extractors.py.
  5. Downstream impact assessed by name: ingest_codebase and anything consuming its call edges, since existing graphs gain edges on re-index.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions