@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 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
@@ -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
- /** Working directory the repo-relative paths resolve against. */
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 `cwd` when
53
- * `importRoots` is not given (default: `.gateforge/fastapi.json`;
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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"AA4CA,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,CAGlF;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,iEAAiE;IACjE,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,CAuC3F"}
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 once at factory
27
- * time and passed to the scanner as an explicit `--import-roots` argv
28
- * flag — never environment state, never per-request mutation.
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 { ...process.env, PYTHONPATH: entries.join(delimiter) };
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
- // Explicit roots win; otherwise the config document (absence normal,
123
- // malformed throws). With no roots at all the spawned command is
124
- // byte-identical to the pre-config surface.
125
- const importRoots = options.importRoots ??
126
- readFastapiScanConfigOrNull(resolve(cwd, options.importRootsConfigPath ?? FASTAPI_SCAN_CONFIG_PATH))
127
- .importRoots ??
128
- [];
129
- const argv = importRoots.length > 0 ? [...command, '--import-roots', JSON.stringify(importRoots)] : [...command];
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: argv,
152
+ command: resolveArgv(cwd),
137
153
  pluginId,
138
154
  pluginVersion,
139
155
  cwd,
@@ -1 +1 @@
1
- {"version":3,"file":"detector.js","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"detector.js","sourceRoot":"","sources":["../src/detector.ts"],"names":[],"mappings":"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"}
@@ -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,CA0EzB"}
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.7.1",
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.7.1",
31
- "@gate-forge/http-contract": "^0.7.1",
32
- "@gate-forge/plugin-protocol": "^0.7.1",
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
- - 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.
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, 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;
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
- # -- imports ------------------------------------------------------------
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
- 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
- )
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, request_schemas=[],
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 = RouterDef(var=owner.id, prefix="", prefix_node=owner, alias_of=alias_of)
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="" if prefix_node is None else _static_string(prefix_node),
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": "FASTAPI_PREFIX_UNRESOLVED",
1536
+ "code": FASTAPI_PREFIX_UNRESOLVED,
1163
1537
  "detail": (
1164
- f"router '{var}' in {relpath} declares a computed prefix; "
1165
- "the effective path cannot be proven statically"
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(relpath: str, route: RouteDef, method: str, effective_path: str, mount: str) -> dict:
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