@nifrajs/cli 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +21 -2
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +778 -64
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
@@ -0,0 +1,1084 @@
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
+ const defined = withoutUndefined(shape)
271
+ fields.push({
272
+ name: property.name,
273
+ optional: property.optional || defined !== shape,
274
+ shape: defined,
275
+ })
276
+ }
277
+ return { kind: "object", fields }
278
+ } finally {
279
+ seen.delete(type)
280
+ }
281
+ }
282
+
283
+ /** What a loader's return type sends: control-flow returns (nothing, a `Response`) carry no data. */
284
+ function dataShape(shape: Shape): Shape {
285
+ const members = shape.kind === "union" ? shape.members : [shape]
286
+ return unionOf(
287
+ members.filter(
288
+ (member) =>
289
+ member.kind !== "undefined" && !(member.kind === "opaque" && member.what === "a Response"),
290
+ ),
291
+ )
292
+ }
293
+
294
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
295
+ const note = (text: string): string => text.replaceAll("*/", "* /")
296
+
297
+ /** `shape` as `t.*` source, objects one field per line below `indent`. */
298
+ export function schemaSource(shape: Shape, indent = ""): string {
299
+ switch (shape.kind) {
300
+ case "string":
301
+ case "number":
302
+ case "boolean":
303
+ case "null":
304
+ case "date":
305
+ return `t.${shape.kind}()`
306
+ case "undefined":
307
+ case "never":
308
+ return "t.null()"
309
+ case "json":
310
+ return shape.reason === undefined
311
+ ? "t.json()"
312
+ : `t.json() /* ${note(shape.reason)} in the source type */`
313
+ case "opaque":
314
+ return `t.json() /* ${note(shape.what)}, which is not JSON */`
315
+ case "literal":
316
+ return `t.literal(${JSON.stringify(shape.value)})`
317
+ case "array":
318
+ return `t.array(${schemaSource(shape.items, indent)})`
319
+ case "tuple":
320
+ return `t.array(${schemaSource(unionOf(shape.items), indent)})`
321
+ case "record":
322
+ return `t.record(${schemaSource(shape.values, indent)})`
323
+ case "deferred":
324
+ return `t.deferred(${schemaSource(shape.value, indent)})`
325
+ case "union":
326
+ return `t.union([${shape.members.map((member) => schemaSource(member, indent)).join(", ")}])`
327
+ case "object": {
328
+ if (shape.fields.length === 0) return "t.object({})"
329
+ const inner = `${indent} `
330
+ const lines = shape.fields.map((field) => {
331
+ const key = IDENTIFIER.test(field.name) ? field.name : JSON.stringify(field.name)
332
+ if (isSensitiveFieldName(field.name))
333
+ return `${inner}// left out: ${key} - a credential-like name; declare it with t.declassified("why", ...) if it must reach the browser`
334
+ if (field.shape.kind === "opaque")
335
+ return `${inner}// left out: ${key} - ${note(field.shape.what)}, which is not JSON`
336
+ const value = schemaSource(field.shape, inner)
337
+ return `${inner}${key}: ${field.optional ? `t.optional(${value})` : value},`
338
+ })
339
+ return `t.object({\n${lines.join("\n")}\n${indent}})`
340
+ }
341
+ }
342
+ }
343
+
344
+ /**
345
+ * The paths `returned` carries that `declared` does not, as `a.b`, `items[].c`: what the output guard
346
+ * drops before the page renders. A declared `t.json()` or record admits everything below it.
347
+ */
348
+ export function undeclaredPaths(returned: Shape, declared: Shape, path = ""): string[] {
349
+ if (declared.kind === "json" || declared.kind === "record") return []
350
+ if (returned.kind === "union") {
351
+ const found = returned.members.flatMap((member) => undeclaredPaths(member, declared, path))
352
+ return [...new Set(found)]
353
+ }
354
+ if (declared.kind === "union") {
355
+ const candidates = declared.members.filter((m) => m.kind !== "null" && m.kind !== "undefined")
356
+ if (candidates.some((m) => m.kind === "json" || m.kind === "record")) return []
357
+ const results = candidates.map((candidate) => undeclaredPaths(returned, candidate, path))
358
+ const [first, ...rest] = results
359
+ return first === undefined ? [] : first.filter((p) => rest.every((r) => r.includes(p)))
360
+ }
361
+ if (returned.kind === "deferred")
362
+ return undeclaredPaths(
363
+ returned.value,
364
+ declared.kind === "deferred" ? declared.value : declared,
365
+ path,
366
+ )
367
+ if (declared.kind === "deferred") return undeclaredPaths(returned, declared.value, path)
368
+ const items = (shape: Shape): Shape | undefined =>
369
+ shape.kind === "array" ? shape.items : shape.kind === "tuple" ? unionOf(shape.items) : undefined
370
+ const returnedItems = items(returned)
371
+ const declaredItems = items(declared)
372
+ if (returnedItems !== undefined && declaredItems !== undefined)
373
+ return undeclaredPaths(returnedItems, declaredItems, `${path}[]`)
374
+ if (returned.kind !== "object" || declared.kind !== "object") return []
375
+ const found: string[] = []
376
+ for (const field of returned.fields) {
377
+ const at = path === "" ? field.name : `${path}.${field.name}`
378
+ const match = declared.fields.find((candidate) => candidate.name === field.name)
379
+ if (match === undefined) found.push(at)
380
+ else found.push(...undeclaredPaths(field.shape, match.shape, at))
381
+ }
382
+ return found
383
+ }
384
+
385
+ const hashOf = (body: string): string => createHash("sha256").update(body).digest("hex").slice(0, 8)
386
+
387
+ /**
388
+ * A schema expression as a formatter cannot change it: comments, whitespace, a comma before a closing
389
+ * bracket and a trailing semicolon dropped, every string written with double quotes, and a quoted key
390
+ * that needs no quotes written bare. Two texts
391
+ * that read the same here are the same schema; an edit to what it declares is not.
392
+ */
393
+ function schemaTokens(text: string): string {
394
+ let out = ""
395
+ let i = 0
396
+ while (i < text.length) {
397
+ const c = text.charAt(i)
398
+ if (c === "/" && text[i + 1] === "/") {
399
+ const end = text.indexOf("\n", i)
400
+ i = end === -1 ? text.length : end
401
+ } else if (c === "/" && text[i + 1] === "*") {
402
+ const end = text.indexOf("*/", i + 2)
403
+ i = end === -1 ? text.length : end + 2
404
+ } else if (c === '"' || c === "'" || c === "`") {
405
+ let value = ""
406
+ let j = i + 1
407
+ while (j < text.length && text[j] !== c) {
408
+ if (text[j] === "\\") {
409
+ const next = text[j + 1] ?? ""
410
+ value += next === c ? next : `\\${next}`
411
+ j += 2
412
+ } else {
413
+ value += text[j]
414
+ j++
415
+ }
416
+ }
417
+ i = j + 1
418
+ let after = i
419
+ while (/\s/.test(text[after] ?? "")) after++
420
+ // `"name": …` and `name: …` declare the same key.
421
+ out +=
422
+ text[after] === ":" && IDENTIFIER.test(value) ? value : `"${value.replaceAll('"', '\\"')}"`
423
+ } else {
424
+ if (!/\s/.test(c)) out += c
425
+ i++
426
+ }
427
+ }
428
+ return out.replace(/,(?=[}\])])/g, "").replace(/;+$/, "")
429
+ }
430
+
431
+ function declarationText(schema: OutputExport, fn: RouteFn, body: string): string {
432
+ return [
433
+ `// ${GENERATED_MARK} ${hashOf(body)} - from the ${fn}'s return type; \`nifra schemas --write\` keeps it current. Delete this line to edit it by hand.`,
434
+ `export const ${schema} = ${body}`,
435
+ ].join("\n")
436
+ }
437
+
438
+ // ---- reading the types -------------------------------------------------------------------------
439
+
440
+ interface ProbeRoute {
441
+ readonly file: string
442
+ readonly fns: readonly RouteFn[]
443
+ readonly schemas: readonly OutputExport[]
444
+ }
445
+
446
+ /** Shapes keyed `<file>#<export>`: a route function's return type, or a schema's output type. */
447
+ interface ProbeResult {
448
+ readonly shapes: ReadonlyMap<string, Shape>
449
+ readonly warnings: readonly string[]
450
+ }
451
+
452
+ const posix = (path: string): string => path.replaceAll("\\", "/")
453
+
454
+ const PROBE_HELPERS = [
455
+ "type __NifraReturned<M, F extends string> = M extends { readonly [K in F]: (...args: any) => infer R } ? Awaited<R> : never",
456
+ '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',
457
+ ]
458
+
459
+ function probeSource(routes: readonly ProbeRoute[]): {
460
+ readonly text: string
461
+ readonly names: ReadonlyMap<string, string>
462
+ } {
463
+ const lines = [...PROBE_HELPERS]
464
+ const names = new Map<string, string>()
465
+ routes.forEach((route, i) => {
466
+ const rel = posix(route.file)
467
+ lines.push(
468
+ `import type * as __nifra_m${i} from ${JSON.stringify(rel.startsWith(".") ? rel : `./${rel}`)}`,
469
+ )
470
+ for (const fn of route.fns) {
471
+ const name = `__nifra_r${i}_${fn}`
472
+ names.set(name, `${route.file}#${fn}`)
473
+ lines.push(`declare const ${name}: __NifraReturned<typeof __nifra_m${i}, "${fn}">`)
474
+ }
475
+ for (const schema of route.schemas) {
476
+ const name = `__nifra_d${i}_${schema}`
477
+ names.set(name, `${route.file}#${schema}`)
478
+ lines.push(`declare const ${name}: __NifraDeclared<typeof __nifra_m${i}, "${schema}">`)
479
+ }
480
+ })
481
+ return { text: `${lines.join("\n")}\n`, names }
482
+ }
483
+
484
+ function legacyReader(
485
+ ts: TypeScriptApi,
486
+ checker: TSApi.TypeChecker,
487
+ location: TSApi.Node,
488
+ ): TypeReader<TSApi.Type> {
489
+ const isReference = (type: TSApi.Type): type is TSApi.TypeReference => "target" in type
490
+ return {
491
+ flag: flagsFrom((name) => Reflect.get(ts.TypeFlags, name)),
492
+ flags: (type) => type.flags,
493
+ members: async (type) => (type.isUnionOrIntersection() ? type.types : []),
494
+ symbolName: async (type) => type.getSymbol()?.name,
495
+ aliasName: async (type) => type.aliasSymbol?.name,
496
+ literal: async (type) => {
497
+ if (type.isStringLiteral() || type.isNumberLiteral()) return type.value
498
+ if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0)
499
+ return checker.typeToString(type) === "true"
500
+ return undefined
501
+ },
502
+ arrayElement: async (type) =>
503
+ checker.isArrayType(type) && isReference(type)
504
+ ? checker.getTypeArguments(type)[0]
505
+ : undefined,
506
+ tupleElements: async (type) =>
507
+ checker.isTupleType(type) && isReference(type) ? checker.getTypeArguments(type) : undefined,
508
+ typeArguments: async (type) => (isReference(type) ? checker.getTypeArguments(type) : []),
509
+ properties: async (type) =>
510
+ checker.getPropertiesOfType(type).map((property) => ({
511
+ name: property.name,
512
+ optional: (property.flags & ts.SymbolFlags.Optional) !== 0,
513
+ type: checker.getTypeOfSymbolAtLocation(property, location),
514
+ })),
515
+ stringIndex: async (type) =>
516
+ checker
517
+ .getIndexInfosOfType(type)
518
+ .find((info) => (info.keyType.flags & ts.TypeFlags.StringLike) !== 0)?.type,
519
+ callable: async (type) => checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0,
520
+ text: async (type) => checker.typeToString(type),
521
+ }
522
+ }
523
+
524
+ interface Symbol7 {
525
+ readonly name: string
526
+ readonly flags: number
527
+ }
528
+
529
+ interface Type7 {
530
+ readonly flags: number
531
+ readonly value?: unknown
532
+ getSymbol(): Promise<Symbol7 | undefined>
533
+ getAliasSymbol?(): Promise<Symbol7 | undefined>
534
+ getTypes?(): Promise<readonly Type7[] | undefined>
535
+ }
536
+
537
+ interface Checker7 {
538
+ getTypeAtPosition(file: string, position: number): Promise<Type7 | undefined>
539
+ getTypeOfSymbol(symbol: Symbol7): Promise<Type7 | undefined>
540
+ getPropertiesOfType(type: Type7): Promise<readonly Symbol7[]>
541
+ getIndexInfosOfType(type: Type7): Promise<readonly { keyType: Type7; valueType: Type7 }[]>
542
+ getSignaturesOfType(type: Type7, kind: number): Promise<readonly unknown[]>
543
+ getTypeArguments(type: Type7): Promise<readonly Type7[]>
544
+ typeToString(type: Type7): Promise<string>
545
+ isTupleType(type: Type7): Promise<boolean>
546
+ isArrayType(type: Type7): Promise<boolean>
547
+ }
548
+
549
+ const CHECKER7_METHODS = [
550
+ "getTypeAtPosition",
551
+ "getTypeOfSymbol",
552
+ "getPropertiesOfType",
553
+ "getIndexInfosOfType",
554
+ "getSignaturesOfType",
555
+ "getTypeArguments",
556
+ "typeToString",
557
+ "isTupleType",
558
+ "isArrayType",
559
+ ] as const
560
+
561
+ function isChecker7(value: unknown): value is Checker7 {
562
+ return (
563
+ typeof value === "object" &&
564
+ value !== null &&
565
+ CHECKER7_METHODS.every((method) => typeof Reflect.get(value, method) === "function")
566
+ )
567
+ }
568
+
569
+ function reader7(semantic: TypeScriptSemanticSession, checker: Checker7): TypeReader<Type7> {
570
+ const flag = flagsFrom((name) => semantic.typeFlags[name])
571
+ const optional = semantic.symbolFlags.Optional
572
+ const call = semantic.signatureKind.Call
573
+ return {
574
+ flag,
575
+ flags: (type) => type.flags,
576
+ members: async (type) => (await type.getTypes?.()) ?? [],
577
+ symbolName: async (type) => (await type.getSymbol())?.name,
578
+ aliasName: async (type) => (await type.getAliasSymbol?.())?.name,
579
+ literal: async (type) => {
580
+ if ((type.flags & flag.BooleanLiteral) !== 0)
581
+ return (await checker.typeToString(type)) === "true"
582
+ return typeof type.value === "string" || typeof type.value === "number"
583
+ ? type.value
584
+ : undefined
585
+ },
586
+ arrayElement: async (type) =>
587
+ (await checker.isArrayType(type)) ? (await checker.getTypeArguments(type))[0] : undefined,
588
+ tupleElements: async (type) =>
589
+ (await checker.isTupleType(type)) ? checker.getTypeArguments(type) : undefined,
590
+ typeArguments: async (type) => {
591
+ try {
592
+ return await checker.getTypeArguments(type)
593
+ } catch {
594
+ return []
595
+ }
596
+ },
597
+ properties: async (type) => {
598
+ const out: Property<Type7>[] = []
599
+ for (const property of await checker.getPropertiesOfType(type)) {
600
+ const propertyType = await checker.getTypeOfSymbol(property)
601
+ if (propertyType === undefined) continue
602
+ out.push({
603
+ name: property.name,
604
+ optional: typeof optional === "number" && (property.flags & optional) !== 0,
605
+ type: propertyType,
606
+ })
607
+ }
608
+ return out
609
+ },
610
+ stringIndex: async (type) =>
611
+ (await checker.getIndexInfosOfType(type)).find(
612
+ (info) => (info.keyType.flags & flag.StringLike) !== 0,
613
+ )?.valueType,
614
+ callable: async (type) =>
615
+ typeof call === "number" && (await checker.getSignaturesOfType(type, call)).length > 0,
616
+ text: (type) => checker.typeToString(type),
617
+ }
618
+ }
619
+
620
+ /** Read every listed route function's return type and schema's output type through one program. */
621
+ async function readShapes(root: string, routes: readonly ProbeRoute[]): Promise<ProbeResult> {
622
+ const warnings: string[] = []
623
+ const shapes = new Map<string, Shape>()
624
+ if (routes.length === 0) return { shapes, warnings }
625
+ // TypeScript 7 resolves node handles only inside a configured project's root, so the probe and a
626
+ // short-lived config extending the app's sit at the root, and are removed afterwards.
627
+ const temp = await mkdtemp(join(root, ".nifra-schemas-"))
628
+ const tempName = basename(temp)
629
+ const probePath = join(root, `.${tempName}.ts`)
630
+ const probeConfigPath = join(root, `.${tempName}.tsconfig.json`)
631
+ const { text: probe, names } = probeSource(routes)
632
+ const routeFiles = routes.map((route) => join(root, route.file))
633
+ let close: (() => Promise<void>) | undefined
634
+ try {
635
+ await writeFile(probePath, probe, "utf8")
636
+ const include = [...routes.map((route) => posix(route.file)), basename(probePath)]
637
+ await writeFile(
638
+ probeConfigPath,
639
+ JSON.stringify(
640
+ existsSync(join(root, "tsconfig.json"))
641
+ ? {
642
+ extends: "./tsconfig.json",
643
+ compilerOptions: { allowImportingTsExtensions: true, noEmit: true },
644
+ include,
645
+ }
646
+ : {
647
+ compilerOptions: {
648
+ module: "ESNext",
649
+ moduleResolution: "Bundler",
650
+ target: "ESNext",
651
+ strict: true,
652
+ allowImportingTsExtensions: true,
653
+ noEmit: true,
654
+ skipLibCheck: true,
655
+ },
656
+ include,
657
+ },
658
+ ),
659
+ "utf8",
660
+ )
661
+ const contents = new Map<string, string>([[probePath, probe]])
662
+ for (const file of routeFiles) contents.set(file, readFileSync(file, "utf8"))
663
+ const loaded = await loadProjectTypeScript(root, undefined, {
664
+ files: [...routeFiles, probePath],
665
+ read: (file) => contents.get(file),
666
+ projectConfig: probeConfigPath,
667
+ })
668
+ close = loaded.session?.close
669
+ if (loaded.unsupported !== undefined) {
670
+ warnings.push(`TypeScript ${loaded.unsupported.version} is outside the supported API`)
671
+ return { shapes, warnings }
672
+ }
673
+ const ts = loaded.compiler
674
+ if (ts === undefined) {
675
+ warnings.push("TypeScript is not installed, so return types could not be read")
676
+ return { shapes, warnings }
677
+ }
678
+ const semantic = loaded.session?.semantic
679
+ if (semantic !== undefined) {
680
+ const checker = (await semantic.getCheckerForFile(probePath)) ?? semantic.checker
681
+ if (!isChecker7(checker)) {
682
+ warnings.push("the TypeScript 7 checker lacks the calls this needs")
683
+ return { shapes, warnings }
684
+ }
685
+ const reader = reader7(semantic, checker)
686
+ for (const [name, key] of names) {
687
+ const at = probe.indexOf(`declare const ${name}:`) + "declare const ".length
688
+ const type = await checker.getTypeAtPosition(probePath, at)
689
+ if (type !== undefined) shapes.set(key, await shapeOf(reader, type, new Set(), 0))
690
+ }
691
+ return { shapes, warnings }
692
+ }
693
+ const parsed = ts.getParsedCommandLineOfConfigFile(
694
+ probeConfigPath,
695
+ {},
696
+ {
697
+ ...ts.sys,
698
+ onUnRecoverableConfigFileDiagnostic: () => {},
699
+ },
700
+ )
701
+ const options: TSApi.CompilerOptions = {
702
+ ...(parsed?.options ?? {}),
703
+ noEmit: true,
704
+ skipLibCheck: true,
705
+ allowImportingTsExtensions: true,
706
+ }
707
+ const program = ts.createProgram([probePath], options, ts.createCompilerHost(options, true))
708
+ const source = program.getSourceFile(probePath)
709
+ if (source === undefined) {
710
+ warnings.push("could not create the type probe")
711
+ return { shapes, warnings }
712
+ }
713
+ const checker = program.getTypeChecker()
714
+ const reader = legacyReader(ts, checker, source)
715
+ for (const statement of source.statements) {
716
+ if (!ts.isVariableStatement(statement)) continue
717
+ for (const declaration of statement.declarationList.declarations) {
718
+ if (!ts.isIdentifier(declaration.name)) continue
719
+ const key = names.get(declaration.name.text)
720
+ if (key === undefined) continue
721
+ const type = checker.getTypeAtLocation(declaration.name)
722
+ shapes.set(key, await shapeOf(reader, type, new Set(), 0))
723
+ }
724
+ }
725
+ return { shapes, warnings }
726
+ } finally {
727
+ await close?.()
728
+ await rm(probePath, { force: true })
729
+ await rm(probeConfigPath, { force: true })
730
+ await rm(temp, { recursive: true, force: true })
731
+ }
732
+ }
733
+
734
+ // ---- planning edits ----------------------------------------------------------------------------
735
+
736
+ export type OutputSchemaStatus =
737
+ | "missing"
738
+ | "generated"
739
+ | "stale"
740
+ | "regenerated"
741
+ | "current"
742
+ | "declared"
743
+ | "no-data"
744
+ | "unreadable"
745
+
746
+ export interface OutputSchemaEntry {
747
+ readonly file: string
748
+ readonly line: number
749
+ readonly schema: OutputExport
750
+ readonly status: OutputSchemaStatus
751
+ /** The schema carries the `nifra:generated` marker. */
752
+ readonly generated: boolean
753
+ /** Returned paths the schema does not declare, so the output guard drops them. */
754
+ readonly dropped: readonly string[]
755
+ /** A generated schema whose text was changed under its marker: stale though its route is not. */
756
+ readonly edited?: boolean
757
+ readonly reason?: string
758
+ }
759
+
760
+ interface SchemaDeclaration {
761
+ /** Start of the marker line when generated, else of the `export`. */
762
+ readonly start: number
763
+ readonly end: number
764
+ /** The text the schema is declared with, after the `=`. */
765
+ readonly value: string
766
+ readonly line: number
767
+ readonly hash: string | undefined
768
+ }
769
+
770
+ interface Target {
771
+ readonly fn: RouteFn
772
+ readonly schema: OutputExport
773
+ /** Where a new declaration goes: above the function's export and its doc comment. */
774
+ readonly insertAt: number
775
+ readonly fnLine: number
776
+ /** `export const loader: <type> = ...`: the checker reads that type, not what the body returns. */
777
+ readonly annotated: boolean
778
+ /** `undefined` when the schema is not exported; `null` when exported in a form not rewritten. */
779
+ readonly declaration: SchemaDeclaration | null | undefined
780
+ }
781
+
782
+ const lineAt = (text: string, index: number): number => text.slice(0, index).split("\n").length
783
+ const lineStart = (text: string, index: number): number => text.lastIndexOf("\n", index - 1) + 1
784
+
785
+ /** The start of the comment block directly above the line at `start`, or `start`. */
786
+ function aboveComments(text: string, start: number): number {
787
+ let at = start
788
+ while (at > 0) {
789
+ const previous = lineStart(text, at - 1)
790
+ const line = text.slice(previous, at - 1).trim()
791
+ if (!(line.startsWith("//") || line.startsWith("/*") || line.startsWith("*"))) break
792
+ at = previous
793
+ }
794
+ return at
795
+ }
796
+
797
+ function declarationOf(
798
+ content: string,
799
+ mask: string,
800
+ schema: OutputExport,
801
+ ): SchemaDeclaration | null | undefined {
802
+ const found = new RegExp(`\\bexport\\s+(?:const|let|var)\\s+${schema}\\b[^=]*=`).exec(mask)
803
+ if (found === null) return exported(mask, schema) === null ? undefined : null
804
+ const valueStart = found.index + found[0].length
805
+ let end = expressionEnd(mask, valueStart)
806
+ if (content[end] === ";") end++
807
+ const exportStart = lineStart(content, found.index)
808
+ const markerStart = exportStart === 0 ? -1 : lineStart(content, exportStart - 1)
809
+ const marker =
810
+ markerStart === -1 ? null : MARKER.exec(content.slice(markerStart, exportStart - 1))
811
+ return {
812
+ start: marker === null ? found.index : markerStart,
813
+ end,
814
+ value: content.slice(valueStart, end),
815
+ line: lineAt(content, found.index),
816
+ hash: marker?.[1],
817
+ }
818
+ }
819
+
820
+ function targetsOf(content: string): Target[] {
821
+ const mask = codePositionMask(content)
822
+ const targets: Target[] = []
823
+ for (const [fn, schema] of PAIRS) {
824
+ const found = exported(mask, fn)
825
+ if (found === null) continue
826
+ const reexport = found[0].startsWith("export {") || found[0].startsWith("export{")
827
+ let after = found.index + found[0].length
828
+ while (/\s/.test(mask[after] ?? "")) after++
829
+ targets.push({
830
+ fn,
831
+ schema,
832
+ insertAt: reexport ? content.length : aboveComments(content, lineStart(content, found.index)),
833
+ fnLine: lineAt(content, found.index),
834
+ annotated:
835
+ !reexport &&
836
+ /^(?:const|let|var)\b/.test(found[0].replace(/^export\s+/, "")) &&
837
+ mask[after] === ":",
838
+ declaration: declarationOf(content, mask, schema),
839
+ })
840
+ }
841
+ return targets
842
+ }
843
+
844
+ const SCHEMA_IMPORT = /\bimport\s*(type\s*)?\{([^}]*)\}\s*from\s*["']@nifrajs\/schema["']/g
845
+ const IMPORT = /^import\b[\s\S]*?from\s*["'][^"'\n]+["'];?|^import\s*["'][^"'\n]+["'];?/gm
846
+ const T_BINDING =
847
+ /\b(?:const|let|var|function|class)\s+t\b|\bimport\s+t\b|[{,]\s*t\s*[,}]|\bas\s+t\b/
848
+
849
+ /**
850
+ * `content` with `t` imported from `@nifrajs/schema`, or `undefined` when `t` already names something
851
+ * else in the file. Strings are blank in the code mask, so imports are read from the text and kept
852
+ * only where the mask shows code.
853
+ */
854
+ function withSchemaImport(content: string): string | undefined {
855
+ const mask = codePositionMask(content)
856
+ const inCode = (match: RegExpExecArray): boolean => mask[match.index] === "i"
857
+ const imports = [...content.matchAll(SCHEMA_IMPORT)].filter(inCode)
858
+ if (imports.some((m) => m[1] === undefined && /(?:^|,)\s*t\s*(?:,|$)/.test(m[2] ?? "")))
859
+ return content
860
+ if (T_BINDING.test(mask)) return undefined
861
+ const named = imports.find((m) => m[1] === undefined)
862
+ if (named !== undefined) {
863
+ const brace = content.indexOf("{", named.index)
864
+ return `${content.slice(0, brace + 1)} t,${content.slice(brace + 1)}`
865
+ }
866
+ let end = 0
867
+ for (const match of content.matchAll(IMPORT))
868
+ if (inCode(match)) end = match.index + match[0].length
869
+ const line = 'import { t } from "@nifrajs/schema"'
870
+ return end === 0
871
+ ? `${line}\n\n${content}`
872
+ : `${content.slice(0, end)}\n${line}${content.slice(end)}`
873
+ }
874
+
875
+ interface Plan {
876
+ readonly entries: OutputSchemaEntry[]
877
+ readonly edits: Map<string, string>
878
+ readonly warnings: readonly string[]
879
+ }
880
+
881
+ /** What each listed route backend half needs, and the file contents that would apply it. */
882
+ async function plan(
883
+ root: string,
884
+ files: readonly string[],
885
+ read: (file: string) => string | undefined,
886
+ ): Promise<Plan> {
887
+ const perFile = new Map<string, { content: string; targets: Target[] }>()
888
+ for (const file of files) {
889
+ const content = read(file)
890
+ if (content === undefined) continue
891
+ const targets = targetsOf(content)
892
+ if (targets.length > 0) perFile.set(file, { content, targets })
893
+ }
894
+ const probe: ProbeRoute[] = [...perFile].map(([file, { targets }]) => ({
895
+ file,
896
+ fns: targets.map((target) => target.fn),
897
+ schemas: targets.filter((t) => t.declaration !== undefined).map((t) => t.schema),
898
+ }))
899
+ const { shapes, warnings } = await readShapes(root, probe)
900
+ const entries: OutputSchemaEntry[] = []
901
+ const edits = new Map<string, string>()
902
+ if (warnings.length > 0) return { entries, edits, warnings }
903
+ for (const [file, { content, targets }] of perFile) {
904
+ const replacements: Array<{ start: number; end: number; text: string; entry: number }> = []
905
+ for (const target of targets) {
906
+ const raw = shapes.get(`${file}#${target.fn}`)
907
+ const declared = shapes.get(`${file}#${target.schema}`)
908
+ const { declaration } = target
909
+ const line = declaration?.line ?? target.fnLine
910
+ const entry = (
911
+ status: OutputSchemaStatus,
912
+ dropped: readonly string[] = [],
913
+ reason?: string,
914
+ edited = false,
915
+ ): number => {
916
+ const schema = target.schema
917
+ const generated = declaration?.hash !== undefined
918
+ const base: OutputSchemaEntry = { file, line, schema, status, generated, dropped }
919
+ const item: OutputSchemaEntry =
920
+ reason !== undefined ? { ...base, reason } : edited ? { ...base, edited } : base
921
+ return entries.push(item) - 1
922
+ }
923
+ if (target.annotated) {
924
+ // A `Route.Loader` annotation returns the schema's own input type: comparing the two would
925
+ // always agree, so the schema is reported unchecked rather than passed.
926
+ entry(
927
+ "unreadable",
928
+ [],
929
+ `${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`,
930
+ )
931
+ continue
932
+ }
933
+ if (raw === undefined) {
934
+ entry("unreadable", [], "TypeScript could not read its type")
935
+ continue
936
+ }
937
+ const returned = dataShape(raw)
938
+ if (returned.kind === "never") {
939
+ entry("no-data")
940
+ continue
941
+ }
942
+ const dropped =
943
+ declared === undefined || declared.kind === "never"
944
+ ? []
945
+ : undeclaredPaths(returned, declared)
946
+ if (declaration === null || (declaration !== undefined && declaration.hash === undefined)) {
947
+ entry("declared", dropped)
948
+ continue
949
+ }
950
+ if (returned.kind === "json" && returned.reason !== undefined) {
951
+ entry(
952
+ "unreadable",
953
+ dropped,
954
+ `the ${target.fn}'s return type is ${returned.reason}: annotate it, or write the schema by hand`,
955
+ )
956
+ continue
957
+ }
958
+ const body = schemaSource(returned)
959
+ if (declaration === undefined) {
960
+ const at = entry("missing")
961
+ const written = declarationText(target.schema, target.fn, body)
962
+ const text = target.insertAt === content.length ? `\n${written}\n` : `${written}\n\n`
963
+ replacements.push({ start: target.insertAt, end: target.insertAt, text, entry: at })
964
+ } else if (
965
+ declaration.hash === hashOf(body) &&
966
+ schemaTokens(declaration.value) === schemaTokens(body)
967
+ ) {
968
+ entry("current")
969
+ } else {
970
+ // The marker still matches what would be written, so the text under it was edited.
971
+ const edited = declaration.hash === hashOf(body)
972
+ const at = entry("stale", dropped, undefined, edited)
973
+ const text = declarationText(target.schema, target.fn, body)
974
+ replacements.push({ start: declaration.start, end: declaration.end, text, entry: at })
975
+ }
976
+ }
977
+ if (replacements.length === 0) continue
978
+ let next = content
979
+ for (const r of replacements.sort((a, b) => b.start - a.start))
980
+ next = `${next.slice(0, r.start)}${r.text}${next.slice(r.end)}`
981
+ const imported = withSchemaImport(next)
982
+ if (imported === undefined) {
983
+ for (const r of replacements) {
984
+ const blocked = entries[r.entry]
985
+ if (blocked !== undefined)
986
+ entries[r.entry] = {
987
+ ...blocked,
988
+ reason: "`t` already names something else in this file, so it was left as is",
989
+ }
990
+ }
991
+ continue
992
+ }
993
+ edits.set(file, imported)
994
+ }
995
+ entries.sort((a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line)
996
+ return { entries, edits, warnings }
997
+ }
998
+
999
+ // ---- entry points ------------------------------------------------------------------------------
1000
+
1001
+ const ROUTE_BACKEND_TS = /\.backend\.[cm]?ts$/
1002
+
1003
+ function routeBackends(dir: string): string[] {
1004
+ const routes = join(dir, "routes")
1005
+ if (!existsSync(routes)) return []
1006
+ return readdirSync(routes, { recursive: true, encoding: "utf8" })
1007
+ .map((file) => posix(join("routes", file)))
1008
+ .filter((file) => ROUTE_BACKEND_TS.test(file) && !file.split("/").includes("node_modules"))
1009
+ .sort(codeUnitOrder)
1010
+ }
1011
+
1012
+ export interface OutputSchemasReport {
1013
+ readonly ok: boolean
1014
+ readonly write: boolean
1015
+ readonly entries: readonly OutputSchemaEntry[]
1016
+ readonly written: readonly string[]
1017
+ readonly warnings: readonly string[]
1018
+ }
1019
+
1020
+ /**
1021
+ * Every route backend half under `<dir>/routes`: report what each loader's and action's schema needs,
1022
+ * and with `write`, write the missing schemas and refresh the stale generated ones. A hand-written
1023
+ * schema is never touched; its entry lists the returned fields it drops.
1024
+ */
1025
+ export async function outputSchemas(
1026
+ dir: string,
1027
+ options: { readonly write?: boolean } = {},
1028
+ ): Promise<OutputSchemasReport> {
1029
+ const write = options.write === true
1030
+ const { entries, edits, warnings } = await plan(dir, routeBackends(dir), (file) => {
1031
+ try {
1032
+ return readFileSync(join(dir, file), "utf8")
1033
+ } catch {
1034
+ return undefined
1035
+ }
1036
+ })
1037
+ const written: string[] = []
1038
+ let final = entries
1039
+ if (write) {
1040
+ for (const [file, content] of edits) {
1041
+ writeFileSync(join(dir, file), content)
1042
+ written.push(file)
1043
+ }
1044
+ final = entries.map((entry) =>
1045
+ !written.includes(entry.file) || entry.reason !== undefined
1046
+ ? entry
1047
+ : entry.status === "missing"
1048
+ ? { ...entry, status: "generated" }
1049
+ : entry.status === "stale"
1050
+ ? { ...entry, status: "regenerated" }
1051
+ : entry,
1052
+ )
1053
+ }
1054
+ return {
1055
+ ok: warnings.length === 0 && !final.some((entry) => entry.status === "stale"),
1056
+ write,
1057
+ entries: final,
1058
+ written,
1059
+ warnings,
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * The generated schemas among `files` whose route now returns a different shape (NF-C033), and those
1065
+ * it could not compare. `warnings` says why nothing could be read at all.
1066
+ */
1067
+ export async function staleGeneratedSchemas(
1068
+ root: string,
1069
+ files: readonly string[],
1070
+ read: (file: string) => string | undefined,
1071
+ ): Promise<{
1072
+ readonly stale: readonly OutputSchemaEntry[]
1073
+ readonly unchecked: readonly OutputSchemaEntry[]
1074
+ readonly warnings: readonly string[]
1075
+ }> {
1076
+ const marked = files.filter((file) => read(file)?.includes(GENERATED_MARK) === true)
1077
+ if (marked.length === 0) return { stale: [], unchecked: [], warnings: [] }
1078
+ const { entries, warnings } = await plan(root, marked, read)
1079
+ return {
1080
+ stale: entries.filter((entry) => entry.status === "stale"),
1081
+ unchecked: entries.filter((entry) => entry.generated && entry.status === "unreadable"),
1082
+ warnings,
1083
+ }
1084
+ }