@nifrajs/cli 4.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +10 -2
  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 +9 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/assure.d.ts +12 -2
  14. package/dist/assure.d.ts.map +1 -1
  15. package/dist/assure.js +59 -8
  16. package/dist/assure.js.map +1 -1
  17. package/dist/capabilities-tool.d.ts.map +1 -1
  18. package/dist/capabilities-tool.js +36 -9
  19. package/dist/capabilities-tool.js.map +1 -1
  20. package/dist/check-diagnostics.d.ts +22 -2
  21. package/dist/check-diagnostics.d.ts.map +1 -1
  22. package/dist/check-diagnostics.js +54 -7
  23. package/dist/check-diagnostics.js.map +1 -1
  24. package/dist/check-scan.d.ts +83 -24
  25. package/dist/check-scan.d.ts.map +1 -1
  26. package/dist/check-scan.js +1516 -318
  27. package/dist/check-scan.js.map +1 -1
  28. package/dist/check.d.ts +2 -2
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +203 -70
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +9 -0
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +215 -50
  35. package/dist/cli.js.map +1 -1
  36. package/dist/client-module.d.ts +20 -0
  37. package/dist/client-module.d.ts.map +1 -0
  38. package/dist/client-module.js +73 -0
  39. package/dist/client-module.js.map +1 -0
  40. package/dist/command-catalog.d.ts +24 -5
  41. package/dist/command-catalog.d.ts.map +1 -1
  42. package/dist/command-catalog.js +194 -45
  43. package/dist/command-catalog.js.map +1 -1
  44. package/dist/component-check.d.ts +13 -0
  45. package/dist/component-check.d.ts.map +1 -0
  46. package/dist/component-check.js +59 -0
  47. package/dist/component-check.js.map +1 -0
  48. package/dist/contract-proof.d.ts +1 -1
  49. package/dist/contract-proof.d.ts.map +1 -1
  50. package/dist/contract-proof.js +2 -24
  51. package/dist/contract-proof.js.map +1 -1
  52. package/dist/contracts.d.ts +19 -0
  53. package/dist/contracts.d.ts.map +1 -1
  54. package/dist/contracts.js +50 -3
  55. package/dist/contracts.js.map +1 -1
  56. package/dist/db-tool.d.ts.map +1 -1
  57. package/dist/db-tool.js +4 -3
  58. package/dist/db-tool.js.map +1 -1
  59. package/dist/dev-bun-config.d.ts +5 -1
  60. package/dist/dev-bun-config.d.ts.map +1 -1
  61. package/dist/dev-bun-config.js +44 -11
  62. package/dist/dev-bun-config.js.map +1 -1
  63. package/dist/dev-feed-tool.d.ts +18 -1
  64. package/dist/dev-feed-tool.d.ts.map +1 -1
  65. package/dist/dev-feed-tool.js +93 -0
  66. package/dist/dev-feed-tool.js.map +1 -1
  67. package/dist/diagnostics.d.ts +3 -0
  68. package/dist/diagnostics.d.ts.map +1 -1
  69. package/dist/diagnostics.js.map +1 -1
  70. package/dist/diff-tool.d.ts +9 -8
  71. package/dist/diff-tool.d.ts.map +1 -1
  72. package/dist/diff-tool.js +28 -24
  73. package/dist/diff-tool.js.map +1 -1
  74. package/dist/doctor.d.ts +13 -0
  75. package/dist/doctor.d.ts.map +1 -1
  76. package/dist/doctor.js +23 -15
  77. package/dist/doctor.js.map +1 -1
  78. package/dist/env-file.d.ts +6 -0
  79. package/dist/env-file.d.ts.map +1 -1
  80. package/dist/env-file.js +6 -0
  81. package/dist/env-file.js.map +1 -1
  82. package/dist/fix-recipes.d.ts +2 -0
  83. package/dist/fix-recipes.d.ts.map +1 -1
  84. package/dist/fix-recipes.js +102 -2
  85. package/dist/fix-recipes.js.map +1 -1
  86. package/dist/hand-built-manifest.d.ts +27 -0
  87. package/dist/hand-built-manifest.d.ts.map +1 -0
  88. package/dist/hand-built-manifest.js +124 -0
  89. package/dist/hand-built-manifest.js.map +1 -0
  90. package/dist/hooks-report.d.ts +23 -0
  91. package/dist/hooks-report.d.ts.map +1 -0
  92. package/dist/hooks-report.js +104 -0
  93. package/dist/hooks-report.js.map +1 -0
  94. package/dist/init-agents.d.ts +18 -2
  95. package/dist/init-agents.d.ts.map +1 -1
  96. package/dist/init-agents.js +20 -13
  97. package/dist/init-agents.js.map +1 -1
  98. package/dist/internal/config-paths.d.ts +16 -0
  99. package/dist/internal/config-paths.d.ts.map +1 -0
  100. package/dist/internal/config-paths.js +124 -0
  101. package/dist/internal/config-paths.js.map +1 -0
  102. package/dist/internal/installed-package.d.ts +4 -0
  103. package/dist/internal/installed-package.d.ts.map +1 -0
  104. package/dist/internal/installed-package.js +17 -0
  105. package/dist/internal/installed-package.js.map +1 -0
  106. package/dist/internal/nifra-project.d.ts +41 -0
  107. package/dist/internal/nifra-project.d.ts.map +1 -0
  108. package/dist/internal/nifra-project.js +183 -0
  109. package/dist/internal/nifra-project.js.map +1 -0
  110. package/dist/internal/project-specifiers.d.ts +29 -0
  111. package/dist/internal/project-specifiers.d.ts.map +1 -0
  112. package/dist/internal/project-specifiers.js +435 -0
  113. package/dist/internal/project-specifiers.js.map +1 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -1
  115. package/dist/internal/script-kind.js +2 -1
  116. package/dist/internal/script-kind.js.map +1 -1
  117. package/dist/internal/source-facts.d.ts +8 -1
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +22 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +1 -1
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +10 -6
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +15 -6
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +87 -25
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/levels-tool.d.ts.map +1 -1
  130. package/dist/levels-tool.js +98 -23
  131. package/dist/levels-tool.js.map +1 -1
  132. package/dist/load.d.ts +33 -10
  133. package/dist/load.d.ts.map +1 -1
  134. package/dist/load.js +60 -10
  135. package/dist/load.js.map +1 -1
  136. package/dist/mcp-context.d.ts +5 -5
  137. package/dist/mcp-context.d.ts.map +1 -1
  138. package/dist/mcp-context.js +8 -23
  139. package/dist/mcp-context.js.map +1 -1
  140. package/dist/mcp-delegate.d.ts +2 -2
  141. package/dist/mcp-delegate.d.ts.map +1 -1
  142. package/dist/mcp-delegate.js +19 -7
  143. package/dist/mcp-delegate.js.map +1 -1
  144. package/dist/mcp-exec.d.ts +13 -4
  145. package/dist/mcp-exec.d.ts.map +1 -1
  146. package/dist/mcp-exec.js +56 -14
  147. package/dist/mcp-exec.js.map +1 -1
  148. package/dist/mcp-http.js +1 -1
  149. package/dist/mcp-io.d.ts +30 -0
  150. package/dist/mcp-io.d.ts.map +1 -1
  151. package/dist/mcp-io.js +63 -0
  152. package/dist/mcp-io.js.map +1 -1
  153. package/dist/mcp-isolate.d.ts.map +1 -1
  154. package/dist/mcp-isolate.js +3 -2
  155. package/dist/mcp-isolate.js.map +1 -1
  156. package/dist/mcp-path.d.ts +2 -1
  157. package/dist/mcp-path.d.ts.map +1 -1
  158. package/dist/mcp-path.js +9 -24
  159. package/dist/mcp-path.js.map +1 -1
  160. package/dist/mcp-project-child.js +1 -1
  161. package/dist/mcp-project-child.js.map +1 -1
  162. package/dist/mcp-root.d.ts.map +1 -1
  163. package/dist/mcp-root.js +50 -10
  164. package/dist/mcp-root.js.map +1 -1
  165. package/dist/mcp-ws.d.ts.map +1 -1
  166. package/dist/mcp-ws.js +3 -1
  167. package/dist/mcp-ws.js.map +1 -1
  168. package/dist/mcp.d.ts.map +1 -1
  169. package/dist/mcp.js +48 -19
  170. package/dist/mcp.js.map +1 -1
  171. package/dist/migrate-layout.d.ts +47 -1
  172. package/dist/migrate-layout.d.ts.map +1 -1
  173. package/dist/migrate-layout.js +1972 -170
  174. package/dist/migrate-layout.js.map +1 -1
  175. package/dist/output-schemas.d.ts +128 -0
  176. package/dist/output-schemas.d.ts.map +1 -0
  177. package/dist/output-schemas.js +975 -0
  178. package/dist/output-schemas.js.map +1 -0
  179. package/dist/port.d.ts.map +1 -1
  180. package/dist/port.js +3 -2
  181. package/dist/port.js.map +1 -1
  182. package/dist/project-facts.d.ts +15 -4
  183. package/dist/project-facts.d.ts.map +1 -1
  184. package/dist/project-facts.js +1 -0
  185. package/dist/project-facts.js.map +1 -1
  186. package/dist/project-path.d.ts +6 -0
  187. package/dist/project-path.d.ts.map +1 -1
  188. package/dist/project-path.js +48 -14
  189. package/dist/project-path.js.map +1 -1
  190. package/dist/recipes/5.0.0.d.ts +11 -0
  191. package/dist/recipes/5.0.0.d.ts.map +1 -0
  192. package/dist/recipes/5.0.0.js +34 -0
  193. package/dist/recipes/5.0.0.js.map +1 -0
  194. package/dist/recipes/index.d.ts +7 -0
  195. package/dist/recipes/index.d.ts.map +1 -1
  196. package/dist/recipes/index.js +2 -0
  197. package/dist/recipes/index.js.map +1 -1
  198. package/dist/release-verification.d.ts +5 -0
  199. package/dist/release-verification.d.ts.map +1 -1
  200. package/dist/release-verification.js +14 -0
  201. package/dist/release-verification.js.map +1 -1
  202. package/dist/replay.d.ts.map +1 -1
  203. package/dist/replay.js +12 -3
  204. package/dist/replay.js.map +1 -1
  205. package/dist/review.d.ts +3 -1
  206. package/dist/review.d.ts.map +1 -1
  207. package/dist/review.js +43 -7
  208. package/dist/review.js.map +1 -1
  209. package/dist/rules/codes.d.ts +12 -0
  210. package/dist/rules/codes.d.ts.map +1 -1
  211. package/dist/rules/codes.js +12 -0
  212. package/dist/rules/codes.js.map +1 -1
  213. package/dist/rules/comment-markers.d.ts +16 -7
  214. package/dist/rules/comment-markers.d.ts.map +1 -1
  215. package/dist/rules/comment-markers.js +58 -19
  216. package/dist/rules/comment-markers.js.map +1 -1
  217. package/dist/rules/contract-visibility.d.ts +3 -0
  218. package/dist/rules/contract-visibility.d.ts.map +1 -0
  219. package/dist/rules/contract-visibility.js +337 -0
  220. package/dist/rules/contract-visibility.js.map +1 -0
  221. package/dist/rules/data-guard.d.ts +13 -0
  222. package/dist/rules/data-guard.d.ts.map +1 -1
  223. package/dist/rules/data-guard.js +356 -37
  224. package/dist/rules/data-guard.js.map +1 -1
  225. package/dist/rules/layout.d.ts +4 -0
  226. package/dist/rules/layout.d.ts.map +1 -0
  227. package/dist/rules/layout.js +19 -0
  228. package/dist/rules/layout.js.map +1 -0
  229. package/dist/rules/legacy.d.ts.map +1 -1
  230. package/dist/rules/legacy.js +178 -44
  231. package/dist/rules/legacy.js.map +1 -1
  232. package/dist/rules/pages.d.ts +50 -0
  233. package/dist/rules/pages.d.ts.map +1 -1
  234. package/dist/rules/pages.js +436 -4
  235. package/dist/rules/pages.js.map +1 -1
  236. package/dist/rules/routes.d.ts +6 -0
  237. package/dist/rules/routes.d.ts.map +1 -1
  238. package/dist/rules/routes.js +111 -16
  239. package/dist/rules/routes.js.map +1 -1
  240. package/dist/rules/secrets.js +2 -2
  241. package/dist/rules/secrets.js.map +1 -1
  242. package/dist/rules/security.d.ts +13 -1
  243. package/dist/rules/security.d.ts.map +1 -1
  244. package/dist/rules/security.js +313 -69
  245. package/dist/rules/security.js.map +1 -1
  246. package/dist/rules/zones.d.ts +13 -0
  247. package/dist/rules/zones.d.ts.map +1 -1
  248. package/dist/rules/zones.js +218 -44
  249. package/dist/rules/zones.js.map +1 -1
  250. package/dist/scaffold.d.ts.map +1 -1
  251. package/dist/scaffold.js +6 -4
  252. package/dist/scaffold.js.map +1 -1
  253. package/dist/sdk.d.ts.map +1 -1
  254. package/dist/sdk.js +14 -5
  255. package/dist/sdk.js.map +1 -1
  256. package/dist/stylex-migrate.d.ts.map +1 -1
  257. package/dist/stylex-migrate.js +290 -24
  258. package/dist/stylex-migrate.js.map +1 -1
  259. package/dist/test-tool.d.ts.map +1 -1
  260. package/dist/test-tool.js +6 -3
  261. package/dist/test-tool.js.map +1 -1
  262. package/dist/typecheck-coverage.d.ts +20 -0
  263. package/dist/typecheck-coverage.d.ts.map +1 -0
  264. package/dist/typecheck-coverage.js +129 -0
  265. package/dist/typecheck-coverage.js.map +1 -0
  266. package/dist/upgrade.d.ts +84 -0
  267. package/dist/upgrade.d.ts.map +1 -1
  268. package/dist/upgrade.js +498 -78
  269. package/dist/upgrade.js.map +1 -1
  270. package/dist/verification-plan.d.ts.map +1 -1
  271. package/dist/verification-plan.js +16 -2
  272. package/dist/verification-plan.js.map +1 -1
  273. package/dist/version-drift.d.ts +22 -0
  274. package/dist/version-drift.d.ts.map +1 -0
  275. package/dist/version-drift.js +94 -0
  276. package/dist/version-drift.js.map +1 -0
  277. package/dist/web-app-options.d.ts +1 -1
  278. package/dist/web-app-options.d.ts.map +1 -1
  279. package/dist/web-app-options.js +1 -1
  280. package/dist/web-app-options.js.map +1 -1
  281. package/dist/work-graph.d.ts.map +1 -1
  282. package/dist/work-graph.js +20 -8
  283. package/dist/work-graph.js.map +1 -1
  284. package/docs/examples.json +55 -7
  285. package/docs/llms-full.txt +2043 -222
  286. package/docs/types.json +1573 -246
  287. package/package.json +11 -12
  288. package/src/agent-files.ts +203 -0
  289. package/src/app-files.ts +34 -4
  290. package/src/assure-hydration.ts +18 -2
  291. package/src/assure.ts +88 -10
  292. package/src/capabilities-tool.ts +47 -9
  293. package/src/check-diagnostics.ts +75 -9
  294. package/src/check-scan.ts +1816 -377
  295. package/src/check.ts +258 -81
  296. package/src/cli.ts +242 -37
  297. package/src/client-module.ts +97 -0
  298. package/src/command-catalog.ts +237 -52
  299. package/src/component-check.ts +79 -0
  300. package/src/contract-proof.ts +3 -25
  301. package/src/contracts.ts +60 -3
  302. package/src/db-tool.ts +5 -2
  303. package/src/dev-bun-config.ts +51 -10
  304. package/src/dev-feed-tool.ts +112 -1
  305. package/src/diagnostics.ts +3 -0
  306. package/src/diff-tool.ts +33 -26
  307. package/src/doctor.ts +43 -21
  308. package/src/env-file.ts +7 -0
  309. package/src/fix-recipes.ts +116 -2
  310. package/src/hand-built-manifest.ts +144 -0
  311. package/src/hooks-report.ts +135 -0
  312. package/src/init-agents.ts +21 -14
  313. package/src/internal/config-paths.ts +154 -0
  314. package/src/internal/installed-package.ts +15 -0
  315. package/src/internal/nifra-project.ts +191 -0
  316. package/src/internal/project-specifiers.ts +477 -0
  317. package/src/internal/script-kind.ts +2 -1
  318. package/src/internal/source-facts.ts +48 -5
  319. package/src/internal/typescript-import.ts +15 -6
  320. package/src/introspect.ts +104 -28
  321. package/src/levels-tool.ts +124 -29
  322. package/src/load.ts +107 -17
  323. package/src/mcp-context.ts +12 -25
  324. package/src/mcp-delegate.ts +22 -10
  325. package/src/mcp-exec.ts +68 -18
  326. package/src/mcp-http.ts +1 -1
  327. package/src/mcp-io.ts +74 -0
  328. package/src/mcp-isolate.ts +9 -2
  329. package/src/mcp-path.ts +8 -22
  330. package/src/mcp-project-child.ts +1 -1
  331. package/src/mcp-root.ts +58 -8
  332. package/src/mcp-ws.ts +4 -2
  333. package/src/mcp.ts +56 -22
  334. package/src/migrate-layout.ts +2257 -176
  335. package/src/output-schemas.ts +1240 -0
  336. package/src/port.ts +3 -2
  337. package/src/project-facts.ts +17 -3
  338. package/src/project-path.ts +44 -13
  339. package/src/recipes/5.0.0.ts +35 -0
  340. package/src/recipes/index.ts +10 -0
  341. package/src/release-verification.ts +20 -0
  342. package/src/replay.ts +14 -3
  343. package/src/review.ts +44 -10
  344. package/src/rules/codes.ts +12 -0
  345. package/src/rules/comment-markers.ts +57 -21
  346. package/src/rules/contract-visibility.ts +383 -0
  347. package/src/rules/data-guard.ts +391 -42
  348. package/src/rules/layout.ts +22 -0
  349. package/src/rules/legacy.ts +206 -52
  350. package/src/rules/pages.ts +481 -4
  351. package/src/rules/routes.ts +124 -16
  352. package/src/rules/secrets.ts +2 -2
  353. package/src/rules/security.ts +391 -72
  354. package/src/rules/zones.ts +232 -42
  355. package/src/scaffold.ts +6 -4
  356. package/src/sdk.ts +14 -5
  357. package/src/stylex-migrate.ts +251 -24
  358. package/src/test-tool.ts +12 -3
  359. package/src/typecheck-coverage.ts +134 -0
  360. package/src/upgrade.ts +576 -72
  361. package/src/verification-plan.ts +33 -2
  362. package/src/version-drift.ts +118 -0
  363. package/src/web-app-options.ts +2 -2
  364. package/src/work-graph.ts +17 -12
