Skip to content

Python: defs nested inside a function are never extracted — app-factory (create_app) codebases lose their entire HTTP layer, and inbound traces silently return zero callers #1548

Description

@Alexiiiine

Version: 0.10.0 (npm, Windows 11)
Impact: on an app-factory codebase this silently removes the entire HTTP layer from the graph, and — worse — makes trace_path --direction inbound return zero callers for functions that clearly have callers.

Summary

The Python extractor never descends into a function body. Any def or class whose scope chain passes through a function is absent from the graph.

This is not a general "nested declarations" gap: defs inside module-level if / try / for / with blocks are indexed, so block statements are transparent. The boundary is specifically a function body. JS/TS is unaffected — a plain function declared inside another function indexes fine.

That makes the Flask/FastAPI application-factory pattern (def create_app(): ... @app.get(...) def handler(): ...) a total blind spot. It is the idiomatic way to structure both frameworks.

Minimal reproduction

app_py.py

def target():
    return 0


def module_level():
    return target()


if True:
    def inside_if():
        return target()


def factory():
    def nested():
        return target()
    return nested

app_js.js

function targetJs() { return 0; }

function moduleLevelJs() { return targetJs(); }

function factoryJs() {
  function nestedJs() { return targetJs(); }
  return nestedJs;
}
$ index_repository  -> nodes=26 edges=30 skipped=0 parse_partial=0

$ search_graph label=Function
total: 10
results: 10  (rows: name label lines in out; qn = group prefix + "." + name)
repro.app_js (app_js.js):
  factoryJs Function 5-8 0 0
  moduleLevelJs Function 3-3 0 1
  nestedJs Function 6-6 0 1
  targetJs Function 1-1 2 0
repro.app_py (app_py.py):
  factory Function 14-17 0 0
  inside_if Function 10-11 0 1
  module_level Function 5-6 0 1
  target Function 1-2 3 0

nested (Python) is the only declaration missing. nestedJs — the exact same shape in JavaScript — is present.

The more serious half: call edges are lost, not re-attributed

$ trace_path function_name=target direction=inbound
callers_total: 3
repro.app_py:
  inside_if 1
  module_level 1
repro.app_py.py:
  __file__ 1

$ trace_path function_name=targetJs direction=inbound
callers_total: 2
repro.app_js:
  moduleLevelJs 1
  nestedJs 1

nested calls target(), but that edge does not appear anywhere — it is not re-attributed to the enclosing factory, which shows out = 0. The call is dropped entirely.

Consequence: for any function reached only from inside an app factory, trace_path --direction inbound returns an empty or short list with no indication that anything was skipped. For the "what can reach this dangerous function?" use case this is a silent false negative, which is worse than an error.

(Minor, visible above: a synthetic <file>.py.__file__ node is reported as a caller at hop 1. It also shows up at hop 2 on real traces. It looks like file-level fallback residue leaking into caller lists.)

Full rule, measured

A 24-case matrix against 0.10.0:

case in graph
module-level def / async def / decorated def yes
method in a class; method in a class-in-a-class yes
def inside module-level if / try / for / with yes
def inside a def no
decorated def inside a def no
class declared inside a def, and all of its methods no
def inside a method no
JS: function in function, arrow in function, object-literal method, class method yes

Real-world impact

On a production FastAPI codebase (~4,600 Python/TS symbols) where the app is built in a single create_app():

  • that one module: 99 of 376 defs indexed (26%) — 278 handlers invisible
  • 90 of 190 route paths resolved (47%). By verb: POST 60/60, PATCH 16/16, PUT 2/2, DELETE 6/6 — but GET only 21/111, because the read endpoints are the ones defined inside the factory
  • core endpoints such as /api/summary are absent, and their handler functions return 0 hits from search_graph
  • this single pattern accounts for ~38% of the repo-wide 750-symbol extraction gap

Every other module in the same repo is 95–100% covered, so the defect is narrow and concentrated — which is also what makes it easy to miss: aggregate coverage still looks healthy.

Suggested fix

Recurse into function_definition / async bodies when collecting definitions, the same way class_body is already traversed, and parent nested symbols to the enclosing function rather than dropping them. Even behind an opt-in flag this would close the gap.

If full recursion is considered too noisy for ordinary closures, a targeted alternative: descend when the inner def carries a decorator (which is exactly the route-handler case) — though the call-edge loss above would remain for undecorated helpers.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    parsing/qualityGraph extraction bugs, false positives, missing edgeswindowsWindows-specific issues

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions