@nifrajs/cli 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
@@ -4,12 +4,17 @@
4
4
  * The server enforces both at runtime, on every request: data a loader or action returns without an
5
5
  * output schema fails the request, and an output schema naming a sensitive field without
6
6
  * `t.declassified` fails the route when it loads. These report what source alone shows, before a
7
- * request ever does. NF-C030 is a warning because only running the loader proves it returns data.
7
+ * request ever does. NF-C030 is an error when the loader's own body returns a literal the runtime
8
+ * refuses (an object, array, string, number or boolean) and a warning otherwise, since only running
9
+ * the loader proves what an expression evaluates to.
8
10
  */
11
+ import { realpathSync } from "node:fs"
12
+ import { isAbsolute, join, relative } from "node:path"
9
13
  import { isSensitiveFieldName } from "@nifrajs/web/zones"
10
14
  import { codePositionMask } from "../check-scan.ts"
11
15
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
12
16
  import { codeUnitOrder } from "../internal/code-unit-order.ts"
17
+ import { createSpecifierResolver } from "../internal/project-specifiers.ts"
13
18
  import type { CheckRule } from "./index.ts"
14
19
  import { zonedFiles } from "./zones.ts"
15
20
 
@@ -18,7 +23,8 @@ const lineAt = (text: string, index: number): number => text.slice(0, index).spl
18
23
  const byPlace = (a: Diagnostic, b: Diagnostic): number =>
19
24
  codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0)
20
25
 
21
- const exported = (mask: string, name: string): RegExpExecArray | null =>
26
+ /** The export statement of `name` in a code-position mask: a declaration or an `export { }` list. */
27
+ export const exported = (mask: string, name: string): RegExpExecArray | null =>
22
28
  new RegExp(
23
29
  `\\bexport\\s+(?:(?:async\\s+)?function\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b|\\bexport\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`,
24
30
  ).exec(mask)
@@ -40,37 +46,129 @@ const closing = (mask: string, open: number): number => {
40
46
  return mask.length
41
47
  }
42
48
 
49
+ const opening = (mask: string, close: number): number => {
50
+ let depth = 0
51
+ for (let i = close; i >= 0; i--) {
52
+ const c = mask[i]
53
+ if (c === ")" || c === "}" || c === "]") depth++
54
+ else if (c === "(" || c === "{" || c === "[") {
55
+ depth--
56
+ if (depth === 0) return i
57
+ }
58
+ }
59
+ return 0
60
+ }
61
+
62
+ const CONTROL_HEAD = /(?:^|[^\w$.])(?:if|for(?:\s+await)?|while|catch|switch|with)\s*$/
63
+ const BLOCK_WORD = /(?:^|[^\w$.])(?:else|try|finally|do)$/
64
+
43
65
  /**
44
- * Whether the function exported as `name` may return data, read from its type-stripped source: an
45
- * arrow's expression body, or a `return <value>` that is not a redirect or a status.
66
+ * Whether the `{` at `open` starts a statement block of the enclosing function: after a control
67
+ * head, `else`/`try`/`finally`/`do`, or a statement boundary. A function, method, class, object or
68
+ * `case` body is not one, so a `return` inside it is never counted as the outer function's.
46
69
  */
