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:
- Attribute to the decorated function —
handler 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.
- Attribute to module scope — needs a module-level pseudo-qname, which
extract_calls_per_function has no concept of today.
- 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
- A decision recorded for the question above (an ADR if option 3).
- Decorator calls captured per that decision, with
@a.b(...) resolving to the same basename convention _callee_basename already uses.
- Tests: bare decorator (
@property, no call), single call decorator, stacked decorators, decorator on a method inside a class, decorator on a class.
- 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.
- Downstream impact assessed by name:
ingest_codebase and anything consuming its call edges, since existing graphs gain edges on re-index.
The gap
Calls made in a decorator are invisible to the code graph.
routeandretryare 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_callshas adecorated_definitionarm:which is byte-identical to the
elsearm 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 exactlyfunction_definition,class_definitionanddecorated_definitionwith 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:
handlergainsrouteandretry. Simple, and matches "this function's definition involves these calls". Conflates what the function calls at runtime with what runs at import time.extract_calls_per_functionhas no concept of today.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_functionoutput for every decorated function in every indexed repository, so it needs a deliberate decision rather than a drive-by.Acceptance criteria
@a.b(...)resolving to the same basename convention_callee_basenamealready uses.@property, no call), single call decorator, stacked decorators, decorator on a method inside a class, decorator on a class.decorated_definitionarm's mutants become killable, since the arm stops being behaviourally identical to the catch-all. Verify with the scoped runner overmcp_server/core/ast_extractors.py.ingest_codebaseand anything consuming its call edges, since existing graphs gain edges on re-index.