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
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 inboundreturn zero callers for functions that clearly have callers.Summary
The Python extractor never descends into a function body. Any
deforclasswhose 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/withblocks are indexed, so block statements are transparent. The boundary is specifically a function body. JS/TS is unaffected — a plainfunctiondeclared inside anotherfunctionindexes 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.pyapp_js.jsnested(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
nestedcallstarget(), but that edge does not appear anywhere — it is not re-attributed to the enclosingfactory, which showsout = 0. The call is dropped entirely.Consequence: for any function reached only from inside an app factory,
trace_path --direction inboundreturns 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:
def/async def/ decorateddefdefinside module-levelif/try/for/withdefinside adefdefinside adefclassdeclared inside adef, and all of its methodsdefinside a methodfunctioninfunction, arrow infunction, object-literal method, class methodReal-world impact
On a production FastAPI codebase (~4,600 Python/TS symbols) where the app is built in a single
create_app():/api/summaryare absent, and their handler functions return 0 hits fromsearch_graphEvery 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/asyncbodies when collecting definitions, the same wayclass_bodyis 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
defcarries a decorator (which is exactly the route-handler case) — though the call-edge loss above would remain for undecorated helpers.Related
function-in-functionin JS does index today, so Python is a strictly worse case.