47
- function mayReturnData(js: string, name: string): boolean {
70
+ function isStatementBlock(mask: string, open: number): boolean {
71
+ let p = open - 1
72
+ while (p >= 0 && /\s/.test(mask[p] ?? "")) p--
73
+ const prev = mask[p]
74
+ if (prev === undefined || prev === ";" || prev === "{" || prev === "}") return true
75
+ if (prev === ")") {
76
+ const head = opening(mask, p)
77
+ return CONTROL_HEAD.test(mask.slice(Math.max(0, head - 16), head))
78
+ }
79
+ return BLOCK_WORD.test(mask.slice(Math.max(0, p - 8), p + 1))
80
+ }
81
+
82
+ /** Offsets of the `return`s that belong to the block at `open` itself, not to a nested function. */
83
+ function ownReturns(mask: string, open: number, close: number): number[] {
84
+ const own: boolean[] = []
85
+ const found: number[] = []
86
+ for (let i = open; i < close; i++) {
87
+ const c = mask[i]
88
+ if (c === "{") own.push(i === open || (own.at(-1) === true && isStatementBlock(mask, i)))
89
+ else if (c === "}") own.pop()
90
+ else if (
91
+ c === "r" &&
92
+ own.at(-1) === true &&
93
+ mask.startsWith("return", i) &&
94
+ !/[\w$.]/.test(mask[i - 1] ?? "") &&
95
+ !/[\w$]/.test(mask[i + 6] ?? "")
96
+ ) {
97
+ found.push(i)
98
+ }
99
+ }
100
+ return found
101
+ }
102
+
103
+ const NUMBER = /^-?(?:0[xob][\da-f_]+|(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:e[+-]?\d+)?)n?$/i
104
+
105
+ /**
106
+ * Whether the expression at `start` is one literal the runtime refuses without an output schema: an
107
+ * object, array, string, template, number or boolean, alone, not part of a larger expression.
108
+ */
109
+ function isDataLiteral(js: string, mask: string, start: number): boolean {
110
+ let from = start
111
+ let to = expressionEnd(mask, start)
112
+ for (;;) {
113
+ // Skip on the source: the mask blanks a string literal, quotes included.
114
+ while (from < to && /\s/.test(js[from] ?? "")) from++
115
+ while (to > from && /\s/.test(js[to - 1] ?? "")) to--
116
+ if (from >= to) return false
117
+ if (mask[from] !== "(" || closing(mask, from) !== to - 1) break
118
+ from++
119
+ to--
120
+ }
121
+ const first = mask[from]
122
+ if (first === "{" || first === "[") return closing(mask, from) === to - 1
123
+ const text = mask.slice(from, to)
124
+ if (text.trim() === "") {
125
+ const quote = js[from] ?? ""
126
+ return `"'\``.includes(quote) && js[to - 1] === quote
127
+ }
128
+ return NUMBER.test(text) || text === "true" || text === "false"
129
+ }
130
+
131
+ type DataReturn = "none" | "possible" | "proven"
132
+
133
+ /**
134
+ * What the function exported as `name` returns, read from its type-stripped source. `proven` when
135
+ * its expression body, or a `return` of its own body, is a data literal; `possible` when an
136
+ * expression body or some `return <value>` is not a redirect or a status; `none` otherwise.
137
+ */
138
+ function dataReturn(js: string, name: string): DataReturn {
48
139
  const mask = codePositionMask(js)
49
140
  const found = exported(mask, name)
50
141
  if (found === null || found[0].startsWith("export {") || found[0].startsWith("export{")) {
51
- return true // declared elsewhere in the file and re-exported: assume it does
142
+ return "possible" // declared elsewhere in the file and re-exported: assume it does
52
143
  }
53
144
  const params = mask.indexOf("(", found.index + found[0].length)
54
- if (params === -1) return true
145
+ if (params === -1) return "possible"
55
146
  let at = closing(mask, params) + 1
56
147
  while (/\s/.test(mask[at] ?? "")) at++
57
148
  if (mask.startsWith("=>", at)) {
58
149
  at += 2
59
- while (/\s/.test(mask[at] ?? "")) at++
60
- if (mask[at] !== "{") return !CONTROL_FLOW.test(js.slice(at, at + 40))
150
+ while (/\s/.test(js[at] ?? "")) at++
151
+ if (mask[at] !== "{") {
152
+ if (CONTROL_FLOW.test(js.slice(at, at + 40))) return "none"
153
+ return isDataLiteral(js, mask, at) ? "proven" : "possible"
154
+ }
61
155
  }
62
- if (mask[at] !== "{") return true
63
- const body = mask.slice(at, closing(mask, at))
156
+ if (mask[at] !== "{") return "possible"
157
+ const end = closing(mask, at)
158
+ for (const index of ownReturns(mask, at, end)) {
159
+ if (isDataLiteral(js, mask, index + "return".length)) return "proven"
160
+ }
161
+ const body = mask.slice(at, end)
64
162
  for (const match of body.matchAll(/\breturn\b/g)) {
65
163
  const from = at + match.index + "return".length
66
164
  const rest = js.slice(from, from + 40).replace(/^[ \t]+/, "")
67
- if (!CONTROL_FLOW.test(rest)) return true
165
+ if (!CONTROL_FLOW.test(rest)) return "possible"
68
166
  }
69
- return false
167
+ return "none"
70
168
  }
71
169
 
72
170
  /** Where an expression starting at `start` ends: a `,`, `;` or closer at its own depth. */
73
- function expressionEnd(mask: string, start: number): number {
171
+ export function expressionEnd(mask: string, start: number): number {
74
172
  let depth = 0
75
173
  for (let i = start; i < mask.length; i++) {
76
174
  const c = mask[i]
@@ -135,6 +233,8 @@ export interface MissingOutputSchema {
135
233
  readonly name: "loader" | "action"
136
234
  readonly schema: "loaderOutput" | "actionOutput"
137
235
  readonly line: number
236
+ /** Its own body returns a data literal, so every request that reaches that return fails. */
237
+ readonly proven: boolean
138
238
  }
139
239
 
140
240
  let transpiler: Bun.Transpiler | undefined
@@ -156,7 +256,10 @@ export function missingOutputSchemas(content: string): MissingOutputSchema[] {
156
256
  } catch {
157
257
  js = content
158
258
  }
159
- if (mayReturnData(js, name)) missing.push({ name, schema, line: lineAt(content, fn.index) })
259
+ const returns = dataReturn(js, name)
260
+ if (returns !== "none") {
261
+ missing.push({ name, schema, line: lineAt(content, fn.index), proven: returns === "proven" })
262
+ }
160
263
  }
161
264
  return missing
162
265
  }
@@ -170,15 +273,22 @@ export const missingOutputRule: CheckRule = {
170
273
  if (classification.zone !== "route-backend") continue
171
274
  const content = ctx.sources.read(file)
172
275
  if (content === undefined) continue
173
- for (const { name, schema, line } of missingOutputSchemas(content)) {
276
+ for (const { name, schema, line, proven } of missingOutputSchemas(content)) {
277
+ const returns = proven
278
+ ? "It returns a data literal, so every request that reaches that return fails"
279
+ : "Data it returns fails the request"
174
280
  findings.push(
175
281
  diagnostic({
176
282
  code: "NF-C030",
177
- severity: "warn",
283
+ severity: proven ? "error" : "warn",
178
284
  file,
179
285
  line,
180
- message: `${file} exports a ${name} but no ${schema}. Data it returns fails the request: everything sent to the browser needs an output schema. Export one: export const ${schema} = t.object({ ... })`,
181
- evidence: [`export: ${name}`, `missing: ${schema}`],
286
+ message: `${file} exports a ${name} but no ${schema}. ${returns}: everything sent to the browser needs an output schema. Export one: export const ${schema} = t.object({ ... })`,
287
+ evidence: [
288
+ `export: ${name}`,
289
+ `missing: ${schema}`,
290
+ ...(proven ? ["returns: a data literal"] : []),
291
+ ],
182
292
  verify: "nifra check",
183
293
  }),
184
294
  )
@@ -191,35 +301,170 @@ export const missingOutputRule: CheckRule = {
191
301
  const OUTPUT_DECLARATION =
192
302
  /\bexport\s+(?:const|let|var)\s+(?:loaderOutput|actionOutput)\b[^=]*=|\boutput\s*:/g
193
303
 
304
+ /** The initializer of the top-level `const`/`let`/`var` named `name`, as an offset just past its `=`. */
305
+ function declarationOf(mask: string, name: string): number | undefined {
306
+ const declaration = new RegExp(
307
+ `(?:^|\\n)(?:export\\s+)?(?:const|let|var)\\s+${name.replaceAll("$", "\\$")}(?![\\w$])[^=]*=`,
308
+ ).exec(mask)
309
+ return declaration === null ? undefined : declaration.index + declaration[0].length
310
+ }
311
+
312
+ /**
313
+ * Where the schema an output declaration names is written: the expression itself, or, when it is a
314
+ * bare name, the initializer of the top-level declaration of that name in the same file, followed
315
+ * through aliases.
316
+ */
317
+ function schemaExpression(mask: string, start: number): { start: number; end: number } {
318
+ let range = { start, end: expressionEnd(mask, start) }
319
+ const seen = new Set<string>()
320
+ for (;;) {
321
+ const name = mask.slice(range.start, range.end).trim()
322
+ if (!/^[A-Za-z_$][\w$]*$/.test(name) || seen.has(name)) return range
323
+ seen.add(name)
324
+ const from = declarationOf(mask, name)
325
+ if (from === undefined) return range
326
+ range = { start: from, end: expressionEnd(mask, from) }
327
+ }
328
+ }
329
+
330
+ /** A name used as a value: not an object key, a member, a callee, or the object of a member. */
331
+ const VALUE_NAME = /(?<![\w$.])[A-Za-z_$][\w$]*(?![\w$])(?!\s*[:(.])/g
332
+
333
+ /** The module `name` is imported from as a named import, and its name there. */
334
+ function importOf(
335
+ source: SchemaSource,
336
+ name: string,
337
+ ): { readonly specifier: string; readonly name: string } | undefined {
338
+ // The mask blanks the specifier string, so it is read from the source past `from`.
339
+ for (const clause of source.mask.matchAll(/\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\b/g)) {
340
+ const quoted = /^\s*(["'])([^"']+)\1/.exec(
341
+ source.content.slice(clause.index + clause[0].length),
342
+ )
343
+ if (quoted === null) continue
344
+ for (const element of (clause[1] ?? "").split(",")) {
345
+ const [imported, local = imported] = element
346
+ .trim()
347
+ .replace(/^type\s+/, "")
348
+ .split(/\s+as\s+/)
349
+ if (local === name && imported !== undefined)
350
+ return { specifier: quoted[2] ?? "", name: imported }
351
+ }
352
+ }
353
+ return undefined
354
+ }
355
+
356
+ interface SchemaSource {
357
+ readonly file: string
358
+ readonly content: string
359
+ readonly mask: string
360
+ }
361
+
362
+ interface SchemaRange {
363
+ readonly source: SchemaSource
364
+ readonly start: number
365
+ readonly end: number
366
+ }
367
+
368
+ /**
369
+ * Every place an output schema is written: its own expression, then each schema it names inside it
370
+ * (`t.object({ user: userSchema })`), followed to this file's top-level declaration of that name or
371
+ * to the project file it is imported from. A schema under `declassified(...)` is not followed.
372
+ */
373
+ function schemaRanges(
374
+ origin: SchemaSource,
375
+ start: number,
376
+ load: (from: string, specifier: string) => SchemaSource | undefined,
377
+ ): SchemaRange[] {
378
+ const ranges: SchemaRange[] = [{ source: origin, ...schemaExpression(origin.mask, start) }]
379
+ const seen = new Set<string>()
380
+ // An array iterator reads the length each step, so ranges pushed below are walked too.
381
+ for (const { source, start: from, end } of ranges) {
382
+ const declassified: Array<readonly [number, number]> = []
383
+ for (const call of source.mask.slice(from, end).matchAll(/\bdeclassified\s*\(/g)) {
384
+ const open = from + call.index + call[0].length - 1
385
+ declassified.push([open, closing(source.mask, open)])
386
+ }
387
+ for (const reference of source.mask.slice(from, end).matchAll(VALUE_NAME)) {
388
+ const at = from + reference.index
389
+ if (declassified.some(([open, close]) => at > open && at < close)) continue
390
+ const name = reference[0]
391
+ if (seen.has(`${source.file}#${name}`)) continue
392
+ seen.add(`${source.file}#${name}`)
393
+ const local = declarationOf(source.mask, name)
394
+ if (local !== undefined) {
395
+ ranges.push({ source, start: local, end: expressionEnd(source.mask, local) })
396
+ continue
397
+ }
398
+ const imported = importOf(source, name)
399
+ const target = imported === undefined ? undefined : load(source.file, imported.specifier)
400
+ const there =
401
+ target === undefined ? undefined : declarationOf(target.mask, imported?.name ?? "")
402
+ if (target !== undefined && there !== undefined) {
403
+ ranges.push({ source: target, start: there, end: expressionEnd(target.mask, there) })
404
+ }
405
+ }
406
+ }
407
+ return ranges
408
+ }
409
+
194
410
  export const sensitiveOutputRule: CheckRule = {
195
411
  code: "NF-C031",
196
412
  title: "Sensitive field in an output schema",
197
413
  async scan(ctx) {
198
414
  const findings: Diagnostic[] = []
415
+ const resolve = createSpecifierResolver()
416
+ const realRoot = (() => {
417
+ try {
418
+ return realpathSync(ctx.root)
419
+ } catch {
420
+ return ctx.root
421
+ }
422
+ })()
423
+ const sources = new Map<string, SchemaSource | undefined>()
424
+ const sourceOf = (file: string): SchemaSource | undefined => {
425
+ if (!sources.has(file)) {
426
+ const content = ctx.sources.read(file)
427
+ sources.set(
428
+ file,
429
+ content === undefined ? undefined : { file, content, mask: codePositionMask(content) },
430
+ )
431
+ }
432
+ return sources.get(file)
433
+ }
434
+ // Only project source: an installed package's schemas are not the app's to rename.
435
+ const load = (from: string, specifier: string): SchemaSource | undefined => {
436
+ const target = resolve(join(ctx.root, from), specifier)
437
+ if (target === undefined) return undefined
438
+ const rel = relative(realRoot, target).replaceAll("\\", "/")
439
+ if (rel.startsWith("../") || isAbsolute(rel) || rel.split("/").includes("node_modules")) {
440
+ return undefined
441
+ }
442
+ return sourceOf(rel)
443
+ }
444
+ // A schema several declarations name, or several routes import, is reported once.
445
+ const reported = new Set<string>()
199
446
  for (const { file, classification } of zonedFiles(ctx).files) {
200
447
  if (classification.zone !== "route-backend" && classification.zone !== "fn") continue
201
- const content = ctx.sources.read(file)
202
- if (content === undefined) continue
203
- const mask = codePositionMask(content)
204
- for (const declaration of mask.matchAll(OUTPUT_DECLARATION)) {
205
- const start = declaration.index + declaration[0].length
206
- for (const { key, index } of sensitiveKeys(
207
- content,
208
- mask,
209
- start,
210
- expressionEnd(mask, start),
211
- )) {
212
- findings.push(
213
- diagnostic({
214
- code: "NF-C031",
215
- severity: "error",
216
- file,
217
- line: lineAt(content, index),
218
- message: `${file} declares "${key}" in an output schema, a field name that usually holds a credential or personal identifier. Remove it, or wrap its schema in t.declassified("why it may reach the browser", ...)`,
219
- evidence: [`field: ${key}`],
220
- verify: "nifra check",
221
- }),
222
- )
448
+ const origin = sourceOf(file)
449
+ if (origin === undefined) continue
450
+ for (const declaration of origin.mask.matchAll(OUTPUT_DECLARATION)) {
451
+ const ranges = schemaRanges(origin, declaration.index + declaration[0].length, load)
452
+ for (const { source, start, end } of ranges) {
453
+ for (const { key, index } of sensitiveKeys(source.content, source.mask, start, end)) {
454
+ if (reported.has(`${source.file}:${index}`)) continue
455
+ reported.add(`${source.file}:${index}`)
456
+ findings.push(
457
+ diagnostic({
458
+ code: "NF-C031",
459
+ severity: "error",
460
+ file: source.file,
461
+ line: lineAt(source.content, index),
462
+ message: `${source.file} declares "${key}" in an output schema, a field name that usually holds a credential or personal identifier. Remove it, or wrap its schema in t.declassified("why it may reach the browser", ...)`,
463
+ evidence: [`field: ${key}`],
464
+ verify: "nifra check",
465
+ }),
466
+ )
467
+ }
223
468
  }
224
469
  }
225
470
  }
@@ -227,4 +472,68 @@ export const sensitiveOutputRule: CheckRule = {
227
472
  },
228
473
  }
229
474
 
230
- export const dataGuardRules = Object.freeze([missingOutputRule, sensitiveOutputRule])
475
+ export const staleGeneratedOutputRule: CheckRule = {
476
+ code: "NF-C033",
477
+ title: "Generated output schema out of date",
478
+ async scan(ctx) {
479
+ const files = zonedFiles(ctx)
480
+ .files.filter((entry) => entry.classification.zone === "route-backend")
481
+ .map((entry) => entry.file)
482
+ .filter((file) => ctx.sources.read(file)?.includes("nifra:generated") === true)
483
+ if (files.length === 0) return []
484
+ const { staleGeneratedSchemas } = await import("../output-schemas.ts")
485
+ const { stale, unchecked, warnings } = await staleGeneratedSchemas(ctx.root, files, (file) =>
486
+ ctx.sources.read(file),
487
+ )
488
+ // A generated schema that could not be compared is reported, never passed silently.
489
+ const notChecked = [
490
+ ...warnings.map((warning) =>
491
+ diagnostic({
492
+ code: "NF-C033",
493
+ severity: "warn",
494
+ message: `${files.length} generated output schema(s) were not checked: ${warning}`,
495
+ evidence: files.map((file) => `file: ${file}`),
496
+ verify: "nifra schemas",
497
+ }),
498
+ ),
499
+ ...unchecked.map((entry) =>
500
+ diagnostic({
501
+ code: "NF-C033",
502
+ severity: "warn",
503
+ file: entry.file,
504
+ line: entry.line,
505
+ message: `${entry.file}'s generated ${entry.schema} was not checked: ${entry.reason ?? "its type could not be read"}`,
506
+ evidence: [`schema: ${entry.schema}`],
507
+ verify: "nifra schemas",
508
+ }),
509
+ ),
510
+ ]
511
+ return [
512
+ ...notChecked,
513
+ ...stale.map((entry) => {
514
+ const fn = entry.schema === "loaderOutput" ? "loader" : "action"
515
+ const change =
516
+ entry.edited === true
517
+ ? "it was edited by hand since"
518
+ : entry.dropped.length > 0
519
+ ? `it now returns ${entry.dropped.join(", ")}, which the schema drops`
520
+ : "what it returns has changed"
521
+ return diagnostic({
522
+ code: "NF-C033",
523
+ severity: "error",
524
+ file: entry.file,
525
+ line: entry.line,
526
+ message: `${entry.file}'s ${entry.schema} was generated from the ${fn}'s return type, and ${change}. Run \`nifra schemas --write\` to regenerate it, or delete its "nifra:generated" line to own it by hand.`,
527
+ evidence: [`schema: ${entry.schema}`, ...entry.dropped.map((path) => `dropped: ${path}`)],
528
+ verify: "nifra schemas",
529
+ })
530
+ }),
531
+ ]
532
+ },
533
+ }
534
+
535
+ export const dataGuardRules = Object.freeze([
536
+ missingOutputRule,
537
+ sensitiveOutputRule,
538
+ staleGeneratedOutputRule,
539
+ ])
@@ -0,0 +1,22 @@
1
+ import { retiredRootFiles } from "../app-files.ts"
2
+ import { diagnostic } from "../diagnostics.ts"
3
+ import type { CheckRule } from "./index.ts"
4
+
5
+ /** Every gate that reads backend/app.ts sees nothing in a retired root file, so the check fails here. */
6
+ export const retiredLayoutRule: CheckRule = {
7
+ code: "NF-C038",
8
+ title: "Retired root layout",
9
+ async scan(ctx) {
10
+ return retiredRootFiles(ctx.root).map(([from, to]) =>
11
+ diagnostic({
12
+ code: "NF-C038",
13
+ severity: "error",
14
+ file: from,
15
+ line: 1,
16
+ message: `${from} is the retired root layout; nifra reads it at ${to}. Until it moves, nifra check runs no contract gate on this app, and the commands that load it refuse it. \`nifra migrate layout\` shows the move; \`--write\` makes it`,
17
+ fix: { recipe: "layout.migrate", command: "nifra migrate layout --write" },
18
+ verify: "nifra check --lints-only",
19
+ }),
20
+ )
21
+ },
22
+ }