@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.
@@ -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
+ }