@gate-forge/pack-fastapi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +171 -0
- package/dist/detector.d.ts +73 -0
- package/dist/detector.d.ts.map +1 -0
- package/dist/detector.js +192 -0
- package/dist/detector.js.map +1 -0
- package/dist/facts.d.ts +65 -0
- package/dist/facts.d.ts.map +1 -0
- package/dist/facts.js +109 -0
- package/dist/facts.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +47 -0
- package/python/gateforge_fastapi_detector/__init__.py +9 -0
- package/python/gateforge_fastapi_detector/__main__.py +99 -0
- package/python/gateforge_fastapi_detector/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/gateforge_fastapi_detector/__pycache__/__init__.cpython-314.pyc +0 -0
- package/python/gateforge_fastapi_detector/__pycache__/__main__.cpython-312.pyc +0 -0
- package/python/gateforge_fastapi_detector/__pycache__/__main__.cpython-314.pyc +0 -0
- package/python/gateforge_fastapi_detector/__pycache__/scan.cpython-312.pyc +0 -0
- package/python/gateforge_fastapi_detector/__pycache__/scan.cpython-314.pyc +0 -0
- package/python/gateforge_fastapi_detector/scan.py +1362 -0
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
"""AST-only FastAPI route discovery for Gateforge (GPP/3 detector).
|
|
2
|
+
|
|
3
|
+
Parses Python source with the stdlib ``ast`` module and reports FastAPI
|
|
4
|
+
server routes as ``http.contract`` evidence resources WITHOUT importing or
|
|
5
|
+
executing any application code (ADR 0004 D1; plan phase 2). Stdlib only
|
|
6
|
+
(Python >= 3.11); the GPP/3 serve loop lives in ``__main__.py``.
|
|
7
|
+
|
|
8
|
+
Detector vocabulary (frozen with the pack):
|
|
9
|
+
|
|
10
|
+
- Contract resources: ``kind`` ``http.contract`` (engine-owned
|
|
11
|
+
evidence-only kind — never a business resource, never classified
|
|
12
|
+
directly). Attributes carry ``role`` (always ``server-route`` here),
|
|
13
|
+
``method`` (one concrete verb per fact), ``rawPath`` (exactly as
|
|
14
|
+
written), ``normalizedPath`` (empty here; canonicalized TypeScript-side
|
|
15
|
+
by the pack wrapper so canonicalization has exactly one implementation),
|
|
16
|
+
``framework`` (``fastapi``), ``handlerSymbol``, ``isAsync``,
|
|
17
|
+
``responseModel``, ``requestSchemaSymbols``, ``tags``, ``operationId``,
|
|
18
|
+
and ``mountProvenance`` (``include-chain`` or ``standalone``).
|
|
19
|
+
- One fact per (effective mounted path, concrete method): an
|
|
20
|
+
``api_route(methods=[...])`` yields one fact per listed method, and a
|
|
21
|
+
router mounted twice yields one fact per mount (plan phase 2.3).
|
|
22
|
+
- Standalone routers (never the target of a resolvable ``include_router``)
|
|
23
|
+
emit their routes at their own prefix with ``mountProvenance``
|
|
24
|
+
``standalone`` — matching the legacy wiring scanner's default mount.
|
|
25
|
+
- Registry functions (the ``def register_all_routers(app): ...
|
|
26
|
+
app.include_router(r, prefix=...)`` pattern): a function whose body
|
|
27
|
+
calls ``include_router`` on one of ITS OWN parameters collects those
|
|
28
|
+
include edges keyed by the parameter. A call site whose argument
|
|
29
|
+
resolves to a known ``FastAPI()``/``APIRouter()`` instance variable
|
|
30
|
+
(same-file assignment — module-level or factory-local — or an import
|
|
31
|
+
binding to another scanned file's instance) has the edges REWIRED onto
|
|
32
|
+
that instance: the includes behave exactly as if written on the
|
|
33
|
+
instance (mount provenance ``include-chain``, the include call's own
|
|
34
|
+
source location, repeated mounts duplicate). Chaining is bounded:
|
|
35
|
+
a registry function may pass its parameter to another helper
|
|
36
|
+
(``def create_app(app): register_all_routers(app)``) up to
|
|
37
|
+
``MAX_RESOLUTION_DEPTH`` helper hops; beyond the bound the outcome is
|
|
38
|
+
one typed unresolved entry naming the function where the chain still
|
|
39
|
+
grows — never a silent drop, never a guess. A call site whose argument
|
|
40
|
+
cannot be resolved to a known instance is a typed unresolved entry
|
|
41
|
+
naming the exact call site, and emits NOTHING: the routers are
|
|
42
|
+
provably included (their source carries prefixes), so prefix-less
|
|
43
|
+
standalone paths would fabricate routes — the honest closed-world
|
|
44
|
+
outcome is the blocking entry. The same applies to computed (non-Name)
|
|
45
|
+
argument expressions. Parameter names that shadow a same-file instance
|
|
46
|
+
variable keep the module-level reading only (no double emission).
|
|
47
|
+
- Package-attribute imports: ``from pkg import attr`` where
|
|
48
|
+
``pkg/attr.py`` does not exist resolves ``attr`` through the package's
|
|
49
|
+
``__init__.py`` module-level bindings — a router assignment
|
|
50
|
+
(``router = APIRouter()``) or an import re-export
|
|
51
|
+
(``from .endpoints import router``), followed up to
|
|
52
|
+
``MAX_RESOLUTION_DEPTH`` hops. Ambiguity flows into the existing typed
|
|
53
|
+
unresolved entries (never a guess).
|
|
54
|
+
- Import roots: when the caller configures them (``scan(...,
|
|
55
|
+
import_roots=[...])``, repo-root-relative directories that act as
|
|
56
|
+
Python import roots, e.g. ``["backend"]``), ABSOLUTE imports resolve
|
|
57
|
+
through them: ``from api.v1.endpoints import activities`` binds
|
|
58
|
+
``<importRoot>/api/v1/endpoints/activities.py`` (or its package
|
|
59
|
+
``__init__.py``), so centrally-registered routers join the mount graph
|
|
60
|
+
with their real prefixes. Uniqueness is mandatory: a dotted module
|
|
61
|
+
matching MORE THAN ONE scanned file across the roots is a typed
|
|
62
|
+
unresolved entry (``FASTAPI_PREFIX_UNRESOLVED`` with an ambiguous-match
|
|
63
|
+
detail) — never a guess. With import roots configured the absolute-
|
|
64
|
+
import suffix heuristic is disabled (explicit roots govern); relative
|
|
65
|
+
imports are unaffected. Without import roots every behavior is exactly
|
|
66
|
+
as before (closed-world: back-compat).
|
|
67
|
+
- ``unresolved`` entries: ``FASTAPI_PREFIX_UNRESOLVED`` for computed
|
|
68
|
+
router/include prefixes, unresolvable or ambiguous include
|
|
69
|
+
targets/imports/aliases, unresolvable registry-function call-site
|
|
70
|
+
arguments, include cycles, and registry chains beyond the helper-depth
|
|
71
|
+
bound; ``HTTP_PATH_DYNAMIC`` for non-literal route paths;
|
|
72
|
+
``HTTP_METHOD_DYNAMIC`` for decorator verbs outside the supported set.
|
|
73
|
+
All are source-located and blocking — nothing disappears silently.
|
|
74
|
+
- No app import, no route execution, no environment or network access
|
|
75
|
+
(plan phase 2 anti-pattern guards).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import ast
|
|
81
|
+
from dataclasses import dataclass, field
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
PLUGIN_ID = "gateforge.pack-fastapi"
|
|
85
|
+
VERSION = "0.1.0"
|
|
86
|
+
|
|
87
|
+
CONTRACT_KIND = "http.contract"
|
|
88
|
+
FRAMEWORK = "fastapi"
|
|
89
|
+
|
|
90
|
+
# Typed outcome codes (mirrored in @gate-forge/http-contract codes.ts).
|
|
91
|
+
FASTAPI_PREFIX_UNRESOLVED = "FASTAPI_PREFIX_UNRESOLVED"
|
|
92
|
+
|
|
93
|
+
_DECORATOR_METHODS = {
|
|
94
|
+
"get": "GET",
|
|
95
|
+
"post": "POST",
|
|
96
|
+
"put": "PUT",
|
|
97
|
+
"patch": "PATCH",
|
|
98
|
+
"delete": "DELETE",
|
|
99
|
+
"head": "HEAD",
|
|
100
|
+
"options": "OPTIONS",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# HTTP verbs FastAPI supports that this pack deliberately does not map to
|
|
104
|
+
# the contract method set: decorated routes are reported as typed
|
|
105
|
+
# unresolved entries instead of silently vanishing.
|
|
106
|
+
_UNSUPPORTED_METHODS = {"trace": "TRACE"}
|
|
107
|
+
|
|
108
|
+
_PRIMITIVE_ANNOTATIONS = {
|
|
109
|
+
"str", "int", "float", "bool", "bytes", "dict", "list", "set", "tuple",
|
|
110
|
+
"Annotated", "Optional", "Union", "Any", "None",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Bounded interprocedural expansion (documented, deterministic): how many
|
|
114
|
+
# helper hops a registry-function parameter may travel before the walk
|
|
115
|
+
# stops, and how many ``__init__.py`` import bindings one name may pass
|
|
116
|
+
# through. Beyond the bound the outcome is a typed unresolved entry —
|
|
117
|
+
# never a silent drop, never a guess.
|
|
118
|
+
MAX_RESOLUTION_DEPTH = 8
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class RouteDef:
|
|
123
|
+
"""One route decorator on one handler function."""
|
|
124
|
+
|
|
125
|
+
methods: list[str] # concrete supported verbs (may be empty)
|
|
126
|
+
path: str | None # literal path, or None when computed
|
|
127
|
+
node: ast.AST # the decorator call (location anchor)
|
|
128
|
+
handler: str # function name
|
|
129
|
+
is_async: bool
|
|
130
|
+
response_model: str | None
|
|
131
|
+
request_schemas: list[str]
|
|
132
|
+
tags: list[str]
|
|
133
|
+
operation_id: str | None
|
|
134
|
+
file: str = "" # file the decorator lives in (survives alias merge)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class RouterDef:
|
|
139
|
+
"""One router-like variable: an ``APIRouter(...)`` or an import alias."""
|
|
140
|
+
|
|
141
|
+
var: str
|
|
142
|
+
prefix: str | None # literal prefix ('' when absent; None when computed)
|
|
143
|
+
prefix_node: ast.AST
|
|
144
|
+
routes: list[RouteDef] = field(default_factory=list)
|
|
145
|
+
# Set when the variable is an import alias bound to a router defined in
|
|
146
|
+
# another scanned module: ``(raw_module, level, imported_name)``.
|
|
147
|
+
alias_of: tuple[str | None, int, str] | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class IncludeEdge:
|
|
152
|
+
"""One ``<owner>.include_router(<target>, prefix=...)`` call.
|
|
153
|
+
|
|
154
|
+
``owner_var`` is the owner expression's root name: an instance var, an
|
|
155
|
+
import alias — or a registry function's PARAMETER (function-mediated
|
|
156
|
+
includes; materialized onto a real instance by the resolver). ``file``
|
|
157
|
+
is the file the call is written in; resolution and locations for the
|
|
158
|
+
edge always use it (materialized edges keep their source file, not the
|
|
159
|
+
mounted instance's file).
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
owner_var: str # owning router/app var (same file)
|
|
163
|
+
target_var: str | None # same-file Name target (var or alias)
|
|
164
|
+
target_alias: tuple[str | None, int, str] | None # Alias.attr target import ref
|
|
165
|
+
target_attrs: list[str] # attribute chain for alias targets (e.g. ['activities', 'router'])
|
|
166
|
+
prefix: str | None # literal include prefix ('' absent; None computed)
|
|
167
|
+
node: ast.AST
|
|
168
|
+
file: str = "" # file the include call lives in
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class FunctionIncludes:
|
|
173
|
+
"""One top-level function's registry-function record.
|
|
174
|
+
|
|
175
|
+
``param_edges`` maps a parameter name to the ``include_router`` edges
|
|
176
|
+
whose owner expression is that parameter (the same edge objects that
|
|
177
|
+
live in ``FileIndex.include_edges``, so identity-based dedup works
|
|
178
|
+
across the bounded chaining passes). Empty for plain functions.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
name: str
|
|
182
|
+
node: ast.AST
|
|
183
|
+
params: tuple[str, ...] # positional parameters (call sites bind positionally)
|
|
184
|
+
param_edges: dict[str, list[IncludeEdge]] = field(default_factory=dict)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class HelperCall:
|
|
189
|
+
"""One plain-name call carrying at least one positional Name argument.
|
|
190
|
+
|
|
191
|
+
Recorded for every such call (module level or inside a top-level
|
|
192
|
+
function); only calls whose callee resolves to a scanned function with
|
|
193
|
+
include-bearing parameters ever participate in propagation.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
callee: str # called function's name as written
|
|
197
|
+
args: list[ast.AST] # positional argument expressions
|
|
198
|
+
node: ast.AST # the call (typed-unresolved anchor)
|
|
199
|
+
enclosing: str | None # enclosing top-level function (None: module level)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class ImportResolution:
|
|
204
|
+
"""Outcome of resolving one import against the scanned set.
|
|
205
|
+
|
|
206
|
+
``module`` is the dotted scanned module (a ``module_map`` key) when
|
|
207
|
+
uniquely resolved. ``ambiguous`` carries the matching scanned files
|
|
208
|
+
when the import matches more than one — failure with proof, never a
|
|
209
|
+
guess.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
module: str | None = None
|
|
213
|
+
ambiguous: tuple[str, ...] | None = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass
|
|
217
|
+
class ImportRef:
|
|
218
|
+
"""One import binding usable for cross-file resolution."""
|
|
219
|
+
|
|
220
|
+
module: str | None # dotted module (absolute portion)
|
|
221
|
+
name: str | None # imported top-level name (None: alias is the module)
|
|
222
|
+
level: int # relative-import depth (0: absolute)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class FileIndex:
|
|
227
|
+
"""Everything one parsed file contributes to the mount graph."""
|
|
228
|
+
|
|
229
|
+
relpath: str
|
|
230
|
+
routers: dict[str, RouterDef] = field(default_factory=dict)
|
|
231
|
+
apps: set[str] = field(default_factory=set)
|
|
232
|
+
imports: dict[str, ImportRef] = field(default_factory=dict)
|
|
233
|
+
include_edges: list[IncludeEdge] = field(default_factory=list)
|
|
234
|
+
functions: dict[str, FunctionIncludes] = field(default_factory=dict)
|
|
235
|
+
helper_calls: list[HelperCall] = field(default_factory=list)
|
|
236
|
+
unsupported: list[tuple[ast.AST, list[str]]] = field(default_factory=list)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def loc(relpath: str, node: ast.AST) -> dict:
|
|
240
|
+
"""Canonical location triple: 1-based line, 0-based col."""
|
|
241
|
+
return {"file": relpath, "line": node.lineno, "col": getattr(node, "col_offset", 0)}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _static_string(node: ast.AST | None) -> str | None:
|
|
245
|
+
"""A literal str constant, or None for anything computed."""
|
|
246
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
247
|
+
return node.value
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _dotted_name(node: ast.AST | None) -> str | None:
|
|
252
|
+
"""A dotted name for Name/Attribute chains, or None."""
|
|
253
|
+
if isinstance(node, ast.Name):
|
|
254
|
+
return node.id
|
|
255
|
+
if isinstance(node, ast.Attribute):
|
|
256
|
+
base = _dotted_name(node.value)
|
|
257
|
+
return None if base is None else f"{base}.{node.attr}"
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _keyword(call: ast.Call, name: str) -> ast.AST | None:
|
|
262
|
+
for keyword in call.keywords:
|
|
263
|
+
if keyword.arg == name:
|
|
264
|
+
return keyword.value
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _elements(node: ast.AST | None) -> list[ast.AST] | None:
|
|
269
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
270
|
+
return list(node.elts)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _ModuleVisitor(ast.NodeVisitor):
|
|
275
|
+
"""Collects routers, apps, imports, include edges, and route decorators.
|
|
276
|
+
|
|
277
|
+
Registry-function collection is top-level only (module or class-body
|
|
278
|
+
``def``); a nested ``def`` stays in the enclosing function's context so
|
|
279
|
+
its ``include_router`` calls still count for the outer parameter.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(self, relpath: str) -> None:
|
|
283
|
+
self.index = FileIndex(relpath=relpath)
|
|
284
|
+
# Current top-level function (registry-function context), or None
|
|
285
|
+
# at module level.
|
|
286
|
+
self._function: FunctionIncludes | None = None
|
|
287
|
+
|
|
288
|
+
# -- imports ------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
291
|
+
for alias in node.names:
|
|
292
|
+
self.index.imports[alias.asname or alias.name.split(".")[0]] = ImportRef(
|
|
293
|
+
module=alias.name, name=None, level=0,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
297
|
+
for alias in node.names:
|
|
298
|
+
self.index.imports[alias.asname or alias.name] = ImportRef(
|
|
299
|
+
module=node.module, name=alias.name, level=node.level,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# -- router / app instances ---------------------------------------------
|
|
303
|
+
|
|
304
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
305
|
+
if isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name):
|
|
306
|
+
kind = node.value.func.id
|
|
307
|
+
if kind in {"FastAPI", "APIRouter"}:
|
|
308
|
+
for target in node.targets:
|
|
309
|
+
if not isinstance(target, ast.Name):
|
|
310
|
+
continue
|
|
311
|
+
if kind == "FastAPI":
|
|
312
|
+
self.index.apps.add(target.id)
|
|
313
|
+
else:
|
|
314
|
+
prefix_node = _keyword(node.value, "prefix")
|
|
315
|
+
prefix: str | None = (
|
|
316
|
+
"" if prefix_node is None else _static_string(prefix_node)
|
|
317
|
+
)
|
|
318
|
+
self.index.routers[target.id] = RouterDef(
|
|
319
|
+
var=target.id, prefix=prefix,
|
|
320
|
+
prefix_node=prefix_node if prefix_node is not None else node.value,
|
|
321
|
+
)
|
|
322
|
+
self.generic_visit(node)
|
|
323
|
+
|
|
324
|
+
# -- functions: route decorators -----------------------------------------
|
|
325
|
+
|
|
326
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
327
|
+
self._visit_callable(node)
|
|
328
|
+
|
|
329
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
330
|
+
self._visit_callable(node, is_async=True)
|
|
331
|
+
|
|
332
|
+
def _visit_callable(self, node, is_async: bool = False) -> None:
|
|
333
|
+
for decorator in node.decorator_list:
|
|
334
|
+
self._visit_route_decorator(decorator, node, is_async)
|
|
335
|
+
if self._function is None:
|
|
336
|
+
# Top-level callable: becomes the registry-function context.
|
|
337
|
+
# Positional parameters only — call sites bind the app argument
|
|
338
|
+
# positionally; the LAST def of a name wins (Python semantics).
|
|
339
|
+
params = tuple(
|
|
340
|
+
argument.arg for argument in (*node.args.posonlyargs, *node.args.args)
|
|
341
|
+
)
|
|
342
|
+
record = FunctionIncludes(name=node.name, node=node, params=params)
|
|
343
|
+
self.index.functions[node.name] = record
|
|
344
|
+
self._function = record
|
|
345
|
+
self.generic_visit(node) # visit_Call records includes everywhere
|
|
346
|
+
self._function = None
|
|
347
|
+
else:
|
|
348
|
+
self.generic_visit(node) # nested def: keep the enclosing context
|
|
349
|
+
|
|
350
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
351
|
+
self._visit_include_call(node)
|
|
352
|
+
self._visit_helper_call(node)
|
|
353
|
+
self.generic_visit(node)
|
|
354
|
+
|
|
355
|
+
# -- detail walkers -------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def _visit_helper_call(self, node: ast.Call) -> None:
|
|
358
|
+
"""Records plain-name calls with positional Name arguments.
|
|
359
|
+
|
|
360
|
+
Bounded noise by construction: resolution only ever matches calls
|
|
361
|
+
whose callee resolves to a scanned function carrying include-bearing
|
|
362
|
+
parameters, so utility calls (``Depends(get_db)``, ``print(x)``)
|
|
363
|
+
are recorded but never participate.
|
|
364
|
+
"""
|
|
365
|
+
if not isinstance(node.func, ast.Name) or not node.args:
|
|
366
|
+
return
|
|
367
|
+
if not any(isinstance(argument, ast.Name) for argument in node.args):
|
|
368
|
+
return
|
|
369
|
+
self.index.helper_calls.append(
|
|
370
|
+
HelperCall(
|
|
371
|
+
callee=node.func.id,
|
|
372
|
+
args=list(node.args),
|
|
373
|
+
node=node,
|
|
374
|
+
enclosing=self._function.name if self._function is not None else None,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _visit_route_decorator(self, node: ast.AST, fn, is_async: bool) -> None:
|
|
379
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
380
|
+
return
|
|
381
|
+
owner = node.func.value
|
|
382
|
+
if not isinstance(owner, ast.Name):
|
|
383
|
+
return # computed router expression: typed outcome, no guess
|
|
384
|
+
method_attr = node.func.attr
|
|
385
|
+
methods: list[str] = []
|
|
386
|
+
if method_attr in _DECORATOR_METHODS:
|
|
387
|
+
methods = [_DECORATOR_METHODS[method_attr]]
|
|
388
|
+
elif method_attr in _UNSUPPORTED_METHODS:
|
|
389
|
+
owner_ref = self.index.routers.get(owner.id)
|
|
390
|
+
self.index.unsupported.append((
|
|
391
|
+
node,
|
|
392
|
+
[_UNSUPPORTED_METHODS[method_attr]],
|
|
393
|
+
))
|
|
394
|
+
if owner_ref is not None:
|
|
395
|
+
owner_ref.routes.append(RouteDef(
|
|
396
|
+
methods=[], path=None, node=node, file=self.index.relpath, handler=fn.name,
|
|
397
|
+
is_async=is_async, response_model=None, request_schemas=[],
|
|
398
|
+
tags=[], operation_id=None,
|
|
399
|
+
))
|
|
400
|
+
return
|
|
401
|
+
elif method_attr == "api_route":
|
|
402
|
+
for element in (_elements(_keyword(node, "methods")) or []):
|
|
403
|
+
verb = _static_string(element)
|
|
404
|
+
if verb is not None:
|
|
405
|
+
methods.append(verb.upper())
|
|
406
|
+
if not methods:
|
|
407
|
+
return
|
|
408
|
+
path = _static_string(node.args[0]) if node.args else None
|
|
409
|
+
entry = RouteDef(
|
|
410
|
+
methods=methods,
|
|
411
|
+
path=path,
|
|
412
|
+
node=node,
|
|
413
|
+
file=self.index.relpath,
|
|
414
|
+
handler=fn.name,
|
|
415
|
+
is_async=is_async,
|
|
416
|
+
response_model=_dotted_name(_keyword(node, "response_model")),
|
|
417
|
+
request_schemas=_request_schema_names(fn, path),
|
|
418
|
+
tags=[
|
|
419
|
+
s for s in (
|
|
420
|
+
_static_string(e) for e in (_elements(_keyword(node, "tags")) or [])
|
|
421
|
+
) if s is not None
|
|
422
|
+
],
|
|
423
|
+
operation_id=_static_string(_keyword(node, "operation_id")),
|
|
424
|
+
)
|
|
425
|
+
router = self.index.routers.get(owner.id)
|
|
426
|
+
if router is None:
|
|
427
|
+
ref = self.index.imports.get(owner.id)
|
|
428
|
+
alias_of = None
|
|
429
|
+
if ref is not None and ref.name is not None:
|
|
430
|
+
alias_of = (ref.module, ref.level, ref.name)
|
|
431
|
+
router = RouterDef(var=owner.id, prefix="", prefix_node=owner, alias_of=alias_of)
|
|
432
|
+
self.index.routers[owner.id] = router
|
|
433
|
+
router.routes.append(entry)
|
|
434
|
+
out_of_set = [m for m in methods if m not in _DECORATOR_METHODS.values()]
|
|
435
|
+
if out_of_set:
|
|
436
|
+
self.index.unsupported.append((node, out_of_set))
|
|
437
|
+
|
|
438
|
+
def _visit_include_call(self, node: ast.Call) -> None:
|
|
439
|
+
if not isinstance(node.func, ast.Attribute) or node.func.attr != "include_router":
|
|
440
|
+
return
|
|
441
|
+
if not isinstance(node.func.value, ast.Name) or not node.args:
|
|
442
|
+
return
|
|
443
|
+
owner = node.func.value.id
|
|
444
|
+
target = node.args[0]
|
|
445
|
+
target_var: str | None = None
|
|
446
|
+
target_alias: tuple[str | None, int, str] | None = None
|
|
447
|
+
target_attrs: list[str] = []
|
|
448
|
+
if isinstance(target, ast.Name):
|
|
449
|
+
target_var = target.id # same-file var or import alias
|
|
450
|
+
elif isinstance(target, ast.Attribute):
|
|
451
|
+
# Attribute chains over an import binding: ``items.router``,
|
|
452
|
+
# or the deeper registry shape ``endpoints.health.router``.
|
|
453
|
+
attrs: list[str] = []
|
|
454
|
+
base: ast.AST = target
|
|
455
|
+
while isinstance(base, ast.Attribute):
|
|
456
|
+
attrs.append(base.attr)
|
|
457
|
+
base = base.value
|
|
458
|
+
if isinstance(base, ast.Name):
|
|
459
|
+
attrs.reverse()
|
|
460
|
+
ref = self.index.imports.get(base.id)
|
|
461
|
+
if ref is not None and ref.name is not None:
|
|
462
|
+
target_alias = (ref.module, ref.level, ref.name)
|
|
463
|
+
target_attrs = attrs
|
|
464
|
+
prefix_node = _keyword(node, "prefix")
|
|
465
|
+
edge = IncludeEdge(
|
|
466
|
+
owner_var=owner,
|
|
467
|
+
target_var=target_var,
|
|
468
|
+
target_alias=target_alias,
|
|
469
|
+
target_attrs=target_attrs,
|
|
470
|
+
prefix="" if prefix_node is None else _static_string(prefix_node),
|
|
471
|
+
node=node,
|
|
472
|
+
file=self.index.relpath,
|
|
473
|
+
)
|
|
474
|
+
self.index.include_edges.append(edge)
|
|
475
|
+
if (
|
|
476
|
+
self._function is not None
|
|
477
|
+
and owner in self._function.params
|
|
478
|
+
):
|
|
479
|
+
# Function-mediated include (the registry-function pattern).
|
|
480
|
+
# The same edge object also lives in include_edges, so the
|
|
481
|
+
# target stays provably-included (suppressed from standalone
|
|
482
|
+
# emission) whether or not the parameter ever resolves.
|
|
483
|
+
self._function.param_edges.setdefault(owner, []).append(edge)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _request_schema_names(fn, path: str | None) -> list[str]:
|
|
487
|
+
"""Top-level annotation names of non-primitive, non-path parameters."""
|
|
488
|
+
path_params: set[str] = set()
|
|
489
|
+
if path:
|
|
490
|
+
for segment in path.split("{")[1:]:
|
|
491
|
+
if "}" in segment:
|
|
492
|
+
path_params.add(segment.split("}")[0].split(":")[0])
|
|
493
|
+
names: list[str] = []
|
|
494
|
+
for arg in list(fn.args.args) + list(fn.args.kwonlyargs):
|
|
495
|
+
if arg.annotation is None or arg.arg in path_params:
|
|
496
|
+
continue
|
|
497
|
+
name = _dotted_name(arg.annotation)
|
|
498
|
+
if name is None:
|
|
499
|
+
continue
|
|
500
|
+
if name.split(".")[0] in _PRIMITIVE_ANNOTATIONS:
|
|
501
|
+
continue
|
|
502
|
+
if "Depends" in name:
|
|
503
|
+
continue
|
|
504
|
+
if name not in names:
|
|
505
|
+
names.append(name)
|
|
506
|
+
return names
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _scan_file(relpath: str, root: Path) -> tuple[FileIndex | None, dict | None]:
|
|
510
|
+
"""Parse one repo-relative file into its index, or a PARSE_ERROR finding.
|
|
511
|
+
|
|
512
|
+
Read errors propagate (the serve loop surfaces them as a plugin error
|
|
513
|
+
frame); only syntax failures become findings — a file that cannot be
|
|
514
|
+
parsed must never read as scanned-and-empty.
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
source = (root / relpath).read_text(encoding="utf-8")
|
|
518
|
+
tree = ast.parse(source, filename=relpath)
|
|
519
|
+
except (SyntaxError, ValueError, UnicodeDecodeError) as exc:
|
|
520
|
+
line = getattr(exc, "lineno", 0) or 0
|
|
521
|
+
msg = exc.msg if isinstance(exc, SyntaxError) else str(exc)
|
|
522
|
+
return None, {
|
|
523
|
+
"code": "PARSE_ERROR",
|
|
524
|
+
"detail": f"{type(exc).__name__}: {msg}",
|
|
525
|
+
"locations": [{"file": relpath, "line": max(line, 1), "col": 0}],
|
|
526
|
+
}
|
|
527
|
+
visitor = _ModuleVisitor(relpath)
|
|
528
|
+
visitor.visit(tree)
|
|
529
|
+
return visitor.index, None
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _normalize_path(rel: str) -> str:
|
|
533
|
+
"""Validate + normalize a repo-root-relative path (fail closed)."""
|
|
534
|
+
path = rel.replace("\\", "/")
|
|
535
|
+
while path.startswith("./"):
|
|
536
|
+
path = path[2:]
|
|
537
|
+
if path == "" or path.startswith("/"):
|
|
538
|
+
raise ValueError(f"target must be a repo-root-relative path, got {rel!r}")
|
|
539
|
+
if ".." in path.split("/"):
|
|
540
|
+
raise ValueError(f"target must be a repo-root-relative path, got {rel!r}")
|
|
541
|
+
return path
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _module_of(relpath: str) -> str:
|
|
545
|
+
"""Dotted module name of a scanned file (``__init__.py`` = package)."""
|
|
546
|
+
without_ext = relpath[:-3] if relpath.endswith(".py") else relpath
|
|
547
|
+
if without_ext.endswith("/__init__"):
|
|
548
|
+
without_ext = without_ext[: -len("/__init__")]
|
|
549
|
+
return without_ext.replace("/", ".")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _root_candidates(dotted: str, import_roots: tuple[str, ...], scanned: frozenset[str]) -> list[str]:
|
|
553
|
+
"""Scanned files a dotted module name maps to under the import roots.
|
|
554
|
+
|
|
555
|
+
``api.v1.activities`` under root ``backend`` matches the scanned files
|
|
556
|
+
``backend/api/v1/activities.py`` and ``backend/api/v1/activities/
|
|
557
|
+
__init__.py`` when present. Deterministic: root order, then module
|
|
558
|
+
file before package; duplicates removed.
|
|
559
|
+
"""
|
|
560
|
+
relative = dotted.replace(".", "/")
|
|
561
|
+
matches: list[str] = []
|
|
562
|
+
for root in import_roots:
|
|
563
|
+
base = f"{root}/{relative}" if root else relative
|
|
564
|
+
for candidate in (f"{base}.py", f"{base}/__init__.py"):
|
|
565
|
+
if candidate in scanned and candidate not in matches:
|
|
566
|
+
matches.append(candidate)
|
|
567
|
+
return matches
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _resolve_import(
|
|
571
|
+
file_relpath: str,
|
|
572
|
+
ref: tuple[str | None, int, str],
|
|
573
|
+
module_map: dict[str, str],
|
|
574
|
+
import_roots: tuple[str, ...] = (),
|
|
575
|
+
scanned: frozenset[str] = frozenset(),
|
|
576
|
+
prefer_name: bool = False,
|
|
577
|
+
) -> ImportResolution:
|
|
578
|
+
"""Resolve an import to one scanned module, or prove why not.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
file_relpath: Repo-relative importing file.
|
|
582
|
+
ref: Parsed import tuple ``(module, relative-level, imported-name)``.
|
|
583
|
+
module_map: Dotted module names available in the scanned set.
|
|
584
|
+
import_roots: Configured repo-root-relative import roots. When
|
|
585
|
+
non-empty, ABSOLUTE imports resolve through them and the
|
|
586
|
+
suffix heuristic is skipped; relative imports are unchanged.
|
|
587
|
+
scanned: Repo-relative scanned file set (required for roots).
|
|
588
|
+
prefer_name: Check ``module.name`` before ``module`` (used by the
|
|
589
|
+
module-import attribute chain, where the imported name is a
|
|
590
|
+
submodule: ``from api.v1 import activities``).
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
ImportResolution: the unique module, an ambiguity proof, or an
|
|
594
|
+
unresolved outcome — never a guess.
|
|
595
|
+
"""
|
|
596
|
+
raw_module, level, name = ref
|
|
597
|
+
parts = _module_of(file_relpath).split(".")
|
|
598
|
+
if level > 0:
|
|
599
|
+
# Relative imports resolve inside the scanned tree itself; import
|
|
600
|
+
# roots do not participate.
|
|
601
|
+
up = level - 1
|
|
602
|
+
parts = parts[: len(parts) - up] if up > 0 else parts
|
|
603
|
+
if raw_module:
|
|
604
|
+
parts.extend(raw_module.split("."))
|
|
605
|
+
candidate = ".".join(parts)
|
|
606
|
+
if candidate in module_map:
|
|
607
|
+
return ImportResolution(module=candidate)
|
|
608
|
+
with_name = f"{candidate}.{name}" if name else candidate
|
|
609
|
+
if with_name in module_map:
|
|
610
|
+
return ImportResolution(module=with_name)
|
|
611
|
+
|
|
612
|
+
# Applications often run with a package directory on PYTHONPATH, so
|
|
613
|
+
# imports such as ``from api.v1.routes`` resolve to ``backend.api.v1.routes``
|
|
614
|
+
# when the repository is scanned from its parent directory. Accept only a
|
|
615
|
+
# unique suffix match; ambiguity remains fail-closed.
|
|
616
|
+
suffix = f".{candidate}" if candidate else ""
|
|
617
|
+
matches = sorted(module for module in module_map if suffix and module.endswith(suffix))
|
|
618
|
+
if len(matches) == 1:
|
|
619
|
+
return ImportResolution(module=matches[0])
|
|
620
|
+
with_name_suffix = f".{with_name}" if with_name else ""
|
|
621
|
+
matches = sorted(module for module in module_map if with_name_suffix and module.endswith(with_name_suffix))
|
|
622
|
+
return ImportResolution(module=matches[0] if len(matches) == 1 else None)
|
|
623
|
+
|
|
624
|
+
candidate = raw_module or ""
|
|
625
|
+
with_name = f"{candidate}.{name}" if name else candidate
|
|
626
|
+
|
|
627
|
+
if not import_roots:
|
|
628
|
+
# Legacy behavior (import roots not configured): exact match, then
|
|
629
|
+
# the unique-suffix heuristic — byte-identical to pre-roots releases.
|
|
630
|
+
if candidate in module_map:
|
|
631
|
+
return ImportResolution(module=candidate)
|
|
632
|
+
if with_name in module_map:
|
|
633
|
+
return ImportResolution(module=with_name)
|
|
634
|
+
suffix = f".{candidate}" if candidate else ""
|
|
635
|
+
matches = sorted(module for module in module_map if suffix and module.endswith(suffix))
|
|
636
|
+
if len(matches) == 1:
|
|
637
|
+
return ImportResolution(module=matches[0])
|
|
638
|
+
with_name_suffix = f".{with_name}" if with_name else ""
|
|
639
|
+
matches = sorted(module for module in module_map if with_name_suffix and module.endswith(with_name_suffix))
|
|
640
|
+
return ImportResolution(module=matches[0] if len(matches) == 1 else None)
|
|
641
|
+
|
|
642
|
+
# Explicit import roots: the two readings of the import, tried in order
|
|
643
|
+
# (the imported name first when it is itself the target module).
|
|
644
|
+
interpretations = (with_name, candidate) if prefer_name else (candidate, with_name)
|
|
645
|
+
for dotted in interpretations:
|
|
646
|
+
if dotted and dotted in module_map:
|
|
647
|
+
return ImportResolution(module=dotted)
|
|
648
|
+
for dotted in interpretations:
|
|
649
|
+
if not dotted:
|
|
650
|
+
continue
|
|
651
|
+
matches = _root_candidates(dotted, import_roots, scanned)
|
|
652
|
+
if len(matches) == 1:
|
|
653
|
+
return ImportResolution(module=_module_of(matches[0]))
|
|
654
|
+
if len(matches) > 1:
|
|
655
|
+
return ImportResolution(ambiguous=tuple(sorted(matches)))
|
|
656
|
+
return ImportResolution(module=None)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class _Resolver:
|
|
660
|
+
"""Composes effective mounted paths over the cross-file mount graph."""
|
|
661
|
+
|
|
662
|
+
def __init__(self, indexes: dict[str, FileIndex], import_roots: tuple[str, ...] = ()) -> None:
|
|
663
|
+
self.indexes = indexes
|
|
664
|
+
self.import_roots = import_roots
|
|
665
|
+
self.scanned: frozenset[str] = frozenset(indexes)
|
|
666
|
+
self.module_map = {_module_of(rel): rel for rel in indexes}
|
|
667
|
+
self.facts: list[dict] = []
|
|
668
|
+
self.unresolved: list[dict] = []
|
|
669
|
+
|
|
670
|
+
def resolve(self) -> None:
|
|
671
|
+
self._materialize_function_includes()
|
|
672
|
+
self._merge_aliases()
|
|
673
|
+
included = self._collect_included()
|
|
674
|
+
for relpath in sorted(self.indexes):
|
|
675
|
+
index = self.indexes[relpath]
|
|
676
|
+
for var in sorted(index.apps):
|
|
677
|
+
self._walk(relpath, var, "", (), "include-chain", included)
|
|
678
|
+
for name in sorted(index.routers):
|
|
679
|
+
router = index.routers[name]
|
|
680
|
+
if router.alias_of is not None:
|
|
681
|
+
continue # merged into the defining router; no double emission
|
|
682
|
+
if name in index.apps:
|
|
683
|
+
continue # app-owned routes emit through the apps loop
|
|
684
|
+
if (relpath, name) in included:
|
|
685
|
+
continue
|
|
686
|
+
self._walk(relpath, name, "", (), "standalone", included)
|
|
687
|
+
|
|
688
|
+
def _materialize_function_includes(self) -> None:
|
|
689
|
+
"""Rewires registry-function includes onto real instances.
|
|
690
|
+
|
|
691
|
+
Interprocedural, bounded, deterministic:
|
|
692
|
+
|
|
693
|
+
1. Every top-level function of every scanned file joins the global
|
|
694
|
+
registry keyed ``(file, name)``; its ``param_edges`` (includes
|
|
695
|
+
written on its own parameters) seed the effective edge sets.
|
|
696
|
+
2. Bounded chaining (snapshot fixed point, at most
|
|
697
|
+
``MAX_RESOLUTION_DEPTH`` applied passes — after pass k a
|
|
698
|
+
parameter k helper hops from the include is complete; one extra
|
|
699
|
+
probe pass detects growth beyond the bound): when a function's
|
|
700
|
+
body calls another scanned function with one of ITS OWN
|
|
701
|
+
parameters as a positional argument, the callee's effective
|
|
702
|
+
edges re-key onto that parameter (identity-deduped, so cycles
|
|
703
|
+
and re-visits add nothing). Growth the probe pass still finds
|
|
704
|
+
is a chain deeper than the bound: one typed unresolved entry
|
|
705
|
+
naming the function where the chain still grows.
|
|
706
|
+
3. Every recorded call site of an include-bearing function is
|
|
707
|
+
materialized: each positional argument that resolves to a known
|
|
708
|
+
instance (``_instance_owner``) receives the corresponding edges
|
|
709
|
+
as ordinary include edges on that instance — same provenance
|
|
710
|
+
discipline as written includes (mount ``include-chain`` at the
|
|
711
|
+
include call's own source location; repeated mounts duplicate).
|
|
712
|
+
Arguments bound to the enclosing function's own parameter were
|
|
713
|
+
already handled by chaining and materialize at the outer call
|
|
714
|
+
site instead.
|
|
715
|
+
|
|
716
|
+
Unresolvable arguments (a Name bound to no known instance, or a
|
|
717
|
+
computed expression) yield a typed unresolved entry naming the
|
|
718
|
+
exact call site and materialize NOTHING: the routers are provably
|
|
719
|
+
included somewhere (they stay suppressed from standalone emission)
|
|
720
|
+
and their source carries prefixes, so emitting prefix-less paths
|
|
721
|
+
would fabricate routes. A parameter name shadowing a same-file
|
|
722
|
+
instance variable keeps the module-level reading only (the edge is
|
|
723
|
+
already walked from the instance; materializing would double-emit).
|
|
724
|
+
"""
|
|
725
|
+
registry: dict[tuple[str, str], FunctionIncludes] = {
|
|
726
|
+
(relpath, name): function
|
|
727
|
+
for relpath, index in sorted(self.indexes.items())
|
|
728
|
+
for name, function in index.functions.items()
|
|
729
|
+
}
|
|
730
|
+
if not registry:
|
|
731
|
+
return
|
|
732
|
+
|
|
733
|
+
def resolve_fn(relpath: str, callee: str) -> tuple[str, str] | None:
|
|
734
|
+
"""(file, function) a plain callee name denotes, or None.
|
|
735
|
+
|
|
736
|
+
Same-file top-level def first, then an import binding resolved
|
|
737
|
+
against the scanned set (deterministic import resolution;
|
|
738
|
+
ambiguity or absence returns None — a plain unknown call makes
|
|
739
|
+
no claim).
|
|
740
|
+
"""
|
|
741
|
+
if callee in self.indexes[relpath].functions:
|
|
742
|
+
return (relpath, callee)
|
|
743
|
+
ref = self.indexes[relpath].imports.get(callee)
|
|
744
|
+
if ref is None or ref.name is None:
|
|
745
|
+
return None
|
|
746
|
+
resolution = _resolve_import(
|
|
747
|
+
relpath, (ref.module, ref.level, ref.name),
|
|
748
|
+
self.module_map, self.import_roots, self.scanned,
|
|
749
|
+
)
|
|
750
|
+
if resolution.module is None:
|
|
751
|
+
return None
|
|
752
|
+
target = self.module_map.get(resolution.module)
|
|
753
|
+
if target is not None and ref.name in self.indexes[target].functions:
|
|
754
|
+
return (target, ref.name)
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
# Effective per-parameter edges, snapshot-propagated (each pass
|
|
758
|
+
# reads only the previous pass's state, so a parameter k helper
|
|
759
|
+
# hops from its include is complete after pass k).
|
|
760
|
+
effective: dict[tuple[str, str], dict[str, list[IncludeEdge]]] = {
|
|
761
|
+
key: {param: list(edges) for param, edges in function.param_edges.items()}
|
|
762
|
+
for key, function in registry.items()
|
|
763
|
+
}
|
|
764
|
+
unconverged: set[tuple[str, str]] = set()
|
|
765
|
+
converged = False
|
|
766
|
+
for _pass in range(MAX_RESOLUTION_DEPTH + 1):
|
|
767
|
+
additions: list[tuple[tuple[str, str], str, IncludeEdge]] = []
|
|
768
|
+
for relpath in sorted(self.indexes):
|
|
769
|
+
index = self.indexes[relpath]
|
|
770
|
+
for call in index.helper_calls:
|
|
771
|
+
if call.enclosing is None:
|
|
772
|
+
continue # chaining concerns parameter-carrying functions only
|
|
773
|
+
fn_key = (relpath, call.enclosing)
|
|
774
|
+
function = registry.get(fn_key)
|
|
775
|
+
if function is None:
|
|
776
|
+
continue
|
|
777
|
+
helper_key = resolve_fn(relpath, call.callee)
|
|
778
|
+
if helper_key is None or helper_key == fn_key:
|
|
779
|
+
continue
|
|
780
|
+
helper_params = registry[helper_key].params
|
|
781
|
+
for position, param in enumerate(helper_params):
|
|
782
|
+
if position >= len(call.args):
|
|
783
|
+
break
|
|
784
|
+
argument = call.args[position]
|
|
785
|
+
if not isinstance(argument, ast.Name):
|
|
786
|
+
continue
|
|
787
|
+
if argument.id not in function.params:
|
|
788
|
+
continue
|
|
789
|
+
if argument.id in index.apps or argument.id in index.routers:
|
|
790
|
+
continue # shadowed param: the module-level instance governs
|
|
791
|
+
bucket = effective[fn_key].setdefault(argument.id, [])
|
|
792
|
+
for edge in effective.get(helper_key, {}).get(param, []):
|
|
793
|
+
if not any(existing is edge for existing in bucket):
|
|
794
|
+
additions.append((fn_key, argument.id, edge))
|
|
795
|
+
if not additions:
|
|
796
|
+
converged = True
|
|
797
|
+
break
|
|
798
|
+
if _pass >= MAX_RESOLUTION_DEPTH:
|
|
799
|
+
# Probe pass (never applied): growth here needs more than
|
|
800
|
+
# MAX_RESOLUTION_DEPTH helper hops — beyond the bound.
|
|
801
|
+
unconverged = {fn_key for fn_key, _param, _edge in additions}
|
|
802
|
+
break
|
|
803
|
+
grew: set[tuple[str, str]] = set()
|
|
804
|
+
for fn_key, param, edge in additions:
|
|
805
|
+
bucket = effective[fn_key].setdefault(param, [])
|
|
806
|
+
if not any(existing is edge for existing in bucket):
|
|
807
|
+
bucket.append(edge)
|
|
808
|
+
grew.add(fn_key)
|
|
809
|
+
unconverged = grew
|
|
810
|
+
if not converged:
|
|
811
|
+
# Still growing after the last pass: chains deeper than the bound.
|
|
812
|
+
for relpath, name in sorted(unconverged):
|
|
813
|
+
function = self.indexes[relpath].functions.get(name)
|
|
814
|
+
if function is None:
|
|
815
|
+
continue
|
|
816
|
+
self.unresolved.append({
|
|
817
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
818
|
+
"detail": (
|
|
819
|
+
f"registry-function include chain through '{name}' in {relpath} "
|
|
820
|
+
f"exceeds the supported helper depth ({MAX_RESOLUTION_DEPTH}); "
|
|
821
|
+
"the effective mount graph cannot be proven statically"
|
|
822
|
+
),
|
|
823
|
+
"location": loc(relpath, function.node),
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
# Materialize every call site whose argument resolves to a known
|
|
827
|
+
# instance (module level or inside a function — the factory shape).
|
|
828
|
+
for relpath in sorted(self.indexes):
|
|
829
|
+
for call in self.indexes[relpath].helper_calls:
|
|
830
|
+
helper_key = resolve_fn(relpath, call.callee)
|
|
831
|
+
if helper_key is None:
|
|
832
|
+
continue
|
|
833
|
+
for position, param in enumerate(registry[helper_key].params):
|
|
834
|
+
if position >= len(call.args):
|
|
835
|
+
break
|
|
836
|
+
edges = effective.get(helper_key, {}).get(param)
|
|
837
|
+
if edges:
|
|
838
|
+
self._materialize_call(
|
|
839
|
+
relpath, call, call.args[position], edges, helper_key,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
def _materialize_call(
|
|
843
|
+
self,
|
|
844
|
+
relpath: str,
|
|
845
|
+
call: HelperCall,
|
|
846
|
+
argument: ast.AST,
|
|
847
|
+
edges: list[IncludeEdge],
|
|
848
|
+
helper_key: tuple[str, str],
|
|
849
|
+
) -> None:
|
|
850
|
+
"""Mounts one call site's edges onto the instance the argument names.
|
|
851
|
+
|
|
852
|
+
See ``_materialize_function_includes`` for the semantics; this is
|
|
853
|
+
the per-argument decision point (chaining passthrough, instance
|
|
854
|
+
rewiring, or the typed unresolvable outcome).
|
|
855
|
+
"""
|
|
856
|
+
enclosing = (
|
|
857
|
+
self.indexes[relpath].functions.get(call.enclosing)
|
|
858
|
+
if call.enclosing is not None
|
|
859
|
+
else None
|
|
860
|
+
)
|
|
861
|
+
if isinstance(argument, ast.Name):
|
|
862
|
+
if enclosing is not None and argument.id in enclosing.params:
|
|
863
|
+
return # chaining passthrough; materialized at the outer call site
|
|
864
|
+
owner = self._instance_owner(relpath, argument.id)
|
|
865
|
+
if owner is not None:
|
|
866
|
+
target_file, owner_var = owner
|
|
867
|
+
self.indexes[target_file].include_edges.extend(
|
|
868
|
+
IncludeEdge(
|
|
869
|
+
owner_var=owner_var,
|
|
870
|
+
target_var=edge.target_var,
|
|
871
|
+
target_alias=edge.target_alias,
|
|
872
|
+
target_attrs=list(edge.target_attrs),
|
|
873
|
+
prefix=edge.prefix,
|
|
874
|
+
node=edge.node,
|
|
875
|
+
file=edge.file,
|
|
876
|
+
)
|
|
877
|
+
for edge in edges
|
|
878
|
+
)
|
|
879
|
+
return
|
|
880
|
+
self.unresolved.append({
|
|
881
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
882
|
+
"detail": (
|
|
883
|
+
f"call to registry function '{helper_key[1]}' in {relpath} passes "
|
|
884
|
+
f"'{argument.id}', which cannot be resolved to a known "
|
|
885
|
+
"FastAPI/APIRouter instance; its include_router calls cannot be "
|
|
886
|
+
"mounted and emit nothing (no prefix-less standalone paths are "
|
|
887
|
+
"fabricated)"
|
|
888
|
+
),
|
|
889
|
+
"location": loc(relpath, call.node),
|
|
890
|
+
})
|
|
891
|
+
return
|
|
892
|
+
self.unresolved.append({
|
|
893
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
894
|
+
"detail": (
|
|
895
|
+
f"call to registry function '{helper_key[1]}' in {relpath} passes a "
|
|
896
|
+
"computed argument expression, which cannot be resolved to a known "
|
|
897
|
+
"FastAPI/APIRouter instance; its include_router calls cannot be "
|
|
898
|
+
"mounted and emit nothing"
|
|
899
|
+
),
|
|
900
|
+
"location": loc(relpath, call.node),
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
def _instance_owner(self, relpath: str, name: str) -> tuple[str, str] | None:
|
|
904
|
+
"""(file, var) when ``name`` denotes a known FastAPI/APIRouter instance.
|
|
905
|
+
|
|
906
|
+
Same-file assignments first (module-level or factory-local — both
|
|
907
|
+
are walked as instances), then an import binding whose target
|
|
908
|
+
module declares the name as an instance (both readings of the
|
|
909
|
+
import, like include-target resolution; ambiguity returns None).
|
|
910
|
+
"""
|
|
911
|
+
index = self.indexes[relpath]
|
|
912
|
+
if name in index.apps or name in index.routers:
|
|
913
|
+
return (relpath, name)
|
|
914
|
+
ref = index.imports.get(name)
|
|
915
|
+
if ref is None or ref.name is None:
|
|
916
|
+
return None
|
|
917
|
+
parsed = (ref.module, ref.level, ref.name)
|
|
918
|
+
for prefer_name in (False, True):
|
|
919
|
+
resolution = _resolve_import(
|
|
920
|
+
relpath, parsed, self.module_map, self.import_roots, self.scanned,
|
|
921
|
+
prefer_name=prefer_name,
|
|
922
|
+
)
|
|
923
|
+
target = self.module_map.get(resolution.module) if resolution.module else None
|
|
924
|
+
if target is not None and (
|
|
925
|
+
ref.name in self.indexes[target].apps
|
|
926
|
+
or ref.name in self.indexes[target].routers
|
|
927
|
+
):
|
|
928
|
+
return (target, ref.name)
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
def _merge_aliases(self) -> None:
|
|
932
|
+
"""Routes declared through import-aliased names join the defining
|
|
933
|
+
router (one router object, many local names). Bounded passes also
|
|
934
|
+
collapse alias chains; unresolvable aliases keep their routes so
|
|
935
|
+
the walk reports them instead of losing them."""
|
|
936
|
+
pending = sorted(
|
|
937
|
+
(relpath, name)
|
|
938
|
+
for relpath, index in self.indexes.items()
|
|
939
|
+
for name, router in index.routers.items()
|
|
940
|
+
if router.alias_of is not None
|
|
941
|
+
)
|
|
942
|
+
for _ in range(len(pending) + 1):
|
|
943
|
+
changed = False
|
|
944
|
+
for relpath, name in pending:
|
|
945
|
+
router = self.indexes[relpath].routers.get(name)
|
|
946
|
+
if router is None or router.alias_of is None or not router.routes:
|
|
947
|
+
continue
|
|
948
|
+
target = self._module_router(relpath, router.alias_of, router.alias_of[2])
|
|
949
|
+
if target is None:
|
|
950
|
+
continue
|
|
951
|
+
target_file, target_var = target
|
|
952
|
+
self.indexes[target_file].routers[target_var].routes.extend(router.routes)
|
|
953
|
+
router.routes = []
|
|
954
|
+
changed = True
|
|
955
|
+
if not changed:
|
|
956
|
+
return
|
|
957
|
+
|
|
958
|
+
def _collect_included(self) -> set[tuple[str, str]]:
|
|
959
|
+
"""(file, var) pairs that are the target of a resolvable include."""
|
|
960
|
+
targets: set[tuple[str, str]] = set()
|
|
961
|
+
for relpath in sorted(self.indexes):
|
|
962
|
+
for edge in self.indexes[relpath].include_edges:
|
|
963
|
+
found, _ = self._resolve_target(edge.file or relpath, edge)
|
|
964
|
+
if found is not None:
|
|
965
|
+
targets.add(found)
|
|
966
|
+
return targets
|
|
967
|
+
|
|
968
|
+
def _resolve_target(
|
|
969
|
+
self, relpath: str, edge: IncludeEdge,
|
|
970
|
+
) -> tuple[tuple[str, str] | None, ImportResolution | None]:
|
|
971
|
+
"""(file, router var) an include edge points at, or None.
|
|
972
|
+
|
|
973
|
+
The second element carries the import resolution when AMBIGUITY
|
|
974
|
+
(not mere absence) caused the failure, so the walk can report the
|
|
975
|
+
matching files instead of a bare "cannot be resolved".
|
|
976
|
+
"""
|
|
977
|
+
if edge.target_var is not None:
|
|
978
|
+
if edge.target_var in self.indexes[relpath].routers:
|
|
979
|
+
return (relpath, edge.target_var), None
|
|
980
|
+
ref = self.indexes[relpath].imports.get(edge.target_var)
|
|
981
|
+
if ref is None:
|
|
982
|
+
return None, None
|
|
983
|
+
return self._module_router_ex(relpath, (ref.module, ref.level, ref.name), ref.name)
|
|
984
|
+
if edge.target_alias is not None and edge.target_attrs:
|
|
985
|
+
return self._resolve_alias_target(relpath, edge)
|
|
986
|
+
return None, None
|
|
987
|
+
|
|
988
|
+
def _resolve_alias_target(
|
|
989
|
+
self, relpath: str, edge: IncludeEdge,
|
|
990
|
+
) -> tuple[tuple[str, str] | None, ImportResolution | None]:
|
|
991
|
+
"""Resolve ``<binding>.<attr chain>`` include targets.
|
|
992
|
+
|
|
993
|
+
``from a import items`` + ``items.router`` is the one-attribute
|
|
994
|
+
shape; ``from api.v1 import endpoints`` + ``endpoints.health.router``
|
|
995
|
+
walks intermediate submodules. With import roots configured, the
|
|
996
|
+
imported NAME may itself be the target module (``from api.v1 import
|
|
997
|
+
activities`` + ``activities.router``): a second resolution with the
|
|
998
|
+
name-qualified module covers that reading. Ambiguity is returned as
|
|
999
|
+
proof — never guessed.
|
|
1000
|
+
"""
|
|
1001
|
+
ref = edge.target_alias
|
|
1002
|
+
assert ref is not None
|
|
1003
|
+
attrs = edge.target_attrs
|
|
1004
|
+
if not self.import_roots and len(attrs) != 1:
|
|
1005
|
+
return None, None # pre-roots behavior: deep chains stay unresolved
|
|
1006
|
+
resolution = _resolve_import(relpath, ref, self.module_map, self.import_roots, self.scanned)
|
|
1007
|
+
if resolution.module is not None:
|
|
1008
|
+
found = self._alias_target_from(resolution.module, attrs)
|
|
1009
|
+
if found is not None:
|
|
1010
|
+
return found, None
|
|
1011
|
+
if not self.import_roots:
|
|
1012
|
+
return None, None # pre-roots behavior: single reading, no retry
|
|
1013
|
+
retry = _resolve_import(
|
|
1014
|
+
relpath, ref, self.module_map, self.import_roots, self.scanned, prefer_name=True,
|
|
1015
|
+
)
|
|
1016
|
+
if retry.module is not None and retry.module != resolution.module:
|
|
1017
|
+
found = self._alias_target_from(retry.module, attrs)
|
|
1018
|
+
if found is not None:
|
|
1019
|
+
return found, None
|
|
1020
|
+
ambiguous = resolution.ambiguous if resolution.ambiguous is not None else retry.ambiguous
|
|
1021
|
+
return None, (ImportResolution(ambiguous=ambiguous) if ambiguous is not None else None)
|
|
1022
|
+
|
|
1023
|
+
def _alias_target_from(self, module: str, attrs: list[str]) -> tuple[str, str] | None:
|
|
1024
|
+
"""(file, var) for ``<module>.<attr chain>``, or None.
|
|
1025
|
+
|
|
1026
|
+
Intermediate attributes must be scanned submodules; the final
|
|
1027
|
+
attribute is either a submodule whose ``router`` variable is a
|
|
1028
|
+
router (``pkg.router`` shape) or a router variable of the current
|
|
1029
|
+
module (``items`` of ``from a import items`` + ``items`` as a
|
|
1030
|
+
plain router object is handled by the Name branch).
|
|
1031
|
+
"""
|
|
1032
|
+
for attr in attrs[:-1]:
|
|
1033
|
+
sub = self.module_map.get(f"{module}.{attr}")
|
|
1034
|
+
if sub is None:
|
|
1035
|
+
return None
|
|
1036
|
+
module = f"{module}.{attr}"
|
|
1037
|
+
final = attrs[-1]
|
|
1038
|
+
# ``from a import items`` + ``items.router``: try a.items first.
|
|
1039
|
+
target_file = self.module_map.get(f"{module}.{final}")
|
|
1040
|
+
if target_file is not None and "router" in self.indexes[target_file].routers:
|
|
1041
|
+
return (target_file, "router")
|
|
1042
|
+
current = self.module_map.get(module)
|
|
1043
|
+
if current is not None and final in self.indexes[current].routers:
|
|
1044
|
+
return (current, final)
|
|
1045
|
+
if current is not None:
|
|
1046
|
+
# ``pkg.attr`` where attr is a package-attribute binding
|
|
1047
|
+
# (``__init__.py`` assignment or re-export).
|
|
1048
|
+
return self._module_binding_target(module, final)
|
|
1049
|
+
return None
|
|
1050
|
+
|
|
1051
|
+
def _module_router(
|
|
1052
|
+
self, relpath: str, ref: tuple[str | None, int, str], var: str,
|
|
1053
|
+
) -> tuple[str, str] | None:
|
|
1054
|
+
"""(file, var) for an imported router name, or None."""
|
|
1055
|
+
target, _ = self._module_router_ex(relpath, ref, var)
|
|
1056
|
+
return target
|
|
1057
|
+
|
|
1058
|
+
def _module_router_ex(
|
|
1059
|
+
self, relpath: str, ref: tuple[str | None, int, str], var: str,
|
|
1060
|
+
) -> tuple[tuple[str, str] | None, ImportResolution | None]:
|
|
1061
|
+
"""(file, var) for an imported router name plus its resolution.
|
|
1062
|
+
|
|
1063
|
+
The imported name resolves through the target module's own
|
|
1064
|
+
module-level bindings when it is not itself a router var or
|
|
1065
|
+
submodule: a package ``__init__.py`` re-export
|
|
1066
|
+
(``from pkg import router`` where the package does
|
|
1067
|
+
``from .endpoints import router``) is followed, bounded by
|
|
1068
|
+
``MAX_RESOLUTION_DEPTH`` hops. Ambiguity is returned as proof —
|
|
1069
|
+
never guessed.
|
|
1070
|
+
"""
|
|
1071
|
+
resolution = _resolve_import(relpath, ref, self.module_map, self.import_roots, self.scanned)
|
|
1072
|
+
if resolution.module is None:
|
|
1073
|
+
return None, resolution
|
|
1074
|
+
target = self._module_binding_target(resolution.module, var)
|
|
1075
|
+
if target is not None:
|
|
1076
|
+
return target, resolution
|
|
1077
|
+
return None, resolution
|
|
1078
|
+
|
|
1079
|
+
def _module_binding_target(
|
|
1080
|
+
self, module: str, var: str, depth: int = 0,
|
|
1081
|
+
) -> tuple[str, str] | None:
|
|
1082
|
+
"""(file, var) for module member ``var``, following bindings.
|
|
1083
|
+
|
|
1084
|
+
In order: a scanned submodule ``<module>.<var>`` whose ``var`` is a
|
|
1085
|
+
router (the module-of-same-name shape), a router var of the module
|
|
1086
|
+
itself (``var = APIRouter()`` — e.g. in a package ``__init__.py``),
|
|
1087
|
+
then a module-level import binding of the module
|
|
1088
|
+
(``from .endpoints import router``) followed recursively. Bounded;
|
|
1089
|
+
cycles return None (typed unresolved at the caller, as before).
|
|
1090
|
+
"""
|
|
1091
|
+
if depth > MAX_RESOLUTION_DEPTH:
|
|
1092
|
+
return None
|
|
1093
|
+
target_file = self.module_map.get(f"{module}.{var}") or self.module_map.get(module)
|
|
1094
|
+
if target_file is None:
|
|
1095
|
+
return None
|
|
1096
|
+
index = self.indexes[target_file]
|
|
1097
|
+
if var in index.routers:
|
|
1098
|
+
return (target_file, var)
|
|
1099
|
+
ref = index.imports.get(var)
|
|
1100
|
+
if ref is None or ref.name is None:
|
|
1101
|
+
return None
|
|
1102
|
+
resolution = _resolve_import(
|
|
1103
|
+
target_file, (ref.module, ref.level, ref.name),
|
|
1104
|
+
self.module_map, self.import_roots, self.scanned,
|
|
1105
|
+
)
|
|
1106
|
+
if resolution.module is None:
|
|
1107
|
+
return None
|
|
1108
|
+
return self._module_binding_target(resolution.module, ref.name, depth + 1)
|
|
1109
|
+
|
|
1110
|
+
def _walk(
|
|
1111
|
+
self,
|
|
1112
|
+
relpath: str,
|
|
1113
|
+
var: str,
|
|
1114
|
+
prefix: str,
|
|
1115
|
+
chain: tuple[str, ...],
|
|
1116
|
+
mount: str,
|
|
1117
|
+
included: set[tuple[str, str]],
|
|
1118
|
+
) -> None:
|
|
1119
|
+
"""Depth-first mount-graph walk emitting facts with composed prefixes."""
|
|
1120
|
+
index = self.indexes[relpath]
|
|
1121
|
+
node_key = f"{relpath}:{var}"
|
|
1122
|
+
if node_key in chain:
|
|
1123
|
+
self.unresolved.append({
|
|
1124
|
+
"code": "FASTAPI_PREFIX_UNRESOLVED",
|
|
1125
|
+
"detail": (
|
|
1126
|
+
f"include cycle through '{var}' in {relpath}; the effective "
|
|
1127
|
+
"mount graph cannot be proven statically"
|
|
1128
|
+
),
|
|
1129
|
+
"location": {"file": relpath, "line": 1, "col": 0},
|
|
1130
|
+
})
|
|
1131
|
+
return
|
|
1132
|
+
router = index.routers.get(var)
|
|
1133
|
+
if router is not None:
|
|
1134
|
+
if router.alias_of is not None:
|
|
1135
|
+
target, resolution = self._module_router_ex(relpath, router.alias_of, router.alias_of[2])
|
|
1136
|
+
if target is None:
|
|
1137
|
+
if resolution is not None and resolution.ambiguous is not None:
|
|
1138
|
+
self.unresolved.append({
|
|
1139
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
1140
|
+
"detail": (
|
|
1141
|
+
f"router '{var}' in {relpath} is an import alias whose target "
|
|
1142
|
+
f"module matches multiple scanned files under the configured "
|
|
1143
|
+
f"import roots ({', '.join(resolution.ambiguous)}); the target "
|
|
1144
|
+
"router cannot be proven uniquely"
|
|
1145
|
+
),
|
|
1146
|
+
"location": loc(relpath, router.prefix_node),
|
|
1147
|
+
})
|
|
1148
|
+
else:
|
|
1149
|
+
self.unresolved.append({
|
|
1150
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
1151
|
+
"detail": (
|
|
1152
|
+
f"router '{var}' in {relpath} is an import alias whose "
|
|
1153
|
+
"target router cannot be resolved in the scanned set"
|
|
1154
|
+
),
|
|
1155
|
+
"location": loc(relpath, router.prefix_node),
|
|
1156
|
+
})
|
|
1157
|
+
return
|
|
1158
|
+
self._walk(target[0], target[1], prefix, chain + (node_key,), mount, included)
|
|
1159
|
+
return
|
|
1160
|
+
if router.prefix is None:
|
|
1161
|
+
self.unresolved.append({
|
|
1162
|
+
"code": "FASTAPI_PREFIX_UNRESOLVED",
|
|
1163
|
+
"detail": (
|
|
1164
|
+
f"router '{var}' in {relpath} declares a computed prefix; "
|
|
1165
|
+
"the effective path cannot be proven statically"
|
|
1166
|
+
),
|
|
1167
|
+
"location": loc(relpath, router.prefix_node),
|
|
1168
|
+
})
|
|
1169
|
+
self._report_computed_paths(relpath, router)
|
|
1170
|
+
return
|
|
1171
|
+
prefix = prefix + router.prefix
|
|
1172
|
+
self._emit_routes(index, router, prefix, mount)
|
|
1173
|
+
elif var not in index.apps:
|
|
1174
|
+
return
|
|
1175
|
+
for edge in index.include_edges:
|
|
1176
|
+
if edge.owner_var != var:
|
|
1177
|
+
continue
|
|
1178
|
+
# Materialized (registry-function) edges resolve and locate at
|
|
1179
|
+
# the file the include call is written in, not the instance's.
|
|
1180
|
+
edge_home = edge.file or relpath
|
|
1181
|
+
target, resolution = self._resolve_target(edge_home, edge)
|
|
1182
|
+
if target is None:
|
|
1183
|
+
if resolution is not None and resolution.ambiguous is not None:
|
|
1184
|
+
self.unresolved.append({
|
|
1185
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
1186
|
+
"detail": (
|
|
1187
|
+
f"include_router target "
|
|
1188
|
+
f"'{edge.target_var or (edge.target_attrs[0] if edge.target_attrs else None)}' "
|
|
1189
|
+
f"in {edge_home} matches multiple scanned files under the configured "
|
|
1190
|
+
f"import roots ({', '.join(resolution.ambiguous)}); the target "
|
|
1191
|
+
"router cannot be proven uniquely"
|
|
1192
|
+
),
|
|
1193
|
+
"location": loc(edge_home, edge.node),
|
|
1194
|
+
})
|
|
1195
|
+
continue
|
|
1196
|
+
self.unresolved.append({
|
|
1197
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
1198
|
+
"detail": (
|
|
1199
|
+
f"include_router target "
|
|
1200
|
+
f"'{edge.target_var or (edge.target_attrs[0] if edge.target_attrs else None)}' in {edge_home} "
|
|
1201
|
+
"cannot be resolved in the scanned set"
|
|
1202
|
+
),
|
|
1203
|
+
"location": loc(edge_home, edge.node),
|
|
1204
|
+
})
|
|
1205
|
+
continue
|
|
1206
|
+
if edge.prefix is None:
|
|
1207
|
+
self.unresolved.append({
|
|
1208
|
+
"code": "FASTAPI_PREFIX_UNRESOLVED",
|
|
1209
|
+
"detail": (
|
|
1210
|
+
f"include_router prefix in {edge_home} is computed; "
|
|
1211
|
+
"the effective path cannot be proven statically"
|
|
1212
|
+
),
|
|
1213
|
+
"location": loc(edge_home, edge.node),
|
|
1214
|
+
})
|
|
1215
|
+
continue
|
|
1216
|
+
self._walk(
|
|
1217
|
+
target[0], target[1], prefix + edge.prefix,
|
|
1218
|
+
chain + (node_key,), "include-chain", included,
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
def _report_computed_paths(self, relpath: str, router: RouterDef) -> None:
|
|
1222
|
+
"""Reports computed route paths even when the prefix already failed,
|
|
1223
|
+
so every unprovable construct carries its own typed entry."""
|
|
1224
|
+
for route in router.routes:
|
|
1225
|
+
if route.methods and route.path is None:
|
|
1226
|
+
self.unresolved.append({
|
|
1227
|
+
"code": "HTTP_PATH_DYNAMIC",
|
|
1228
|
+
"detail": (
|
|
1229
|
+
f"route path for '{route.handler}' in {relpath} is "
|
|
1230
|
+
"computed; the effective path cannot be proven statically"
|
|
1231
|
+
),
|
|
1232
|
+
"location": loc(relpath, route.node),
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
def _emit_routes(self, index: FileIndex, router: RouterDef, prefix: str, mount: str) -> None:
|
|
1236
|
+
for route in router.routes:
|
|
1237
|
+
if not route.methods:
|
|
1238
|
+
if not any(node is route.node for node, _ in index.unsupported):
|
|
1239
|
+
self.unresolved.append({
|
|
1240
|
+
"code": "HTTP_METHOD_DYNAMIC",
|
|
1241
|
+
"detail": (
|
|
1242
|
+
f"route decorator on '{route.handler}' in "
|
|
1243
|
+
f"{index.relpath} declares only computed verbs"
|
|
1244
|
+
),
|
|
1245
|
+
"location": loc(index.relpath, route.node),
|
|
1246
|
+
})
|
|
1247
|
+
continue
|
|
1248
|
+
if route.path is None:
|
|
1249
|
+
self.unresolved.append({
|
|
1250
|
+
"code": "HTTP_PATH_DYNAMIC",
|
|
1251
|
+
"detail": (
|
|
1252
|
+
f"route path for '{route.handler}' in {route.file} is "
|
|
1253
|
+
"computed; the effective path cannot be proven statically"
|
|
1254
|
+
),
|
|
1255
|
+
"location": loc(route.file, route.node),
|
|
1256
|
+
})
|
|
1257
|
+
continue
|
|
1258
|
+
for method in route.methods:
|
|
1259
|
+
self.facts.append(
|
|
1260
|
+
_fact(route.file, route, method, prefix + route.path, mount)
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _fact(relpath: str, route: RouteDef, method: str, effective_path: str, mount: str) -> dict:
|
|
1265
|
+
handler_qname = f"{relpath[:-3].replace('/', '.')}:{route.handler}"
|
|
1266
|
+
return {
|
|
1267
|
+
"schemaVersion": 1,
|
|
1268
|
+
"kind": CONTRACT_KIND,
|
|
1269
|
+
"source": relpath,
|
|
1270
|
+
"location": loc(relpath, route.node),
|
|
1271
|
+
"detectorVersion": VERSION,
|
|
1272
|
+
"attributes": {
|
|
1273
|
+
"role": "server-route",
|
|
1274
|
+
"method": method,
|
|
1275
|
+
"rawPath": route.path or "",
|
|
1276
|
+
"normalizedPath": "", # canonicalized by the TS wrapper (single impl)
|
|
1277
|
+
"effectivePath": effective_path,
|
|
1278
|
+
"framework": FRAMEWORK,
|
|
1279
|
+
"handlerSymbol": handler_qname,
|
|
1280
|
+
"isAsync": route.is_async,
|
|
1281
|
+
"responseModel": route.response_model,
|
|
1282
|
+
"requestSchemaSymbols": route.request_schemas,
|
|
1283
|
+
"tags": route.tags,
|
|
1284
|
+
"operationId": route.operation_id,
|
|
1285
|
+
"mountProvenance": mount,
|
|
1286
|
+
},
|
|
1287
|
+
"id": f"http.contract:{relpath}:{route.handler}:{method}:{effective_path}",
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def scan(paths: list[str], root: Path | None = None, import_roots: list[str] | None = None) -> dict:
|
|
1292
|
+
"""Collect the full deterministic discovery outcome for one request.
|
|
1293
|
+
|
|
1294
|
+
Args:
|
|
1295
|
+
paths: Repo-relative files to scan.
|
|
1296
|
+
root: Scan root (default: process cwd); every path resolves under it.
|
|
1297
|
+
import_roots: Optional repo-root-relative directories that act as
|
|
1298
|
+
Python import roots for ABSOLUTE imports (the central-router-
|
|
1299
|
+
registry pattern). Each is validated like a scan path; a bad
|
|
1300
|
+
root raises (fail closed). Uniqueness of resolution is
|
|
1301
|
+
mandatory: an import matching several scanned files across the
|
|
1302
|
+
roots is a typed unresolved entry, never a guess.
|
|
1303
|
+
|
|
1304
|
+
Raises:
|
|
1305
|
+
OSError: A scanned file is missing/unreadable — surfaced as a
|
|
1306
|
+
plugin error frame by the serve loop (fail closed).
|
|
1307
|
+
"""
|
|
1308
|
+
base = root if root is not None else Path.cwd()
|
|
1309
|
+
relpaths = [_normalize_path(p) for p in paths]
|
|
1310
|
+
roots = tuple(_normalize_path(r) for r in (import_roots or ()))
|
|
1311
|
+
|
|
1312
|
+
indexes: dict[str, FileIndex] = {}
|
|
1313
|
+
findings: list[dict] = []
|
|
1314
|
+
scanned: list[str] = []
|
|
1315
|
+
for relpath in relpaths:
|
|
1316
|
+
if not relpath.endswith(".py"):
|
|
1317
|
+
continue
|
|
1318
|
+
index, finding = _scan_file(relpath, base)
|
|
1319
|
+
if finding is not None:
|
|
1320
|
+
findings.append(finding)
|
|
1321
|
+
continue
|
|
1322
|
+
scanned.append(relpath)
|
|
1323
|
+
indexes[relpath] = index
|
|
1324
|
+
|
|
1325
|
+
resolver = _Resolver(indexes, roots)
|
|
1326
|
+
resolver.resolve()
|
|
1327
|
+
|
|
1328
|
+
for relpath in sorted(indexes):
|
|
1329
|
+
for node, verbs in indexes[relpath].unsupported:
|
|
1330
|
+
resolver.unresolved.append({
|
|
1331
|
+
"code": "HTTP_METHOD_DYNAMIC",
|
|
1332
|
+
"detail": (
|
|
1333
|
+
f"route decorator in {relpath} uses verb(s) {sorted(verbs)} "
|
|
1334
|
+
"outside the supported set"
|
|
1335
|
+
),
|
|
1336
|
+
"location": loc(relpath, node),
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
resources = resolver.facts
|
|
1340
|
+
unresolved = resolver.unresolved
|
|
1341
|
+
|
|
1342
|
+
resources.sort(key=lambda r: r["id"])
|
|
1343
|
+
unresolved.sort(
|
|
1344
|
+
key=lambda u: (
|
|
1345
|
+
u["location"]["file"], u["location"]["line"],
|
|
1346
|
+
u["location"]["col"], u["code"], u["detail"],
|
|
1347
|
+
)
|
|
1348
|
+
)
|
|
1349
|
+
findings.sort(
|
|
1350
|
+
key=lambda f: (
|
|
1351
|
+
f["code"], f["detail"],
|
|
1352
|
+
f["locations"][0]["file"] if f["locations"] else "",
|
|
1353
|
+
f["locations"][0]["line"] if f["locations"] else 0,
|
|
1354
|
+
)
|
|
1355
|
+
)
|
|
1356
|
+
return {
|
|
1357
|
+
"resources": resources,
|
|
1358
|
+
"unresolved": unresolved,
|
|
1359
|
+
"findings": findings,
|
|
1360
|
+
"classificationSignals": [], # minted by the TS wrapper post-canonicalization
|
|
1361
|
+
"scannedPaths": sorted(scanned),
|
|
1362
|
+
}
|