@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
@@ -0,0 +1,1240 @@
1
+ /**
2
+ * Output schemas from a route's own return types. `loaderOutput` and `actionOutput` stay the reviewed
3
+ * allow-list of what reaches the browser: nothing here runs at request time, and no schema is derived
4
+ * implicitly. `nifra schemas` reads each loader's and action's TypeScript return type, writes a
5
+ * schema for review where a route has none, keeps the schemas it wrote current, and names the
6
+ * returned fields a hand-written schema drops. `nifra check` (NF-C033) fails on a generated schema
7
+ * whose route has changed what it returns since.
8
+ */
9
+
10
+ import { createHash } from "node:crypto"
11
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
12
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
13
+ import { basename, join } from "node:path"
14
+ import { isSensitiveFieldName } from "@nifrajs/web/zones"
15
+ import type * as TSApi from "typescript"
16
+ import { codePositionMask } from "./check-scan.ts"
17
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
18
+ import {
19
+ loadProjectTypeScript,
20
+ type TypeScriptApi,
21
+ type TypeScriptSemanticSession,
22
+ } from "./internal/typescript-import.ts"
23
+ import { exported, expressionEnd } from "./rules/data-guard.ts"
24
+
25
+ export type RouteFn = "loader" | "action"
26
+ export type OutputExport = "loaderOutput" | "actionOutput"
27
+
28
+ const PAIRS: ReadonlyArray<readonly [RouteFn, OutputExport]> = [
29
+ ["loader", "loaderOutput"],
30
+ ["action", "actionOutput"],
31
+ ]
32
+
33
+ /** The comment that marks a schema `nifra schemas` wrote, with the hash of what it wrote. */
34
+ export const GENERATED_MARK = "nifra:generated"
35
+ const MARKER = /^[ \t]*\/\/ nifra:generated ([0-9a-f]{8})\b/
36
+
37
+ /** A JSON-side reading of a TypeScript type: what a schema for it would have to describe. */
38
+ export type Shape =
39
+ | { readonly kind: "string" | "number" | "boolean" | "null" | "date" | "undefined" | "never" }
40
+ | { readonly kind: "json"; readonly reason?: string }
41
+ | { readonly kind: "literal"; readonly value: string | number | boolean }
42
+ | { readonly kind: "array"; readonly items: Shape }
43
+ | { readonly kind: "tuple"; readonly items: readonly Shape[] }
44
+ | { readonly kind: "object"; readonly fields: readonly Field[] }
45
+ | { readonly kind: "record"; readonly values: Shape }
46
+ | { readonly kind: "union"; readonly members: readonly Shape[] }
47
+ | { readonly kind: "deferred"; readonly value: Shape }
48
+ | { readonly kind: "opaque"; readonly what: string }
49
+
50
+ export interface Field {
51
+ readonly name: string
52
+ readonly optional: boolean
53
+ readonly shape: Shape
54
+ }
55
+
56
+ const FLAG_NAMES = [
57
+ "Any",
58
+ "Unknown",
59
+ "Never",
60
+ "Undefined",
61
+ "Void",
62
+ "Null",
63
+ "StringLiteral",
64
+ "NumberLiteral",
65
+ "BooleanLiteral",
66
+ "StringLike",
67
+ "NumberLike",
68
+ "BooleanLike",
69
+ "BigIntLike",
70
+ "ESSymbolLike",
71
+ "Union",
72
+ "Intersection",
73
+ "Object",
74
+ "NonPrimitive",
75
+ ] as const
76
+ type Flags = Readonly<Record<(typeof FLAG_NAMES)[number], number>>
77
+
78
+ const flagsFrom = (read: (name: string) => unknown): Flags => {
79
+ const at = (name: (typeof FLAG_NAMES)[number]): number => {
80
+ const value = read(name)
81
+ return typeof value === "number" ? value : 0
82
+ }
83
+ return {
84
+ Any: at("Any"),
85
+ Unknown: at("Unknown"),
86
+ Never: at("Never"),
87
+ Undefined: at("Undefined"),
88
+ Void: at("Void"),
89
+ Null: at("Null"),
90
+ StringLiteral: at("StringLiteral"),
91
+ NumberLiteral: at("NumberLiteral"),
92
+ BooleanLiteral: at("BooleanLiteral"),
93
+ StringLike: at("StringLike"),
94
+ NumberLike: at("NumberLike"),
95
+ BooleanLike: at("BooleanLike"),
96
+ BigIntLike: at("BigIntLike"),
97
+ ESSymbolLike: at("ESSymbolLike"),
98
+ Union: at("Union"),
99
+ Intersection: at("Intersection"),
100
+ Object: at("Object"),
101
+ NonPrimitive: at("NonPrimitive"),
102
+ }
103
+ }
104
+
105
+ interface Property<T> {
106
+ readonly name: string
107
+ readonly optional: boolean
108
+ readonly type: T
109
+ }
110
+
111
+ /** The checker calls the walker needs, over TypeScript 5/6's synchronous API or 7's asynchronous one. */
112
+ interface TypeReader<T> {
113
+ readonly flag: Flags
114
+ flags(type: T): number
115
+ members(type: T): Promise<readonly T[]>
116
+ symbolName(type: T): Promise<string | undefined>
117
+ aliasName(type: T): Promise<string | undefined>
118
+ literal(type: T): Promise<string | number | boolean | undefined>
119
+ arrayElement(type: T): Promise<T | undefined>
120
+ tupleElements(type: T): Promise<readonly T[] | undefined>
121
+ typeArguments(type: T): Promise<readonly T[]>
122
+ properties(type: T): Promise<readonly Property<T>[]>
123
+ stringIndex(type: T): Promise<T | undefined>
124
+ callable(type: T): Promise<boolean>
125
+ text(type: T): Promise<string>
126
+ }
127
+
128
+ const MAX_DEPTH = 12
129
+ const JSON_ALIASES = new Set(["JsonValue", "JSONValue", "Json"])
130
+ const NOT_JSON = new Set([
131
+ "Map",
132
+ "Set",
133
+ "WeakMap",
134
+ "WeakSet",
135
+ "RegExp",
136
+ "Error",
137
+ "Promise",
138
+ "URL",
139
+ "URLSearchParams",
140
+ "Headers",
141
+ "Request",
142
+ "Response",
143
+ "Blob",
144
+ "File",
145
+ "FormData",
146
+ "ArrayBuffer",
147
+ "SharedArrayBuffer",
148
+ "DataView",
149
+ "ReadableStream",
150
+ "WritableStream",
151
+ "Function",
152
+ ])
153
+ const TYPED_ARRAY = /^(?:Big)?(?:Int|Uint|Float)(?:8|16|32|64)(?:Clamped)?Array$/
154
+
155
+ const sameShape = (a: Shape, b: Shape): boolean => JSON.stringify(a) === JSON.stringify(b)
156
+
157
+ function unionOf(input: readonly Shape[]): Shape {
158
+ const flat = input
159
+ .flatMap((member) => (member.kind === "union" ? member.members : [member]))
160
+ .filter((member) => member.kind !== "never")
161
+ const json = flat.find((member) => member.kind === "json")
162
+ if (json !== undefined) return json
163
+ const isBool = (member: Shape): boolean =>
164
+ member.kind === "literal" && typeof member.value === "boolean"
165
+ const both =
166
+ flat.some((m) => m.kind === "literal" && m.value === true) &&
167
+ flat.some((m) => m.kind === "literal" && m.value === false)
168
+ const members: Shape[] = []
169
+ for (const member of flat) {
170
+ const next: Shape = both && isBool(member) ? { kind: "boolean" } : member
171
+ if (!members.some((seen) => sameShape(seen, next))) members.push(next)
172
+ }
173
+ if (members.length === 0) return { kind: "never" }
174
+ if (members.length === 1 && members[0] !== undefined) return members[0]
175
+ // TypeScript versions order union members differently; a canonical order keeps the generated text,
176
+ // and the hash in its marker, the same under each. `null` reads best last.
177
+ const rank = (shape: Shape): string =>
178
+ `${shape.kind === "null" || shape.kind === "undefined" ? 1 : 0}${JSON.stringify(shape)}`
179
+ return { kind: "union", members: members.sort((a, b) => codeUnitOrder(rank(a), rank(b))) }
180
+ }
181
+
182
+ function withoutUndefined(shape: Shape): Shape {
183
+ if (shape.kind !== "union" || !shape.members.some((member) => member.kind === "undefined"))
184
+ return shape
185
+ return unionOf(shape.members.filter((member) => member.kind !== "undefined"))
186
+ }
187
+
188
+ async function shapeOf<T>(
189
+ reader: TypeReader<T>,
190
+ type: T,
191
+ seen: Set<T>,
192
+ depth: number,
193
+ ): Promise<Shape> {
194
+ if (depth > MAX_DEPTH) return { kind: "json", reason: "nested too deep to read" }
195
+ const f = reader.flag
196
+ const flags = reader.flags(type)
197
+ const is = (mask: number): boolean => (flags & mask) !== 0
198
+ if (is(f.Any)) return { kind: "json", reason: "any" }
199
+ if (is(f.Unknown)) return { kind: "json", reason: "unknown" }
200
+ if (is(f.Never)) return { kind: "never" }
201
+ if (is(f.Undefined | f.Void)) return { kind: "undefined" }
202
+ if (is(f.Null)) return { kind: "null" }
203
+ if (is(f.StringLiteral | f.NumberLiteral | f.BooleanLiteral)) {
204
+ const value = await reader.literal(type)
205
+ if (value !== undefined) return { kind: "literal", value }
206
+ }
207
+ if (is(f.StringLike)) return { kind: "string" }
208
+ if (is(f.NumberLike)) return { kind: "number" }
209
+ // `boolean` is the union `true | false` with the Boolean flag, so this precedes the union branch.
210
+ if (is(f.BooleanLike)) return { kind: "boolean" }
211
+ if (is(f.BigIntLike)) return { kind: "opaque", what: "a bigint" }
212
+ if (is(f.ESSymbolLike)) return { kind: "opaque", what: "a symbol" }
213
+ const alias = await reader.aliasName(type)
214
+ if (alias !== undefined && JSON_ALIASES.has(alias)) return { kind: "json" }
215
+ if (is(f.Union)) {
216
+ const members = await reader.members(type)
217
+ return unionOf(
218
+ await Promise.all(members.map((member) => shapeOf(reader, member, seen, depth + 1))),
219
+ )
220
+ }
221
+ if (seen.has(type)) return { kind: "json", reason: "a recursive type" }
222
+ if (is(f.Intersection)) {
223
+ // A branded primitive (`string & { __brand: "Id" }`) crosses the wire as the primitive.
224
+ for (const part of await reader.members(type)) {
225
+ if ((reader.flags(part) & (f.StringLike | f.NumberLike | f.BooleanLike)) !== 0)
226
+ return shapeOf(reader, part, seen, depth + 1)
227
+ }
228
+ }
229
+ if (!is(f.Object | f.Intersection | f.NonPrimitive))
230
+ return { kind: "opaque", what: await reader.text(type) }
231
+ const tuple = await reader.tupleElements(type)
232
+ if (tuple !== undefined) {
233
+ return {
234
+ kind: "tuple",
235
+ items: await Promise.all(tuple.map((item) => shapeOf(reader, item, seen, depth + 1))),
236
+ }
237
+ }
238
+ const element = await reader.arrayElement(type)
239
+ if (element !== undefined)
240
+ return { kind: "array", items: await shapeOf(reader, element, seen, depth + 1) }
241
+ const name = await reader.symbolName(type)
242
+ if (name === "Date") return { kind: "date" }
243
+ if (name !== undefined && (NOT_JSON.has(name) || TYPED_ARRAY.test(name)))
244
+ return { kind: "opaque", what: `a ${name}` }
245
+ if (await reader.callable(type)) return { kind: "opaque", what: "a function" }
246
+ seen.add(type)
247
+ try {
248
+ const properties = await reader.properties(type)
249
+ // A `Response`, or nifra's own response carrier (`redirect()`, `status()`): control flow, not data.
250
+ if (properties.some((property) => property.name === "toResponse"))
251
+ return { kind: "opaque", what: "a Response" }
252
+ const promise = properties.find((property) => property.name === "promise")
253
+ if (promise !== undefined && properties.some((p) => p.name === "__nifra_deferred")) {
254
+ const [inner] = await reader.typeArguments(promise.type)
255
+ return {
256
+ kind: "deferred",
257
+ value:
258
+ inner === undefined
259
+ ? { kind: "json", reason: "unknown" }
260
+ : await shapeOf(reader, inner, seen, depth + 1),
261
+ }
262
+ }
263
+ const index = await reader.stringIndex(type)
264
+ if (index !== undefined && properties.length === 0)
265
+ return { kind: "record", values: await shapeOf(reader, index, seen, depth + 1) }
266
+ const fields: Field[] = []
267
+ for (const property of properties) {
268
+ if (property.name.startsWith("__@") || (await reader.callable(property.type))) continue
269
+ const shape = await shapeOf(reader, property.type, seen, depth + 1)
270
+ // `key?: undefined` is how TypeScript lines up a union of object literals: a key the other
271
+ // members have and this one never sends.
272
+ if (shape.kind === "undefined" || shape.kind === "never") continue
273
+ const defined = withoutUndefined(shape)
274
+ fields.push({
275
+ name: property.name,
276
+ optional: property.optional || defined !== shape,
277
+ shape: defined,
278
+ })
279
+ }
280
+ return { kind: "object", fields }
281
+ } finally {
282
+ seen.delete(type)
283
+ }
284
+ }
285
+
286
+ /** What a loader's return type sends: control-flow returns (nothing, a `Response`) carry no data. */
287
+ function dataShape(shape: Shape): Shape {
288
+ const members = shape.kind === "union" ? shape.members : [shape]
289
+ return unionOf(
290
+ members.filter(
291
+ (member) =>
292
+ member.kind !== "undefined" && !(member.kind === "opaque" && member.what === "a Response"),
293
+ ),
294
+ )
295
+ }
296
+
297
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
298
+ const note = (text: string): string => text.replaceAll("*/", "* /")
299
+
300
+ /** `shape` as `t.*` source, objects one field per line below `indent`. */
301
+ export function schemaSource(shape: Shape, indent = ""): string {
302
+ switch (shape.kind) {
303
+ case "string":
304
+ case "number":
305
+ case "boolean":
306
+ case "null":
307
+ case "date":
308
+ return `t.${shape.kind}()`
309
+ case "undefined":
310
+ case "never":
311
+ return "t.null()"
312
+ case "json":
313
+ return shape.reason === undefined
314
+ ? "t.json()"
315
+ : `t.json() /* ${note(shape.reason)} in the source type */`
316
+ case "opaque":
317
+ return `t.json() /* ${note(shape.what)}, which is not JSON */`
318
+ case "literal":
319
+ return `t.literal(${JSON.stringify(shape.value)})`
320
+ case "array":
321
+ return `t.array(${schemaSource(shape.items, indent)})`
322
+ case "tuple":
323
+ return `t.array(${schemaSource(unionOf(shape.items), indent)})`
324
+ case "record":
325
+ return `t.record(${schemaSource(shape.values, indent)})`
326
+ case "deferred":
327
+ return `t.deferred(${schemaSource(shape.value, indent)})`
328
+ case "union":
329
+ return `t.union([${shape.members.map((member) => schemaSource(member, indent)).join(", ")}])`
330
+ case "object": {
331
+ if (shape.fields.length === 0) return "t.object({})"
332
+ const inner = `${indent} `
333
+ const lines = shape.fields.map((field) => {
334
+ const key = IDENTIFIER.test(field.name) ? field.name : JSON.stringify(field.name)
335
+ if (isSensitiveFieldName(field.name))
336
+ return `${inner}// left out: ${key} - a credential-like name; declare it with t.declassified("why", ...) if it must reach the browser`
337
+ if (field.shape.kind === "opaque")
338
+ return `${inner}// left out: ${key} - ${note(field.shape.what)}, which is not JSON`
339
+ const value = schemaSource(field.shape, inner)
340
+ return `${inner}${key}: ${field.optional ? `t.optional(${value})` : value},`
341
+ })
342
+ return `t.object({\n${lines.join("\n")}\n${indent}})`
343
+ }
344
+ }
345
+ }
346
+
347
+ /** The paths a draft of `shape` leaves out: credential-like names, and values that are not JSON. */
348
+ export function omittedPaths(shape: Shape, path = ""): string[] {
349
+ switch (shape.kind) {
350
+ case "array":
351
+ return omittedPaths(shape.items, `${path}[]`)
352
+ case "tuple":
353
+ return omittedPaths(unionOf(shape.items), `${path}[]`)
354
+ case "record":
355
+ return omittedPaths(shape.values, `${path}{}`)
356
+ case "deferred":
357
+ return omittedPaths(shape.value, path)
358
+ case "union":
359
+ return [...new Set(shape.members.flatMap((member) => omittedPaths(member, path)))]
360
+ case "object":
361
+ return shape.fields.flatMap((field) => {
362
+ const at = path === "" ? field.name : `${path}.${field.name}`
363
+ return isSensitiveFieldName(field.name) || field.shape.kind === "opaque"
364
+ ? [at]
365
+ : omittedPaths(field.shape, at)
366
+ })
367
+ default:
368
+ return []
369
+ }
370
+ }
371
+
372
+ /** The paths a draft of `shape` admits any value at: `any` or `unknown` there, or a type too deep or
373
+ * recursive to read. The output guard drops nothing below them. */
374
+ export function openPaths(shape: Shape, path = ""): string[] {
375
+ switch (shape.kind) {
376
+ case "json":
377
+ return shape.reason === undefined ? [] : [path === "" ? "(the whole value)" : path]
378
+ case "array":
379
+ return openPaths(shape.items, `${path}[]`)
380
+ case "tuple":
381
+ return openPaths(unionOf(shape.items), `${path}[]`)
382
+ case "record":
383
+ return openPaths(shape.values, `${path}{}`)
384
+ case "deferred":
385
+ return openPaths(shape.value, path)
386
+ case "union":
387
+ return [...new Set(shape.members.flatMap((member) => openPaths(member, path)))]
388
+ case "object":
389
+ return shape.fields.flatMap((field) =>
390
+ isSensitiveFieldName(field.name) || field.shape.kind === "opaque"
391
+ ? []
392
+ : openPaths(field.shape, path === "" ? field.name : `${path}.${field.name}`),
393
+ )
394
+ default:
395
+ return []
396
+ }
397
+ }
398
+
399
+ /**
400
+ * The paths `returned` carries that `declared` does not, as `a.b`, `items[].c`: what the output guard
401
+ * drops before the page renders. A declared `t.json()` or record admits everything below it.
402
+ */
403
+ export function undeclaredPaths(returned: Shape, declared: Shape, path = ""): string[] {
404
+ if (declared.kind === "json" || declared.kind === "record") return []
405
+ if (returned.kind === "union") {
406
+ const found = returned.members.flatMap((member) => undeclaredPaths(member, declared, path))
407
+ return [...new Set(found)]
408
+ }
409
+ if (declared.kind === "union") {
410
+ const candidates = declared.members.filter((m) => m.kind !== "null" && m.kind !== "undefined")
411
+ if (candidates.some((m) => m.kind === "json" || m.kind === "record")) return []
412
+ const results = candidates.map((candidate) => undeclaredPaths(returned, candidate, path))
413
+ const [first, ...rest] = results
414
+ return first === undefined ? [] : first.filter((p) => rest.every((r) => r.includes(p)))
415
+ }
416
+ if (returned.kind === "deferred")
417
+ return undeclaredPaths(
418
+ returned.value,
419
+ declared.kind === "deferred" ? declared.value : declared,
420
+ path,
421
+ )
422
+ if (declared.kind === "deferred") return undeclaredPaths(returned, declared.value, path)
423
+ const items = (shape: Shape): Shape | undefined =>
424
+ shape.kind === "array" ? shape.items : shape.kind === "tuple" ? unionOf(shape.items) : undefined
425
+ const returnedItems = items(returned)
426
+ const declaredItems = items(declared)
427
+ if (returnedItems !== undefined && declaredItems !== undefined)
428
+ return undeclaredPaths(returnedItems, declaredItems, `${path}[]`)
429
+ if (returned.kind !== "object" || declared.kind !== "object") return []
430
+ const found: string[] = []
431
+ for (const field of returned.fields) {
432
+ const at = path === "" ? field.name : `${path}.${field.name}`
433
+ const match = declared.fields.find((candidate) => candidate.name === field.name)
434
+ if (match === undefined) found.push(at)
435
+ else found.push(...undeclaredPaths(field.shape, match.shape, at))
436
+ }
437
+ return found
438
+ }
439
+
440
+ const hashOf = (body: string): string => createHash("sha256").update(body).digest("hex").slice(0, 8)
441
+
442
+ /**
443
+ * A schema expression as a formatter cannot change it: comments, whitespace, a comma before a closing
444
+ * bracket and a trailing semicolon dropped, every string written with double quotes, and a quoted key
445
+ * that needs no quotes written bare. Two texts
446
+ * that read the same here are the same schema; an edit to what it declares is not.
447
+ */
448
+ function schemaTokens(text: string): string {
449
+ let out = ""
450
+ let i = 0
451
+ while (i < text.length) {
452
+ const c = text.charAt(i)
453
+ if (c === "/" && text[i + 1] === "/") {
454
+ const end = text.indexOf("\n", i)
455
+ i = end === -1 ? text.length : end
456
+ } else if (c === "/" && text[i + 1] === "*") {
457
+ const end = text.indexOf("*/", i + 2)
458
+ i = end === -1 ? text.length : end + 2
459
+ } else if (c === '"' || c === "'" || c === "`") {
460
+ let value = ""
461
+ let j = i + 1
462
+ while (j < text.length && text[j] !== c) {
463
+ if (text[j] === "\\") {
464
+ const next = text[j + 1] ?? ""
465
+ value += next === c ? next : `\\${next}`
466
+ j += 2
467
+ } else {
468
+ value += text[j]
469
+ j++
470
+ }
471
+ }
472
+ i = j + 1
473
+ let after = i
474
+ while (/\s/.test(text[after] ?? "")) after++
475
+ // `"name": …` and `name: …` declare the same key.
476
+ out +=
477
+ text[after] === ":" && IDENTIFIER.test(value) ? value : `"${value.replaceAll('"', '\\"')}"`
478
+ } else {
479
+ if (!/\s/.test(c)) out += c
480
+ i++
481
+ }
482
+ }
483
+ return out.replace(/,(?=[}\])])/g, "").replace(/;+$/, "")
484
+ }
485
+
486
+ function declarationText(schema: OutputExport, fn: RouteFn, body: string): string {
487
+ return [
488
+ `// ${GENERATED_MARK} ${hashOf(body)} - drafted from the ${fn}'s return type; review that the browser may see every field it declares. \`nifra schemas --write\` keeps it current; delete this line to edit it by hand.`,
489
+ `export const ${schema} = ${body}`,
490
+ ].join("\n")
491
+ }
492
+
493
+ // ---- reading the types -------------------------------------------------------------------------
494
+
495
+ interface ProbeRoute {
496
+ readonly file: string
497
+ readonly fns: readonly RouteFn[]
498
+ readonly schemas: readonly OutputExport[]
499
+ }
500
+
501
+ /** Shapes keyed `<file>#<export>`: a route function's return type, or a schema's output type. */
502
+ interface ProbeResult {
503
+ readonly shapes: ReadonlyMap<string, Shape>
504
+ readonly warnings: readonly string[]
505
+ }
506
+
507
+ const posix = (path: string): string => path.replaceAll("\\", "/")
508
+
509
+ const PROBE_HELPERS = [
510
+ "type __NifraReturned<M, F extends string> = M extends { readonly [K in F]: (...args: any) => infer R } ? Awaited<R> : never",
511
+ 'type __NifraDeclared<M, N extends string> = M extends { readonly [K in N]: infer S } ? S extends { readonly "~standard": { readonly types?: infer T } } ? NonNullable<T> extends { readonly output: infer O } ? O : never : never : never',
512
+ ]
513
+
514
+ function probeSource(routes: readonly ProbeRoute[]): {
515
+ readonly text: string
516
+ readonly names: ReadonlyMap<string, string>
517
+ } {
518
+ const lines = [...PROBE_HELPERS]
519
+ const names = new Map<string, string>()
520
+ routes.forEach((route, i) => {
521
+ const rel = posix(route.file)
522
+ lines.push(
523
+ `import type * as __nifra_m${i} from ${JSON.stringify(rel.startsWith(".") ? rel : `./${rel}`)}`,
524
+ )
525
+ for (const fn of route.fns) {
526
+ const name = `__nifra_r${i}_${fn}`
527
+ names.set(name, `${route.file}#${fn}`)
528
+ lines.push(`declare const ${name}: __NifraReturned<typeof __nifra_m${i}, "${fn}">`)
529
+ }
530
+ for (const schema of route.schemas) {
531
+ const name = `__nifra_d${i}_${schema}`
532
+ names.set(name, `${route.file}#${schema}`)
533
+ lines.push(`declare const ${name}: __NifraDeclared<typeof __nifra_m${i}, "${schema}">`)
534
+ }
535
+ })
536
+ return { text: `${lines.join("\n")}\n`, names }
537
+ }
538
+
539
+ function legacyReader(
540
+ ts: TypeScriptApi,
541
+ checker: TSApi.TypeChecker,
542
+ location: TSApi.Node,
543
+ ): TypeReader<TSApi.Type> {
544
+ const isReference = (type: TSApi.Type): type is TSApi.TypeReference => "target" in type
545
+ return {
546
+ flag: flagsFrom((name) => Reflect.get(ts.TypeFlags, name)),
547
+ flags: (type) => type.flags,
548
+ members: async (type) => (type.isUnionOrIntersection() ? type.types : []),
549
+ symbolName: async (type) => type.getSymbol()?.name,
550
+ aliasName: async (type) => type.aliasSymbol?.name,
551
+ literal: async (type) => {
552
+ if (type.isStringLiteral() || type.isNumberLiteral()) return type.value
553
+ if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0)
554
+ return checker.typeToString(type) === "true"
555
+ return undefined
556
+ },
557
+ arrayElement: async (type) =>
558
+ checker.isArrayType(type) && isReference(type)
559
+ ? checker.getTypeArguments(type)[0]
560
+ : undefined,
561
+ tupleElements: async (type) =>
562
+ checker.isTupleType(type) && isReference(type) ? checker.getTypeArguments(type) : undefined,
563
+ typeArguments: async (type) => (isReference(type) ? checker.getTypeArguments(type) : []),
564
+ properties: async (type) =>
565
+ checker.getPropertiesOfType(type).map((property) => ({
566
+ name: property.name,
567
+ optional: (property.flags & ts.SymbolFlags.Optional) !== 0,
568
+ type: checker.getTypeOfSymbolAtLocation(property, location),
569
+ })),
570
+ stringIndex: async (type) =>
571
+ checker
572
+ .getIndexInfosOfType(type)
573
+ .find((info) => (info.keyType.flags & ts.TypeFlags.StringLike) !== 0)?.type,
574
+ callable: async (type) => checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0,
575
+ text: async (type) => checker.typeToString(type),
576
+ }
577
+ }
578
+
579
+ interface Symbol7 {
580
+ readonly name: string
581
+ readonly flags: number
582
+ }
583
+
584
+ interface Type7 {
585
+ readonly flags: number
586
+ readonly value?: unknown
587
+ getSymbol(): Promise<Symbol7 | undefined>
588
+ getAliasSymbol?(): Promise<Symbol7 | undefined>
589
+ getTypes?(): Promise<readonly Type7[] | undefined>
590
+ }
591
+
592
+ interface Checker7 {
593
+ getTypeAtPosition(file: string, position: number): Promise<Type7 | undefined>
594
+ getTypeOfSymbol(symbol: Symbol7): Promise<Type7 | undefined>
595
+ getPropertiesOfType(type: Type7): Promise<readonly Symbol7[]>
596
+ getIndexInfosOfType(type: Type7): Promise<readonly { keyType: Type7; valueType: Type7 }[]>
597
+ getSignaturesOfType(type: Type7, kind: number): Promise<readonly unknown[]>
598
+ getTypeArguments(type: Type7): Promise<readonly Type7[]>
599
+ typeToString(type: Type7): Promise<string>
600
+ isTupleType(type: Type7): Promise<boolean>
601
+ isArrayType(type: Type7): Promise<boolean>
602
+ }
603
+
604
+ const CHECKER7_METHODS = [
605
+ "getTypeAtPosition",
606
+ "getTypeOfSymbol",
607
+ "getPropertiesOfType",
608
+ "getIndexInfosOfType",
609
+ "getSignaturesOfType",
610
+ "getTypeArguments",
611
+ "typeToString",
612
+ "isTupleType",
613
+ "isArrayType",
614
+ ] as const
615
+
616
+ function isChecker7(value: unknown): value is Checker7 {
617
+ return (
618
+ typeof value === "object" &&
619
+ value !== null &&
620
+ CHECKER7_METHODS.every((method) => typeof Reflect.get(value, method) === "function")
621
+ )
622
+ }
623
+
624
+ function reader7(semantic: TypeScriptSemanticSession, checker: Checker7): TypeReader<Type7> {
625
+ const flag = flagsFrom((name) => semantic.typeFlags[name])
626
+ const optional = semantic.symbolFlags.Optional
627
+ const call = semantic.signatureKind.Call
628
+ return {
629
+ flag,
630
+ flags: (type) => type.flags,
631
+ members: async (type) => (await type.getTypes?.()) ?? [],
632
+ symbolName: async (type) => (await type.getSymbol())?.name,
633
+ aliasName: async (type) => (await type.getAliasSymbol?.())?.name,
634
+ literal: async (type) => {
635
+ if ((type.flags & flag.BooleanLiteral) !== 0)
636
+ return (await checker.typeToString(type)) === "true"
637
+ return typeof type.value === "string" || typeof type.value === "number"
638
+ ? type.value
639
+ : undefined
640
+ },
641
+ arrayElement: async (type) =>
642
+ (await checker.isArrayType(type)) ? (await checker.getTypeArguments(type))[0] : undefined,
643
+ tupleElements: async (type) =>
644
+ (await checker.isTupleType(type)) ? checker.getTypeArguments(type) : undefined,
645
+ typeArguments: async (type) => {
646
+ try {
647
+ return await checker.getTypeArguments(type)
648
+ } catch {
649
+ return []
650
+ }
651
+ },
652
+ properties: async (type) => {
653
+ const out: Property<Type7>[] = []
654
+ for (const property of await checker.getPropertiesOfType(type)) {
655
+ const propertyType = await checker.getTypeOfSymbol(property)
656
+ if (propertyType === undefined) continue
657
+ out.push({
658
+ name: property.name,
659
+ optional: typeof optional === "number" && (property.flags & optional) !== 0,
660
+ type: propertyType,
661
+ })
662
+ }
663
+ return out
664
+ },
665
+ stringIndex: async (type) =>
666
+ (await checker.getIndexInfosOfType(type)).find(
667
+ (info) => (info.keyType.flags & flag.StringLike) !== 0,
668
+ )?.valueType,
669
+ callable: async (type) =>
670
+ typeof call === "number" && (await checker.getSignaturesOfType(type, call)).length > 0,
671
+ text: (type) => checker.typeToString(type),
672
+ }
673
+ }
674
+
675
+ /** Read every listed route function's return type and schema's output type through one program. */
676
+ async function readShapes(
677
+ root: string,
678
+ routes: readonly ProbeRoute[],
679
+ sources: ReadonlyMap<string, string>,
680
+ ): Promise<ProbeResult> {
681
+ const warnings: string[] = []
682
+ const shapes = new Map<string, Shape>()
683
+ if (routes.length === 0) return { shapes, warnings }
684
+ // TypeScript 7 resolves node handles only inside a configured project's root, so the probe and a
685
+ // short-lived config extending the app's sit at the root, and are removed afterwards.
686
+ const temp = await mkdtemp(join(root, ".nifra-schemas-"))
687
+ const tempName = basename(temp)
688
+ const probePath = join(root, `.${tempName}.ts`)
689
+ const probeConfigPath = join(root, `.${tempName}.tsconfig.json`)
690
+ const { text: probe, names } = probeSource(routes)
691
+ const routeFiles = routes.map((route) => join(root, route.file))
692
+ let close: (() => Promise<void>) | undefined
693
+ try {
694
+ await writeFile(probePath, probe, "utf8")
695
+ const include = [...routes.map((route) => posix(route.file)), basename(probePath)]
696
+ await writeFile(
697
+ probeConfigPath,
698
+ JSON.stringify(
699
+ existsSync(join(root, "tsconfig.json"))
700
+ ? {
701
+ extends: "./tsconfig.json",
702
+ compilerOptions: { allowImportingTsExtensions: true, noEmit: true },
703
+ include,
704
+ }
705
+ : {
706
+ compilerOptions: {
707
+ module: "ESNext",
708
+ moduleResolution: "Bundler",
709
+ target: "ESNext",
710
+ strict: true,
711
+ allowImportingTsExtensions: true,
712
+ noEmit: true,
713
+ skipLibCheck: true,
714
+ },
715
+ include,
716
+ },
717
+ ),
718
+ "utf8",
719
+ )
720
+ // The routes and every planned file the caller supplies are read from `sources`, not the disk:
721
+ // a migration plans against the layout it has not written yet.
722
+ const contents = new Map<string, string>([[probePath, probe]])
723
+ for (const [file, text] of sources) contents.set(join(root, file), text)
724
+ for (const file of routeFiles)
725
+ if (!contents.has(file)) contents.set(file, readFileSync(file, "utf8"))
726
+ const loaded = await loadProjectTypeScript(root, undefined, {
727
+ files: [...contents.keys()],
728
+ read: (file) => contents.get(file),
729
+ projectConfig: probeConfigPath,
730
+ })
731
+ close = loaded.session?.close
732
+ if (loaded.unsupported !== undefined) {
733
+ warnings.push(`TypeScript ${loaded.unsupported.version} is outside the supported API`)
734
+ return { shapes, warnings }
735
+ }
736
+ const ts = loaded.compiler
737
+ if (ts === undefined) {
738
+ warnings.push("TypeScript is not installed, so return types could not be read")
739
+ return { shapes, warnings }
740
+ }
741
+ const semantic = loaded.session?.semantic
742
+ if (semantic !== undefined) {
743
+ const checker = (await semantic.getCheckerForFile(probePath)) ?? semantic.checker
744
+ if (!isChecker7(checker)) {
745
+ warnings.push("the TypeScript 7 checker lacks the calls this needs")
746
+ return { shapes, warnings }
747
+ }
748
+ const reader = reader7(semantic, checker)
749
+ for (const [name, key] of names) {
750
+ const at = probe.indexOf(`declare const ${name}:`) + "declare const ".length
751
+ const type = await checker.getTypeAtPosition(probePath, at)
752
+ if (type !== undefined) shapes.set(key, await shapeOf(reader, type, new Set(), 0))
753
+ }
754
+ return { shapes, warnings }
755
+ }
756
+ const parsed = ts.getParsedCommandLineOfConfigFile(
757
+ probeConfigPath,
758
+ {},
759
+ {
760
+ ...ts.sys,
761
+ onUnRecoverableConfigFileDiagnostic: () => {},
762
+ },
763
+ )
764
+ const options: TSApi.CompilerOptions = {
765
+ ...(parsed?.options ?? {}),
766
+ noEmit: true,
767
+ skipLibCheck: true,
768
+ allowImportingTsExtensions: true,
769
+ }
770
+ const program = ts.createProgram([probePath], options, overlaidHost(ts, options, contents))
771
+ const source = program.getSourceFile(probePath)
772
+ if (source === undefined) {
773
+ warnings.push("could not create the type probe")
774
+ return { shapes, warnings }
775
+ }
776
+ const checker = program.getTypeChecker()
777
+ const reader = legacyReader(ts, checker, source)
778
+ for (const statement of source.statements) {
779
+ if (!ts.isVariableStatement(statement)) continue
780
+ for (const declaration of statement.declarationList.declarations) {
781
+ if (!ts.isIdentifier(declaration.name)) continue
782
+ const key = names.get(declaration.name.text)
783
+ if (key === undefined) continue
784
+ const type = checker.getTypeAtLocation(declaration.name)
785
+ shapes.set(key, await shapeOf(reader, type, new Set(), 0))
786
+ }
787
+ }
788
+ return { shapes, warnings }
789
+ } finally {
790
+ await close?.()
791
+ await rm(probePath, { force: true })
792
+ await rm(probeConfigPath, { force: true })
793
+ await rm(temp, { recursive: true, force: true })
794
+ }
795
+ }
796
+
797
+ /** A compiler host that reads `contents` (absolute path -> text) before the disk. */
798
+ function overlaidHost(
799
+ ts: TypeScriptApi,
800
+ options: TSApi.CompilerOptions,
801
+ contents: ReadonlyMap<string, string>,
802
+ ): TSApi.CompilerHost {
803
+ const host = ts.createCompilerHost(options, true)
804
+ const key = (file: string): string => posix(ts.sys.resolvePath(file))
805
+ const overlay = new Map([...contents].map(([file, text]) => [key(file), text]))
806
+ const folders = new Set<string>()
807
+ for (const file of overlay.keys())
808
+ for (let at = file.lastIndexOf("/"); at > 0; at = file.lastIndexOf("/", at - 1))
809
+ folders.add(file.slice(0, at))
810
+ const { fileExists, readFile, getSourceFile, directoryExists } = host
811
+ return Object.assign(host, {
812
+ fileExists: (file: string): boolean => overlay.has(key(file)) || fileExists.call(host, file),
813
+ readFile: (file: string): string | undefined =>
814
+ overlay.get(key(file)) ?? readFile.call(host, file),
815
+ directoryExists: (folder: string): boolean =>
816
+ folders.has(key(folder)) ||
817
+ (directoryExists === undefined
818
+ ? ts.sys.directoryExists(folder)
819
+ : directoryExists.call(host, folder)),
820
+ getSourceFile: (
821
+ file: string,
822
+ language: TSApi.ScriptTarget | TSApi.CreateSourceFileOptions,
823
+ onError?: (message: string) => void,
824
+ fresh?: boolean,
825
+ ): TSApi.SourceFile | undefined => {
826
+ const text = overlay.get(key(file))
827
+ return text === undefined
828
+ ? getSourceFile.call(host, file, language, onError, fresh)
829
+ : ts.createSourceFile(file, text, language)
830
+ },
831
+ })
832
+ }
833
+
834
+ // ---- planning edits ----------------------------------------------------------------------------
835
+
836
+ export type OutputSchemaStatus =
837
+ | "missing"
838
+ | "generated"
839
+ | "stale"
840
+ | "regenerated"
841
+ | "current"
842
+ | "declared"
843
+ | "no-data"
844
+ | "unreadable"
845
+
846
+ export interface OutputSchemaEntry {
847
+ readonly file: string
848
+ readonly line: number
849
+ readonly schema: OutputExport
850
+ readonly status: OutputSchemaStatus
851
+ /** The schema carries the `nifra:generated` marker. */
852
+ readonly generated: boolean
853
+ /** Returned paths the schema does not declare, so the output guard drops them. */
854
+ readonly dropped: readonly string[]
855
+ /** A generated schema whose text was changed under its marker: stale though its route is not. */
856
+ readonly edited?: boolean
857
+ readonly reason?: string
858
+ /** Returned paths a draft leaves out: credential-like names, and values that are not JSON. */
859
+ readonly omitted?: readonly string[]
860
+ /** Returned paths a generated schema admits any value at, because the type there is `any` or
861
+ * `unknown`: the output guard drops nothing below them until the type says what is sent. */
862
+ readonly open?: readonly string[]
863
+ }
864
+
865
+ interface SchemaDeclaration {
866
+ /** Start of the marker line when generated, else of the `export`. */
867
+ readonly start: number
868
+ readonly end: number
869
+ /** The text the schema is declared with, after the `=`. */
870
+ readonly value: string
871
+ readonly line: number
872
+ readonly hash: string | undefined
873
+ }
874
+
875
+ interface Target {
876
+ readonly fn: RouteFn
877
+ readonly schema: OutputExport
878
+ /** Where a new declaration goes: above the function's export and its doc comment. */
879
+ readonly insertAt: number
880
+ readonly fnLine: number
881
+ /** `export const loader: <type> = ...`: the checker reads that type, not what the body returns. */
882
+ readonly annotated: boolean
883
+ /** `undefined` when the schema is not exported; `null` when exported in a form not rewritten. */
884
+ readonly declaration: SchemaDeclaration | null | undefined
885
+ }
886
+
887
+ const lineAt = (text: string, index: number): number => text.slice(0, index).split("\n").length
888
+ const lineStart = (text: string, index: number): number => text.lastIndexOf("\n", index - 1) + 1
889
+
890
+ /** The start of the comment block directly above the line at `start`, or `start`. */
891
+ function aboveComments(text: string, start: number): number {
892
+ let at = start
893
+ while (at > 0) {
894
+ const previous = lineStart(text, at - 1)
895
+ const line = text.slice(previous, at - 1).trim()
896
+ if (!(line.startsWith("//") || line.startsWith("/*") || line.startsWith("*"))) break
897
+ at = previous
898
+ }
899
+ return at
900
+ }
901
+
902
+ function declarationOf(
903
+ content: string,
904
+ mask: string,
905
+ schema: OutputExport,
906
+ ): SchemaDeclaration | null | undefined {
907
+ const found = new RegExp(`\\bexport\\s+(?:const|let|var)\\s+${schema}\\b[^=]*=`).exec(mask)
908
+ if (found === null) return exported(mask, schema) === null ? undefined : null
909
+ const valueStart = found.index + found[0].length
910
+ let end = expressionEnd(mask, valueStart)
911
+ if (content[end] === ";") end++
912
+ const exportStart = lineStart(content, found.index)
913
+ const markerStart = exportStart === 0 ? -1 : lineStart(content, exportStart - 1)
914
+ const marker =
915
+ markerStart === -1 ? null : MARKER.exec(content.slice(markerStart, exportStart - 1))
916
+ return {
917
+ start: marker === null ? found.index : markerStart,
918
+ end,
919
+ value: content.slice(valueStart, end),
920
+ line: lineAt(content, found.index),
921
+ hash: marker?.[1],
922
+ }
923
+ }
924
+
925
+ function targetsOf(content: string): Target[] {
926
+ const mask = codePositionMask(content)
927
+ const targets: Target[] = []
928
+ for (const [fn, schema] of PAIRS) {
929
+ const found = exported(mask, fn)
930
+ if (found === null) continue
931
+ const reexport = found[0].startsWith("export {") || found[0].startsWith("export{")
932
+ let after = found.index + found[0].length
933
+ while (/\s/.test(mask[after] ?? "")) after++
934
+ targets.push({
935
+ fn,
936
+ schema,
937
+ insertAt: reexport ? content.length : aboveComments(content, lineStart(content, found.index)),
938
+ fnLine: lineAt(content, found.index),
939
+ annotated:
940
+ !reexport &&
941
+ /^(?:const|let|var)\b/.test(found[0].replace(/^export\s+/, "")) &&
942
+ mask[after] === ":",
943
+ declaration: declarationOf(content, mask, schema),
944
+ })
945
+ }
946
+ return targets
947
+ }
948
+
949
+ const SCHEMA_IMPORT = /\bimport\s*(type\s*)?\{([^}]*)\}\s*from\s*["']@nifrajs\/schema["']/g
950
+ const IMPORT = /^import\b[\s\S]*?from\s*["'][^"'\n]+["'];?|^import\s*["'][^"'\n]+["'];?/gm
951
+ const T_BINDING =
952
+ /\b(?:const|let|var|function|class)\s+t\b|\bimport\s+t\b|[{,]\s*t\s*[,}]|\bas\s+t\b/
953
+
954
+ /**
955
+ * `content` with `t` imported from `@nifrajs/schema`, or `undefined` when `t` already names something
956
+ * else in the file. Strings are blank in the code mask, so imports are read from the text and kept
957
+ * only where the mask shows code.
958
+ */
959
+ function withSchemaImport(content: string): string | undefined {
960
+ const mask = codePositionMask(content)
961
+ const inCode = (match: RegExpExecArray): boolean => mask[match.index] === "i"
962
+ const imports = [...content.matchAll(SCHEMA_IMPORT)].filter(inCode)
963
+ if (imports.some((m) => m[1] === undefined && /(?:^|,)\s*t\s*(?:,|$)/.test(m[2] ?? "")))
964
+ return content
965
+ if (T_BINDING.test(mask)) return undefined
966
+ const named = imports.find((m) => m[1] === undefined)
967
+ if (named !== undefined) {
968
+ const brace = content.indexOf("{", named.index)
969
+ return `${content.slice(0, brace + 1)} t,${content.slice(brace + 1)}`
970
+ }
971
+ let end = 0
972
+ for (const match of content.matchAll(IMPORT))
973
+ if (inCode(match)) end = match.index + match[0].length
974
+ const line = 'import { t } from "@nifrajs/schema"'
975
+ return end === 0
976
+ ? `${line}\n\n${content}`
977
+ : `${content.slice(0, end)}\n${line}${content.slice(end)}`
978
+ }
979
+
980
+ export interface OutputSchemaPlan {
981
+ readonly entries: OutputSchemaEntry[]
982
+ /** App-relative file -> its content with the planned schemas written. */
983
+ readonly edits: Map<string, string>
984
+ readonly warnings: readonly string[]
985
+ }
986
+
987
+ export interface OutputSchemaPlanOptions {
988
+ /**
989
+ * App-relative file -> text the types are read from instead of the disk: a layout planned but not
990
+ * written yet. The routes' own text comes from `read`.
991
+ */
992
+ readonly overlay?: ReadonlyMap<string, string>
993
+ /**
994
+ * Only these declarations, and only where none exists: a draft for a missing schema, never a
995
+ * refresh of a generated one or a change to a hand-written one.
996
+ */
997
+ readonly draft?: (file: string, schema: OutputExport) => boolean
998
+ }
999
+
1000
+ /**
1001
+ * What each listed route backend half needs, and the file contents that would apply it. `nifra
1002
+ * schemas`, `nifra migrate layout` and the NF-C030 fix share it, so one return type drafts one schema.
1003
+ */
1004
+ export async function planOutputSchemas(
1005
+ root: string,
1006
+ files: readonly string[],
1007
+ read: (file: string) => string | undefined,
1008
+ options: OutputSchemaPlanOptions = {},
1009
+ ): Promise<OutputSchemaPlan> {
1010
+ const { draft } = options
1011
+ const perFile = new Map<string, { content: string; targets: Target[] }>()
1012
+ for (const file of files) {
1013
+ const content = read(file)
1014
+ if (content === undefined) continue
1015
+ const targets = targetsOf(content)
1016
+ if (targets.length > 0) perFile.set(file, { content, targets })
1017
+ }
1018
+ const chosen = (file: string, target: Target): boolean =>
1019
+ draft === undefined || (draft(file, target.schema) && target.declaration === undefined)
1020
+ const probe: ProbeRoute[] = [...perFile].flatMap(([file, { targets }]) => {
1021
+ const read = targets.filter((target) => chosen(file, target))
1022
+ return read.length === 0
1023
+ ? []
1024
+ : [
1025
+ {
1026
+ file,
1027
+ fns: read.map((target) => target.fn),
1028
+ schemas: read.filter((t) => t.declaration !== undefined).map((t) => t.schema),
1029
+ },
1030
+ ]
1031
+ })
1032
+ const sources = new Map(options.overlay)
1033
+ for (const [file, { content }] of perFile) sources.set(file, content)
1034
+ const { shapes, warnings } = await readShapes(root, probe, sources)
1035
+ const entries: OutputSchemaEntry[] = []
1036
+ const edits = new Map<string, string>()
1037
+ if (warnings.length > 0) return { entries, edits, warnings }
1038
+ for (const [file, { content, targets }] of perFile) {
1039
+ const mask = codePositionMask(content)
1040
+ const replacements: Array<{ start: number; end: number; text: string; entry: number }> = []
1041
+ for (const target of targets) {
1042
+ if (!chosen(file, target)) continue
1043
+ const raw = shapes.get(`${file}#${target.fn}`)
1044
+ const declared = shapes.get(`${file}#${target.schema}`)
1045
+ const { declaration } = target
1046
+ const line = declaration?.line ?? target.fnLine
1047
+ const entry = (
1048
+ status: OutputSchemaStatus,
1049
+ dropped: readonly string[] = [],
1050
+ reason?: string,
1051
+ edited = false,
1052
+ omitted: readonly string[] = [],
1053
+ open: readonly string[] = [],
1054
+ ): number => {
1055
+ const schema = target.schema
1056
+ const generated = declaration?.hash !== undefined
1057
+ const base: OutputSchemaEntry = { file, line, schema, status, generated, dropped }
1058
+ const item: OutputSchemaEntry =
1059
+ reason !== undefined ? { ...base, reason } : edited ? { ...base, edited } : base
1060
+ const withOmitted = omitted.length === 0 ? item : { ...item, omitted }
1061
+ return entries.push(open.length === 0 ? withOmitted : { ...withOmitted, open }) - 1
1062
+ }
1063
+ if (target.annotated) {
1064
+ // A `Route.Loader` annotation returns the schema's own input type: comparing the two would
1065
+ // always agree, so the schema is reported unchecked rather than passed.
1066
+ entry(
1067
+ "unreadable",
1068
+ [],
1069
+ `${target.fn} has a type annotation, so its return type is the annotation's, not what it returns. Remove the annotation to have the schema generated and checked, or delete the "nifra:generated" line to own the schema by hand`,
1070
+ )
1071
+ continue
1072
+ }
1073
+ if (raw === undefined) {
1074
+ entry("unreadable", [], "TypeScript could not read its type")
1075
+ continue
1076
+ }
1077
+ const returned = dataShape(raw)
1078
+ if (returned.kind === "never") {
1079
+ entry("no-data")
1080
+ continue
1081
+ }
1082
+ const dropped =
1083
+ declared === undefined || declared.kind === "never"
1084
+ ? []
1085
+ : undeclaredPaths(returned, declared)
1086
+ if (declaration === null || (declaration !== undefined && declaration.hash === undefined)) {
1087
+ entry("declared", dropped)
1088
+ continue
1089
+ }
1090
+ if (returned.kind === "json" && returned.reason !== undefined) {
1091
+ entry(
1092
+ "unreadable",
1093
+ dropped,
1094
+ `the ${target.fn}'s return type is ${returned.reason}: annotate it, or write the schema by hand`,
1095
+ )
1096
+ continue
1097
+ }
1098
+ const body = schemaSource(returned)
1099
+ const omitted = omittedPaths(returned)
1100
+ const open = openPaths(returned)
1101
+ if (declaration === undefined) {
1102
+ // The name bound any other way (declared, imported) would clash with the one written here.
1103
+ if (new RegExp(`(?<![\\w$.])${target.schema}(?![\\w$])`).test(mask)) {
1104
+ entry(
1105
+ "missing",
1106
+ [],
1107
+ `\`${target.schema}\` already names something in this file, so no draft was written`,
1108
+ )
1109
+ continue
1110
+ }
1111
+ const at = entry("missing", [], undefined, false, omitted, open)
1112
+ const written = declarationText(target.schema, target.fn, body)
1113
+ const text = target.insertAt === content.length ? `\n${written}\n` : `${written}\n\n`
1114
+ replacements.push({ start: target.insertAt, end: target.insertAt, text, entry: at })
1115
+ } else if (
1116
+ declaration.hash === hashOf(body) &&
1117
+ schemaTokens(declaration.value) === schemaTokens(body)
1118
+ ) {
1119
+ entry("current", [], undefined, false, [], open)
1120
+ } else {
1121
+ // The marker still matches what would be written, so the text under it was edited.
1122
+ const edited = declaration.hash === hashOf(body)
1123
+ const at = entry("stale", dropped, undefined, edited, omitted, open)
1124
+ const text = declarationText(target.schema, target.fn, body)
1125
+ replacements.push({ start: declaration.start, end: declaration.end, text, entry: at })
1126
+ }
1127
+ }
1128
+ if (replacements.length === 0) continue
1129
+ let next = content
1130
+ for (const r of replacements.sort((a, b) => b.start - a.start))
1131
+ next = `${next.slice(0, r.start)}${r.text}${next.slice(r.end)}`
1132
+ const imported = withSchemaImport(next)
1133
+ if (imported === undefined) {
1134
+ for (const r of replacements) {
1135
+ const blocked = entries[r.entry]
1136
+ if (blocked !== undefined)
1137
+ entries[r.entry] = {
1138
+ ...blocked,
1139
+ reason: "`t` already names something else in this file, so it was left as is",
1140
+ }
1141
+ }
1142
+ continue
1143
+ }
1144
+ edits.set(file, imported)
1145
+ }
1146
+ entries.sort((a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line)
1147
+ return { entries, edits, warnings }
1148
+ }
1149
+
1150
+ // ---- entry points ------------------------------------------------------------------------------
1151
+
1152
+ const ROUTE_BACKEND_TS = /\.backend\.[cm]?ts$/
1153
+
1154
+ function routeBackends(dir: string): string[] {
1155
+ const routes = join(dir, "routes")
1156
+ if (!existsSync(routes)) return []
1157
+ return readdirSync(routes, { recursive: true, encoding: "utf8" })
1158
+ .map((file) => posix(join("routes", file)))
1159
+ .filter((file) => ROUTE_BACKEND_TS.test(file) && !file.split("/").includes("node_modules"))
1160
+ .sort(codeUnitOrder)
1161
+ }
1162
+
1163
+ export interface OutputSchemasReport {
1164
+ readonly ok: boolean
1165
+ readonly write: boolean
1166
+ readonly entries: readonly OutputSchemaEntry[]
1167
+ readonly written: readonly string[]
1168
+ readonly warnings: readonly string[]
1169
+ }
1170
+
1171
+ /**
1172
+ * Every route backend half under `<dir>/routes`: report what each loader's and action's schema needs,
1173
+ * and with `write`, write the missing schemas and refresh the stale generated ones. A hand-written
1174
+ * schema is never touched; its entry lists the returned fields it drops.
1175
+ */
1176
+ export async function outputSchemas(
1177
+ dir: string,
1178
+ options: { readonly write?: boolean; readonly check?: boolean } = {},
1179
+ ): Promise<OutputSchemasReport> {
1180
+ const write = options.write === true
1181
+ const { entries, edits, warnings } = await planOutputSchemas(dir, routeBackends(dir), (file) => {
1182
+ try {
1183
+ return readFileSync(join(dir, file), "utf8")
1184
+ } catch {
1185
+ return undefined
1186
+ }
1187
+ })
1188
+ const written: string[] = []
1189
+ let final = entries
1190
+ if (write) {
1191
+ for (const [file, content] of edits) {
1192
+ writeFileSync(join(dir, file), content)
1193
+ written.push(file)
1194
+ }
1195
+ final = entries.map((entry) =>
1196
+ !written.includes(entry.file) || entry.reason !== undefined
1197
+ ? entry
1198
+ : entry.status === "missing"
1199
+ ? { ...entry, status: "generated" }
1200
+ : entry.status === "stale"
1201
+ ? { ...entry, status: "regenerated" }
1202
+ : entry,
1203
+ )
1204
+ }
1205
+ // `check` also fails what a review still owes: a schema not written yet, and one that admits anything.
1206
+ const owed = (entry: OutputSchemaEntry): boolean =>
1207
+ entry.status === "missing" || (entry.open?.length ?? 0) > 0
1208
+ return {
1209
+ ok:
1210
+ warnings.length === 0 &&
1211
+ !final.some((entry) => entry.status === "stale" || (options.check === true && owed(entry))),
1212
+ write,
1213
+ entries: final,
1214
+ written,
1215
+ warnings,
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * The generated schemas among `files` whose route now returns a different shape (NF-C033), and those
1221
+ * it could not compare. `warnings` says why nothing could be read at all.
1222
+ */
1223
+ export async function staleGeneratedSchemas(
1224
+ root: string,
1225
+ files: readonly string[],
1226
+ read: (file: string) => string | undefined,
1227
+ ): Promise<{
1228
+ readonly stale: readonly OutputSchemaEntry[]
1229
+ readonly unchecked: readonly OutputSchemaEntry[]
1230
+ readonly warnings: readonly string[]
1231
+ }> {
1232
+ const marked = files.filter((file) => read(file)?.includes(GENERATED_MARK) === true)
1233
+ if (marked.length === 0) return { stale: [], unchecked: [], warnings: [] }
1234
+ const { entries, warnings } = await planOutputSchemas(root, marked, read)
1235
+ return {
1236
+ stale: entries.filter((entry) => entry.status === "stale"),
1237
+ unchecked: entries.filter((entry) => entry.generated && entry.status === "unreadable"),
1238
+ warnings,
1239
+ }
1240
+ }