@nifrajs/cli 4.0.0 → 5.0.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.
Files changed (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
package/src/introspect.ts CHANGED
@@ -13,12 +13,12 @@ import {
13
13
  snapshotProjectEvidence,
14
14
  } from "@nifrajs/core/evidence"
15
15
  import { compileRoutePattern, type RoutePatternSegment } from "@nifrajs/core/pattern"
16
- import type { ReflectedRoute } from "@nifrajs/core/reflection"
16
+ import type { ReflectedMount, ReflectedRoute } from "@nifrajs/core/reflection"
17
17
  import type { Manifest } from "@nifrajs/web"
18
18
  import { discoverRoutes } from "@nifrajs/web/fs"
19
19
  import { normalizeMountPath } from "@nifrajs/web/route-manifest"
20
20
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
21
- import type { LoadedApp } from "./load.ts"
21
+ import type { IntrospectedApp } from "./load.ts"
22
22
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
23
23
 
24
24
  /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
@@ -225,6 +225,22 @@ export function apiRoutesIndexSection(routes: readonly ReflectedRoute[]): string
225
225
  return `## API routes (backend/app.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form - or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
226
226
  }
227
227
 
228
+ /** The routes opaque mounts declare, under `prefix`, or `undefined` when there are none. Listed apart
229
+ * from the API routes: nifra serves them but never analyzed them. */
230
+ function declaredMountRoutesSection(
231
+ mounts: readonly ReflectedMount[],
232
+ prefix: string | undefined,
233
+ ): string | undefined {
234
+ const lines = mounts.flatMap((mount) =>
235
+ (mount.routes ?? [])
236
+ .filter((route) => prefix === undefined || route.path.startsWith(prefix))
237
+ .map((route) => `- \`${route.method} ${route.path}\` - ${mount.path} (${mount.opaque})`),
238
+ )
239
+ return lines.length === 0
240
+ ? undefined
241
+ : `## Declared by opaque mounts (not analyzed)\n\n${lines.join("\n")}`
242
+ }
243
+
228
244
  /** Markdown section listing the file-routed pages (URL pattern → source file). */
