@nifrajs/cli 3.3.0 → 4.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 (425) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +7 -15
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +162 -713
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +58 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +323 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts +1 -0
  30. package/dist/check.d.ts.map +1 -1
  31. package/dist/check.js +145 -54
  32. package/dist/check.js.map +1 -1
  33. package/dist/cli.d.ts +42 -16
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +306 -87
  36. package/dist/cli.js.map +1 -1
  37. package/dist/command-catalog.d.ts +60 -5
  38. package/dist/command-catalog.d.ts.map +1 -1
  39. package/dist/command-catalog.js +298 -33
  40. package/dist/command-catalog.js.map +1 -1
  41. package/dist/config-target.d.ts +8 -0
  42. package/dist/config-target.d.ts.map +1 -0
  43. package/dist/config-target.js +45 -0
  44. package/dist/config-target.js.map +1 -0
  45. package/dist/contracts.d.ts +3 -2
  46. package/dist/contracts.d.ts.map +1 -1
  47. package/dist/contracts.js +56 -16
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db-audit.d.ts +37 -0
  50. package/dist/db-audit.d.ts.map +1 -0
  51. package/dist/db-audit.js +99 -0
  52. package/dist/db-audit.js.map +1 -0
  53. package/dist/db-child.d.ts +64 -0
  54. package/dist/db-child.d.ts.map +1 -0
  55. package/dist/db-child.js +213 -0
  56. package/dist/db-child.js.map +1 -0
  57. package/dist/db-config.d.ts +60 -0
  58. package/dist/db-config.d.ts.map +1 -0
  59. package/dist/db-config.js +176 -0
  60. package/dist/db-config.js.map +1 -0
  61. package/dist/db-tool.d.ts +84 -0
  62. package/dist/db-tool.d.ts.map +1 -0
  63. package/dist/db-tool.js +537 -0
  64. package/dist/db-tool.js.map +1 -0
  65. package/dist/dev-bun-config.d.ts +20 -13
  66. package/dist/dev-bun-config.d.ts.map +1 -1
  67. package/dist/dev-bun-config.js +98 -41
  68. package/dist/dev-bun-config.js.map +1 -1
  69. package/dist/dev-feed-tool.d.ts +106 -0
  70. package/dist/dev-feed-tool.d.ts.map +1 -0
  71. package/dist/dev-feed-tool.js +713 -0
  72. package/dist/dev-feed-tool.js.map +1 -0
  73. package/dist/dev-server-client.d.ts +67 -0
  74. package/dist/dev-server-client.d.ts.map +1 -0
  75. package/dist/dev-server-client.js +195 -0
  76. package/dist/dev-server-client.js.map +1 -0
  77. package/dist/diagnostics.d.ts +86 -0
  78. package/dist/diagnostics.d.ts.map +1 -1
  79. package/dist/diagnostics.js +85 -0
  80. package/dist/diagnostics.js.map +1 -1
  81. package/dist/diff-tool.d.ts +3 -3
  82. package/dist/diff-tool.d.ts.map +1 -1
  83. package/dist/diff-tool.js +6 -5
  84. package/dist/diff-tool.js.map +1 -1
  85. package/dist/docs-search.d.ts.map +1 -1
  86. package/dist/docs-search.js +12 -4
  87. package/dist/docs-search.js.map +1 -1
  88. package/dist/doctor.d.ts +17 -0
  89. package/dist/doctor.d.ts.map +1 -1
  90. package/dist/doctor.js +76 -15
  91. package/dist/doctor.js.map +1 -1
  92. package/dist/env-file.d.ts +15 -0
  93. package/dist/env-file.d.ts.map +1 -1
  94. package/dist/env-file.js +73 -1
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/fix-recipes.d.ts.map +1 -1
  97. package/dist/fix-recipes.js +41 -21
  98. package/dist/fix-recipes.js.map +1 -1
  99. package/dist/frontend-guidance.js +5 -5
  100. package/dist/frontend-guidance.js.map +1 -1
  101. package/dist/i18n-check.d.ts +16 -0
  102. package/dist/i18n-check.d.ts.map +1 -0
  103. package/dist/i18n-check.js +154 -0
  104. package/dist/i18n-check.js.map +1 -0
  105. package/dist/init-agents.d.ts +49 -13
  106. package/dist/init-agents.d.ts.map +1 -1
  107. package/dist/init-agents.js +239 -34
  108. package/dist/init-agents.js.map +1 -1
  109. package/dist/internal/code-unit-order.d.ts +4 -0
  110. package/dist/internal/code-unit-order.d.ts.map +1 -0
  111. package/dist/internal/code-unit-order.js +6 -0
  112. package/dist/internal/code-unit-order.js.map +1 -0
  113. package/dist/internal/script-kind.d.ts +7 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -0
  115. package/dist/internal/script-kind.js +12 -0
  116. package/dist/internal/script-kind.js.map +1 -0
  117. package/dist/internal/source-facts.d.ts +5 -0
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +61 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +49 -24
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +507 -67
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +6 -4
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +93 -74
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/learn.js +3 -3
  130. package/dist/learn.js.map +1 -1
  131. package/dist/levels-tool.d.ts +10 -0
  132. package/dist/levels-tool.d.ts.map +1 -1
  133. package/dist/levels-tool.js +33 -3
  134. package/dist/levels-tool.js.map +1 -1
  135. package/dist/load.d.ts +71 -16
  136. package/dist/load.d.ts.map +1 -1
  137. package/dist/load.js +78 -18
  138. package/dist/load.js.map +1 -1
  139. package/dist/manifest-tool.d.ts.map +1 -1
  140. package/dist/manifest-tool.js +3 -2
  141. package/dist/manifest-tool.js.map +1 -1
  142. package/dist/mcp-app-tool.d.ts.map +1 -1
  143. package/dist/mcp-app-tool.js +2 -0
  144. package/dist/mcp-app-tool.js.map +1 -1
  145. package/dist/mcp-context.d.ts.map +1 -1
  146. package/dist/mcp-context.js +5 -6
  147. package/dist/mcp-context.js.map +1 -1
  148. package/dist/mcp-delegate.d.ts +57 -0
  149. package/dist/mcp-delegate.d.ts.map +1 -0
  150. package/dist/mcp-delegate.js +130 -0
  151. package/dist/mcp-delegate.js.map +1 -0
  152. package/dist/mcp-docs-tools.d.ts.map +1 -1
  153. package/dist/mcp-docs-tools.js +23 -5
  154. package/dist/mcp-docs-tools.js.map +1 -1
  155. package/dist/mcp-exec.d.ts +3 -0
  156. package/dist/mcp-exec.d.ts.map +1 -1
  157. package/dist/mcp-exec.js +263 -131
  158. package/dist/mcp-exec.js.map +1 -1
  159. package/dist/mcp-http.d.ts.map +1 -1
  160. package/dist/mcp-http.js +20 -5
  161. package/dist/mcp-http.js.map +1 -1
  162. package/dist/mcp-io.d.ts +26 -0
  163. package/dist/mcp-io.d.ts.map +1 -1
  164. package/dist/mcp-io.js +113 -0
  165. package/dist/mcp-io.js.map +1 -1
  166. package/dist/mcp-isolate.d.ts +48 -0
  167. package/dist/mcp-isolate.d.ts.map +1 -0
  168. package/dist/mcp-isolate.js +222 -0
  169. package/dist/mcp-isolate.js.map +1 -0
  170. package/dist/mcp-path.d.ts +10 -0
  171. package/dist/mcp-path.d.ts.map +1 -0
  172. package/dist/mcp-path.js +51 -0
  173. package/dist/mcp-path.js.map +1 -0
  174. package/dist/mcp-project-child.d.ts +74 -0
  175. package/dist/mcp-project-child.d.ts.map +1 -0
  176. package/dist/mcp-project-child.js +130 -0
  177. package/dist/mcp-project-child.js.map +1 -0
  178. package/dist/mcp-reflect.d.ts.map +1 -1
  179. package/dist/mcp-reflect.js +2 -2
  180. package/dist/mcp-reflect.js.map +1 -1
  181. package/dist/mcp-render.d.ts.map +1 -1
  182. package/dist/mcp-render.js +64 -45
  183. package/dist/mcp-render.js.map +1 -1
  184. package/dist/mcp-root.d.ts +37 -9
  185. package/dist/mcp-root.d.ts.map +1 -1
  186. package/dist/mcp-root.js +124 -25
  187. package/dist/mcp-root.js.map +1 -1
  188. package/dist/mcp-run.d.ts +18 -1
  189. package/dist/mcp-run.d.ts.map +1 -1
  190. package/dist/mcp-run.js +159 -55
  191. package/dist/mcp-run.js.map +1 -1
  192. package/dist/mcp-ws.js +4 -1
  193. package/dist/mcp-ws.js.map +1 -1
  194. package/dist/mcp.d.ts +18 -5
  195. package/dist/mcp.d.ts.map +1 -1
  196. package/dist/mcp.js +165 -82
  197. package/dist/mcp.js.map +1 -1
  198. package/dist/migrate-layout.d.ts +44 -0
  199. package/dist/migrate-layout.d.ts.map +1 -0
  200. package/dist/migrate-layout.js +982 -0
  201. package/dist/migrate-layout.js.map +1 -0
  202. package/dist/openapi-tool.d.ts +2 -2
  203. package/dist/openapi-tool.d.ts.map +1 -1
  204. package/dist/openapi-tool.js.map +1 -1
  205. package/dist/openapi-types.d.ts +2 -1
  206. package/dist/openapi-types.d.ts.map +1 -1
  207. package/dist/openapi-types.js +240 -12
  208. package/dist/openapi-types.js.map +1 -1
  209. package/dist/pipeline-report.d.ts +3 -2
  210. package/dist/pipeline-report.d.ts.map +1 -1
  211. package/dist/pipeline-report.js +19 -16
  212. package/dist/pipeline-report.js.map +1 -1
  213. package/dist/port.d.ts +5 -4
  214. package/dist/port.d.ts.map +1 -1
  215. package/dist/port.js +46 -29
  216. package/dist/port.js.map +1 -1
  217. package/dist/project-facts.d.ts +28 -4
  218. package/dist/project-facts.d.ts.map +1 -1
  219. package/dist/project-facts.js +12 -2
  220. package/dist/project-facts.js.map +1 -1
  221. package/dist/recipes/4.0.0.d.ts +9 -0
  222. package/dist/recipes/4.0.0.d.ts.map +1 -0
  223. package/dist/recipes/4.0.0.js +34 -0
  224. package/dist/recipes/4.0.0.js.map +1 -0
  225. package/dist/recipes/index.d.ts.map +1 -1
  226. package/dist/recipes/index.js +2 -0
  227. package/dist/recipes/index.js.map +1 -1
  228. package/dist/release-verification.d.ts +11 -1
  229. package/dist/release-verification.d.ts.map +1 -1
  230. package/dist/release-verification.js +47 -4
  231. package/dist/release-verification.js.map +1 -1
  232. package/dist/review.d.ts +21 -0
  233. package/dist/review.d.ts.map +1 -0
  234. package/dist/review.js +823 -0
  235. package/dist/review.js.map +1 -0
  236. package/dist/route-types.d.ts +28 -0
  237. package/dist/route-types.d.ts.map +1 -0
  238. package/dist/route-types.js +106 -0
  239. package/dist/route-types.js.map +1 -0
  240. package/dist/rules/codes.d.ts +9 -0
  241. package/dist/rules/codes.d.ts.map +1 -1
  242. package/dist/rules/codes.js +9 -0
  243. package/dist/rules/codes.js.map +1 -1
  244. package/dist/rules/data-guard.d.ts +12 -0
  245. package/dist/rules/data-guard.d.ts.map +1 -0
  246. package/dist/rules/data-guard.js +213 -0
  247. package/dist/rules/data-guard.js.map +1 -0
  248. package/dist/rules/index.d.ts +3 -0
  249. package/dist/rules/index.d.ts.map +1 -1
  250. package/dist/rules/index.js +17 -5
  251. package/dist/rules/index.js.map +1 -1
  252. package/dist/rules/islands.d.ts.map +1 -1
  253. package/dist/rules/islands.js +7 -4
  254. package/dist/rules/islands.js.map +1 -1
  255. package/dist/rules/legacy.d.ts +3 -1
  256. package/dist/rules/legacy.d.ts.map +1 -1
  257. package/dist/rules/legacy.js +829 -34
  258. package/dist/rules/legacy.js.map +1 -1
  259. package/dist/rules/nano.d.ts.map +1 -1
  260. package/dist/rules/nano.js +15 -6
  261. package/dist/rules/nano.js.map +1 -1
  262. package/dist/rules/pages.d.ts +15 -0
  263. package/dist/rules/pages.d.ts.map +1 -0
  264. package/dist/rules/pages.js +88 -0
  265. package/dist/rules/pages.js.map +1 -0
  266. package/dist/rules/routes.d.ts +11 -0
  267. package/dist/rules/routes.d.ts.map +1 -1
  268. package/dist/rules/routes.js +180 -2
  269. package/dist/rules/routes.js.map +1 -1
  270. package/dist/rules/secrets.d.ts +10 -0
  271. package/dist/rules/secrets.d.ts.map +1 -0
  272. package/dist/rules/secrets.js +169 -0
  273. package/dist/rules/secrets.js.map +1 -0
  274. package/dist/rules/security.d.ts +7 -0
  275. package/dist/rules/security.d.ts.map +1 -1
  276. package/dist/rules/security.js +171 -65
  277. package/dist/rules/security.js.map +1 -1
  278. package/dist/rules/typescript.d.ts +16 -0
  279. package/dist/rules/typescript.d.ts.map +1 -0
  280. package/dist/rules/typescript.js +59 -0
  281. package/dist/rules/typescript.js.map +1 -0
  282. package/dist/rules/zones.d.ts +17 -0
  283. package/dist/rules/zones.d.ts.map +1 -0
  284. package/dist/rules/zones.js +174 -0
  285. package/dist/rules/zones.js.map +1 -0
  286. package/dist/scaffold.d.ts +25 -12
  287. package/dist/scaffold.d.ts.map +1 -1
  288. package/dist/scaffold.js +154 -90
  289. package/dist/scaffold.js.map +1 -1
  290. package/dist/sdk.d.ts +17 -3
  291. package/dist/sdk.d.ts.map +1 -1
  292. package/dist/sdk.js +941 -153
  293. package/dist/sdk.js.map +1 -1
  294. package/dist/search-terms.d.ts +2 -0
  295. package/dist/search-terms.d.ts.map +1 -1
  296. package/dist/search-terms.js +23 -8
  297. package/dist/search-terms.js.map +1 -1
  298. package/dist/smoke.d.ts +79 -0
  299. package/dist/smoke.d.ts.map +1 -0
  300. package/dist/smoke.js +494 -0
  301. package/dist/smoke.js.map +1 -0
  302. package/dist/sync-manifest.d.ts.map +1 -1
  303. package/dist/sync-manifest.js +2 -1
  304. package/dist/sync-manifest.js.map +1 -1
  305. package/dist/sync-routes.d.ts +4 -3
  306. package/dist/sync-routes.d.ts.map +1 -1
  307. package/dist/sync-routes.js +4 -3
  308. package/dist/sync-routes.js.map +1 -1
  309. package/dist/test-tool.d.ts.map +1 -1
  310. package/dist/test-tool.js +30 -11
  311. package/dist/test-tool.js.map +1 -1
  312. package/dist/types-search.d.ts.map +1 -1
  313. package/dist/types-search.js +11 -5
  314. package/dist/types-search.js.map +1 -1
  315. package/dist/upgrade.d.ts +36 -6
  316. package/dist/upgrade.d.ts.map +1 -1
  317. package/dist/upgrade.js +167 -29
  318. package/dist/upgrade.js.map +1 -1
  319. package/dist/verification-plan.d.ts.map +1 -1
  320. package/dist/verification-plan.js +8 -0
  321. package/dist/verification-plan.js.map +1 -1
  322. package/dist/verification.d.ts.map +1 -1
  323. package/dist/verification.js +45 -12
  324. package/dist/verification.js.map +1 -1
  325. package/dist/web-app-options.d.ts +19 -0
  326. package/dist/web-app-options.d.ts.map +1 -0
  327. package/dist/web-app-options.js +49 -0
  328. package/dist/web-app-options.js.map +1 -0
  329. package/dist/work-graph.d.ts +3 -0
  330. package/dist/work-graph.d.ts.map +1 -1
  331. package/dist/work-graph.js +33 -16
  332. package/dist/work-graph.js.map +1 -1
  333. package/docs/examples.json +185 -29
  334. package/docs/llms-full.txt +4123 -548
  335. package/docs/types.json +14391 -10060
  336. package/package.json +19 -11
  337. package/src/app-files.ts +40 -0
  338. package/src/assure-hydration.ts +139 -46
  339. package/src/assure.ts +17 -2
  340. package/src/capabilities-tool.ts +35 -9
  341. package/src/cdn-check.ts +268 -0
  342. package/src/check-diagnostics.ts +199 -797
  343. package/src/check-scan.ts +394 -63
  344. package/src/check.ts +146 -58
  345. package/src/cli.ts +347 -92
  346. package/src/command-catalog.ts +407 -45
  347. package/src/config-target.ts +47 -0
  348. package/src/contracts.ts +66 -15
  349. package/src/db-audit.ts +128 -0
  350. package/src/db-child.ts +285 -0
  351. package/src/db-config.ts +224 -0
  352. package/src/db-tool.ts +690 -0
  353. package/src/dev-bun-config.ts +116 -40
  354. package/src/dev-feed-tool.ts +912 -0
  355. package/src/dev-server-client.ts +267 -0
  356. package/src/diagnostics.ts +179 -0
  357. package/src/diff-tool.ts +6 -5
  358. package/src/docs-search.ts +10 -3
  359. package/src/doctor.ts +98 -15
  360. package/src/env-file.ts +78 -1
  361. package/src/fix-recipes.ts +44 -31
  362. package/src/frontend-guidance.ts +5 -5
  363. package/src/i18n-check.ts +179 -0
  364. package/src/init-agents.ts +301 -38
  365. package/src/internal/code-unit-order.ts +5 -0
  366. package/src/internal/script-kind.ts +11 -0
  367. package/src/internal/source-facts.ts +96 -22
  368. package/src/internal/typescript-import.ts +663 -68
  369. package/src/introspect.ts +93 -82
  370. package/src/learn.ts +3 -3
  371. package/src/levels-tool.ts +47 -2
  372. package/src/load.ts +144 -32
  373. package/src/manifest-tool.ts +3 -2
  374. package/src/mcp-app-tool.ts +2 -0
  375. package/src/mcp-context.ts +5 -7
  376. package/src/mcp-delegate.ts +162 -0
  377. package/src/mcp-docs-tools.ts +23 -5
  378. package/src/mcp-exec.ts +301 -161
  379. package/src/mcp-http.ts +22 -5
  380. package/src/mcp-io.ts +130 -0
  381. package/src/mcp-isolate.ts +287 -0
  382. package/src/mcp-path.ts +50 -0
  383. package/src/mcp-project-child.ts +182 -0
  384. package/src/mcp-reflect.ts +3 -2
  385. package/src/mcp-render.ts +72 -42
  386. package/src/mcp-root.ts +130 -24
  387. package/src/mcp-run.ts +190 -56
  388. package/src/mcp-ws.ts +4 -1
  389. package/src/mcp.ts +193 -91
  390. package/src/migrate-layout.ts +1121 -0
  391. package/src/openapi-tool.ts +7 -3
  392. package/src/openapi-types.ts +315 -13
  393. package/src/pipeline-report.ts +22 -18
  394. package/src/port.ts +53 -30
  395. package/src/project-facts.ts +42 -8
  396. package/src/recipes/4.0.0.ts +35 -0
  397. package/src/recipes/index.ts +2 -0
  398. package/src/release-verification.ts +74 -5
  399. package/src/review.ts +988 -0
  400. package/src/route-types.ts +133 -0
  401. package/src/rules/codes.ts +9 -0
  402. package/src/rules/data-guard.ts +230 -0
  403. package/src/rules/index.ts +18 -5
  404. package/src/rules/islands.ts +12 -4
  405. package/src/rules/legacy.ts +947 -35
  406. package/src/rules/nano.ts +18 -6
  407. package/src/rules/pages.ts +99 -0
  408. package/src/rules/routes.ts +181 -2
  409. package/src/rules/secrets.ts +174 -0
  410. package/src/rules/security.ts +198 -70
  411. package/src/rules/typescript.ts +77 -0
  412. package/src/rules/zones.ts +195 -0
  413. package/src/scaffold.ts +175 -98
  414. package/src/sdk.ts +1215 -158
  415. package/src/search-terms.ts +24 -7
  416. package/src/smoke.ts +607 -0
  417. package/src/sync-manifest.ts +2 -1
  418. package/src/sync-routes.ts +4 -3
  419. package/src/test-tool.ts +32 -11
  420. package/src/types-search.ts +11 -4
  421. package/src/upgrade.ts +205 -25
  422. package/src/verification-plan.ts +27 -0
  423. package/src/verification.ts +50 -12
  424. package/src/web-app-options.ts +57 -0
  425. package/src/work-graph.ts +40 -16
