@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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * NF-C027: a page file whose URL sits under the backend's mount path.
3
3
  *
4
- * `backend/app.ts`'s backend is mounted at `apiPrefix` (default `/api`) ahead of page routing, and
4
+ * `backend/app.ts`'s backend is mounted at `apiPrefix`, when one is set, ahead of page routing, and
5
5
  * its 404 is final, so `routes/api/report.tsx` never renders - every request for it gets the
6
6
  * backend's 404. `createWebApp` refuses such an app at startup and `nifra build` refuses to build
7
7
  * it; this reports it earlier, without running app code (check's pre-`loadApp` invariant): the
@@ -14,11 +14,16 @@
14
14
  import { existsSync, readFileSync } from "node:fs"
15
15
  import { join } from "node:path"
16
16
  import { discoverRoutes } from "@nifrajs/web/fs"
17
- import { shadowedPages } from "@nifrajs/web/route-manifest"
17
+ import { BACKEND_ROUTE_EXPORTS, shadowedPages } from "@nifrajs/web/route-manifest"
18
+ import type * as TSApi from "typescript"
18
19
  import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
19
20
  import { stripComments } from "../check-scan.ts"
20
21
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
22
+ import { codeUnitOrder } from "../internal/code-unit-order.ts"
23
+ import { hasWebConfig } from "../internal/nifra-project.ts"
24
+ import { scriptKindOf } from "../internal/script-kind.ts"
21
25
  import type { CheckRule } from "./index.ts"
26
+ import { loadRuleTypeScript } from "./typescript.ts"
22
27
 
23
28
  /** What a static read of a framework config says about `apiPrefix`. */
24
29
  export type StaticApiPrefix =
@@ -51,7 +56,7 @@ export const shadowedPageRule: CheckRule = {
51
56
  if (!existsSync(backendFile) || !existsSync(routesDir)) return []
52
57
  if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
53
58
 
54
- let apiPrefix = "/api"
59
+ let apiPrefix = ""
55
60
  // backend/framework.ts first: the generated server entry imports from it, and the build refuses
56
61
  // a nifra.config.ts whose forwarded fields differ from it.
57
62
  const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
@@ -96,4 +101,476 @@ export const shadowedPageRule: CheckRule = {
96
101
  },
97
102
  }
98
103
 
99
- export const pageRules = Object.freeze([shadowedPageRule])
104
+ const IN_LAYOUTS = /\binLayouts\b/
105
+
106
+ /** An import of the root layout from a page beside it, statically or dynamically. */
107
+ const importsRootLayout = (code: string, layoutFile: string): boolean => {
108
+ const stem = layoutFile.replace(/\.[^./]+$/, "")
109
+ return [`"./${stem}`, `'./${stem}`].some((quoted) =>
110
+ [`${quoted}"`, `${quoted}'`, `${quoted}.`].some((opening) => {
111
+ for (let at = code.indexOf(opening); at !== -1; at = code.indexOf(opening, at + 1)) {
112
+ const before = code.slice(Math.max(0, at - 16), at)
113
+ if (/(?:\bfrom|\bimport)\s*\(?\s*$/.test(before)) return true
114
+ }
115
+ return false
116
+ }),
117
+ )
118
+ }
119
+
120
+ /**
121
+ * NF-C034: a root status page (`_404`, `_<status>`) beside a root `_layout` that imports that layout
122
+ * to wrap itself. Status pages render inside their layouts, so such a page renders the layout twice.
123
+ * A page that names `inLayouts` at all has decided, so it is not reported.
124
+ */
125
+ export const statusLayoutRule: CheckRule = {
126
+ code: "NF-C034",
127
+ title: "Root status page wraps the root layout itself",
128
+ async scan(ctx) {
129
+ const routesDir = join(ctx.root, "routes")
130
+ if (!existsSync(routesDir)) return []
131
+ let manifest: ReturnType<typeof discoverRoutes>
132
+ try {
133
+ manifest = discoverRoutes(routesDir)
134
+ } catch {
135
+ return []
136
+ }
137
+ const layout = manifest.layouts._layout
138
+ if (layout === undefined) return []
139
+ const pages = [
140
+ ...(manifest.notFound === undefined ? [] : [manifest.notFound.file]),
141
+ ...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
142
+ ]
143
+ const findings: Diagnostic[] = []
144
+ for (const file of pages.sort()) {
145
+ const source = stripComments(readFileSync(join(routesDir, file), "utf8"))
146
+ if (IN_LAYOUTS.test(source) || !importsRootLayout(source, layout.file)) continue
147
+ findings.push(
148
+ diagnostic({
149
+ code: "NF-C034",
150
+ severity: "warn",
151
+ file: `routes/${file}`,
152
+ message: `routes/${file} imports routes/${layout.file} to wrap itself, and status pages render inside their layouts, so it renders routes/${layout.file} twice. Remove the wrap, or add \`export const inLayouts = false\` to keep rendering the page alone.`,
153
+ evidence: [`layout: routes/${layout.file}`],
154
+ verify: "nifra check",
155
+ }),
156
+ )
157
+ }
158
+ return findings
159
+ },
160
+ }
161
+
162
+ /** The two framework configs' sources, `undefined` for one the app lacks. */
163
+ export interface ApiPrefixSources {
164
+ readonly framework: string | undefined
165
+ readonly config: string | undefined
166
+ }
167
+
168
+ /** `apiPrefix` as the app runs it: nifra.config.ts's own, else backend/framework.ts's, which every
169
+ * command takes for an option nifra.config.ts leaves unset. */
170
+ function effectiveApiPrefix({ framework, config }: ApiPrefixSources): StaticApiPrefix {
171
+ const own: StaticApiPrefix =
172
+ config === undefined ? { kind: "default" } : readStaticApiPrefix(config)
173
+ return own.kind !== "default" || framework === undefined ? own : readStaticApiPrefix(framework)
174
+ }
175
+
176
+ /**
177
+ * NF-C035: `backend/app.ts` exports a backend that is not served over HTTP, so a browser call to `/api`
178
+ * reaches page routing. The framework config sets no `apiPrefix`; loader-only is a valid choice, and
179
+ * the finding asks for it to be stated. An unreadable `apiPrefix` is NF-C027's.
180
+ */
181
+ export const implicitApiMountRule: CheckRule = {
182
+ code: "NF-C035",
183
+ title: "Backend not served over HTTP",
184
+ async scan(ctx) {
185
+ const backendFile = join(ctx.root, BACKEND_APP_FILE)
186
+ // An API-only app serves its backend itself; there is no page routing to fall into.
187
+ if (!existsSync(backendFile) || !hasWebConfig(ctx.root, ".")) return []
188
+ if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
189
+ const read = (file: string): string | undefined =>
190
+ existsSync(join(ctx.root, file)) ? readFileSync(join(ctx.root, file), "utf8") : undefined
191
+ const sources = { framework: read(FRAMEWORK_FILE), config: read(CONFIG_FILE) }
192
+ if (sources.framework === undefined && sources.config === undefined) return []
193
+ if (effectiveApiPrefix(sources).kind !== "default") return []
194
+ const file = sources.framework === undefined ? CONFIG_FILE : FRAMEWORK_FILE
195
+ return [
196
+ diagnostic({
197
+ code: "NF-C035",
198
+ severity: "warn",
199
+ file,
200
+ message: `${BACKEND_APP_FILE}'s backend is not served over HTTP, because ${file} sets no apiPrefix: a browser call to /api reaches page routing instead. Add \`export const apiPrefix = "/api"\` to ${file} to serve it there, as 4.x did by default, or \`export const apiPrefix = ""\` to keep it loader-only.`,
201
+ fix: { recipe: "framework.api-prefix", command: "nifra fix --code NF-C035" },
202
+ verify: "nifra check",
203
+ }),
204
+ ]
205
+ },
206
+ }
207
+
208
+ const appendLine = (source: string, line: string): string =>
209
+ source === "" ? line : `${source}${source.endsWith("\n") ? "" : "\n"}${line}`
210
+
211
+ /** Append `export const apiPrefix = "/api"` to a framework config that sets none, creating it if needed. */
212
+ export function withDefaultApiPrefix(source: string | undefined): string | undefined {
213
+ if (source !== undefined && readStaticApiPrefix(source).kind !== "default") return undefined
214
+ return appendLine(source ?? "", 'export const apiPrefix = "/api"\n')
215
+ }
216
+
217
+ /**
218
+ * The edit that serves the backend at `/api`: the mount in backend/framework.ts, where the server
219
+ * entry reads it, or in nifra.config.ts when it is the only config. A file that needs no edit comes
220
+ * back `undefined`; an `apiPrefix` either file sets is never replaced.
221
+ */
222
+ export function apiPrefixFix(sources: ApiPrefixSources): ApiPrefixSources {
223
+ if (effectiveApiPrefix(sources).kind !== "default")
224
+ return { framework: undefined, config: undefined }
225
+ return sources.framework === undefined
226
+ ? { framework: undefined, config: withDefaultApiPrefix(sources.config) }
227
+ : { framework: withDefaultApiPrefix(sources.framework), config: undefined }
228
+ }
229
+
230
+ const LOADER_EXPORT =
231
+ /\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+loader\b|\bexport\s*\{[^}]*\bloader\b[^}]*\}/
232
+ const SCRIPT_LAYOUT = /\.[cm]?[jt]sx?$/
233
+
234
+ // Read from the node, not `ts.getModifiers`: TypeScript 7's API has no getModifiers or canHaveModifiers.
235
+ const hasModifier = (
236
+ node: TSApi.VariableStatement | TSApi.FunctionDeclaration | TSApi.ClassDeclaration,
237
+ kind: TSApi.SyntaxKind,
238
+ ): boolean => node.modifiers?.some((m) => m.kind === kind) === true
239
+
240
+ /** The function an exported `name` names: a declaration, or a const initialized with one. */
241
+ function exportedFunction(
242
+ ts: typeof TSApi,
243
+ tree: TSApi.SourceFile,
244
+ name: string,
245
+ ): TSApi.FunctionDeclaration | TSApi.ArrowFunction | TSApi.FunctionExpression | undefined {
246
+ for (const statement of tree.statements) {
247
+ if (ts.isFunctionDeclaration(statement)) {
248
+ if (statement.name?.text === name && hasModifier(statement, ts.SyntaxKind.ExportKeyword))
249
+ return statement
250
+ continue
251
+ }
252
+ if (!ts.isVariableStatement(statement)) continue
253
+ if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue
254
+ for (const declaration of statement.declarationList.declarations) {
255
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
256
+ let init = declaration.initializer
257
+ while (
258
+ init !== undefined &&
259
+ (ts.isParenthesizedExpression(init) ||
260
+ ts.isAsExpression(init) ||
261
+ ts.isSatisfiesExpression(init))
262
+ ) {
263
+ init = init.expression
264
+ }
265
+ if (init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
266
+ return init
267
+ }
268
+ }
269
+ return undefined
270
+ }
271
+
272
+ /** Where an exported function `meta` reads its args' `data`: a destructured `data`, or `args.data`. */
273
+ export function metaDataReads(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
274
+ const meta = exportedFunction(ts, tree, "meta")
275
+ const param = meta?.parameters[0]
276
+ if (param === undefined) return []
277
+ const lineOf = (node: TSApi.Node) =>
278
+ tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
279
+ if (ts.isObjectBindingPattern(param.name)) {
280
+ return param.name.elements
281
+ .filter((element) => (element.propertyName ?? element.name).getText(tree) === "data")
282
+ .map(lineOf)
283
+ }
284
+ if (!ts.isIdentifier(param.name) || meta?.body === undefined) return []
285
+ const args = param.name.text
286
+ const reads: number[] = []
287
+ const visit = (node: TSApi.Node): void => {
288
+ if (
289
+ ts.isPropertyAccessExpression(node) &&
290
+ ts.isIdentifier(node.expression) &&
291
+ node.expression.text === args &&
292
+ node.name.text === "data"
293
+ ) {
294
+ reads.push(lineOf(node))
295
+ }
296
+ ts.forEachChild(node, visit)
297
+ }
298
+ visit(meta.body)
299
+ return reads
300
+ }
301
+
302
+ /**
303
+ * NF-C040: a layout whose function `meta` reads `data` and which has no loader. A layout's `meta`
304
+ * receives its own loader data, `null` without a loader, so `data` there is always `null`; the page's
305
+ * data is `pageData`. Until 5.0 a layout's `meta` received the page's data as `data`, which is what such
306
+ * a `meta` was written against. A layout with a loader reading `data` reads its own, so it is not reported.
307
+ */
308
+ export const layoutMetaDataRule: CheckRule = {
309
+ code: "NF-C040",
310
+ title: "Layout meta reads data its layout never loads",
311
+ async scan(ctx) {
312
+ const routesDir = join(ctx.root, "routes")
313
+ if (!existsSync(routesDir)) return []
314
+ let manifest: ReturnType<typeof discoverRoutes>
315
+ try {
316
+ manifest = discoverRoutes(routesDir)
317
+ } catch {
318
+ return []
319
+ }
320
+ const layouts = Object.values(manifest.layouts)
321
+ .filter((layout) => SCRIPT_LAYOUT.test(layout.file))
322
+ .sort((a, b) => codeUnitOrder(a.file, b.file))
323
+ if (layouts.length === 0) return []
324
+ const loaded = await loadRuleTypeScript(ctx)
325
+ const ts = loaded.compiler
326
+ if (ts === undefined) return []
327
+ const findings: Diagnostic[] = []
328
+ for (const layout of layouts) {
329
+ const halves = [layout.file, ...(layout.backend === undefined ? [] : [layout.backend])]
330
+ const loads = halves.some((file) =>
331
+ LOADER_EXPORT.test(stripComments(readFileSync(join(routesDir, file), "utf8"))),
332
+ )
333
+ if (loads) continue
334
+ const text = readFileSync(join(routesDir, layout.file), "utf8")
335
+ const tree = ts.createSourceFile(
336
+ layout.file,
337
+ text,
338
+ ts.ScriptTarget.Latest,
339
+ true,
340
+ scriptKindOf(ts, layout.file),
341
+ )
342
+ for (const line of metaDataReads(ts, tree)) {
343
+ findings.push(
344
+ diagnostic({
345
+ code: "NF-C040",
346
+ severity: "warn",
347
+ file: `routes/${layout.file}`,
348
+ line,
349
+ message: `routes/${layout.file}'s meta reads \`data\`, which is this layout's own loader data, and the layout has no loader, so it is always null. Read the page's data from \`pageData\` instead.`,
350
+ evidence: [
351
+ "data: the layout's loader data (null without a loader)",
352
+ "pageData: the page's loader data",
353
+ ],
354
+ verify: "nifra check",
355
+ }),
356
+ )
357
+ }
358
+ }
359
+ return findings
360
+ },
361
+ }
362
+
363
+ const GATE_EXPORT = /\bexport\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*true\b/
364
+ const MIDDLEWARE_EXPORT =
365
+ /\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+middleware\b|\bexport\s*\{[^}]*\bmiddleware\b[^}]*\}/
366
+
367
+ /** Lines where an exported `loader` refuses access: `redirect()`, `statusPage(401|403)`, `new Response()`. */
368
+ export function loaderRefusals(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
369
+ const loader = exportedFunction(ts, tree, "loader")
370
+ if (loader?.body === undefined) return []
371
+ const lines: number[] = []
372
+ const lineOf = (node: TSApi.Node): number =>
373
+ tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
374
+ const visit = (node: TSApi.Node): void => {
375
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
376
+ const status = node.arguments[0]
377
+ if (
378
+ node.expression.text === "redirect" ||
379
+ (node.expression.text === "statusPage" &&
380
+ status !== undefined &&
381
+ ts.isNumericLiteral(status) &&
382
+ (status.text === "401" || status.text === "403"))
383
+ )
384
+ lines.push(lineOf(node))
385
+ } else if (
386
+ ts.isNewExpression(node) &&
387
+ ts.isIdentifier(node.expression) &&
388
+ node.expression.text === "Response"
389
+ ) {
390
+ lines.push(lineOf(node))
391
+ }
392
+ ts.forEachChild(node, visit)
393
+ }
394
+ visit(loader.body)
395
+ return lines
396
+ }
397
+
398
+ /**
399
+ * NF-C042: a layout loader that refuses access - a redirect, a 401 or 403, a `Response` - in a layout
400
+ * that is not a gate and exports no middleware. A client navigation that keeps the layout skips that
401
+ * loader, and an action below it runs without it, so the check reads as a guard and is not one. A
402
+ * refusal inside a helper the loader calls is not seen; the development warning covers that.
403
+ */
404
+ export const ungatedLayoutRefusalRule: CheckRule = {
405
+ code: "NF-C042",
406
+ title: "Layout loader refuses access without a gate",
407
+ async scan(ctx) {
408
+ const routesDir = join(ctx.root, "routes")
409
+ if (!existsSync(routesDir)) return []
410
+ let manifest: ReturnType<typeof discoverRoutes>
411
+ try {
412
+ manifest = discoverRoutes(routesDir)
413
+ } catch {
414
+ return []
415
+ }
416
+ const layouts = Object.values(manifest.layouts)
417
+ .filter((layout) => SCRIPT_LAYOUT.test(layout.file))
418
+ .sort((a, b) => codeUnitOrder(a.file, b.file))
419
+ if (layouts.length === 0) return []
420
+ const sources = layouts.map((layout) =>
421
+ [layout.file, ...(layout.backend === undefined ? [] : [layout.backend])].map((file) => ({
422
+ file,
423
+ text: readFileSync(join(routesDir, file), "utf8"),
424
+ })),
425
+ )
426
+ if (!sources.flat().some((half) => LOADER_EXPORT.test(stripComments(half.text)))) return []
427
+ const loaded = await loadRuleTypeScript(ctx)
428
+ const ts = loaded.compiler
429
+ if (ts === undefined) return []
430
+ const findings: Diagnostic[] = []
431
+ for (const halves of sources) {
432
+ const code = halves.map((half) => stripComments(half.text))
433
+ if (code.some((text) => GATE_EXPORT.test(text) || MIDDLEWARE_EXPORT.test(text))) continue
434
+ for (const half of halves) {
435
+ if (!LOADER_EXPORT.test(stripComments(half.text))) continue
436
+ const tree = ts.createSourceFile(
437
+ half.file,
438
+ half.text,
439
+ ts.ScriptTarget.Latest,
440
+ true,
441
+ scriptKindOf(ts, half.file),
442
+ )
443
+ const [line] = loaderRefusals(ts, tree)
444
+ if (line === undefined) continue
445
+ findings.push(
446
+ diagnostic({
447
+ code: "NF-C042",
448
+ severity: "warn",
449
+ file: `routes/${half.file}`,
450
+ line,
451
+ message: `routes/${half.file}'s loader refuses access, but the layout is not a gate. A client navigation that keeps the layout skips this loader, and an action below it runs without it, so it does not protect the pages under it. Export \`gate = true\` from this file, or move the check into its \`middleware\`.`,
452
+ evidence: [
453
+ "gate = true: the loader runs on every request and before every action, ahead of the page loader",
454
+ "middleware: runs before every page and action below the directory",
455
+ ],
456
+ verify: "nifra check",
457
+ }),
458
+ )
459
+ }
460
+ }
461
+ return findings
462
+ },
463
+ }
464
+
465
+ /** The names a module exports at runtime, each with its line: declarations and `export { ... }` lists.
466
+ * `export *` names nothing it can read, and type-only exports carry no value. */
467
+ function runtimeExports(
468
+ ts: typeof TSApi,
469
+ tree: TSApi.SourceFile,
470
+ ): Array<{ readonly name: string; readonly line: number }> {
471
+ const found: Array<{ name: string; line: number }> = []
472
+ const add = (name: string, node: TSApi.Node): void => {
473
+ found.push({ name, line: tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1 })
474
+ }
475
+ for (const statement of tree.statements) {
476
+ if (ts.isExportDeclaration(statement)) {
477
+ if (statement.isTypeOnly || statement.exportClause === undefined) continue
478
+ if (!ts.isNamedExports(statement.exportClause)) continue
479
+ for (const element of statement.exportClause.elements) {
480
+ if (!element.isTypeOnly) add(element.name.text, element)
481
+ }
482
+ continue
483
+ }
484
+ if (
485
+ !(
486
+ ts.isVariableStatement(statement) ||
487
+ ts.isFunctionDeclaration(statement) ||
488
+ ts.isClassDeclaration(statement)
489
+ ) ||
490
+ !hasModifier(statement, ts.SyntaxKind.ExportKeyword) ||
491
+ hasModifier(statement, ts.SyntaxKind.DeclareKeyword)
492
+ )
493
+ continue
494
+ if (ts.isVariableStatement(statement)) {
495
+ for (const declaration of statement.declarationList.declarations) {
496
+ if (ts.isIdentifier(declaration.name)) add(declaration.name.text, declaration)
497
+ }
498
+ } else if (statement.name !== undefined) {
499
+ add(statement.name.text, statement)
500
+ }
501
+ }
502
+ return found
503
+ }
504
+
505
+ /**
506
+ * NF-C041: a route's frontend file (`x.tsx`, a layout, a status or error page) exports a name only
507
+ * the server reads, such as `loader` or `action`. The frontend file ships to the browser whole, so
508
+ * `nifra build` and the server refuse it; this reports it before either runs.
509
+ */
510
+ export const frontendServerExportRule: CheckRule = {
511
+ code: "NF-C041",
512
+ title: "Server-only export in a route frontend file",
513
+ async scan(ctx) {
514
+ const routesDir = join(ctx.root, "routes")
515
+ if (!existsSync(routesDir)) return []
516
+ let manifest: ReturnType<typeof discoverRoutes>
517
+ try {
518
+ manifest = discoverRoutes(routesDir)
519
+ } catch {
520
+ return []
521
+ }
522
+ const files = new Set<string>()
523
+ for (const entry of [
524
+ ...manifest.routes,
525
+ ...Object.values(manifest.layouts),
526
+ ...Object.values(manifest.errors ?? {}),
527
+ ...(manifest.notFound === undefined ? [] : [manifest.notFound]),
528
+ ...Object.values(manifest.notFounds ?? {}),
529
+ ...Object.values(manifest.statusPages ?? {}),
530
+ ...Object.values(manifest.loadings ?? {}),
531
+ ]) {
532
+ if (SCRIPT_LAYOUT.test(entry.file)) files.add(entry.file)
533
+ }
534
+ if (files.size === 0) return []
535
+ const loaded = await loadRuleTypeScript(ctx)
536
+ const ts = loaded.compiler
537
+ if (ts === undefined) return []
538
+ const findings: Diagnostic[] = []
539
+ for (const file of [...files].sort(codeUnitOrder)) {
540
+ const text = readFileSync(join(routesDir, file), "utf8")
541
+ const tree = ts.createSourceFile(
542
+ file,
543
+ text,
544
+ ts.ScriptTarget.Latest,
545
+ true,
546
+ scriptKindOf(ts, file),
547
+ )
548
+ for (const { name, line } of runtimeExports(ts, tree)) {
549
+ if (!BACKEND_ROUTE_EXPORTS.has(name)) continue
550
+ const backend = `routes/${file.replace(SCRIPT_LAYOUT, ".backend.ts")}`
551
+ findings.push(
552
+ diagnostic({
553
+ code: "NF-C041",
554
+ severity: "error",
555
+ file: `routes/${file}`,
556
+ line,
557
+ message: `routes/${file} exports "${name}", which runs on the server only, and a route's frontend file ships to the browser whole, so nifra build and the server refuse it. Move it to ${backend}.`,
558
+ evidence: [`export: ${name}`, `backend half: ${backend}`],
559
+ fix: { recipe: "route.split", command: "nifra fix --code NF-C041" },
560
+ verify: "nifra check",
561
+ }),
562
+ )
563
+ }
564
+ }
565
+ return findings
566
+ },
567
+ }
568
+
569
+ export const pageRules = Object.freeze([
570
+ shadowedPageRule,
571
+ statusLayoutRule,
572
+ implicitApiMountRule,
573
+ layoutMetaDataRule,
574
+ frontendServerExportRule,
575
+ ungatedLayoutRefusalRule,
576
+ ])