@gate-forge/pack-fastapi 0.7.1 → 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 +67 -0
- package/dist/detector.d.ts +9 -4
- package/dist/detector.d.ts.map +1 -1
- package/dist/detector.js +31 -15
- package/dist/detector.js.map +1 -1
- package/dist/facts.d.ts.map +1 -1
- package/dist/facts.js +10 -0
- package/dist/facts.js.map +1 -1
- package/package.json +8 -5
- package/python/gateforge_fastapi_detector/scan.py +455 -51
- package/python/gateforge_fastapi_detector/__pycache__/__init__.cpython-314.pyc +0 -0
- package/python/gateforge_fastapi_detector/__pycache__/__main__.cpython-314.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
|
|
@@ -145,6 +173,45 @@ handler names these very facts carry, and blocks ambiguity with typed
|
|
|
145
173
|
detection remains for genuinely stale authority signals (declaration
|
|
146
174
|
markers, adapter bindings, read-only declarations).
|
|
147
175
|
|
|
176
|
+
## Response-model wire names
|
|
177
|
+
|
|
178
|
+
Each server-route fact carries `attributes.responseModelFields`: the wire
|
|
179
|
+
names its response model answers to, in declaration order — every pydantic
|
|
180
|
+
field name plus every `Field(alias=...)` it declares, including the fields
|
|
181
|
+
it inherits from a base class the scanned set also proves. The model is
|
|
182
|
+
the decorator's `response_model=` when it declares one and the handler's
|
|
183
|
+
return annotation otherwise (FastAPI's own default), with the containers
|
|
184
|
+
FastAPI unwraps peeled off: `list[InvoiceOut]`, `Optional[MoneyOut]` and
|
|
185
|
+
`MoneyOut | None` all report the element model.
|
|
186
|
+
|
|
187
|
+
`detail` is always among the names: FastAPI answers a failed request with
|
|
188
|
+
`{"detail": ...}` (an `HTTPException`) or a `detail` list (request
|
|
189
|
+
validation), so a frontend reading `detail` to report an error is never
|
|
190
|
+
evidence of a dropped success-model field.
|
|
191
|
+
|
|
192
|
+
The attribute is **absent** whenever the wire names are not statically
|
|
193
|
+
computable, never partial:
|
|
194
|
+
|
|
195
|
+
- a model configuring alias generation (`model_config = ConfigDict(...)`
|
|
196
|
+
or a pydantic v1 `class Config`) — every name differs;
|
|
197
|
+
- a base class outside the scanned set — inherited fields unknown;
|
|
198
|
+
- a shape that is not one model: `dict`, a union of two models, a
|
|
199
|
+
computed annotation, no annotation at all.
|
|
200
|
+
|
|
201
|
+
The existing `responseModel` attribute keeps its exact previous meaning
|
|
202
|
+
(the decorator declaration only) and its exact previous bytes; a return
|
|
203
|
+
annotation is a new fact, never a change of the old one.
|
|
204
|
+
|
|
205
|
+
`gateforge check` cross-checks these names against the fields a frontend
|
|
206
|
+
actually reads (reported by `@gate-forge/pack-http` as `responseReads`)
|
|
207
|
+
and emits one **non-blocking** `RESPONSE_FIELD_MISSING_FROM_MODEL`
|
|
208
|
+
advisory per field a joined endpoint's model does not declare — naming the
|
|
209
|
+
endpoint, the field, the frontend file and line, and the names the model
|
|
210
|
+
does declare. A field counts as present under its own name, under the other
|
|
211
|
+
case style (`due_date` ≡ `dueDate`) or under a declared alias. A
|
|
212
|
+
repository whose routes declare no provable response model produces no
|
|
213
|
+
advisory and a byte-identical report.
|
|
214
|
+
|
|
148
215
|
## Setup
|
|
149
216
|
|
|
150
217
|
```yaml
|
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';
|
|
@@ -107,7 +108,11 @@ export function readFastapiScanConfigOrNull(path) {
|
|
|
107
108
|
*/
|
|
108
109
|
export function pythonEnvironment(extra = []) {
|
|
109
110
|
const entries = [...extra, PACK_PYTHON_DIR, PROTOCOL_PYTHON_DIR];
|
|
110
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
...process.env,
|
|
113
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
114
|
+
PYTHONPATH: entries.join(delimiter),
|
|
115
|
+
};
|
|
111
116
|
}
|
|
112
117
|
/**
|
|
113
118
|
* Creates a discover-capable detector module. The default export of the
|
|
@@ -116,24 +121,35 @@ export function pythonEnvironment(extra = []) {
|
|
|
116
121
|
export function createFastapiDetector(options = {}) {
|
|
117
122
|
const command = options.command ?? DEFAULT_COMMAND;
|
|
118
123
|
const env = options.env ?? pythonEnvironment();
|
|
119
|
-
const cwd = options.cwd ?? process.cwd();
|
|
120
124
|
const pluginId = options.pluginId ?? PACK_PLUGIN_ID;
|
|
121
125
|
const pluginVersion = options.pluginVersion ?? PACK_VERSION;
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
};
|
|
130
145
|
return {
|
|
131
146
|
async discover(paths) {
|
|
132
147
|
if (paths.length === 0) {
|
|
133
148
|
return { resources: [], unresolved: [], findings: [], classificationSignals: [] };
|
|
134
149
|
}
|
|
150
|
+
const cwd = resolveRoot();
|
|
135
151
|
const session = new PluginSession({
|
|
136
|
-
command:
|
|
152
|
+
command: resolveArgv(cwd),
|
|
137
153
|
pluginId,
|
|
138
154
|
pluginVersion,
|
|
139
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/dist/facts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"facts.d.ts","sourceRoot":"","sources":["../src/facts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,YAAY,EAClB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAE7D,qEAAqE;AACrE,KAAK,QAAQ,GAAG,YAAY,CAAC;AAE7B,4EAA4E;AAC5E,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,CAAC,CAAC;IACjB,IAAI,EAAE,eAAe,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,QAAQ,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,uBAAuB;IACtC,4EAA4E;IAC5E,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,gEAAgE;IAChE,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,0EAA0E;IAC1E,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,QAAQ,CAAA;KAAE,CAAC,CAAC;IACxE,kEAAkE;IAClE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;CAC/C;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,SAAS,gBAAgB,EAAE,GACrC,uBAAuB,
|
|
1
|
+
{"version":3,"file":"facts.d.ts","sourceRoot":"","sources":["../src/facts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,YAAY,EAClB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAE7D,qEAAqE;AACrE,KAAK,QAAQ,GAAG,YAAY,CAAC;AAE7B,4EAA4E;AAC5E,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,CAAC,CAAC;IACjB,IAAI,EAAE,eAAe,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,QAAQ,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,uBAAuB;IACtC,4EAA4E;IAC5E,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,gEAAgE;IAChE,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,0EAA0E;IAC1E,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,QAAQ,CAAA;KAAE,CAAC,CAAC;IACxE,kEAAkE;IAClE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;CAC/C;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,SAAS,gBAAgB,EAAE,GACrC,uBAAuB,CAqFzB"}
|
package/dist/facts.js
CHANGED
|
@@ -81,6 +81,16 @@ export function canonicalizeFacts(resources) {
|
|
|
81
81
|
if (typeof responseModel === 'string' && responseModel.length > 0) {
|
|
82
82
|
fact.responseSchemaSymbols = [responseModel];
|
|
83
83
|
}
|
|
84
|
+
// Wire names the response model answers to (plan 2026-09-25 Phase 4b
|
|
85
|
+
// item 5). The python scanner emits them only for a provably
|
|
86
|
+
// concrete model, so anything else arrives absent here too and the
|
|
87
|
+
// shared fact carries no field list to compare frontend reads against.
|
|
88
|
+
const responseModelFields = attributes['responseModelFields'];
|
|
89
|
+
if (Array.isArray(responseModelFields) && responseModelFields.length > 0) {
|
|
90
|
+
const names = responseModelFields.filter((name) => typeof name === 'string' && name.length > 0);
|
|
91
|
+
if (names.length === responseModelFields.length)
|
|
92
|
+
fact.responseModelFields = names;
|
|
93
|
+
}
|
|
84
94
|
const requestSchemas = attributes['requestSchemaSymbols'];
|
|
85
95
|
if (Array.isArray(requestSchemas)) {
|
|
86
96
|
const names = requestSchemas.filter((name) => typeof name === 'string' && name.length > 0);
|
package/dist/facts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"facts.js","sourceRoot":"","sources":["../src/facts.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"facts.js","sourceRoot":"","sources":["../src/facts.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",
|
|
@@ -128,6 +153,11 @@ class RouteDef:
|
|
|
128
153
|
handler: str # function name
|
|
129
154
|
is_async: bool
|
|
130
155
|
response_model: str | None
|
|
156
|
+
# The model FastAPI actually answers with: the decorator's explicit
|
|
157
|
+
# `response_model=` when present, else the handler's return annotation
|
|
158
|
+
# (FastAPI's own default). Additive: `response_model` above keeps its
|
|
159
|
+
# exact previous meaning and emission.
|
|
160
|
+
effective_response_model: str | None
|
|
131
161
|
request_schemas: list[str]
|
|
132
162
|
tags: list[str]
|
|
133
163
|
operation_id: str | None
|
|
@@ -145,6 +175,13 @@ class RouterDef:
|
|
|
145
175
|
# Set when the variable is an import alias bound to a router defined in
|
|
146
176
|
# another scanned module: ``(raw_module, level, imported_name)``.
|
|
147
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
|
|
148
185
|
|
|
149
186
|
|
|
150
187
|
@dataclass
|
|
@@ -222,6 +259,32 @@ class ImportRef:
|
|
|
222
259
|
level: int # relative-import depth (0: absolute)
|
|
223
260
|
|
|
224
261
|
|
|
262
|
+
@dataclass
|
|
263
|
+
class ModelDef:
|
|
264
|
+
"""One class definition whose response fields are statically provable.
|
|
265
|
+
|
|
266
|
+
Plan 2026-09-25 Phase 4b item 5: a dropped response-model field is
|
|
267
|
+
invisible from the frontend (every test mocks it), so the pack proves
|
|
268
|
+
the wire names a model answers to. ``proven`` is the fail-closed
|
|
269
|
+
switch: a model that configures alias generation (pydantic v1
|
|
270
|
+
``class Config`` / v2 ``model_config``) has wire names this AST pass
|
|
271
|
+
cannot compute, so it is reported as NOT proven and contributes no
|
|
272
|
+
field list at all — silence, never a wrong answer.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
name: str
|
|
276
|
+
fields: list[str] = field(default_factory=list) # declared names, written order
|
|
277
|
+
aliases: dict[str, str] = field(default_factory=dict) # field name -> declared alias
|
|
278
|
+
bases: list[str] = field(default_factory=list) # base class names
|
|
279
|
+
proven: bool = True
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Base classes that carry no response fields of their own. Any OTHER base
|
|
283
|
+
# must resolve inside the scanned set: an unresolvable base means inherited
|
|
284
|
+
# fields are unknown, so the model is not proven.
|
|
285
|
+
_FIELDLESS_BASES = frozenset({"BaseModel", "BaseModelRoot", "RootModel", "object"})
|
|
286
|
+
|
|
287
|
+
|
|
225
288
|
@dataclass
|
|
226
289
|
class FileIndex:
|
|
227
290
|
"""Everything one parsed file contributes to the mount graph."""
|
|
@@ -234,6 +297,7 @@ class FileIndex:
|
|
|
234
297
|
functions: dict[str, FunctionIncludes] = field(default_factory=dict)
|
|
235
298
|
helper_calls: list[HelperCall] = field(default_factory=list)
|
|
236
299
|
unsupported: list[tuple[ast.AST, list[str]]] = field(default_factory=list)
|
|
300
|
+
models: dict[str, ModelDef] = field(default_factory=dict)
|
|
237
301
|
|
|
238
302
|
|
|
239
303
|
def loc(relpath: str, node: ast.AST) -> dict:
|
|
@@ -248,6 +312,41 @@ def _static_string(node: ast.AST | None) -> str | None:
|
|
|
248
312
|
return None
|
|
249
313
|
|
|
250
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
|
+
|
|
251
350
|
def _dotted_name(node: ast.AST | None) -> str | None:
|
|
252
351
|
"""A dotted name for Name/Attribute chains, or None."""
|
|
253
352
|
if isinstance(node, ast.Name):
|
|
@@ -258,6 +357,42 @@ def _dotted_name(node: ast.AST | None) -> str | None:
|
|
|
258
357
|
return None
|
|
259
358
|
|
|
260
359
|
|
|
360
|
+
# Containers whose single element type IS the response model
|
|
361
|
+
# (``-> list[InvoiceOut]``); anything else (``dict[str, X]``, a union of
|
|
362
|
+
# two models, a computed expression) is an unknown shape.
|
|
363
|
+
_MODEL_CONTAINERS = frozenset({
|
|
364
|
+
"list", "List", "Sequence", "Iterable", "set", "Set", "frozenset",
|
|
365
|
+
"Optional", "Union", "tuple", "Tuple",
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _model_name(node: ast.AST | None) -> str | None:
|
|
370
|
+
"""The model class a response annotation or ``response_model=`` names.
|
|
371
|
+
|
|
372
|
+
Unwraps the containers FastAPI itself unwraps (``list[X]``,
|
|
373
|
+
``Optional[X]``, ``X | None``) and returns the dotted class name. Any
|
|
374
|
+
other shape — a dict, a union of different models, a computed
|
|
375
|
+
expression — returns None: an unknown shape has no field list, and the
|
|
376
|
+
advisory must never guess one.
|
|
377
|
+
"""
|
|
378
|
+
if node is None:
|
|
379
|
+
return None
|
|
380
|
+
if isinstance(node, (ast.Name, ast.Attribute)):
|
|
381
|
+
return _dotted_name(node)
|
|
382
|
+
if isinstance(node, ast.Subscript):
|
|
383
|
+
base = _dotted_name(node.value)
|
|
384
|
+
if base is None or base.split(".")[-1] not in _MODEL_CONTAINERS:
|
|
385
|
+
return None
|
|
386
|
+
elements = _elements(node.slice) or [node.slice]
|
|
387
|
+
names = {_model_name(element) for element in elements}
|
|
388
|
+
names.discard(None)
|
|
389
|
+
return names.pop() if len(names) == 1 else None
|
|
390
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
391
|
+
left, right = _model_name(node.left), _model_name(node.right)
|
|
392
|
+
return left if left is not None and left == right else None
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
261
396
|
def _keyword(call: ast.Call, name: str) -> ast.AST | None:
|
|
262
397
|
for keyword in call.keywords:
|
|
263
398
|
if keyword.arg == name:
|
|
@@ -271,6 +406,61 @@ def _elements(node: ast.AST | None) -> list[ast.AST] | None:
|
|
|
271
406
|
return None
|
|
272
407
|
|
|
273
408
|
|
|
409
|
+
def _declared_alias(value: ast.AST | None) -> str | None:
|
|
410
|
+
"""The static alias a ``Field(alias=...)`` declaration carries."""
|
|
411
|
+
if not isinstance(value, ast.Call):
|
|
412
|
+
return None
|
|
413
|
+
for keyword in value.keywords:
|
|
414
|
+
if keyword.arg in {"alias", "validation_alias", "serialization_alias"}:
|
|
415
|
+
return _static_string(keyword.value)
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _is_class_var(annotation: ast.AST | None) -> bool:
|
|
420
|
+
"""``ClassVar[...]`` annotations are not response fields."""
|
|
421
|
+
if not isinstance(annotation, ast.Subscript):
|
|
422
|
+
return False
|
|
423
|
+
base = _dotted_name(annotation.value)
|
|
424
|
+
return base is not None and base.split(".")[-1] == "ClassVar"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _model_def(node: ast.ClassDef) -> ModelDef:
|
|
428
|
+
"""The statically provable field list of one class definition.
|
|
429
|
+
|
|
430
|
+
Alias generation (``model_config = ConfigDict(alias_generator=...)``
|
|
431
|
+
or a pydantic v1 ``class Config``) makes the wire names
|
|
432
|
+
uncomputable here, so the model is reported as NOT proven rather than
|
|
433
|
+
as a partial list. ``ClassVar`` members and private names are not
|
|
434
|
+
pydantic fields and are skipped.
|
|
435
|
+
"""
|
|
436
|
+
model = ModelDef(name=node.name)
|
|
437
|
+
for base in node.bases:
|
|
438
|
+
name = _dotted_name(base)
|
|
439
|
+
model.bases.append(name if name is not None else "?")
|
|
440
|
+
for statement in node.body:
|
|
441
|
+
if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name):
|
|
442
|
+
name = statement.target.id
|
|
443
|
+
if name.startswith("_") or _is_class_var(statement.annotation):
|
|
444
|
+
continue
|
|
445
|
+
if name == "model_config":
|
|
446
|
+
model.proven = False
|
|
447
|
+
continue
|
|
448
|
+
model.fields.append(name)
|
|
449
|
+
alias = _declared_alias(statement.value)
|
|
450
|
+
if alias is not None:
|
|
451
|
+
model.aliases[name] = alias
|
|
452
|
+
elif isinstance(statement, ast.Assign):
|
|
453
|
+
for target in statement.targets:
|
|
454
|
+
if isinstance(target, ast.Name) and not target.id.startswith("_"):
|
|
455
|
+
if target.id == "model_config":
|
|
456
|
+
model.proven = False
|
|
457
|
+
else:
|
|
458
|
+
model.fields.append(target.id)
|
|
459
|
+
elif isinstance(statement, ast.ClassDef) and statement.name in {"Config", "model_config"}:
|
|
460
|
+
model.proven = False
|
|
461
|
+
return model
|
|
462
|
+
|
|
463
|
+
|
|
274
464
|
class _ModuleVisitor(ast.NodeVisitor):
|
|
275
465
|
"""Collects routers, apps, imports, include edges, and route decorators.
|
|
276
466
|
|
|
@@ -279,13 +469,14 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
279
469
|
its ``include_router`` calls still count for the outer parameter.
|
|
280
470
|
"""
|
|
281
471
|
|
|
282
|
-
def __init__(self, relpath: str) -> None:
|
|
472
|
+
def __init__(self, relpath: str, constants: dict[str, str] | None = None) -> None:
|
|
283
473
|
self.index = FileIndex(relpath=relpath)
|
|
284
474
|
# Current top-level function (registry-function context), or None
|
|
285
475
|
# at module level.
|
|
286
476
|
self._function: FunctionIncludes | None = None
|
|
287
|
-
|
|
288
|
-
|
|
477
|
+
# Module-level string constants, for literal prefix folding
|
|
478
|
+
# (`PREFIX = "/api/v1"`; `APIRouter(prefix=PREFIX)`).
|
|
479
|
+
self._constants: dict[str, str] = constants or {}
|
|
289
480
|
|
|
290
481
|
def visit_Import(self, node: ast.Import) -> None:
|
|
291
482
|
for alias in node.names:
|
|
@@ -302,23 +493,47 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
302
493
|
# -- router / app instances ---------------------------------------------
|
|
303
494
|
|
|
304
495
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
496
|
+
for target in node.targets:
|
|
497
|
+
if isinstance(target, ast.Name):
|
|
498
|
+
self._record_instance(target.id, node.value)
|
|
499
|
+
self.generic_visit(node)
|
|
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
|
+
|
|
533
|
+
# -- response models -----------------------------------------------------
|
|
534
|
+
|
|
535
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
536
|
+
self.index.models[node.name] = _model_def(node)
|
|
322
537
|
self.generic_visit(node)
|
|
323
538
|
|
|
324
539
|
# -- functions: route decorators -----------------------------------------
|
|
@@ -394,7 +609,8 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
394
609
|
if owner_ref is not None:
|
|
395
610
|
owner_ref.routes.append(RouteDef(
|
|
396
611
|
methods=[], path=None, node=node, file=self.index.relpath, handler=fn.name,
|
|
397
|
-
is_async=is_async, response_model=None,
|
|
612
|
+
is_async=is_async, response_model=None,
|
|
613
|
+
effective_response_model=None, request_schemas=[],
|
|
398
614
|
tags=[], operation_id=None,
|
|
399
615
|
))
|
|
400
616
|
return
|
|
@@ -414,6 +630,9 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
414
630
|
handler=fn.name,
|
|
415
631
|
is_async=is_async,
|
|
416
632
|
response_model=_dotted_name(_keyword(node, "response_model")),
|
|
633
|
+
effective_response_model=(
|
|
634
|
+
_model_name(_keyword(node, "response_model")) or _model_name(fn.returns)
|
|
635
|
+
),
|
|
417
636
|
request_schemas=_request_schema_names(fn, path),
|
|
418
637
|
tags=[
|
|
419
638
|
s for s in (
|
|
@@ -428,7 +647,19 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
428
647
|
alias_of = None
|
|
429
648
|
if ref is not None and ref.name is not None:
|
|
430
649
|
alias_of = (ref.module, ref.level, ref.name)
|
|
431
|
-
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
|
+
)
|
|
432
663
|
self.index.routers[owner.id] = router
|
|
433
664
|
router.routes.append(entry)
|
|
434
665
|
out_of_set = [m for m in methods if m not in _DECORATOR_METHODS.values()]
|
|
@@ -467,7 +698,10 @@ class _ModuleVisitor(ast.NodeVisitor):
|
|
|
467
698
|
target_var=target_var,
|
|
468
699
|
target_alias=target_alias,
|
|
469
700
|
target_attrs=target_attrs,
|
|
470
|
-
prefix=
|
|
701
|
+
prefix=(
|
|
702
|
+
"" if prefix_node is None
|
|
703
|
+
else _literal_prefix(prefix_node, self._constants)
|
|
704
|
+
),
|
|
471
705
|
node=node,
|
|
472
706
|
file=self.index.relpath,
|
|
473
707
|
)
|
|
@@ -524,7 +758,7 @@ def _scan_file(relpath: str, root: Path) -> tuple[FileIndex | None, dict | None]
|
|
|
524
758
|
"detail": f"{type(exc).__name__}: {msg}",
|
|
525
759
|
"locations": [{"file": relpath, "line": max(line, 1), "col": 0}],
|
|
526
760
|
}
|
|
527
|
-
visitor = _ModuleVisitor(relpath)
|
|
761
|
+
visitor = _ModuleVisitor(relpath, _module_string_constants(tree))
|
|
528
762
|
visitor.visit(tree)
|
|
529
763
|
return visitor.index, None
|
|
530
764
|
|
|
@@ -656,6 +890,94 @@ def _resolve_import(
|
|
|
656
890
|
return ImportResolution(module=None)
|
|
657
891
|
|
|
658
892
|
|
|
893
|
+
class _ModelIndex:
|
|
894
|
+
"""Resolves a response-model symbol to the wire names it answers to.
|
|
895
|
+
|
|
896
|
+
Bounded like every other channel here: the defining class must be in
|
|
897
|
+
the scanned set, reachable as a same-file definition or through one
|
|
898
|
+
resolved import, and every base class must resolve too. Anything the
|
|
899
|
+
AST pass cannot compute (an alias generator, an unresolvable base, an
|
|
900
|
+
unknown shape) yields ``None`` — the caller then reports no fields at
|
|
901
|
+
all rather than a partial list.
|
|
902
|
+
"""
|
|
903
|
+
|
|
904
|
+
def __init__(self, indexes: dict[str, FileIndex], import_roots: tuple[str, ...] = ()) -> None:
|
|
905
|
+
self.models: dict[str, dict[str, ModelDef]] = {
|
|
906
|
+
relpath: index.models for relpath, index in indexes.items()
|
|
907
|
+
}
|
|
908
|
+
self.indexes = indexes
|
|
909
|
+
self.import_roots = import_roots
|
|
910
|
+
self.scanned: frozenset[str] = frozenset(indexes)
|
|
911
|
+
self.module_map = {_module_of(rel): rel for rel in indexes}
|
|
912
|
+
|
|
913
|
+
def fields_for(self, relpath: str, model: str | None) -> list[str] | None:
|
|
914
|
+
"""Every wire name one response model answers to, or None."""
|
|
915
|
+
if model is None or model == "":
|
|
916
|
+
return None
|
|
917
|
+
name = model
|
|
918
|
+
prefix = ""
|
|
919
|
+
if "." in model:
|
|
920
|
+
prefix, name = model.rsplit(".", 1)
|
|
921
|
+
if prefix not in self.module_map:
|
|
922
|
+
return None
|
|
923
|
+
located = self._locate(relpath, name)
|
|
924
|
+
if located is None:
|
|
925
|
+
return None
|
|
926
|
+
found = self._collect(relpath, name, set())
|
|
927
|
+
if found is None or not found:
|
|
928
|
+
return None
|
|
929
|
+
return found
|
|
930
|
+
|
|
931
|
+
def _locate(self, relpath: str, name: str) -> ModelDef | None:
|
|
932
|
+
"""The class definition a name refers to, or None."""
|
|
933
|
+
own = self.models.get(relpath, {}).get(name)
|
|
934
|
+
if own is not None:
|
|
935
|
+
return own
|
|
936
|
+
ref = self.indexes.get(relpath)
|
|
937
|
+
if ref is None:
|
|
938
|
+
return None
|
|
939
|
+
imported = ref.imports.get(name)
|
|
940
|
+
if imported is None or imported.name is None:
|
|
941
|
+
return None
|
|
942
|
+
resolution = _resolve_import(
|
|
943
|
+
relpath,
|
|
944
|
+
(imported.module, imported.level, imported.name),
|
|
945
|
+
self.module_map,
|
|
946
|
+
self.import_roots,
|
|
947
|
+
self.scanned,
|
|
948
|
+
)
|
|
949
|
+
if resolution.module is None:
|
|
950
|
+
return None
|
|
951
|
+
target = self.module_map.get(resolution.module)
|
|
952
|
+
return None if target is None else self.models.get(target, {}).get(name)
|
|
953
|
+
|
|
954
|
+
def _collect(self, relpath: str, name: str, seen: set[tuple[str, str]]) -> list[str] | None:
|
|
955
|
+
"""Field and alias names of one model plus its provable bases."""
|
|
956
|
+
key = (relpath, name)
|
|
957
|
+
if key in seen:
|
|
958
|
+
return None
|
|
959
|
+
seen.add(key)
|
|
960
|
+
located = self._locate(relpath, name)
|
|
961
|
+
if located is None or not located.proven:
|
|
962
|
+
return None
|
|
963
|
+
names: list[str] = []
|
|
964
|
+
for base in located.bases:
|
|
965
|
+
if base in _FIELDLESS_BASES:
|
|
966
|
+
continue
|
|
967
|
+
if base == "?":
|
|
968
|
+
return None
|
|
969
|
+
inherited = self._collect(relpath, base, seen)
|
|
970
|
+
if inherited is None:
|
|
971
|
+
return None
|
|
972
|
+
names.extend(inherited)
|
|
973
|
+
for field in located.fields:
|
|
974
|
+
alias = located.aliases.get(field)
|
|
975
|
+
for value in (alias, field) if alias is not None else (field,):
|
|
976
|
+
if value not in names:
|
|
977
|
+
names.append(value)
|
|
978
|
+
return names
|
|
979
|
+
|
|
980
|
+
|
|
659
981
|
class _Resolver:
|
|
660
982
|
"""Composes effective mounted paths over the cross-file mount graph."""
|
|
661
983
|
|
|
@@ -664,6 +986,7 @@ class _Resolver:
|
|
|
664
986
|
self.import_roots = import_roots
|
|
665
987
|
self.scanned: frozenset[str] = frozenset(indexes)
|
|
666
988
|
self.module_map = {_module_of(rel): rel for rel in indexes}
|
|
989
|
+
self.models = _ModelIndex(indexes, import_roots)
|
|
667
990
|
self.facts: list[dict] = []
|
|
668
991
|
self.unresolved: list[dict] = []
|
|
669
992
|
|
|
@@ -671,6 +994,7 @@ class _Resolver:
|
|
|
671
994
|
self._materialize_function_includes()
|
|
672
995
|
self._merge_aliases()
|
|
673
996
|
included = self._collect_included()
|
|
997
|
+
unmounted: list[tuple[str, str, RouterDef]] = []
|
|
674
998
|
for relpath in sorted(self.indexes):
|
|
675
999
|
index = self.indexes[relpath]
|
|
676
1000
|
for var in sorted(index.apps):
|
|
@@ -684,6 +1008,56 @@ class _Resolver:
|
|
|
684
1008
|
if (relpath, name) in included:
|
|
685
1009
|
continue
|
|
686
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
|
+
})
|
|
687
1061
|
|
|
688
1062
|
def _materialize_function_includes(self) -> None:
|
|
689
1063
|
"""Rewires registry-function includes onto real instances.
|
|
@@ -1159,10 +1533,17 @@ class _Resolver:
|
|
|
1159
1533
|
return
|
|
1160
1534
|
if router.prefix is None:
|
|
1161
1535
|
self.unresolved.append({
|
|
1162
|
-
"code":
|
|
1536
|
+
"code": FASTAPI_PREFIX_UNRESOLVED,
|
|
1163
1537
|
"detail": (
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
+
)
|
|
1166
1547
|
),
|
|
1167
1548
|
"location": loc(relpath, router.prefix_node),
|
|
1168
1549
|
})
|
|
@@ -1257,33 +1638,56 @@ class _Resolver:
|
|
|
1257
1638
|
continue
|
|
1258
1639
|
for method in route.methods:
|
|
1259
1640
|
self.facts.append(
|
|
1260
|
-
_fact(route.file, route, method, prefix + route.path, mount)
|
|
1641
|
+
_fact(route.file, route, method, prefix + route.path, mount, self.models)
|
|
1261
1642
|
)
|
|
1262
1643
|
|
|
1263
1644
|
|
|
1264
|
-
def _fact(
|
|
1645
|
+
def _fact(
|
|
1646
|
+
relpath: str,
|
|
1647
|
+
route: RouteDef,
|
|
1648
|
+
method: str,
|
|
1649
|
+
effective_path: str,
|
|
1650
|
+
mount: str,
|
|
1651
|
+
models: _ModelIndex,
|
|
1652
|
+
) -> dict:
|
|
1265
1653
|
handler_qname = f"{relpath[:-3].replace('/', '.')}:{route.handler}"
|
|
1654
|
+
attributes = {
|
|
1655
|
+
"role": "server-route",
|
|
1656
|
+
"method": method,
|
|
1657
|
+
"rawPath": route.path or "",
|
|
1658
|
+
"normalizedPath": "", # canonicalized by the TS wrapper (single impl)
|
|
1659
|
+
"effectivePath": effective_path,
|
|
1660
|
+
"framework": FRAMEWORK,
|
|
1661
|
+
"handlerSymbol": handler_qname,
|
|
1662
|
+
"isAsync": route.is_async,
|
|
1663
|
+
"responseModel": route.response_model,
|
|
1664
|
+
"requestSchemaSymbols": route.request_schemas,
|
|
1665
|
+
"tags": route.tags,
|
|
1666
|
+
"operationId": route.operation_id,
|
|
1667
|
+
"mountProvenance": mount,
|
|
1668
|
+
}
|
|
1669
|
+
# Wire names the response model answers to (plan 2026-09-25 Phase 4b
|
|
1670
|
+
# item 5). Absent whenever the model is not statically provable — an
|
|
1671
|
+
# unresolvable symbol, an alias generator, an unprovable base, a
|
|
1672
|
+
# non-model shape — so the frontend-read check has nothing to compare
|
|
1673
|
+
# against and stays silent.
|
|
1674
|
+
fields = models.fields_for(route.file, route.effective_response_model)
|
|
1675
|
+
if fields is not None:
|
|
1676
|
+
# FastAPI answers a FAILED request with ``{"detail": ...}``
|
|
1677
|
+
# (HTTPException) or a ``detail`` list (request validation), and a
|
|
1678
|
+
# frontend reads it to report the error. It is therefore a
|
|
1679
|
+
# legitimate field of every route this pack reports, never
|
|
1680
|
+
# evidence of a dropped success-model field.
|
|
1681
|
+
if "detail" not in fields:
|
|
1682
|
+
fields.append("detail")
|
|
1683
|
+
attributes["responseModelFields"] = fields
|
|
1266
1684
|
return {
|
|
1267
1685
|
"schemaVersion": 1,
|
|
1268
1686
|
"kind": CONTRACT_KIND,
|
|
1269
1687
|
"source": relpath,
|
|
1270
1688
|
"location": loc(relpath, route.node),
|
|
1271
1689
|
"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
|
-
},
|
|
1690
|
+
"attributes": attributes,
|
|
1287
1691
|
"id": f"http.contract:{relpath}:{route.handler}:{method}:{effective_path}",
|
|
1288
1692
|
}
|
|
1289
1693
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|