@@ -22,6 +22,7 @@ const ALIASES: Readonly<Record<string, readonly string[]>> = {
22
22
  cache: ["caching", "isr"],
23
23
  caching: ["cache", "isr"],
24
24
  client: ["typed"],
25
+ cookie: ["cookies", "session", "sessions"],
25
26
  deploy: ["deployment", "edge", "worker", "workers", "vercel", "deno"],
26
27
  deployment: ["deploy", "edge", "worker", "workers", "vercel", "deno"],
27
28
  loader: ["loaders"],
@@ -47,11 +48,18 @@ const ALIASES: Readonly<Record<string, readonly string[]>> = {
47
48
  export const MAX_QUERY_CHARS = 256
48
49
  export const MAX_QUERY_TERMS = 12
49
50
 
51
+ // A camelCase word also stays whole: "WebSockets" splits to "web" + "sockets", which a
52
+ // "websocket" query never matches.
53
+ const CAMEL_WORD = /[A-Za-z0-9]*[a-z0-9][A-Z][A-Za-z0-9]*/g
54
+
50
55
  export const tokenize = (s: string): string[] =>
51
- s
52
- .toLowerCase()
53
- .split(/[^a-z0-9]+/)
54
- .filter((t) => t.length > 1)
56
+ [
57
+ ...s
58
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
59
+ .toLowerCase()
60
+ .split(/[^a-z0-9]+/),
61
+ ...(s.match(CAMEL_WORD) ?? []).map((word) => word.toLowerCase()),
62
+ ].filter((t) => t.length > 1)
55
63
 
56
64
  export function queryTermGroups(query: string): SearchTermGroup[] {
57
65
  return [...new Set(tokenize(query.slice(0, MAX_QUERY_CHARS)))]
@@ -73,12 +81,21 @@ function tokenMatches(term: string, candidate: string): boolean {
73
81
  }
74
82
 
75
83
  export function tokenSetHas(tokens: ReadonlySet<string>, group: SearchTermGroup): boolean {
84
+ return tokenSetScore(tokens, group) > 0
85
+ }
86
+
87
+ /** Score the strongest name/heading match while keeping exact query words ahead of aliases. */
88
+ export function tokenSetScore(tokens: ReadonlySet<string>, group: SearchTermGroup): number {
89
+ let best = 0
76
90
  for (const variant of group.variants) {
77
- for (const token of tokens) {
78
- if (tokenMatches(variant, token)) return true
91
+ for (const candidate of tokens) {
92
+ if (!tokenMatches(variant, candidate)) continue
93
+ const score =
94
+ variant === group.term && candidate === variant ? 3 : candidate === variant ? 2 : 1
95
+ if (score > best) best = score
79
96
  }
80
97
  }
81
- return false
98
+ return best
82
99
  }
83
100
 
84
101
  export function countBodyHits(lowerBody: string, group: SearchTermGroup, maxHits: number): number {
package/src/smoke.ts ADDED
@@ -0,0 +1,607 @@
1
+ import { realpath, stat } from "node:fs/promises"
2
+ import { isAbsolute, relative, resolve } from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+
5
+ export interface SmokeApp {
6
+ fetch(request: Request): Response | Promise<Response>
7
+ }
8
+
9
+ export interface SmokeServer {
10
+ readonly origin: string
11
+ readonly fetch?: (request: Request) => Response | Promise<Response>
12
+ stop(): void | Promise<void>
13
+ }
14
+
15
+ export interface SmokeRequestCheck {
16
+ readonly id: string
17
+ readonly path: string
18
+ readonly method?: string
19
+ readonly headers?: Readonly<Record<string, string>>
20
+ readonly body?: string
21
+ readonly expectedStatus: number
22
+ readonly expectedHeaders?: Readonly<Record<string, string>>
23
+ readonly contentType?: "json" | "text"
24
+ }
25
+
26
+ export interface SmokeSsrCheck {
27
+ readonly path: string
28
+ readonly marker: string
29
+ readonly expectedStatus?: number
30
+ readonly expectedHeaders?: Readonly<Record<string, string>>
31
+ }
32
+
33
+ export interface SmokeHookContext {
34
+ readonly app: SmokeApp
35
+ readonly origin: string
36
+ request(check: SmokeRequestCheck): Promise<Response>
37
+ }
38
+
39
+ export interface SmokeContractResult {
40
+ readonly ok: boolean
41
+ readonly failures?: number
42
+ readonly gaps?: number
43
+ }
44
+
45
+ export interface SmokeFixture {
46
+ readonly app: SmokeApp
47
+ /** Starts the built production server. Omit only when explicitly using in-process mode. */
48
+ readonly start?: (app: SmokeApp) => SmokeServer | Promise<SmokeServer>
49
+ /** Required SSR document check. */
50
+ readonly ssr?: SmokeSsrCheck
51
+ /** At least one mounted/API witness is required for a green smoke run. */
52
+ readonly mountedApi?: readonly SmokeRequestCheck[]
53
+ /** Defaults to GET /__nifra_smoke_missing__. */
54
+ readonly notFound?: SmokeRequestCheck
55
+ /** Header checks, including CSP/security headers when the application configures them. */
56
+ readonly securityHeaders?: readonly SmokeRequestCheck[]
57
+ /** Optional callback/guard witnesses; credentials stay inside the fixture. */
58
+ readonly authCallbacks?: readonly SmokeRequestCheck[]
59
+ /** Optional response-contract laboratory hook. Its result is summarized without payloads. */
60
+ readonly contract?: (
61
+ context: SmokeHookContext,
62
+ ) => SmokeContractResult | Promise<SmokeContractResult>
63
+ /** Optional browser hydration hook supplied by the application/test environment. */
64
+ readonly hydration?: (context: SmokeHookContext) => void | Promise<void>
65
+ }
66
+
67
+ export interface SmokeRunOptions {
68
+ /** Allow a deliberately in-process run without a production server start. Default false. */
69
+ readonly inProcess?: boolean
70
+ }
71
+
72
+ export interface SmokeCheckResult {
73
+ readonly id: string
74
+ readonly status: "pass" | "fail" | "skip"
75
+ readonly category:
76
+ | "start"
77
+ | "ssr"
78
+ | "api"
79
+ | "not-found"
80
+ | "headers"
81
+ | "auth"
82
+ | "contract"
83
+ | "hydration"
84
+ readonly message?: string
85
+ }
86
+
87
+ export interface SmokeReport {
88
+ readonly version: 1
89
+ readonly ok: boolean
90
+ readonly mode: "production" | "in-process"
91
+ readonly checks: readonly SmokeCheckResult[]
92
+ readonly counts: {
93
+ readonly passed: number
94
+ readonly failed: number
95
+ readonly skipped: number
96
+ }
97
+ }
98
+
99
+ const safeId = (value: string, fallback: string): string =>
100
+ /^[A-Za-z0-9._-]{1,80}$/.test(value) ? value : fallback
101
+
102
+ const MAX_SMOKE_PATH_LENGTH = 4_096
103
+ const MAX_SMOKE_MARKER_LENGTH = 4_096
104
+ const MAX_SMOKE_BODY_LENGTH = 1_048_576
105
+ const MAX_SMOKE_RESPONSE_BODY_LENGTH = 1_048_576
106
+ const MAX_SMOKE_HEADER_NAME_LENGTH = 256
107
+ const MAX_SMOKE_HEADER_VALUE_LENGTH = 8_192
108
+ const HTTP_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
109
+ const SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/
110
+
111
+ function hasControlCharacter(value: string, includeBackslash = false): boolean {
112
+ for (let index = 0; index < value.length; index++) {
113
+ const code = value.charCodeAt(index)
114
+ if (code <= 0x1f || code === 0x7f || (includeBackslash && code === 0x5c)) return true
115
+ }
116
+ return false
117
+ }
118
+
119
+ function isRecord(value: unknown): value is Record<string, unknown> {
120
+ return typeof value === "object" && value !== null && !Array.isArray(value)
121
+ }
122
+
123
+ function validStatus(value: unknown): value is number {
124
+ return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
125
+ }
126
+
127
+ function validRelativePath(value: unknown): value is string {
128
+ return (
129
+ typeof value === "string" &&
130
+ value.length > 0 &&
131
+ value.length <= MAX_SMOKE_PATH_LENGTH &&
132
+ value.startsWith("/") &&
133
+ !value.startsWith("//") &&
134
+ !SCHEME_PREFIX.test(value) &&
135
+ !hasControlCharacter(value, true)
136
+ )
137
+ }
138
+
139
+ function headerError(value: unknown, label: string): string | undefined {
140
+ if (!isRecord(value)) return `${label} must be an object`
141
+ for (const [name, headerValue] of Object.entries(value)) {
142
+ if (name.length === 0 || name.length > MAX_SMOKE_HEADER_NAME_LENGTH || !HTTP_TOKEN.test(name))
143
+ return `${label} contains an invalid header name`
144
+ if (
145
+ typeof headerValue !== "string" ||
146
+ headerValue.length > MAX_SMOKE_HEADER_VALUE_LENGTH ||
147
+ hasControlCharacter(headerValue)
148
+ )
149
+ return `${label} contains an invalid header value`
150
+ }
151
+ try {
152
+ new Headers(value as Record<string, string>)
153
+ } catch {
154
+ return `${label} contains an invalid header`
155
+ }
156
+ return undefined
157
+ }
158
+
159
+ function requestCheckError(value: unknown, label: string): string | undefined {
160
+ if (!isRecord(value)) return `${label} must be an object`
161
+ if (typeof value.id !== "string" || !/^[A-Za-z0-9._-]{1,80}$/.test(value.id))
162
+ return `${label}.id must be a short safe identifier`
163
+ if (!validRelativePath(value.path)) return `${label}.path must be a local absolute path`
164
+ if (value.method !== undefined) {
165
+ if (
166
+ typeof value.method !== "string" ||
167
+ value.method.length === 0 ||
168
+ value.method.length > 32 ||
169
+ !HTTP_TOKEN.test(value.method)
170
+ )
171
+ return `${label}.method is invalid`
172
+ }
173
+ if (!validStatus(value.expectedStatus)) return `${label}.expectedStatus is invalid`
174
+ if (value.headers !== undefined) {
175
+ const error = headerError(value.headers, `${label}.headers`)
176
+ if (error !== undefined) return error
177
+ }
178
+ if (value.expectedHeaders !== undefined) {
179
+ const error = headerError(value.expectedHeaders, `${label}.expectedHeaders`)
180
+ if (error !== undefined) return error
181
+ }
182
+ if (
183
+ value.body !== undefined &&
184
+ (typeof value.body !== "string" || value.body.length > MAX_SMOKE_BODY_LENGTH)
185
+ )
186
+ return `${label}.body is invalid or too large`
187
+ if (
188
+ value.contentType !== undefined &&
189
+ value.contentType !== "json" &&
190
+ value.contentType !== "text"
191
+ )
192
+ return `${label}.contentType is invalid`
193
+ const method = typeof value.method === "string" ? value.method.toUpperCase() : "GET"
194
+ if (value.body !== undefined && (method === "GET" || method === "HEAD"))
195
+ return `${label}.body cannot be used with ${method}`
196
+ return undefined
197
+ }
198
+
199
+ function smokeSsrError(value: unknown): string | undefined {
200
+ if (!isRecord(value)) return "ssr check must be an object"
201
+ if (!validRelativePath(value.path)) return "ssr.path must be a local absolute path"
202
+ if (
203
+ typeof value.marker !== "string" ||
204
+ value.marker.length === 0 ||
205
+ value.marker.length > MAX_SMOKE_MARKER_LENGTH
206
+ )
207
+ return "ssr.marker must be non-empty and bounded"
208
+ if (value.expectedStatus !== undefined && !validStatus(value.expectedStatus))
209
+ return "ssr.expectedStatus is invalid"
210
+ if (value.expectedHeaders !== undefined)
211
+ return headerError(value.expectedHeaders, "ssr.expectedHeaders")
212
+ return undefined
213
+ }
214
+
215
+ function fixtureError(fixture: unknown, inProcess: boolean): string | undefined {
216
+ if (!isRecord(fixture)) return "fixture must export an object"
217
+ const app = fixture.app
218
+ if ((typeof app !== "object" && typeof app !== "function") || app === null)
219
+ return "fixture.app must be an object with fetch(request)"
220
+ if (typeof (app as { fetch?: unknown }).fetch !== "function")
221
+ return "fixture.app.fetch must be a function"
222
+ if (!inProcess && typeof fixture.start !== "function")
223
+ return "fixture.start must be a function in production mode"
224
+ if (inProcess && fixture.start !== undefined && typeof fixture.start !== "function")
225
+ return "fixture.start must be a function when provided"
226
+ if (fixture.ssr === undefined) return "SSR check is not configured"
227
+ const ssrError = smokeSsrError(fixture.ssr)
228
+ if (ssrError !== undefined) return ssrError
229
+
230
+ const mountedApi = fixture.mountedApi
231
+ if (!Array.isArray(mountedApi) || mountedApi.length === 0)
232
+ return "at least one mounted API witness is required"
233
+ for (let index = 0; index < mountedApi.length; index++) {
234
+ const error = requestCheckError(mountedApi[index], `mountedApi[${index}]`)
235
+ if (error !== undefined) return error
236
+ }
237
+
238
+ const notFound = fixture.notFound ?? {
239
+ id: "not-found",
240
+ path: "/__nifra_smoke_missing__",
241
+ expectedStatus: 404,
242
+ }
243
+ const notFoundError = requestCheckError(notFound, "notFound")
244
+ if (notFoundError !== undefined) return notFoundError
245
+ if (!isRecord(notFound) || notFound.expectedStatus !== 404)
246
+ return "notFound.expectedStatus must be 404"
247
+
248
+ for (const [field, category] of [
249
+ ["securityHeaders", "securityHeaders"],
250
+ ["authCallbacks", "authCallbacks"],
251
+ ] as const) {
252
+ const checks = fixture[field]
253
+ if (checks === undefined) continue
254
+ if (!Array.isArray(checks)) return `${category} must be an array`
255
+ for (let index = 0; index < checks.length; index++) {
256
+ const error = requestCheckError(checks[index], `${category}[${index}]`)
257
+ if (error !== undefined) return error
258
+ }
259
+ }
260
+ if (fixture.contract !== undefined && typeof fixture.contract !== "function")
261
+ return "contract must be a function"
262
+ if (fixture.hydration !== undefined && typeof fixture.hydration !== "function")
263
+ return "hydration must be a function"
264
+ return undefined
265
+ }
266
+
267
+ const responseContentType = (response: Response): string =>
268
+ response.headers.get("content-type")?.split(";", 1)[0] ?? ""
269
+
270
+ const expectedHeaderMatches = (
271
+ response: Response,
272
+ expected: Readonly<Record<string, string>> | undefined,
273
+ ): boolean =>
274
+ Object.entries(expected ?? {}).every(([name, value]) => response.headers.get(name) === value)
275
+
276
+ function responseLike(value: unknown): value is Response {
277
+ if (value === null || typeof value !== "object") return false
278
+ const candidate = value as {
279
+ readonly status?: unknown
280
+ readonly headers?: { readonly get?: unknown }
281
+ readonly text?: unknown
282
+ readonly body?: unknown
283
+ }
284
+ return (
285
+ typeof candidate.status === "number" &&
286
+ Number.isInteger(candidate.status) &&
287
+ candidate.status >= 100 &&
288
+ candidate.status <= 599 &&
289
+ typeof candidate.headers?.get === "function" &&
290
+ typeof candidate.text === "function" &&
291
+ (candidate.body === null || typeof candidate.body === "object")
292
+ )
293
+ }
294
+
295
+ async function boundedResponseText(
296
+ response: Response,
297
+ ): Promise<{ readonly text?: string; readonly tooLarge: boolean }> {
298
+ if (response.body === null) return { text: "", tooLarge: false }
299
+ const reader = response.body.getReader()
300
+ const chunks: Uint8Array[] = []
301
+ let total = 0
302
+ try {
303
+ for (;;) {
304
+ const next = await reader.read()
305
+ if (next.done) break
306
+ total += next.value.byteLength
307
+ if (total > MAX_SMOKE_RESPONSE_BODY_LENGTH) {
308
+ try {
309
+ await reader.cancel()
310
+ } catch {
311
+ // The response is already a failed witness; cancellation errors are not actionable here.
312
+ }
313
+ return { tooLarge: true }
314
+ }
315
+ chunks.push(next.value)
316
+ }
317
+ } finally {
318
+ reader.releaseLock()
319
+ }
320
+ const bytes = new Uint8Array(total)
321
+ let offset = 0
322
+ for (const chunk of chunks) {
323
+ bytes.set(chunk, offset)
324
+ offset += chunk.byteLength
325
+ }
326
+ return { text: new TextDecoder().decode(bytes), tooLarge: false }
327
+ }
328
+
329
+ function requestFor(origin: string, check: SmokeRequestCheck): Request {
330
+ const method = check.method ?? "GET"
331
+ const headers = new Headers(check.headers)
332
+ return new Request(new URL(check.path, origin).href, {
333
+ method,
334
+ headers,
335
+ ...(check.body === undefined ? {} : { body: check.body }),
336
+ })
337
+ }
338
+
339
+ function validateRequestCheck(
340
+ check: SmokeRequestCheck,
341
+ response: Response,
342
+ ): Promise<{ readonly ok: boolean; readonly message?: string }> {
343
+ return (async () => {
344
+ if (!responseLike(response)) return { ok: false, message: "fetch returned an invalid response" }
345
+ if (response.status !== check.expectedStatus) {
346
+ return { ok: false, message: `status ${response.status}, expected ${check.expectedStatus}` }
347
+ }
348
+ if (!expectedHeaderMatches(response, check.expectedHeaders)) {
349
+ return { ok: false, message: "expected header missing or mismatched" }
350
+ }
351
+ if (check.contentType !== undefined) {
352
+ const expected = check.contentType === "json" ? "application/json" : "text/plain"
353
+ if (responseContentType(response) !== expected) {
354
+ return { ok: false, message: `content type ${responseContentType(response) || "missing"}` }
355
+ }
356
+ if (check.contentType === "json") {
357
+ const body = await boundedResponseText(response)
358
+ if (body.tooLarge) return { ok: false, message: "response body is too large" }
359
+ try {
360
+ JSON.parse(body.text ?? "")
361
+ } catch {
362
+ return { ok: false, message: "response is not valid JSON" }
363
+ }
364
+ }
365
+ }
366
+ return { ok: true }
367
+ })()
368
+ }
369
+
370
+ /** Run the safe, bounded production smoke checks declared by one application fixture. */
371
+ export async function runSmoke(
372
+ fixture: SmokeFixture,
373
+ options: SmokeRunOptions = {},
374
+ ): Promise<SmokeReport> {
375
+ const checks: SmokeCheckResult[] = []
376
+ const add = (
377
+ category: SmokeCheckResult["category"],
378
+ id: string,
379
+ status: SmokeCheckResult["status"],
380
+ message?: string,
381
+ ): void => {
382
+ checks.push({
383
+ category,
384
+ id: safeId(id, `${category}-${checks.length + 1}`),
385
+ status,
386
+ ...(message === undefined ? {} : { message }),
387
+ })
388
+ }
389
+
390
+ const inProcess = options.inProcess === true
391
+ const invalidFixture = fixtureError(fixture, inProcess)
392
+ if (invalidFixture !== undefined) {
393
+ add("start", "fixture", "fail", invalidFixture)
394
+ return reportOf(checks, inProcess ? "in-process" : "production")
395
+ }
396
+ let server: SmokeServer | undefined
397
+ try {
398
+ if (inProcess) {
399
+ add("start", "in-process", "pass")
400
+ } else {
401
+ try {
402
+ server = await fixture.start!(fixture.app)
403
+ if (
404
+ server === null ||
405
+ typeof server.origin !== "string" ||
406
+ server.origin.trim() === "" ||
407
+ typeof server.stop !== "function" ||
408
+ (server.fetch !== undefined && typeof server.fetch !== "function")
409
+ ) {
410
+ add("start", "production-start", "fail", "start function returned an invalid server")
411
+ return reportOf(checks, "production")
412
+ }
413
+ const originUrl = new URL(server.origin)
414
+ if (
415
+ (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") ||
416
+ originUrl.username !== "" ||
417
+ originUrl.password !== "" ||
418
+ originUrl.pathname !== "/" ||
419
+ originUrl.search !== "" ||
420
+ originUrl.hash !== ""
421
+ ) {
422
+ add("start", "production-start", "fail", "production server origin is invalid")
423
+ return reportOf(checks, "production")
424
+ }
425
+ add("start", "production-start", "pass")
426
+ } catch {
427
+ add("start", "production-start", "fail", "production server failed to start")
428
+ return reportOf(checks, "production")
429
+ }
430
+ }
431
+
432
+ const origin = server?.origin ?? "http://nifra-smoke.invalid"
433
+ const fetchRequest = async (check: SmokeRequestCheck): Promise<Response> => {
434
+ const request = requestFor(origin, check)
435
+ return server?.fetch !== undefined
436
+ ? await server.fetch(request)
437
+ : inProcess
438
+ ? await fixture.app.fetch(request)
439
+ : await globalThis.fetch(request)
440
+ }
441
+ const hookContext: SmokeHookContext = { app: fixture.app, origin, request: fetchRequest }
442
+
443
+ if (fixture.ssr === undefined) {
444
+ add("ssr", "ssr", "fail", "SSR check is not configured")
445
+ } else {
446
+ try {
447
+ const response = await fetchRequest({
448
+ id: "ssr",
449
+ path: fixture.ssr.path,
450
+ expectedStatus: fixture.ssr.expectedStatus ?? 200,
451
+ ...(fixture.ssr.expectedHeaders === undefined
452
+ ? {}
453
+ : { expectedHeaders: fixture.ssr.expectedHeaders }),
454
+ })
455
+ if (!responseLike(response)) {
456
+ add("ssr", "ssr", "fail", "fetch returned an invalid response")
457
+ } else if (response.status !== (fixture.ssr.expectedStatus ?? 200)) {
458
+ add("ssr", "ssr", "fail", `status ${response.status}`)
459
+ } else if (responseContentType(response) !== "text/html") {
460
+ add("ssr", "ssr", "fail", "response is not HTML")
461
+ } else {
462
+ const body = await boundedResponseText(response)
463
+ if (body.tooLarge) add("ssr", "ssr", "fail", "response body is too large")
464
+ else if (!(body.text ?? "").includes(fixture.ssr.marker))
465
+ add("ssr", "ssr", "fail", "SSR marker is missing")
466
+ else if (!expectedHeaderMatches(response, fixture.ssr.expectedHeaders))
467
+ add("ssr", "ssr", "fail", "expected header missing or mismatched")
468
+ else add("ssr", "ssr", "pass")
469
+ }
470
+ } catch {
471
+ add("ssr", "ssr", "fail", "SSR request failed")
472
+ }
473
+ }
474
+
475
+ const mountedApi = fixture.mountedApi
476
+ if (mountedApi === undefined || mountedApi.length === 0) {
477
+ add("api", "mounted-api", "fail", "at least one mounted API witness is required")
478
+ } else {
479
+ for (const check of mountedApi) {
480
+ try {
481
+ const result = await validateRequestCheck(check, await fetchRequest(check))
482
+ add("api", check.id, result.ok ? "pass" : "fail", result.message)
483
+ } catch {
484
+ add("api", check.id, "fail", "mounted API request failed")
485
+ }
486
+ }
487
+ }
488
+
489
+ const notFound = fixture.notFound ?? {
490
+ id: "not-found",
491
+ path: "/__nifra_smoke_missing__",
492
+ expectedStatus: 404,
493
+ }
494
+ try {
495
+ const result = await validateRequestCheck(notFound, await fetchRequest(notFound))
496
+ add("not-found", notFound.id, result.ok ? "pass" : "fail", result.message)
497
+ } catch {
498
+ add("not-found", notFound.id, "fail", "404 request failed")
499
+ }
500
+
501
+ for (const check of fixture.securityHeaders ?? []) {
502
+ try {
503
+ const result = await validateRequestCheck(check, await fetchRequest(check))
504
+ add("headers", check.id, result.ok ? "pass" : "fail", result.message)
505
+ } catch {
506
+ add("headers", check.id, "fail", "security-header request failed")
507
+ }
508
+ }
509
+ if (fixture.securityHeaders === undefined) add("headers", "security-headers", "skip")
510
+
511
+ for (const check of fixture.authCallbacks ?? []) {
512
+ try {
513
+ const result = await validateRequestCheck(check, await fetchRequest(check))
514
+ add("auth", check.id, result.ok ? "pass" : "fail", result.message)
515
+ } catch {
516
+ add("auth", check.id, "fail", "auth callback request failed")
517
+ }
518
+ }
519
+ if (fixture.authCallbacks === undefined) add("auth", "auth-callbacks", "skip")
520
+
521
+ if (fixture.contract === undefined) {
522
+ add("contract", "response-contract", "skip")
523
+ } else {
524
+ try {
525
+ const result = await fixture.contract(hookContext)
526
+ if (
527
+ result === undefined ||
528
+ typeof result !== "object" ||
529
+ result === null ||
530
+ typeof result.ok !== "boolean" ||
531
+ (result.failures !== undefined &&
532
+ (!Number.isInteger(result.failures) || result.failures < 0)) ||
533
+ (result.gaps !== undefined && (!Number.isInteger(result.gaps) || result.gaps < 0))
534
+ ) {
535
+ add("contract", "response-contract", "fail", "contract hook returned no verdict")
536
+ } else if (!result.ok || (result.failures ?? 0) > 0 || (result.gaps ?? 0) > 0) {
537
+ add("contract", "response-contract", "fail", "contract witnesses failed")
538
+ } else {
539
+ add("contract", "response-contract", "pass")
540
+ }
541
+ } catch {
542
+ add("contract", "response-contract", "fail", "contract hook failed")
543
+ }
544
+ }
545
+
546
+ if (fixture.hydration === undefined) {
547
+ add("hydration", "hydration", "skip")
548
+ } else {
549
+ try {
550
+ await fixture.hydration(hookContext)
551
+ add("hydration", "hydration", "pass")
552
+ } catch {
553
+ add("hydration", "hydration", "fail", "hydration hook failed")
554
+ }
555
+ }
556
+ } finally {
557
+ if (server !== undefined) {
558
+ try {
559
+ await server.stop()
560
+ } catch {
561
+ add("start", "production-stop", "fail", "production server failed to stop")
562
+ }
563
+ }
564
+ }
565
+ return reportOf(checks, inProcess ? "in-process" : "production")
566
+ }
567
+
568
+ function reportOf(checks: readonly SmokeCheckResult[], mode: SmokeReport["mode"]): SmokeReport {
569
+ const passed = checks.filter((check) => check.status === "pass").length
570
+ const failed = checks.filter((check) => check.status === "fail").length
571
+ const skipped = checks.length - passed - failed
572
+ return {
573
+ version: 1,
574
+ ok: failed === 0,
575
+ mode,
576
+ checks: Object.freeze([...checks]),
577
+ counts: { passed, failed, skipped },
578
+ }
579
+ }
580
+
581
+ /** Load a user-owned smoke fixture without executing arbitrary project code during catalog/help. */
582
+ export async function loadSmokeFixture(
583
+ cwd: string,
584
+ fixturePath = "nifra.smoke.ts",
585
+ ): Promise<SmokeFixture> {
586
+ if (typeof fixturePath !== "string" || fixturePath.length === 0 || fixturePath.length > 256)
587
+ throw new Error("smoke fixture path must be a non-empty bounded project-relative path")
588
+ const path = resolve(cwd, fixturePath)
589
+ const projectRoot = await realpath(cwd).catch(() => undefined)
590
+ const fixtureRoot = await realpath(path).catch(() => undefined)
591
+ if (projectRoot === undefined || fixtureRoot === undefined)
592
+ throw new Error("smoke fixture not found")
593
+ const relativeFixture = relative(projectRoot, fixtureRoot)
594
+ if (relativeFixture === "" || relativeFixture.startsWith("..") || isAbsolute(relativeFixture))
595
+ throw new Error("smoke fixture must be inside the project")
596
+ const fixtureStats = await stat(fixtureRoot).catch(() => undefined)
597
+ if (fixtureStats === undefined || !fixtureStats.isFile())
598
+ throw new Error("smoke fixture must resolve to a file")
599
+ const specifier = pathToFileURL(fixtureRoot)
600
+ specifier.searchParams.set("nifra-smoke", String(Date.now()))
601
+ const loaded = (await import(specifier.href)) as { default?: unknown }
602
+ const fixture = loaded.default
603
+ if (fixture === null || typeof fixture !== "object") {
604
+ throw new Error("smoke fixture must default-export an object")
605
+ }
606
+ return fixture as SmokeFixture
607
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { join } from "node:path"
14
14
  import { Glob } from "bun"
15
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
15
16
 
16
17
  // The marker `generateServerManifest` writes at the top of the file - so a user file merely named
17
18
  // `server-manifest.ts` is never mistaken for a generated one. Mirrors `nifra check`'s drift scan.
@@ -77,7 +78,7 @@ export async function syncServerManifests(cwd: string): Promise<SyncManifestResu
77
78
  const drift = diffManifestRoutes(before, after)
78
79
  results.push({ file: rel, changed, added: drift.missing, removed: drift.extra })
79
80
  }
80
- return results.sort((a, b) => a.file.localeCompare(b.file))
81
+ return results.sort((a, b) => codeUnitOrder(a.file, b.file))
81
82
  }
82
83
 
83
84
  /** CLI entry: sync every generated manifest, print a per-file summary + the client-rebuild caveat. */
@@ -24,9 +24,10 @@ export interface SyncRoutesResult {
24
24
  }
25
25
 
26
26
  /**
27
- * Generate the route-search types `.d.ts` from `<cwd>/routes` (the nifra convention). Returns `null` when
28
- * there is no `routes/` directory (an API-only project - nothing to type). Never loads `framework.ts`: it
29
- * needs only the route tree, so it works before a build and on a broken framework config.
27
+ * Generate the route-search types `.d.ts` from `<cwd>/routes` (the nifra convention). Returns
28
+ * `null` when there is no `routes/` directory (an API-only project - nothing to type). Never loads
29
+ * `backend/framework.ts`: it needs only the route tree, so it works before a build and on a broken
30
+ * framework config.
30
31
  */
31
32
  export async function syncRouteTypes(cwd: string): Promise<SyncRoutesResult | null> {
32
33
  const routesDir = join(cwd, "routes")