@@ -20,6 +20,13 @@ export interface SourceFacts {
20
20
  method: string,
21
21
  path: string,
22
22
  ): boolean | undefined
23
+ /** A `.method(argument, …)` member call whose first argument is written exactly as `argument`. */
24
+ isRouteArgumentAt(
25
+ source: TSApi.SourceFile,
26
+ position: number,
27
+ method: string,
28
+ argument: string,
29
+ ): boolean | undefined
23
30
  /** A call to the free function `callee` whose argument at `pathIndex` is the literal `path`. */
24
31
  isFunctionRouteCallAt(
25
32
  source: TSApi.SourceFile,
@@ -75,8 +82,26 @@ function rawResponseExpression(ts: TypeScriptApi, node: TSApi.Expression): boole
75
82
  )
76
83
  }
77
84
 
78
- export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
85
+ export interface SourceFactsOptions {
86
+ /** Whether the compiler settings for a parsed file keep an import whose named specifiers are all
87
+ * type-only (`verbatimModuleSyntax`). Without it such an import is erased. */
88
+ readonly keepsTypeOnlyImports?: (file: string) => boolean
89
+ }
90
+
91
+ export function createSourceFacts(
92
+ ts: TypeScriptApi,
93
+ options: SourceFactsOptions = {},
94
+ ): SourceFacts {
79
95
  const cache = new Map<string, { content: string; source: TSApi.SourceFile | undefined }>()
96
+ // `{}` loads its module in every bundler; a list of type-only specifiers is erased unless the
97
+ // compiler keeps it as `import {} from "x"`.
98
+ const loadsModule = (
99
+ source: TSApi.SourceFile,
100
+ elements: readonly { readonly isTypeOnly: boolean }[],
101
+ ): boolean =>
102
+ elements.length === 0 ||
103
+ elements.some((element) => !element.isTypeOnly) ||
104
+ (options.keepsTypeOnlyImports?.(source.fileName) ?? false)
80
105
 
81
106
  const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
82
107
  const cached = cache.get(file)
@@ -111,7 +136,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
111
136
  if (reexport.isTypeOnly) return false
112
137
  const named = reexport.exportClause
113
138
  if (named !== undefined && ts.isNamedExports(named))
114
- return named.elements.some((element) => !element.isTypeOnly)
139
+ return loadsModule(source, named.elements)
115
140
  return true // `export *` and `export * as ns`
116
141
  }
