@gate-forge/pack-fastapi 0.8.0 → 0.9.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/README.md +28 -0
- package/dist/detector.d.ts +9 -4
- package/dist/detector.d.ts.map +1 -1
- package/dist/detector.js +26 -14
- package/dist/detector.js.map +1 -1
- package/package.json +8 -5
- package/python/gateforge_fastapi_detector/scan.py +192 -33
- 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/README.md
CHANGED
|
@@ -123,6 +123,34 @@ bounded and deterministic:
|
|
|
123
123
|
invented). A parameter name shadowing a same-file instance variable
|
|
124
124
|
keeps the module-level reading only (no double emission).
|
|
125
125
|
|
|
126
|
+
|
|
127
|
+
## Prefixes that are provable, and routers nothing mounts
|
|
128
|
+
|
|
129
|
+
- **Annotated definitions are definitions.** `router: APIRouter =
|
|
130
|
+
APIRouter(prefix="/api/v1")` is indexed exactly like `router =
|
|
131
|
+
APIRouter(prefix="/api/v1")`, prefix included. (It used to be ignored,
|
|
132
|
+
and every route behind it was published at the router's own prefix with
|
|
133
|
+
no typed outcome.)
|
|
134
|
+
- **Constant prefixes fold.** A prefix that is a module-level string
|
|
135
|
+
constant — `API = "/api/v1"` with `APIRouter(prefix=API)` **or**
|
|
136
|
+
`app.include_router(router, prefix=API)` — is provable from the source,
|
|
137
|
+
so the route keeps its real path. An f-string, an attribute, or a call
|
|
138
|
+
is genuinely computed: the router yields a typed
|
|
139
|
+
`FASTAPI_PREFIX_UNRESOLVED` entry and no route is emitted.
|
|
140
|
+
- **A router object built by an unmodeled expression** (`router =
|
|
141
|
+
build_router()`, a subscript, an attribute) has an unknown prefix, so
|
|
142
|
+
its routes are reported as `FASTAPI_PREFIX_UNRESOLVED` naming the
|
|
143
|
+
variable — never as prefix-less paths that no app serves.
|
|
144
|
+
- **`FASTAPI_ROUTER_UNMOUNTED`.** A router that no scanned
|
|
145
|
+
`include_router` targets is served by no scanned app. Its routes keep
|
|
146
|
+
their standalone emission (the declared prefix is all the scan knows),
|
|
147
|
+
and one typed entry names the router, its file and every declared
|
|
148
|
+
route, so "the `/api/v1` prefix was not applied" is never the answer
|
|
149
|
+
when the real one is "nothing mounts this router". Reported only when
|
|
150
|
+
the scanned set shows an application and has no unresolvable include;
|
|
151
|
+
with a partial scan, or an include the scan cannot follow, the pack
|
|
152
|
+
says nothing it cannot prove.
|
|
153
|
+
|
|
126
154
|
## Output model (ADR 0004 D1)
|
|
127
155
|
|
|
128
156
|
One `http.contract` resource per (effective mounted path, concrete
|
package/dist/detector.d.ts
CHANGED
|
@@ -37,7 +37,12 @@ export interface FastapiDetectorOptions {
|
|
|
37
37
|
command?: readonly string[];
|
|
38
38
|
/** Subprocess environment (default: {@link pythonEnvironment}). */
|
|
39
39
|
env?: NodeJS.ProcessEnv;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Working directory the repo-relative paths resolve against (default:
|
|
42
|
+
* `process.cwd()` AT DISCOVER TIME, not at factory time — the default
|
|
43
|
+
* export is created at module import and `check --staged` moves the
|
|
44
|
+
* process cwd to the candidate checkout before discovery).
|
|
45
|
+
*/
|
|
41
46
|
cwd?: string;
|
|
42
47
|
/** Handshake-pinned plugin id (default: the pack id). */
|
|
43
48
|
pluginId?: string;
|
|
@@ -49,9 +54,9 @@ export interface FastapiDetectorOptions {
|
|
|
49
54
|
*/
|
|
50
55
|
importRoots?: readonly string[];
|
|
51
56
|
/**
|
|
52
|
-
* Repo-relative path of a config document (JSON) read from
|
|
53
|
-
* `importRoots` is not given (default:
|
|
54
|
-
* absence is normal, malformed throws).
|
|
57
|
+
* Repo-relative path of a config document (JSON) read from the root in
|
|
58
|
+
* force at discover time when `importRoots` is not given (default:
|
|
59
|
+
* `.gateforge/fastapi.json`; absence is normal, malformed throws).
|
|
55
60
|
*/
|
|
56
61
|
importRootsConfigPath?: string;
|
|
57
62
|
}
|
package/dist/detector.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"AA6CA,2EAA2E;AAC3E,eAAO,MAAM,eAAe,UAAkD,CAAC;AAE/E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,4BAA4B,CAAC;AAElE,iEAAiE;AACjE,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC;AAED,eAAO,MAAM,2BAA2B,EAAE,iBAAsB,CAAC;AAsBjE;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,iBAAiB,CAkClF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,GAAE,SAAS,MAAM,EAAO,GAAG,MAAM,CAAC,UAAU,CAOlF;AAED,iDAAiD;AACjD,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,mEAAmE;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,oEAAoE;AACpE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CACN,KAAK,EAAE,SAAS,MAAM,EAAE,GACvB,OAAO,CAAC;QACT,SAAS,EAAE,OAAO,EAAE,CAAC;QACrB,UAAU,EAAE,OAAO,EAAE,CAAC;QACtB,QAAQ,EAAE,OAAO,EAAE,CAAC;QACpB,qBAAqB,EAAE,OAAO,EAAE,CAAC;QACjC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;KACzB,CAAC,CAAC;CACJ;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,sBAA2B,GAAG,eAAe,CAiD3F"}
|
package/dist/detector.js
CHANGED
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
* handler corroboration over the facts this pack emits).
|
|
23
23
|
*
|
|
24
24
|
* Determinism: the python scan is pure over (paths, file bytes). The
|
|
25
|
-
* optional `.gateforge/fastapi.json` config (import roots for absolute
|
|
26
|
-
* imports, the central-router-registry pattern) is read
|
|
27
|
-
* time and passed to the scanner as an explicit
|
|
28
|
-
* flag — never environment state, never per-request
|
|
25
|
+
* The optional `.gateforge/fastapi.json` config (import roots for absolute
|
|
26
|
+
* imports, the central-router-registry pattern) is read from the root that
|
|
27
|
+
* is in force at DISCOVER time and passed to the scanner as an explicit
|
|
28
|
+
* `--import-roots` argv flag — never environment state, never per-request
|
|
29
|
+
* mutation.
|
|
29
30
|
*/
|
|
30
31
|
import { readFileSync } from 'node:fs';
|
|
31
32
|
import { delimiter, resolve } from 'node:path';
|
|
@@ -120,24 +121,35 @@ export function pythonEnvironment(extra = []) {
|
|
|
120
121
|
export function createFastapiDetector(options = {}) {
|
|
121
122
|
const command = options.command ?? DEFAULT_COMMAND;
|
|
122
123
|
const env = options.env ?? pythonEnvironment();
|
|
123
|
-
const cwd = options.cwd ?? process.cwd();
|
|
124
124
|
const pluginId = options.pluginId ?? PACK_PLUGIN_ID;
|
|
125
125
|
const pluginVersion = options.pluginVersion ?? PACK_VERSION;
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
126
|
+
// The repo root is resolved at DISCOVER time unless the caller pinned one
|
|
127
|
+
// explicitly: the default export of this pack is created at module import
|
|
128
|
+
// (the CLI imports it at startup), and `gateforge check --staged` moves the
|
|
129
|
+
// process cwd to the staged candidate checkout before discovery runs. A
|
|
130
|
+
// root captured at factory time would pin the loader's cwd and read the
|
|
131
|
+
// user's worktree bytes instead of the gated ones. The same holds for the
|
|
132
|
+
// config document: `.gateforge/fastapi.json` is read from the root in
|
|
133
|
+
// force at this discover call. Explicit options always win.
|
|
134
|
+
const resolveRoot = () => options.cwd ?? process.cwd();
|
|
135
|
+
const resolveArgv = (cwd) => {
|
|
136
|
+
// Explicit roots win; otherwise the config document (absence normal,
|
|
137
|
+
// malformed throws). With no roots at all the spawned command is
|
|
138
|
+
// byte-identical to the pre-config surface.
|
|
139
|
+
const importRoots = options.importRoots ??
|
|
140
|
+
readFastapiScanConfigOrNull(resolve(cwd, options.importRootsConfigPath ?? FASTAPI_SCAN_CONFIG_PATH))
|
|
141
|
+
.importRoots ??
|
|
142
|
+
[];
|
|
143
|
+
return importRoots.length > 0 ? [...command, '--import-roots', JSON.stringify(importRoots)] : [...command];
|
|
144
|
+
};
|
|
134
145
|
return {
|
|
135
146
|
async discover(paths) {
|
|
136
147
|
if (paths.length === 0) {
|
|
137
148
|
return { resources: [], unresolved: [], findings: [], classificationSignals: [] };
|
|
138
149
|
}
|
|
150
|
+
const cwd = resolveRoot();
|
|
139
151
|
const session = new PluginSession({
|
|
140
|
-
command:
|
|
152
|
+
command: resolveArgv(cwd),
|
|
141
153
|
pluginId,
|
|
142
154
|
pluginVersion,
|
|
143
155
|
cwd,
|
package/dist/detector.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detector.js","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"detector.js","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gate-forge/pack-fastapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "FastAPI server-route detector: Python AST scanner served over GPP/3 (pack-sqlalchemy pattern), emitting http.contract evidence facts with canonical effective paths plus exposure/lifecycle signals (ADR 0004 D1, plan phase 2).",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"README.md",
|
|
21
21
|
"dist",
|
|
22
|
-
"python"
|
|
22
|
+
"python",
|
|
23
|
+
"!**/__pycache__",
|
|
24
|
+
"!**/*.pyc",
|
|
25
|
+
"!**/*.pyo"
|
|
23
26
|
],
|
|
24
27
|
"scripts": {
|
|
25
28
|
"build": "tsc -p tsconfig.build.json",
|
|
@@ -27,9 +30,9 @@
|
|
|
27
30
|
"test": "vitest run"
|
|
28
31
|
},
|
|
29
32
|
"dependencies": {
|
|
30
|
-
"@gate-forge/core": "^0.
|
|
31
|
-
"@gate-forge/http-contract": "^0.
|
|
32
|
-
"@gate-forge/plugin-protocol": "^0.
|
|
33
|
+
"@gate-forge/core": "^0.9.0",
|
|
34
|
+
"@gate-forge/http-contract": "^0.9.0",
|
|
35
|
+
"@gate-forge/plugin-protocol": "^0.9.0",
|
|
33
36
|
"zod": "^4.1.5"
|
|
34
37
|
},
|
|
35
38
|
"publishConfig": {
|
|
@@ -19,9 +19,26 @@ Detector vocabulary (frozen with the pack):
|
|
|
19
19
|
- One fact per (effective mounted path, concrete method): an
|
|
20
20
|
``api_route(methods=[...])`` yields one fact per listed method, and a
|
|
21
21
|
router mounted twice yields one fact per mount (plan phase 2.3).
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
``
|
|
22
|
+
``Router creation that the AST pass cannot model`` (``build_router()``,
|
|
23
|
+
a subscript, an attribute, any expression that is not a plain
|
|
24
|
+
``APIRouter(...)`` call) is a typed ``FASTAPI_PREFIX_UNRESOLVED``
|
|
25
|
+
outcome naming the variable and its file — NEVER a silently
|
|
26
|
+
prefix-less router: a route whose effective path cannot be proven is
|
|
27
|
+
never reported as served.
|
|
28
|
+
- Literal prefixes: a prefix expression that is a module-level string
|
|
29
|
+
constant (``PREFIX = "/api/v1"``) is folded to its literal, for both
|
|
30
|
+
``APIRouter(prefix=...)`` and ``include_router(prefix=...)``, so the
|
|
31
|
+
mount prefix reaches the effective path. Anything else computed stays
|
|
32
|
+
typed-unresolved.
|
|
33
|
+
- Annotated router definitions (``router: APIRouter = APIRouter(...)``)
|
|
34
|
+
are router definitions exactly like ``router = APIRouter(...)``,
|
|
35
|
+
prefix included.
|
|
36
|
+
- Unmounted routers: when the whole mount graph is provable (no
|
|
37
|
+
``FASTAPI_PREFIX_UNRESOLVED`` anywhere in the scan), a router that no
|
|
38
|
+
include edge targets is dead code in the scanned set: its routes keep
|
|
39
|
+
their standalone emission, and one ``FASTAPI_ROUTER_UNMOUNTED`` entry
|
|
40
|
+
names the router, its file, and every declared route so the reader can
|
|
41
|
+
see that no scanned app serves those paths.
|
|
25
42
|
- Registry functions (the ``def register_all_routers(app): ...
|
|
26
43
|
app.include_router(r, prefix=...)`` pattern): a function whose body
|
|
27
44
|
calls ``include_router`` on one of ITS OWN parameters collects those
|
|
@@ -65,10 +82,14 @@ Detector vocabulary (frozen with the pack):
|
|
|
65
82
|
imports are unaffected. Without import roots every behavior is exactly
|
|
66
83
|
as before (closed-world: back-compat).
|
|
67
84
|
- ``unresolved`` entries: ``FASTAPI_PREFIX_UNRESOLVED`` for computed
|
|
68
|
-
router/include prefixes,
|
|
69
|
-
targets/imports/aliases,
|
|
70
|
-
arguments, include cycles,
|
|
71
|
-
|
|
85
|
+
router/include prefixes, router creations the AST pass cannot model,
|
|
86
|
+
unresolvable or ambiguous include targets/imports/aliases,
|
|
87
|
+
unresolvable registry-function call-site arguments, include cycles,
|
|
88
|
+
and registry chains beyond the helper-depth bound;
|
|
89
|
+
``FASTAPI_ROUTER_UNMOUNTED`` for a router no include edge targets —
|
|
90
|
+
reported only when the scanned set shows an application and no
|
|
91
|
+
unresolvable include, since "unmounted" is otherwise unprovable;
|
|
92
|
+
``HTTP_PATH_DYNAMIC`` for non-literal route paths;
|
|
72
93
|
``HTTP_METHOD_DYNAMIC`` for decorator verbs outside the supported set.
|
|
73
94
|
All are source-located and blocking — nothing disappears silently.
|
|
74
95
|
- No app import, no route execution, no environment or network access
|
|
@@ -89,6 +110,10 @@ FRAMEWORK = "fastapi"
|
|
|
89
110
|
|
|
90
111
|
# Typed outcome codes (mirrored in @gate-forge/http-contract codes.ts).
|
|
91
112
|
FASTAPI_PREFIX_UNRESOLVED = "FASTAPI_PREFIX_UNRESOLVED"
|
|
113
|
+
# A router no include edge targets, reported only when the mount graph
|
|
114
|
+
# itself is provable: the paths the standalone fallback emits are served
|
|
115
|
+
# by no scanned app, and the reader must be told which file declares them.
|
|
116
|
+
FASTAPI_ROUTER_UNMOUNTED = "FASTAPI_ROUTER_UNMOUNTED"
|
|
92
117
|
|
|
93
118
|
_DECORATOR_METHODS = {
|
|
94
119
|
"get": "GET",
|
|
@@ -150,6 +175,13 @@ class RouterDef:
|
|
|
150
175
|
# Set when the variable is an import alias bound to a router defined in
|
|
151
176
|
# another scanned module: ``(raw_module, level, imported_name)``.
|
|
152
177
|
alias_of: tuple[str | None, int, str] | None = None
|
|
178
|
+
# True when the router OBJECT comes from an expression this AST pass
|
|
179
|
+
# cannot model (``build_router()``, a subscript, an attribute, any
|
|
180
|
+
# expression that is not a plain ``APIRouter(...)`` call). Its own
|
|
181
|
+
# prefix and therefore every route's effective path are unprovable:
|
|
182
|
+
# the walk reports a typed outcome instead of emitting a fabricated
|
|
183
|
+
# prefix-less path.
|
|
184
|
+
creation_computed: bool = False
|
|
153
185
|
|
|
154
186
|
|
|
155
187
|
@dataclass
|
|
@@ -280,6 +312,41 @@ def _static_string(node: ast.AST | None) -> str | None:
|
|
|
280
312
|
return None
|
|
281
313
|
|
|
282
314
|
|
|
315
|
+
def _literal_prefix(node: ast.AST | None, constants: dict[str, str]) -> str | None:
|
|
316
|
+
"""A mount prefix that provably is one literal string, else None.
|
|
317
|
+
|
|
318
|
+
Beyond a literal (``prefix="/api/v1"``) this folds a module-level
|
|
319
|
+
string CONSTANT (``PREFIX = "/api/v1"; APIRouter(prefix=PREFIX)``):
|
|
320
|
+
that value is fixed by the source, so the effective path is provable
|
|
321
|
+
and the route must not disappear behind a computed-prefix outcome.
|
|
322
|
+
Anything else (an f-string, an attribute, a call, a concatenation)
|
|
323
|
+
stays computed and is reported as typed-unresolved.
|
|
324
|
+
"""
|
|
325
|
+
literal = _static_string(node)
|
|
326
|
+
if literal is not None:
|
|
327
|
+
return literal
|
|
328
|
+
if isinstance(node, ast.Name):
|
|
329
|
+
return constants.get(node.id)
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _module_string_constants(tree: ast.Module) -> dict[str, str]:
|
|
334
|
+
"""Module-level string constants, for literal prefix folding."""
|
|
335
|
+
constants: dict[str, str] = {}
|
|
336
|
+
for statement in tree.body:
|
|
337
|
+
if isinstance(statement, ast.Assign):
|
|
338
|
+
literal = _static_string(statement.value)
|
|
339
|
+
if literal is not None:
|
|
340
|
+
for target in statement.targets:
|
|
341
|
+
if isinstance(target, ast.Name):
|
|
342
|
+
constants[target.id] = literal
|
|
343
|
+
elif isinstance(statement, ast.AnnAssign):
|
|
344
|
+
literal = _static_string(statement.value)
|
|
345
|
+
if literal is not None and isinstance(statement.target, ast.Name):
|
|
346
|
+
constants[statement.target.id] = literal
|
|
347
|
+
return constants
|
|
348
|
+
|
|
349
|
+
|
|
283
350
|
def _dotted_name(node: ast.AST | None) -> str | None:
|
|
284
351
|
"""A dotted name for Name/Attribute chains, or None."""
|
|
285
352
|
if isinstance(node, ast.Name):
|
|
@@ -402,13 +469,14 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
402
469
|
its ``include_router`` calls still count for the outer parameter.
|
|
403
470
|
"""
|
|
404
471
|
|
|
405
|
-
def __init__(self, relpath: str) -> None:
|
|
472
|
+
def __init__(self, relpath: str, constants: dict[str, str] | None = None) -> None:
|
|
406
473
|
self.index = FileIndex(relpath=relpath)
|
|
407
474
|
# Current top-level function (registry-function context), or None
|
|
408
475
|
# at module level.
|
|
409
476
|
self._function: FunctionIncludes | None = None
|
|
410
|
-
|
|
411
|
-
|
|
477
|
+
# Module-level string constants, for literal prefix folding
|
|
478
|
+
# (`PREFIX = "/api/v1"`; `APIRouter(prefix=PREFIX)`).
|
|
479
|
+
self._constants: dict[str, str] = constants or {}
|
|
412
480
|
|
|
413
481
|
def visit_Import(self, node: ast.Import) -> None:
|
|
414
482
|
for alias in node.names:
|
|
@@ -425,25 +493,43 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
425
493
|
# -- router / app instances ---------------------------------------------
|
|
426
494
|
|
|
427
495
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
for target in node.targets:
|
|
432
|
-
if not isinstance(target, ast.Name):
|
|
433
|
-
continue
|
|
434
|
-
if kind == "FastAPI":
|
|
435
|
-
self.index.apps.add(target.id)
|
|
436
|
-
else:
|
|
437
|
-
prefix_node = _keyword(node.value, "prefix")
|
|
438
|
-
prefix: str | None = (
|
|
439
|
-
"" if prefix_node is None else _static_string(prefix_node)
|
|
440
|
-
)
|
|
441
|
-
self.index.routers[target.id] = RouterDef(
|
|
442
|
-
var=target.id, prefix=prefix,
|
|
443
|
-
prefix_node=prefix_node if prefix_node is not None else node.value,
|
|
444
|
-
)
|
|
496
|
+
for target in node.targets:
|
|
497
|
+
if isinstance(target, ast.Name):
|
|
498
|
+
self._record_instance(target.id, node.value)
|
|
445
499
|
self.generic_visit(node)
|
|
446
500
|
|
|
501
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
502
|
+
# `router: APIRouter = APIRouter(prefix="/api/v1")` defines a
|
|
503
|
+
# router exactly like the unannotated form; before this, the
|
|
504
|
+
# annotated shape produced a silently prefix-less route.
|
|
505
|
+
if node.value is not None and isinstance(node.target, ast.Name):
|
|
506
|
+
self._record_instance(node.target.id, node.value)
|
|
507
|
+
self.generic_visit(node)
|
|
508
|
+
|
|
509
|
+
def _record_instance(self, name: str, value: ast.AST) -> None:
|
|
510
|
+
"""Indexes one ``FastAPI()`` / ``APIRouter()`` binding."""
|
|
511
|
+
if not (isinstance(value, ast.Call) and isinstance(value.func, ast.Name)):
|
|
512
|
+
return
|
|
513
|
+
kind = value.func.id
|
|
514
|
+
if kind == "FastAPI":
|
|
515
|
+
self.index.apps.add(name)
|
|
516
|
+
# Routes declared straight on the app (`@app.get("/health")`)
|
|
517
|
+
# belong to it with no router prefix of their own.
|
|
518
|
+
self.index.routers.setdefault(
|
|
519
|
+
name, RouterDef(var=name, prefix="", prefix_node=value),
|
|
520
|
+
)
|
|
521
|
+
return
|
|
522
|
+
if kind != "APIRouter":
|
|
523
|
+
return
|
|
524
|
+
prefix_node = _keyword(value, "prefix")
|
|
525
|
+
prefix: str | None = (
|
|
526
|
+
"" if prefix_node is None else _literal_prefix(prefix_node, self._constants)
|
|
527
|
+
)
|
|
528
|
+
self.index.routers[name] = RouterDef(
|
|
529
|
+
var=name, prefix=prefix,
|
|
530
|
+
prefix_node=prefix_node if prefix_node is not None else value,
|
|
531
|
+
)
|
|
532
|
+
|
|
447
533
|
# -- response models -----------------------------------------------------
|
|
448
534
|
|
|
449
535
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
@@ -561,7 +647,19 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
561
647
|
alias_of = None
|
|
562
648
|
if ref is not None and ref.name is not None:
|
|
563
649
|
alias_of = (ref.module, ref.level, ref.name)
|
|
564
|
-
router
|
|
650
|
+
# No router definition and no import binding for this name: the
|
|
651
|
+
# router object comes from an expression this pass cannot model
|
|
652
|
+
# (`build_router()`, a subscript, a re-assignment). Its own
|
|
653
|
+
# prefix is therefore UNKNOWN — a synthetic `prefix=""` here
|
|
654
|
+
# would publish every one of its routes at a path no app serves,
|
|
655
|
+
# with no typed outcome at all.
|
|
656
|
+
router = RouterDef(
|
|
657
|
+
var=owner.id,
|
|
658
|
+
prefix="" if alias_of is not None else None,
|
|
659
|
+
prefix_node=owner,
|
|
660
|
+
alias_of=alias_of,
|
|
661
|
+
creation_computed=alias_of is None,
|
|
662
|
+
)
|
|
565
663
|
self.index.routers[owner.id] = router
|
|
566
664
|
router.routes.append(entry)
|
|
567
665
|
out_of_set = [m for m in methods if m not in _DECORATOR_METHODS.values()]
|
|
@@ -600,7 +698,10 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
600
698
|
target_var=target_var,
|
|
601
699
|
target_alias=target_alias,
|
|
602
700
|
target_attrs=target_attrs,
|
|
603
|
-
prefix=
|
|
701
|
+
prefix=(
|
|
702
|
+
"" if prefix_node is None
|
|
703
|
+
else _literal_prefix(prefix_node, self._constants)
|
|
704
|
+
),
|
|
604
705
|
node=node,
|
|
605
706
|
file=self.index.relpath,
|
|
606
707
|
)
|
|
@@ -657,7 +758,7 @@ def _scan_file(relpath: str, root: Path) -> tuple[FileIndex | None, dict | None]
|
|
|
657
758
|
"detail": f"{type(exc).__name__}: {msg}",
|
|
658
759
|
"locations": [{"file": relpath, "line": max(line, 1), "col": 0}],
|
|
659
760
|
}
|
|
660
|
-
visitor = _ModuleVisitor(relpath)
|
|
761
|
+
visitor = _ModuleVisitor(relpath, _module_string_constants(tree))
|
|
661
762
|
visitor.visit(tree)
|
|
662
763
|
return visitor.index, None
|
|
663
764
|
|
|
@@ -893,6 +994,7 @@ class _Resolver:
|
|
|
893
994
|
self._materialize_function_includes()
|
|
894
995
|
self._merge_aliases()
|
|
895
996
|
included = self._collect_included()
|
|
997
|
+
unmounted: list[tuple[str, str, RouterDef]] = []
|
|
896
998
|
for relpath in sorted(self.indexes):
|
|
897
999
|
index = self.indexes[relpath]
|
|
898
1000
|
for var in sorted(index.apps):
|
|
@@ -906,6 +1008,56 @@ class _Resolver:
|
|
|
906
1008
|
if (relpath, name) in included:
|
|
907
1009
|
continue
|
|
908
1010
|
self._walk(relpath, name, "", (), "standalone", included)
|
|
1011
|
+
if router.routes:
|
|
1012
|
+
unmounted.append((relpath, name, router))
|
|
1013
|
+
self._report_unmounted_routers(
|
|
1014
|
+
unmounted, apps_present=any(index.apps for index in self.indexes.values()),
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
def _report_unmounted_routers(
|
|
1018
|
+
self,
|
|
1019
|
+
unmounted: list[tuple[str, str, RouterDef]],
|
|
1020
|
+
apps_present: bool,
|
|
1021
|
+
) -> None:
|
|
1022
|
+
"""Names routers no include edge mounts, when that is provable.
|
|
1023
|
+
|
|
1024
|
+
A router that no scanned include edge targets is served by no
|
|
1025
|
+
scanned app: the standalone fallback still reports its routes (a
|
|
1026
|
+
route's path is a claim, and its declared prefix is all this pass
|
|
1027
|
+
knows), but the reader must see that nothing mounts them — the
|
|
1028
|
+
routes appear at the router's OWN prefix, without whatever mount
|
|
1029
|
+
prefix the sibling modules carry.
|
|
1030
|
+
|
|
1031
|
+
Reported ONLY when the scanned set actually shows the application
|
|
1032
|
+
and its mount graph is otherwise fully proven. With no app
|
|
1033
|
+
instance the mounting code may simply be outside the scan, and
|
|
1034
|
+
with an unresolvable include this scan cannot say which routers
|
|
1035
|
+
that include would have reached — in both cases "unmounted" would
|
|
1036
|
+
be a claim about files nobody could see, or would bury the real
|
|
1037
|
+
blocker (already reported) under unrelated entries.
|
|
1038
|
+
"""
|
|
1039
|
+
if not apps_present:
|
|
1040
|
+
return
|
|
1041
|
+
if any(entry["code"] == FASTAPI_PREFIX_UNRESOLVED for entry in self.unresolved):
|
|
1042
|
+
return
|
|
1043
|
+
for relpath, name, router in unmounted:
|
|
1044
|
+
routes = sorted(
|
|
1045
|
+
f"{method} {router.prefix or ''}{route.path}"
|
|
1046
|
+
for route in router.routes
|
|
1047
|
+
if route.path is not None
|
|
1048
|
+
for method in route.methods
|
|
1049
|
+
)
|
|
1050
|
+
self.unresolved.append({
|
|
1051
|
+
"code": FASTAPI_ROUTER_UNMOUNTED,
|
|
1052
|
+
"detail": (
|
|
1053
|
+
f"router '{name}' in {relpath} is never included by any app in the "
|
|
1054
|
+
f"scanned set; its {len(routes)} route(s) "
|
|
1055
|
+
f"({', '.join(routes)}) are reported at the router's own prefix, "
|
|
1056
|
+
"which no scanned app serves — mount the router, or delete it if it "
|
|
1057
|
+
"is dead code"
|
|
1058
|
+
),
|
|
1059
|
+
"location": loc(relpath, router.prefix_node),
|
|
1060
|
+
})
|
|
909
1061
|
|
|
910
1062
|
def _materialize_function_includes(self) -> None:
|
|
911
1063
|
"""Rewires registry-function includes onto real instances.
|
|
@@ -1381,10 +1533,17 @@ class _Resolver:
|
|
|
1381
1533
|
return
|
|
1382
1534
|
if router.prefix is None:
|
|
1383
1535
|
self.unresolved.append({
|
|
1384
|
-
"code":
|
|
1536
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
1385
1537
|
"detail": (
|
|
1386
|
-
|
|
1387
|
-
|
|
1538
|
+
(
|
|
1539
|
+
f"router '{var}' in {relpath} is created by an expression that "
|
|
1540
|
+
"cannot be modeled statically (its own prefix and routes are "
|
|
1541
|
+
"unknown), so its effective paths are not reported"
|
|
1542
|
+
)
|
|
1543
|
+
if router.creation_computed else (
|
|
1544
|
+
f"router '{var}' in {relpath} declares a computed prefix; "
|
|
1545
|
+
"the effective path cannot be proven statically"
|
|
1546
|
+
)
|
|
1388
1547
|
),
|
|
1389
1548
|
"location": loc(relpath, router.prefix_node),
|
|
1390
1549
|
})
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|