@nifrajs/cli 4.0.2 → 5.1.0

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