117
142
  const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration)
@@ -121,9 +146,9 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
121
146
  const clause = declaration.importClause
122
147
  if (clause === undefined) return true // side-effect import
123
148
  if (clause.isTypeOnly) return false
124
- if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
125
- return clause.namedBindings.elements.some((element) => !element.isTypeOnly)
126
- }
149
+ // A default binding is a runtime import whatever its named list holds.
150
+ const named = clause.name === undefined ? clause.namedBindings : undefined
151
+ if (named !== undefined && ts.isNamedImports(named)) return loadsModule(source, named.elements)
127
152
  return true // default and namespace bindings are runtime imports
128
153
  }
129
154
 
@@ -142,6 +167,23 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
142
167
  )
143
168
  }
144
169
 
170
+ const isRouteArgumentAt = (
171
+ source: TSApi.SourceFile,
172
+ position: number,
173
+ method: string,
174
+ argument: string,
175
+ ): boolean | undefined => {
176
+ const call = ancestorAt(ts, source, position, ts.isCallExpression)
177
+ if (call === undefined || !ts.isPropertyAccessExpression(call.expression)) return false
178
+ const first = call.arguments[0]
179
+ return (
180
+ call.expression.name.text.toUpperCase() === method &&
181
+ call.arguments.length > 1 &&
182
+ first !== undefined &&
183
+ first.getText(source) === argument
184
+ )
185
+ }
186
+
145
187
  const isFunctionRouteCallAt = (
146
188
  source: TSApi.SourceFile,
147
189
  position: number,
@@ -200,6 +242,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
200
242
  parse,
201
243
  isValueImportAt,
202
244
  isRouteRegistrationAt,
245
+ isRouteArgumentAt,
203
246
  isFunctionRouteCallAt,
204
247
  isResponseSyntaxAt,
205
248
  isDynamicImportAt,
@@ -48,11 +48,14 @@ export function unsupportedTypeScriptMessage(version: string): string {
48
48
  return `${NIFRA_TS_UNSUPPORTED}: TypeScript ${version} is outside the compiler API versions supported by Nifra scanners. Use TypeScript 5, 6, or 7, or pin the project to TypeScript 6 (for example, \`bun add -d typescript@^6\`).`
49
49
  }
50
50
 
51
- export function unsupportedTypeScriptDiagnostic(version: string): Diagnostic {
51
+ export function unsupportedTypeScriptDiagnostic(
52
+ version: string,
53
+ message = unsupportedTypeScriptMessage(version),
54
+ ): Diagnostic {
52
55
  return diagnostic({
53
56
  code: NIFRA_TS_UNSUPPORTED,
54
57
  severity: "error",
55
- message: unsupportedTypeScriptMessage(version),
58
+ message,
56
59
  fix: {
57
60
  recipe: "toolchain.install-typescript",
58
61
  command: "bun add -d typescript@^6",
@@ -320,9 +323,9 @@ function syntaxKinds(module: UnknownRecord, names: readonly string[]): ReadonlyS
320
323
 
321
324
  function createVirtualFileSystem(
322
325
  sourceFiles: ReadonlyMap<string, string>,
323
- root: string,
326
+ roots: readonly string[],
324
327
  ): UnknownRecord {
325
- const rootPaths = pathVariants(root)
328
+ const rootPaths = [...new Set(roots.flatMap(pathVariants))]
326
329
  const virtualFiles: string[] = []
327
330
  const seenFiles = new Set<string>()
328
331
  for (const file of sourceFiles.keys()) {
@@ -412,7 +415,11 @@ function sourceEntries(
412
415
  const content = sourceIndex.read(file)
413
416
  if (content === undefined) continue
414
417
  const absolute = isAbsolute(file) ? file : resolve(root, file)
415
- const normalized = normalizedPath(absolute)
418
+ // The session opens script extensions only; a component's script blocks are opened as TypeScript
419
+ // beside it, and `createSourceFile` finds them by their text.
420
+ const normalized = normalizedPath(
421
+ /\.(?:svelte|vue)$/.test(absolute) ? `${absolute}.ts` : absolute,
422
+ )
416
423
  paths.push(normalized)
417
424
  for (const variant of pathVariants(normalized)) files.set(variant, content)
418
425
  }
@@ -447,7 +454,9 @@ async function createTypeScript7Session(
447
454
 
448
455
  const api = new API({
449
456
  cwd: sessionRoot,
450
- fs: createVirtualFileSystem(files, sessionRoot),
457
+ // The root as the caller spells it too: a file not yet on disk keeps that spelling (`/var`, not
458
+ // `/private/var`), and the session reaches it through the caller's config.
459
+ fs: createVirtualFileSystem(files, [sessionRoot, root]),
451
460
  })
452
461
  let snapshot: TypeScript7Snapshot | undefined
453
462
  let configuredProject: TypeScript7Project | undefined
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
  // ===================================================================================================
@@ -403,6 +438,9 @@ export interface RouteTableEntry {
403
438
  * through the same web app, no hand-wired `if (pathname.startsWith("/api/"))`). Pages are never
404
439
  * auto-mounted (they ARE the page router); only API routes carry this flag. */
405
440
  readonly autoMounted?: boolean
441
+ /** True for an API route outside a set `apiPrefix` while `apiStrip` is off: the mount passes the
442
+ * request's full path, so no HTTP request reaches it; loaders still call it in-process. */
443
+ readonly inProcessOnly?: boolean
406
444
  }
407
445
 
408
446
  /** A page route + whether its module exports an `action` (→ it also serves POST). The caller resolves
@@ -416,7 +454,7 @@ export interface PageRouteInput {
416
454
  export interface RouteTableInput {
417
455
  readonly pages: readonly PageRouteInput[]
418
456
  readonly api: ReadonlyArray<Pick<ReflectedRoute, "method" | "path">>
419
- /** The page router's `apiPrefix` (default `/api`); an API route at/under it is auto-mounted. `""`
457
+ /** The page router's `apiPrefix`; an API route at/under it is auto-mounted. Unset or `""`
420
458
  * disables the auto-mount, so no API route is marked auto-mounted. */
421
459
  readonly apiPrefix?: string
422
460
  /** The backend declares its routes without the prefix and is served at `apiPrefix + path`: every API
@@ -440,7 +478,7 @@ function isUnderPrefix(path: string, prefix: string): boolean {
440
478
  * joined methods, for stable output.
441
479
  */
442
480
  export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
443
- const configured = input.apiPrefix ?? "/api"
481
+ const configured = input.apiPrefix ?? ""
444
482
  // Read the prefix as the server's mount table does (`/api/` and `/api/*` are `/api`).
445
483
  const apiPrefix = configured === "" ? "" : (normalizeMountPath(configured) ?? configured)
446
484
  const stripped = input.apiStrip === true && apiPrefix !== ""
@@ -471,6 +509,8 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
471
509
  path,
472
510
  methods: [...info.methods].sort(),
473
511
  autoMounted: info.autoMounted,
512
+ // biome-ignore lint/plugin/noConditionalEmptyObjectSpread: only a route outside apiPrefix carries the flag, so every other row keeps its shape.
513
+ ...(apiPrefix !== "" && !info.autoMounted ? { inProcessOnly: true } : {}),
474
514
  })
475
515
  }
476
516
  return rows.sort(
@@ -509,7 +549,12 @@ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
509
549
  const display = rows.map((r) => ({
510
550
  methods: r.methods.join(", "),
511
551
  kind: r.kind,
512
- path: r.kind === "api" && r.autoMounted ? `${r.path} (auto-mounted)` : r.path,
552
+ path:
553
+ r.kind === "api" && r.autoMounted
554
+ ? `${r.path} (auto-mounted)`
555
+ : r.inProcessOnly === true
556
+ ? `${r.path} (in-process only: outside apiPrefix)`
557
+ : r.path,
513
558
  client: clientSpellingFor(r.path),
514
559
  }))
515
560
  const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
@@ -542,6 +587,7 @@ export interface RouteJsonRow {
542
587
  readonly methods: readonly string[]
543
588
  readonly file?: string
544
589
  readonly autoMounted?: boolean
590
+ readonly inProcessOnly?: boolean
545
591
  /** The reserved typed-client key a static segment of this path collides with, when one does. */
546
592
  readonly reservedKey?: string
547
593
  /** The typed-client call spelling that reaches this route, present only alongside `reservedKey`. */
@@ -564,6 +610,8 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
564
610
  methods: r.methods,
565
611
  ...(r.file !== undefined ? { file: r.file } : {}),
566
612
  ...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
613
+ // biome-ignore lint/plugin/noConditionalEmptyObjectSpread: `--json` rows gain the key only where it is true, so existing consumers see no change.
614
+ ...(r.inProcessOnly === true ? { inProcessOnly: true } : {}),
567
615
  ...(client === undefined
568
616
  ? {}
569
617
  : { reservedKey: client.reserved, clientSpelling: client.spelling }),
@@ -683,12 +731,15 @@ interface MaybeActionModule {
683
731
  * text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
684
732
  * degrades to GET-only for that route (never throws the whole command).
685
733
  */
686
- async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
734
+ async function collectRouteTable(app: IntrospectedApp): Promise<RouteTableEntry[]> {
735
+ const { framework } = app
687
736
  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.
737
+ if (framework !== undefined) {
738
+ try {
739
+ manifest = discoverRoutes(app.routesDir)
740
+ } catch {
741
+ // No (or unreadable) routes/ - fall through with no pages; the table still shows API routes.
742
+ }
692
743
  }
693
744
  const pages: PageRouteInput[] = []
694
745
  for (const route of manifest?.routes ?? []) {
@@ -704,14 +755,39 @@ async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
704
755
  const rows = buildRouteTable({
705
756
  pages,
706
757
  api: backendRoutes(app.backend),
707
- ...(app.framework.apiPrefix !== undefined ? { apiPrefix: app.framework.apiPrefix } : {}),
708
- ...(app.framework.apiStrip !== undefined ? { apiStrip: app.framework.apiStrip } : {}),
758
+ ...(framework?.apiPrefix !== undefined ? { apiPrefix: framework.apiPrefix } : {}),
759
+ ...(framework?.apiStrip !== undefined ? { apiStrip: framework.apiStrip } : {}),
709
760
  })
710
761
  return rows
711
762
  }
712
763
 
764
+ /**
765
+ * The warning `nifra dev` and `nifra build` print for backend routes no HTTP request reaches, read from
766
+ * the routes the backend registers, prefixes and mounts resolved; `undefined` when there are none.
767
+ */
768
+ export function unservedRoutesWarning(
769
+ app: Pick<IntrospectedApp, "framework" | "backend">,
770
+ ): string | undefined {
771
+ const apiPrefix = app.framework?.apiPrefix ?? ""
772
+ if (apiPrefix === "" || app.backend === undefined) return undefined
773
+ const rows = buildRouteTable({
774
+ pages: [],
775
+ api: backendRoutes(app.backend),
776
+ apiPrefix,
777
+ apiStrip: app.framework?.apiStrip === true,
778
+ }).filter((row) => row.inProcessOnly === true)
779
+ if (rows.length === 0) return undefined
780
+ const listed = rows.map((row) => `${row.methods.join(",")} ${row.path}`)
781
+ const named =
782
+ listed.length > 3
783
+ ? `${listed.slice(0, 3).join(", ")} and ${listed.length - 3} more`
784
+ : listed.join(", ")
785
+ const one = rows.length === 1
786
+ return `[nifra] ${one ? "a backend route is" : `${rows.length} backend routes are`} served over HTTP nowhere: ${named}. backend/app.ts is mounted at apiPrefix ${apiPrefix} with apiStrip off, so it sees each request's full path, and ${one ? "this route sits" : "these sit"} outside it; loaders still call ${one ? "it" : "them"} in-process. Register ${one ? "it" : "them"} under ${apiPrefix}, or export \`apiStrip = true\` to serve every backend route at ${apiPrefix} plus its path.`
787
+ }
788
+
713
789
  export async function describeRoutes(
714
- app: LoadedApp,
790
+ app: IntrospectedApp,
715
791
  opts: { readonly json?: boolean } = {},
716
792
  ): Promise<string> {
717
793
  const rows = await collectRouteTable(app)
@@ -720,7 +796,7 @@ export async function describeRoutes(
720
796
 
721
797
  /** Gather and render the public route graph for `nifra routes --graph`. */
722
798
  export async function describeRouteGraph(
723
- app: LoadedApp,
799
+ app: IntrospectedApp,
724
800
  opts: { readonly json?: boolean } = {},
725
801
  ): Promise<string> {
726
802
  const graph = buildRouteGraph(await collectRouteTable(app))
@@ -13,10 +13,21 @@
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
+ AssuredRoute,
19
+ InvariantSetup,
20
+ InvariantSetupResult,
21
+ InvariantsConfig,
22
+ } from "@nifrajs/core/assurance"
17
23
  import { snapshotCapabilities } from "@nifrajs/core/capabilities"
18
24
  import { buildNifraManifest, parseNifraManifest } from "@nifrajs/core/manifest"
19
- import { type ContractTestApp, runAdversarialContract } from "@nifrajs/testing"
25
+ import {
26
+ type AdversarialContractOptions,
27
+ type ContractTestApp,
28
+ runAdversarialContract,
29
+ } from "@nifrajs/testing"
30
+ import { RESPONSE_CONTRACT_BASELINE_HINT, responseContractGaps } from "./assure.ts"
20
31
  import {
21
32
  type CapabilityProjectReport,
22
33
  diffCapabilitySnapshots,
@@ -176,44 +187,132 @@ export async function collectVerificationLevels(
176
187
  "no isolated invariant executor configured - add `invariants.executor` in nifra.assurance.ts",
177
188
  )
178
189
  } 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,
190
+ l4Reasons.push(...(await runInvariants(config.source, config.invariants, options.seed ?? 1)))
191
+ }
192
+ statuses.push({
193
+ level: 4,
194
+ name: "invariant-tested",
195
+ ok: l4Reasons.length === 0,
196
+ reasons: l4Reasons,
197
+ })
198
+
199
+ return finalize(statuses, assurance.routes)
200
+ }
201
+
202
+ /**
203
+ * Run L4 against the configured target. `setup` runs at most once, on the first request, and its
204
+ * `teardown` runs once the run ends - after a failure too - so an app can provision a throwaway target
205
+ * only when L4 actually exercises it.
206
+ */
207
+ async function runInvariants(
208
+ source: unknown,
209
+ invariants: InvariantsConfig,
210
+ seed: number,
211
+ ): Promise<string[]> {
212
+ let provisioned: Promise<InvariantSetupResult | undefined> | undefined
213
+ const provision = (setup: InvariantSetup): Promise<InvariantSetupResult | undefined> => {
214
+ provisioned ??= Promise.resolve()
215
+ .then(setup)
216
+ .then(
217
+ (result) => result ?? undefined,
218
+ (error: unknown) => {
219
+ throw new Error(`invariants.setup failed: ${messageOf(error)}`)
220
+ },
221
+ )
222
+ return provisioned
223
+ }
224
+ const fetch = async (request: Request): Promise<Response> => {
225
+ const ready = invariants.setup === undefined ? undefined : await provision(invariants.setup)
226
+ const executor = ready?.executor ?? invariants.executor
227
+ if (executor === undefined) {
228
+ throw new Error(
229
+ "no invariant executor: invariants.setup returned none and invariants.executor is not set",
230
+ )
231
+ }
232
+ return executor(request)
233
+ }
234
+ const options: AdversarialContractOptions = {
235
+ seed,
236
+ runtimes: [{ name: "isolated", fetch }],
237
+ expectedValidationStatuses: [400, 422],
238
+ requireCoverage: true,
239
+ }
240
+ const runOptions: Record<string, unknown> = {}
241
+ for (const name of INVARIANT_RUN_OPTIONS) {
242
+ if (invariants[name] !== undefined) runOptions[name] = invariants[name]
243
+ }
244
+ const reasons: string[] = []
245
+ try {
246
+ const report = await runAdversarialContract(source as ContractTestApp, {
247
+ ...options,
248
+ ...runOptions,
184
249
  })
185
- if (!invariants.ok) {
186
- l4Reasons.push(
187
- ...invariants.failures
250
+ const target = await targetFailure(provisioned, invariants)
251
+ if (target !== undefined) reasons.push(target)
252
+ else if (!report.ok) {
253
+ reasons.push(
254
+ ...report.failures
188
255
  .slice(0, 5)
189
256
  .map(
190
257
  (failure) =>
191
258
  `${failure.runtime}: ${failure.id} - ${failure.message} (replay seed ${failure.replay.seed})`,
192
259
  ),
193
260
  )
194
- l4Reasons.push(
195
- ...invariants.gaps
196
- .slice(0, Math.max(0, 5 - l4Reasons.length))
261
+ reasons.push(
262
+ ...report.gaps
263
+ .slice(0, Math.max(0, 5 - reasons.length))
197
264
  .map(
198
265
  (gap) =>
199
266
  `${gap.route ?? "contract"}${gap.target ? ` ${gap.target}` : ""}: ${gap.message}`,
200
267
  ),
201
268
  )
202
269
  }
270
+ } catch (error) {
271
+ reasons.push((await targetFailure(provisioned, invariants)) ?? messageOf(error))
272
+ } finally {
273
+ const ready = await provisioned?.catch(() => undefined)
274
+ if (ready?.teardown !== undefined) {
275
+ try {
276
+ await ready.teardown()
277
+ } catch (error) {
278
+ reasons.push(`invariants teardown failed: ${messageOf(error)}`)
279
+ }
280
+ }
203
281
  }
204
- statuses.push({
205
- level: 4,
206
- name: "invariant-tested",
207
- ok: l4Reasons.length === 0,
208
- reasons: l4Reasons,
209
- })
282
+ return reasons
283
+ }
210
284
 
211
- return finalize(statuses, assurance.routes)
285
+ const INVARIANT_RUN_OPTIONS = [
286
+ "witnesses",
287
+ "prepareRequest",
288
+ "isRejected",
289
+ "include",
290
+ "maxMutationsPerInput",
291
+ ] as const
292
+
293
+ /** The setup or missing-executor failure every case hit, if the target never came up. */
294
+ async function targetFailure(
295
+ provisioned: Promise<InvariantSetupResult | undefined> | undefined,
296
+ invariants: InvariantsConfig,
297
+ ): Promise<string | undefined> {
298
+ if (provisioned === undefined) return undefined
299
+ try {
300
+ const ready = await provisioned
301
+ if (ready?.executor === undefined && invariants.executor === undefined) {
302
+ return "no invariant executor: invariants.setup returned none and invariants.executor is not set"
303
+ }
304
+ return undefined
305
+ } catch (error) {
306
+ return messageOf(error)
307
+ }
212
308
  }
213
309
 
310
+ const messageOf = (error: unknown): string =>
311
+ error instanceof Error ? error.message : String(error)
312
+
214
313
  function finalize(
215
314
  statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
216
- routes: readonly { readonly responseContract?: "enforce" | "warn" | "unchecked" }[],
315
+ routes: readonly AssuredRoute[],
217
316
  ): VerificationLevelsResult {
218
317
  // Cumulative: the achieved level is the last rung with every rung below it also holding.
219
318
  let achieved = -1
@@ -225,6 +324,7 @@ function finalize(
225
324
  const enforced = declared.filter((route) => route.responseContract === "enforce").length
226
325
  const warnOnly = declared.filter((route) => route.responseContract === "warn").length
227
326
  const typedOnly = declared.filter((route) => route.responseContract === "unchecked").length
327
+ const gaps = responseContractGaps(routes)
228
328
  const responseContracts: ResponseContractStatus = {
229
329
  declared: declared.length,
230
330
  enforced,
@@ -236,14 +336,9 @@ function finalize(
236
336
  declared.length === 0
237
337
  ? ["no response/error schemas declared"]
238
338
  : [
239
- ...(typedOnly > 0
240
- ? [
241
- `${typedOnly} declared route(s) are typed-only; install responseContract("enforce")`,
242
- ]
243
- : []),
244
- ...(warnOnly > 0
245
- ? [`${warnOnly} declared route(s) are warn-only and still send the original payload`]
246
- : []),
339
+ ...(gaps.typedOnlyReason === undefined ? [] : [gaps.typedOnlyReason]),
340
+ ...(gaps.warnOnlyReason === undefined ? [] : [gaps.warnOnlyReason]),
341
+ ...(typedOnly + warnOnly > 0 ? [RESPONSE_CONTRACT_BASELINE_HINT] : []),
247
342
  ],
248
343
  }
249
344
  return Object.freeze({