229
245
  export function pageRoutesSection(manifest: Manifest | undefined): string {
230
246
  if (!manifest || manifest.routes.length === 0) {
@@ -236,6 +252,10 @@ export function pageRoutesSection(manifest: Manifest | undefined): string {
236
252
  return `## Page routes (routes/)\n\n${lines.join("\n")}`
237
253
  }
238
254
 
255
+ const PAGES_CONVENTION = `- **Pages:** file-routed under \`routes/\`. A route is two files: \`x.tsx\` (browser) and \`x.backend.ts\`
256
+ (\`loader\`, \`action\`, \`middleware\`; server only). Server code lives under \`backend/\`, code both sides
257
+ use under \`shared/\`, components under \`frontend/\`; the browser build refuses backend code outright.`
258
+
239
259
  const CONVENTIONS = `## Conventions (summary)
240
260
 
241
261
  - **Backend:** \`server()\` from \`@nifrajs/core\`; returning a value sends JSON. Validate untrusted input with
@@ -253,9 +273,7 @@ const CONVENTIONS = `## Conventions (summary)
253
273
  \`res.data\` from the backend's route types, so the compiler catches any frontend/backend drift. Never
254
274
  hand-roll \`fetch\` + ad-hoc response types for an internal API - that's exactly how screens drift. It
255
275
  never throws: branch on \`res.ok ? res.data : res.error\`.
256
- - **Pages:** file-routed under \`routes/\`. A route is two files: \`x.tsx\` (browser) and \`x.backend.ts\`
257
- (\`loader\`, \`action\`, \`middleware\`; server only). Server code lives under \`backend/\`, code both sides
258
- use under \`shared/\`, components under \`frontend/\`; the browser build refuses backend code outright.
276
+ ${PAGES_CONVENTION}
259
277
  - \`app.fetch(Request)\` is the universal entry. Full reference: this repo's \`AGENTS.md\`, or \`llms-full.txt\`.`
260
278
 
261
279
  /** Optional narrowing for {@link describeProject} - a path prefix and/or one section. A filtered
@@ -274,15 +292,18 @@ export interface ContextFilter {
274
292
  * body/query/response shapes + the typed-client `call` form), and omits the conventions block (the agent
275
293
  * already has it from the index call).
276
294
  */
277
- export function describeProject(app: LoadedApp, filter?: ContextFilter): string {
295
+ export function describeProject(app: IntrospectedApp, filter?: ContextFilter): string {
278
296
  let manifest: Manifest | undefined
279
- try {
280
- manifest = discoverRoutes(app.routesDir)
281
- } catch {
282
- // No (or unreadable) routes/ - the page-routes section reports that.
297
+ if (app.framework !== undefined) {
298
+ try {
299
+ manifest = discoverRoutes(app.routesDir)
300
+ } catch {
301
+ // No (or unreadable) routes/ - the page-routes section reports that.
302
+ }
283
303
  }
284
304
  const prefix = filter?.path
285
- let routes = backendRoutes(app.backend)
305
+ const evidence = snapshotProjectEvidence(app.backend)
306
+ let routes = reflectedRoutesFromEvidence(evidence)
286
307
  let pages = manifest
287
308
  if (prefix !== undefined && prefix !== "") {
288
309
  routes = routes.filter((r) => r.path.startsWith(prefix))
@@ -296,20 +317,34 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
296
317
  const sections: string[] = [`# nifra project: ${app.cwd.slice(app.cwd.lastIndexOf("/") + 1)}`]
297
318
  if (isIndex) {
298
319
  sections.push(
299
- "A full-stack nifra app - Web-standard, one app on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below).",
320
+ app.framework === undefined
321
+ ? "An API-only nifra app (backend/app.ts, no web config) - Web-standard, one backend on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below)."
322
+ : "A full-stack nifra app - Web-standard, one app on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below).",
300
323
  // Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
301
324
  // wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
302
325
  "> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
326
+ )
327
+ if (app.framework !== undefined) {
303
328
  // The authoritative answer, not the static reading `nifra_check`/`nifra_doctor` give: this runs on
304
329
  // the LOADED config, so it is the same decision `nifra dev` and `nifra build` make. Stated in the
305
330
  // index because it governs where a plugin goes and which toolchain compiles a component - an edit
306
331
  // put in the other pipeline's slot is accepted and then silently never runs.
307
- `**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
308
- )
332
+ sections.push(
333
+ `**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
334
+ )
335
+ }
309
336
  }
310
337
  if (filter?.kind !== "pages") {
311
338
  // Index → compact `METHOD path` list; a slice → the full schema + call-form detail.
312
339
  sections.push(isIndex ? apiRoutesIndexSection(routes) : apiRoutesSection(routes))
340
+ const declared = declaredMountRoutesSection(evidence.mounts ?? [], prefix || undefined)
341
+ if (declared !== undefined) sections.push(declared)
342
+ }
343
+ if (app.framework === undefined) {
344
+ if (filter?.kind === "pages")
345
+ sections.push("## Page routes\n\nAn API-only app serves no pages.")
346
+ if (isIndex) sections.push(CONVENTIONS.replace(`${PAGES_CONVENTION}\n`, ""))
347
+ return sections.join("\n\n")
313
348
  }
314
349
  if (filter?.kind !== "api") sections.push(pageRoutesSection(pages))
315
350
  if (isIndex) sections.push(CONVENTIONS)
@@ -368,7 +403,7 @@ export function routesToJsonFromEvidence(
368
403
  }
369
404
 
370
405
  export function routesToJson(
371
- app: LoadedApp,
406
+ app: IntrospectedApp,
372
407
  pathPrefix?: string,
373
408
  evidence?: ProjectEvidenceSnapshot,
374
409
  ): RouteJson[] {
@@ -382,7 +417,7 @@ export function routesToJson(
382
417
  // `nifra routes` answers a different question: "what does this app actually serve, and with which
383
418
  // methods?" - so an agent sees instantly that `POST /api/explain` is (or isn't) mounted, instead of
384
419
  // discovering it via a 405. It marks which backend routes are AUTO-MOUNTED under the page router's
385
- // `apiPrefix` (default `/api`), and `--json` makes it machine-consumable. The table-building +
420
+ // `apiPrefix`, and `--json` makes it machine-consumable. The table-building +
386
421
  // rendering are pure (no fs / no module loads), so they're unit-tested; the cwd gathering is the thin
387
422
  // async wrapper `describeRoutes`.
388
423
  // ===================================================================================================
@@ -416,7 +451,7 @@ export interface PageRouteInput {
416
451
  export interface RouteTableInput {
417
452
  readonly pages: readonly PageRouteInput[]
418
453
  readonly api: ReadonlyArray<Pick<ReflectedRoute, "method" | "path">>
419
- /** The page router's `apiPrefix` (default `/api`); an API route at/under it is auto-mounted. `""`
454
+ /** The page router's `apiPrefix`; an API route at/under it is auto-mounted. Unset or `""`
420
455
  * disables the auto-mount, so no API route is marked auto-mounted. */
421
456
  readonly apiPrefix?: string
422
457
  /** The backend declares its routes without the prefix and is served at `apiPrefix + path`: every API
@@ -440,7 +475,7 @@ function isUnderPrefix(path: string, prefix: string): boolean {
440
475
  * joined methods, for stable output.
441
476
  */
442
477
  export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
443
- const configured = input.apiPrefix ?? "/api"
478
+ const configured = input.apiPrefix ?? ""
444
479
  // Read the prefix as the server's mount table does (`/api/` and `/api/*` are `/api`).
445
480
  const apiPrefix = configured === "" ? "" : (normalizeMountPath(configured) ?? configured)
446
481
  const stripped = input.apiStrip === true && apiPrefix !== ""
@@ -683,12 +718,15 @@ interface MaybeActionModule {
683
718
  * text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
684
719
  * degrades to GET-only for that route (never throws the whole command).
685
720
  */
686
- async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
721
+ async function collectRouteTable(app: IntrospectedApp): Promise<RouteTableEntry[]> {
722
+ const { framework } = app
687
723
  let manifest: Manifest | undefined
688
- try {
689
- manifest = discoverRoutes(app.routesDir)
690
- } catch {
691
- // No (or unreadable) routes/ - fall through with no pages; the table still shows API routes.
724
+ if (framework !== undefined) {
725
+ try {
726
+ manifest = discoverRoutes(app.routesDir)
727
+ } catch {
728
+ // No (or unreadable) routes/ - fall through with no pages; the table still shows API routes.
729
+ }
692
730
  }
693
731
  const pages: PageRouteInput[] = []
694
732
  for (const route of manifest?.routes ?? []) {
@@ -704,14 +742,14 @@ async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
704
742
  const rows = buildRouteTable({
705
743
  pages,
706
744
  api: backendRoutes(app.backend),
707
- ...(app.framework.apiPrefix !== undefined ? { apiPrefix: app.framework.apiPrefix } : {}),
708
- ...(app.framework.apiStrip !== undefined ? { apiStrip: app.framework.apiStrip } : {}),
745
+ ...(framework?.apiPrefix !== undefined ? { apiPrefix: framework.apiPrefix } : {}),
746
+ ...(framework?.apiStrip !== undefined ? { apiStrip: framework.apiStrip } : {}),
709
747
  })
710
748
  return rows
711
749
  }
712
750
 
713
751
  export async function describeRoutes(
714
- app: LoadedApp,
752
+ app: IntrospectedApp,
715
753
  opts: { readonly json?: boolean } = {},
716
754
  ): Promise<string> {
717
755
  const rows = await collectRouteTable(app)
@@ -720,7 +758,7 @@ export async function describeRoutes(
720
758
 
721
759
  /** Gather and render the public route graph for `nifra routes --graph`. */
722
760
  export async function describeRouteGraph(
723
- app: LoadedApp,
761
+ app: IntrospectedApp,
724
762
  opts: { readonly json?: boolean } = {},
725
763
  ): Promise<string> {
726
764
  const graph = buildRouteGraph(await collectRouteTable(app))
@@ -13,10 +13,19 @@
13
13
 
14
14
  import { existsSync } from "node:fs"
15
15
  import { resolve } from "node:path"
16
- import type { AssuranceReport } from "@nifrajs/core/assurance"
16
+ import type {
17
+ AssuranceReport,
18
+ InvariantSetup,
19
+ InvariantSetupResult,
20
+ InvariantsConfig,
21
+ } from "@nifrajs/core/assurance"
17
22
  import { snapshotCapabilities } from "@nifrajs/core/capabilities"
18
23
  import { buildNifraManifest, parseNifraManifest } from "@nifrajs/core/manifest"
19
- import { type ContractTestApp, runAdversarialContract } from "@nifrajs/testing"
24
+ import {
25
+ type AdversarialContractOptions,
26
+ type ContractTestApp,
27
+ runAdversarialContract,
28
+ } from "@nifrajs/testing"
20
29
  import {
21
30
  type CapabilityProjectReport,
22
31
  diffCapabilitySnapshots,
@@ -176,41 +185,129 @@ export async function collectVerificationLevels(
176
185
  "no isolated invariant executor configured - add `invariants.executor` in nifra.assurance.ts",
177
186
  )
178
187
  } else {
179
- const invariants = await runAdversarialContract(config.source as ContractTestApp, {
180
- seed: options.seed ?? 1,
181
- runtimes: [{ name: "isolated", fetch: config.invariants.executor }],
182
- expectedValidationStatuses: [400, 422],
183
- requireCoverage: true,
188
+ l4Reasons.push(...(await runInvariants(config.source, config.invariants, options.seed ?? 1)))
189
+ }
190
+ statuses.push({
191
+ level: 4,
192
+ name: "invariant-tested",
193
+ ok: l4Reasons.length === 0,
194
+ reasons: l4Reasons,
195
+ })
196
+
197
+ return finalize(statuses, assurance.routes)
198
+ }
199
+
200
+ /**
201
+ * Run L4 against the configured target. `setup` runs at most once, on the first request, and its
202
+ * `teardown` runs once the run ends - after a failure too - so an app can provision a throwaway target
203
+ * only when L4 actually exercises it.
204
+ */
205
+ async function runInvariants(
206
+ source: unknown,
207
+ invariants: InvariantsConfig,
208
+ seed: number,
209
+ ): Promise<string[]> {
210
+ let provisioned: Promise<InvariantSetupResult | undefined> | undefined
211
+ const provision = (setup: InvariantSetup): Promise<InvariantSetupResult | undefined> => {
212
+ provisioned ??= Promise.resolve()
213
+ .then(setup)
214
+ .then(
215
+ (result) => result ?? undefined,
216
+ (error: unknown) => {
217
+ throw new Error(`invariants.setup failed: ${messageOf(error)}`)
218
+ },
219
+ )
220
+ return provisioned
221
+ }
222
+ const fetch = async (request: Request): Promise<Response> => {
223
+ const ready = invariants.setup === undefined ? undefined : await provision(invariants.setup)
224
+ const executor = ready?.executor ?? invariants.executor
225
+ if (executor === undefined) {
226
+ throw new Error(
227
+ "no invariant executor: invariants.setup returned none and invariants.executor is not set",
228
+ )
229
+ }
230
+ return executor(request)
231
+ }
232
+ const options: AdversarialContractOptions = {
233
+ seed,
234
+ runtimes: [{ name: "isolated", fetch }],
235
+ expectedValidationStatuses: [400, 422],
236
+ requireCoverage: true,
237
+ }
238
+ const runOptions: Record<string, unknown> = {}
239
+ for (const name of INVARIANT_RUN_OPTIONS) {
240
+ if (invariants[name] !== undefined) runOptions[name] = invariants[name]
241
+ }
242
+ const reasons: string[] = []
243
+ try {
244
+ const report = await runAdversarialContract(source as ContractTestApp, {
245
+ ...options,
246
+ ...runOptions,
184
247
  })
185
- if (!invariants.ok) {
186
- l4Reasons.push(
187
- ...invariants.failures
248
+ const target = await targetFailure(provisioned, invariants)
249
+ if (target !== undefined) reasons.push(target)
250
+ else if (!report.ok) {
251
+ reasons.push(
252
+ ...report.failures
188
253
  .slice(0, 5)
189
254
  .map(
190
255
  (failure) =>
191
256
  `${failure.runtime}: ${failure.id} - ${failure.message} (replay seed ${failure.replay.seed})`,
192
257
  ),
193
258
  )
194
- l4Reasons.push(
195
- ...invariants.gaps
196
- .slice(0, Math.max(0, 5 - l4Reasons.length))
259
+ reasons.push(
260
+ ...report.gaps
261
+ .slice(0, Math.max(0, 5 - reasons.length))
197
262
  .map(
198
263
  (gap) =>
199
264
  `${gap.route ?? "contract"}${gap.target ? ` ${gap.target}` : ""}: ${gap.message}`,
200
265
  ),
201
266
  )
202
267
  }
268
+ } catch (error) {
269
+ reasons.push((await targetFailure(provisioned, invariants)) ?? messageOf(error))
270
+ } finally {
271
+ const ready = await provisioned?.catch(() => undefined)
272
+ if (ready?.teardown !== undefined) {
273
+ try {
274
+ await ready.teardown()
275
+ } catch (error) {
276
+ reasons.push(`invariants teardown failed: ${messageOf(error)}`)
277
+ }
278
+ }
203
279
  }
204
- statuses.push({
205
- level: 4,
206
- name: "invariant-tested",
207
- ok: l4Reasons.length === 0,
208
- reasons: l4Reasons,
209
- })
280
+ return reasons
281
+ }
210
282
 
211
- return finalize(statuses, assurance.routes)
283
+ const INVARIANT_RUN_OPTIONS = [
284
+ "witnesses",
285
+ "prepareRequest",
286
+ "isRejected",
287
+ "include",
288
+ "maxMutationsPerInput",
289
+ ] as const
290
+
291
+ /** The setup or missing-executor failure every case hit, if the target never came up. */
292
+ async function targetFailure(
293
+ provisioned: Promise<InvariantSetupResult | undefined> | undefined,
294
+ invariants: InvariantsConfig,
295
+ ): Promise<string | undefined> {
296
+ if (provisioned === undefined) return undefined
297
+ try {
298
+ const ready = await provisioned
299
+ if (ready?.executor === undefined && invariants.executor === undefined) {
300
+ return "no invariant executor: invariants.setup returned none and invariants.executor is not set"
301
+ }
302
+ return undefined
303
+ } catch (error) {
304
+ return messageOf(error)
305
+ }
212
306
  }
213
307
 
308
+ const messageOf = (error: unknown): string =>
309
+ error instanceof Error ? error.message : String(error)
310
+
214
311
  function finalize(
215
312
  statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
216
313
  routes: readonly { readonly responseContract?: "enforce" | "warn" | "unchecked" }[],
package/src/load.ts CHANGED
@@ -40,6 +40,12 @@ export type PluginsField =
40
40
  * don't).
41
41
  */
42
42
  export interface NifraFramework {
43
+ /** Optional coordinated Vite 8 component graph. Tooling stays in nifra.config.ts. */
44
+ readonly componentPipeline?: Omit<
45
+ import("@nifrajs/web/server-components/build").ComponentPipelineOptions,
46
+ "root" | "outDir" | "port" | "plugins"
47
+ >
48
+ readonly serverComponents?: CreateWebAppOptions["serverComponents"]
43
49
  /** The render adapter, e.g. `reactAdapter` from `@nifrajs/web-react`. */
44
50
  readonly adapter: unknown
45
51
  /** The client runtime module providing `mountRouter`, e.g. `"@nifrajs/web-react/client"`. */
@@ -72,8 +78,11 @@ export interface NifraFramework {
72
78
  * targets use the socket peer either way. */
73
79
  readonly clientIp?: "platform"
74
80
  /** `nifra dev` settings. `indicator: false` turns the in-page issues badge off for every run
75
- * (`nifra dev --no-indicator` does it for one); browser errors still reach `nifra errors`. */
76
- readonly dev?: { readonly indicator?: boolean }
81
+ * (`nifra dev --no-indicator` does it for one); browser errors still reach `nifra errors`.
82
+ * `allowedHosts` lists host names the Bun dev server answers besides loopback ones; it refuses
83
+ * every other `Host` so a DNS-rebound page cannot read it. The Vite pipeline uses Vite's own
84
+ * `server.allowedHosts`. */
85
+ readonly dev?: { readonly indicator?: boolean; readonly allowedHosts?: readonly string[] }
77
86
  /** Static files directory. Defaults to `<app>/public`; `false` disables it. */
78
87
  readonly publicDir?: string | false
79
88
  /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
@@ -85,9 +94,9 @@ export interface NifraFramework {
85
94
  readonly cssCodeSplit?: boolean
86
95
  /** Framework-owned SSR stylesheet activation. Deferred mode is intended for aggregate CSS. */
87
96
  readonly cssLoading?: CssLoadingMode
88
- /** Path `backend/app.ts`'s backend is served at (default `"/api"`; `""` turns the mount off). A
89
- * page file under it can never render, so the app refuses to start and `nifra build` refuses to
90
- * build.
97
+ /** Path `backend/app.ts`'s backend is served at over HTTP, such as `"/api"`; unset or `""`, it is
98
+ * not mounted. A page file under it can never render, so the app refuses to start and `nifra build`
99
+ * refuses to build.
91
100
  *
92
101
  * The fields from here down reach the generated server entry, so like `use` they MUST be exported
93
102
  * from `backend/framework.ts` (re-export them from `nifra.config.ts` when both exist). */
@@ -106,7 +115,14 @@ export interface NifraFramework {
106
115
 
107
116
  /** The {@link NifraFramework} fields forwarded to `createWebApp` and imported by the generated server
108
117
  * entry, in one list so every command that builds the app forwards the same set. */
109
- export const FRAMEWORK_WEB_OPTIONS = ["apiPrefix", "apiStrip", "mounts", "csp", "nonce"] as const
118
+ export const FRAMEWORK_WEB_OPTIONS = [
119
+ "apiPrefix",
120
+ "apiStrip",
121
+ "mounts",
122
+ "csp",
123
+ "nonce",
124
+ "serverComponents",
125
+ ] as const
110
126
  export type FrameworkWebOption = (typeof FRAMEWORK_WEB_OPTIONS)[number]
111
127
 
112
128
  /** Refuse a forwarded field of the wrong shape at load, naming the field, rather than letting it fail
@@ -115,6 +131,24 @@ function assertFrameworkWebOptions(fw: Partial<NifraFramework>, configFile: stri
115
131
  const wrong = (field: string, expected: string): never => {
116
132
  throw new Error(`[nifra] ${configFile}: \`${field}\` must be ${expected}.`)
117
133
  }
134
+ if (fw.componentPipeline !== undefined) {
135
+ const pipeline = fw.componentPipeline
136
+ if (
137
+ typeof pipeline !== "object" ||
138
+ pipeline === null ||
139
+ !["react", "vue", "svelte", "solid", "preact"].includes(pipeline.adapter) ||
140
+ typeof pipeline.entries !== "object" ||
141
+ pipeline.entries === null ||
142
+ typeof pipeline.entries.client !== "string" ||
143
+ typeof pipeline.entries.ssr !== "string" ||
144
+ (pipeline.adapter === "react" && typeof pipeline.entries.rsc !== "string") ||
145
+ (pipeline.adapter !== "react" && pipeline.entries.rsc !== undefined)
146
+ )
147
+ wrong(
148
+ "componentPipeline",
149
+ "an adapter plus existing client/ssr entries and a React-only rsc entry",
150
+ )
151
+ }
118
152
  if (fw.apiPrefix !== undefined && typeof fw.apiPrefix !== "string") {
119
153
  wrong("apiPrefix", 'a path string such as "/api", or "" to turn the backend mount off')
120
154
  }
@@ -143,6 +177,13 @@ function assertFrameworkWebOptions(fw: Partial<NifraFramework>, configFile: stri
143
177
  const indicator: unknown = Reflect.get(fw.dev, "indicator")
144
178
  if (indicator !== undefined && typeof indicator !== "boolean")
145
179
  wrong("dev.indicator", "a boolean")
180
+ const allowedHosts: unknown = Reflect.get(fw.dev, "allowedHosts")
181
+ if (
182
+ allowedHosts !== undefined &&
183
+ (!Array.isArray(allowedHosts) ||
184
+ allowedHosts.some((host) => typeof host !== "string" || host.trim() === ""))
185
+ )
186
+ wrong("dev.allowedHosts", 'an array of host names such as ["app.test"]')
146
187
  }
147
188
  }
148
189
 
@@ -198,6 +239,23 @@ export interface LoadedApp {
198
239
  readonly backend: unknown
199
240
  }
200
241
 
242
+ /** An API-only app as the backend readers see it: `backend/app.ts` and no web config. */
243
+ export interface BackendOnlyApp {
244
+ readonly cwd: string
245
+ readonly backend: unknown
246
+ readonly framework?: undefined
247
+ }
248
+
249
+ /** What the backend readers (routes, context, hooks, OpenAPI, the app's MCP surface) load. */
250
+ export type IntrospectedApp = LoadedApp | BackendOnlyApp
251
+
252
+ /** How a command reaches the app. The MCP server hands out one whose calls both refuse. */
253
+ export interface AppLoader {
254
+ (outDirName?: string): Promise<LoadedApp>
255
+ /** The whole app with a web config, else `backend/app.ts` alone. */
256
+ readonly backend: () => Promise<IntrospectedApp>
257
+ }
258
+
201
259
  export interface LoadAppOptions {
202
260
  /** Optional dynamic-import query used by long-lived MCP processes to bust Bun's module cache. */
203
261
  readonly importQuery?: string
@@ -258,22 +316,24 @@ const isAdapter = (v: unknown): boolean => typeof v === "object" && v !== null
258
316
  const importWithQuery = (path: string, query: string | undefined): Promise<unknown> =>
259
317
  import(query === undefined || query === "" ? path : `${path}?${query}`)
260
318
 
319
+ /** Whether `cwd` has a web config file, the one thing that makes it more than its backend. */
320
+ export const hasAppConfig = (cwd: string): boolean =>
321
+ existsSync(resolve(cwd, CONFIG_FILE)) || existsSync(resolve(cwd, FRAMEWORK_FILE))
322
+
261
323
  /**
262
- * What reads only the backend (`nifra openapi`): the whole app when it has a web config, else
263
- * `backend/app.ts` alone, so an API-only app needs no adapter to describe its routes.
324
+ * What reads only the backend: the whole app when it has a web config, else `backend/app.ts`
325
+ * alone, so an API-only app needs no adapter for its routes, context, hooks or OpenAPI document.
264
326
  */
265
327
  export async function loadBackendApp(
266
328
  cwd: string,
267
329
  options: LoadAppOptions = {},
268
- ): Promise<Pick<LoadedApp, "cwd" | "backend">> {
269
- if (existsSync(resolve(cwd, CONFIG_FILE)) || existsSync(resolve(cwd, FRAMEWORK_FILE))) {
270
- return loadApp(cwd, "dist", options)
271
- }
330
+ ): Promise<IntrospectedApp> {
331
+ if (hasAppConfig(cwd)) return loadApp(cwd, "dist", options)
272
332
  assertCurrentLayout(cwd)
273
333
  const backendPath = resolve(cwd, BACKEND_APP_FILE)
274
334
  if (!existsSync(backendPath)) {
275
335
  throw new Error(
276
- `[nifra] no ${BACKEND_APP_FILE} in ${cwd}: the OpenAPI document describes its routes`,
336
+ `[nifra] no ${CONFIG_FILE}, ${FRAMEWORK_FILE} or ${BACKEND_APP_FILE} in ${cwd}: a web app needs its config, an API-only app its ${BACKEND_APP_FILE}`,
277
337
  )
278
338
  }
279
339
  const backend = (
@@ -303,7 +363,11 @@ export async function loadApp(
303
363
  " nifra is zero-config but needs one of them exporting at least:\n" +
304
364
  " export const adapter = reactAdapter // from @nifrajs/web-react\n" +
305
365
  ' export const clientModule = "@nifrajs/web-react/client"\n' +
306
- " (create-nifra scaffolds this for you.)",
366
+ " (create-nifra scaffolds this for you.)" +
367
+ (existsSync(resolve(cwd, BACKEND_APP_FILE))
368
+ ? `\n With ${BACKEND_APP_FILE} and no web config this is an API-only app: \`nifra routes\`,\n` +
369
+ " `nifra context`, `nifra openapi` and `nifra mcp` read its backend; pages need the config."
370
+ : ""),
307
371
  )
308
372
  }
309
373
  if (!existsSync(resolve(cwd, "routes"))) {
@@ -8,7 +8,7 @@
8
8
  import { realpathSync } from "node:fs"
9
9
  import { readFile } from "node:fs/promises"
10
10
  import { basename, isAbsolute, relative, resolve } from "node:path"
11
- import type { LoadedApp } from "./load.ts"
11
+ import type { AppLoader } from "./load.ts"
12
12
  import { createCachedAppLoader } from "./mcp-exec.ts"
13
13
  import { resolveMcpProjectPath } from "./mcp-path.ts"
14
14
  import type { McpPrompt, McpResource, McpServerFeatures, McpTool } from "./mcp-protocol.ts"
@@ -20,11 +20,11 @@ function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
20
20
 
21
21
  export async function openApiHandler(
22
22
  args: Record<string, unknown>,
23
- loadAppCached: (outDirName?: string) => Promise<LoadedApp>,
23
+ loadAppCached: AppLoader,
24
24
  ): Promise<string> {
25
25
  const { renderOpenApiWithTypes } = await import("./openapi-tool.ts")
26
26
  const pathPrefix = typeof args.path === "string" ? args.path : undefined
27
- return renderOpenApiWithTypes(await loadAppCached(), openApiFormat(args), pathPrefix)
27
+ return renderOpenApiWithTypes(await loadAppCached.backend(), openApiFormat(args), pathPrefix)
28
28
  }
29
29
 
30
30
  async function readProjectFile(
@@ -57,7 +57,7 @@ function promptText(
57
57
 
58
58
  export function projectResources(
59
59
  cwd: string,
60
- loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
60
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
61
61
  ): McpResource[] {
62
62
  return [
63
63
  {
@@ -68,7 +68,7 @@ export function projectResources(
68
68
  mimeType: "application/json",
69
69
  read: async () => {
70
70
  const { routesToJson } = await import("./introspect.ts")
71
- return { text: JSON.stringify(routesToJson(await loadAppCached()), null, 2) }
71
+ return { text: JSON.stringify(routesToJson(await loadAppCached.backend()), null, 2) }
72
72
  },
73
73
  },
74
74
  {
@@ -165,7 +165,7 @@ export function projectPrompts(): McpPrompt[] {
165
165
 
166
166
  export function projectFeatures(
167
167
  cwd: string,
168
- loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
168
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
169
169
  ): McpServerFeatures {
170
170
  return { resources: projectResources(cwd, loadAppCached), prompts: projectPrompts() }
171
171
  }
@@ -218,7 +218,7 @@ export function dirError(dir: string | undefined): string {
218
218
  /** Build the project-scoped tools for `cwd`. */
219
219
  export interface CommandMcpToolOptions {
220
220
  readonly cwd: string
221
- readonly loadAppCached?: () => Promise<LoadedApp>
221
+ readonly loadAppCached?: AppLoader
222
222
  }
223
223
 
224
224
  /** Adapt one executable catalog spec to its MCP descriptor and project-scoped handler. */