@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
@@ -8,21 +8,38 @@
8
8
  * anything but shared code. NF-C029 owns private environment reads in browser code.
9
9
  */
10
10
  import { existsSync, readFileSync, realpathSync } from "node:fs"
11
- import { dirname, isAbsolute, join, relative } from "node:path"
11
+ import { isAbsolute, join, relative } from "node:path"
12
12
  import {
13
+ browserDenial,
13
14
  type Classification,
14
15
  createZoneClassifier,
15
16
  importAllowed,
16
17
  importRuleMessage,
17
18
  privateEnvReads,
18
19
  privateEnvReason,
20
+ specifierPackage,
19
21
  type ZoneClassifier,
22
+ ZoneConfigError,
20
23
  } from "@nifrajs/web/zones"
21
24
  import { CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
22
- import { importSites, stripComments } from "../check-scan.ts"
25
+ import { COMPONENT_FILE, importSites, stripComments } from "../check-scan.ts"
26
+ import { appDirs, clientModuleRoot } from "../client-module.ts"
27
+ import { componentCheckReceipts } from "../component-check.ts"
23
28
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
24
29
  import { codeUnitOrder } from "../internal/code-unit-order.ts"
30
+ import { installedManifest } from "../internal/installed-package.ts"
31
+ import {
32
+ createApiOnlyProbe,
33
+ createKindProbe,
34
+ createNifraProjectProbe,
35
+ createWebAppProbe,
36
+ hasWebConfig,
37
+ withApiKindHint,
38
+ } from "../internal/nifra-project.ts"
39
+ import { createSpecifierResolver } from "../internal/project-specifiers.ts"
40
+ import { createSourceFacts } from "../internal/source-facts.ts"
25
41
  import type { CheckRule, RuleContext } from "./index.ts"
42
+ import { loadRuleTypeScript } from "./typescript.ts"
26
43
 
27
44
  const ZONE_FOLDER = new Set(["routes", "frontend", "backend", "shared"])
28
45
  const FRONTEND = new Set(["route-frontend", "frontend"])
@@ -31,11 +48,35 @@ const BACKEND = new Set(["backend", "route-backend"])
31
48
  const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
32
49
  const CODE_FILE = /\.(?:[cm]?[jt]sx?|svelte|vue|mdx)$/
33
50
 
34
- /** The app a project file belongs to: the path before its first zone folder, or `undefined`. */
35
- function appOf(file: string): string | undefined {
51
+ /**
52
+ * The app a project file belongs to: the path before its first zone folder, else the deepest app in
53
+ * `apps` that encloses it, since a folder `nifra.zones` declares or a `.frontend`-style suffix zones a
54
+ * file anywhere in its app. `undefined` for a file in no app.
55
+ */
56
+ function appOf(file: string, apps: readonly string[]): string | undefined {
36
57
  const parts = file.split("/")
37
58
  const at = parts.findIndex((part, i) => i < parts.length - 1 && ZONE_FOLDER.has(part))
38
- return at === -1 ? undefined : parts.slice(0, at).join("/")
59
+ if (at !== -1) return parts.slice(0, at).join("/")
60
+ let nearest: string | undefined
61
+ for (const app of apps) {
62
+ if (app !== "" && !file.startsWith(`${app}/`)) continue
63
+ if (nearest === undefined || app.length > nearest.length) nearest = app
64
+ }
65
+ return nearest
66
+ }
67
+
68
+ /**
69
+ * A file's text as the build compiles it. The source index holds a `.svelte`/`.vue` file as its script
70
+ * blocks only, for the parsers; its markup carries expressions the build compiles too, so a rule that
71
+ * reads components itself gets the whole file.
72
+ */
73
+ export function buildText(ctx: RuleContext, file: string): string | undefined {
74
+ if (!COMPONENT_FILE.test(file)) return ctx.sources.read(file)
75
+ try {
76
+ return readFileSync(join(ctx.root, file), "utf8")
77
+ } catch {
78
+ return undefined
79
+ }
39
80
  }
40
81
 
41
82
  interface ZonedFile {
@@ -48,28 +89,62 @@ interface ZonedFile {
48
89
  export function zonedFiles(ctx: RuleContext): {
49
90
  readonly files: readonly ZonedFile[]
50
91
  readonly classifierFor: (app: string) => ZoneClassifier
92
+ /** Settings that cannot be applied (`nifra.zones`, `nifra.kind`); their apps' files are left out. */
93
+ readonly configErrors: ReadonlyArray<{ readonly file: string; readonly message: string }>
51
94
  } {
52
- const classifiers = new Map<string, ZoneClassifier>()
53
- const classifierFor = (app: string): ZoneClassifier => {
95
+ const classifiers = new Map<string, ZoneClassifier | ZoneConfigError>()
96
+ const load = (app: string): ZoneClassifier | ZoneConfigError => {
54
97
  let classifier = classifiers.get(app)
55
98
  if (classifier === undefined) {
56
99
  const appRoot = join(ctx.root, app)
57
- classifier = createZoneClassifier({
58
- appRoot,
59
- generatedFiles: [join(appRoot, "server-manifest.ts")],
60
- })
100
+ try {
101
+ classifier = createZoneClassifier({
102
+ appRoot,
103
+ generatedFiles: [join(appRoot, "server-manifest.ts")],
104
+ })
105
+ } catch (error) {
106
+ if (!(error instanceof ZoneConfigError)) throw error
107
+ classifier = error
108
+ }
61
109
  classifiers.set(app, classifier)
62
110
  }
63
111
  return classifier
64
112
  }
113
+ const classifierFor = (app: string): ZoneClassifier => {
114
+ const classifier = load(app)
115
+ if (classifier instanceof ZoneConfigError) throw classifier
116
+ return classifier
117
+ }
65
118
  const files: ZonedFile[] = []
119
+ const configErrors: Array<{ file: string; message: string }> = []
120
+ const reportOnce = (file: string, message: string): void => {
121
+ if (!configErrors.some((entry) => entry.message === message))
122
+ configErrors.push({ file, message })
123
+ }
124
+ // Another framework's app keeps folders named `routes/` too; nifra builds none of it.
125
+ const inNifraProject = createNifraProjectProbe(ctx.root)
126
+ const kindOf = createKindProbe(ctx.root)
127
+ const apiOnly = createApiOnlyProbe(ctx.root)
128
+ const apps = appDirs(ctx.sources.files)
66
129
  for (const file of ctx.sources.files) {
67
130
  if (!CODE_FILE.test(file)) continue
68
- const app = appOf(file)
69
- if (app === undefined) continue
70
- files.push({ file, app, classification: classifierFor(app).classify(join(ctx.root, file)) })
131
+ const app = appOf(file, apps)
132
+ if (app === undefined || !inNifraProject(app)) continue
133
+ const declared = kindOf(app)
134
+ if (declared.invalid !== undefined) {
135
+ reportOnce(declared.manifest ?? "package.json", declared.invalid)
136
+ continue
137
+ }
138
+ // An API-only project has no browser, so nothing in it is zoned.
139
+ if (apiOnly(app)) continue
140
+ const classifier = load(app)
141
+ if (classifier instanceof ZoneConfigError) {
142
+ reportOnce(app === "" ? "package.json" : `${app}/package.json`, classifier.message)
143
+ continue
144
+ }
145
+ files.push({ file, app, classification: classifier.classify(join(ctx.root, file)) })
71
146
  }
72
- return { files, classifierFor }
147
+ return { files, classifierFor, configErrors }
73
148
  }
74
149
 
75
150
  const real = (path: string): string => {
@@ -87,41 +162,131 @@ function display(root: string, file: string): string {
87
162
  return rel.startsWith("../") || isAbsolute(rel) ? file : rel
88
163
  }
89
164
 
90
- /** A relative import resolved against the importing file, or `undefined` when it does not resolve. */
91
- function resolveRelative(root: string, from: string, specifier: string): string | undefined {
92
- if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
93
- try {
94
- // Relative specifiers only: a bare one would let the resolver reach for a global install.
95
- return Bun.resolveSync(specifier, dirname(join(root, from)))
96
- } catch {
97
- return undefined
98
- }
165
+ // The unzoned files one check follows through, at most: each is a build refusal of its own.
166
+ const MAX_UNZONED = 5000
167
+
168
+ interface Importer {
169
+ readonly file: string
170
+ readonly app: string
171
+ /** `"error"` for a file in no zone, reached from code a build loads. */
172
+ readonly zone: Classification["zone"]
99
173
  }
100
174
 
101
175
  export const zoneImportRule: CheckRule = {
102
176
  code: "NF-C028",
103
177
  title: "Zone import check",
104
178
  async scan(ctx) {
105
- const { files, classifierFor } = zonedFiles(ctx)
106
- const findings: Diagnostic[] = []
107
- for (const { file, app, classification: from } of files) {
108
- if (from.zone === "error" || from.zone === "library" || from.zone === "generated") continue
179
+ const { files, classifierFor, configErrors } = zonedFiles(ctx)
180
+ const { receipts } = await componentCheckReceipts(ctx.root, ctx.sources)
181
+ const findings: Diagnostic[] = configErrors.map(({ file, message }) =>
182
+ diagnostic({
183
+ code: "NF-C028",
184
+ severity: "error",
185
+ file,
186
+ message,
187
+ verify: "nifra check",
188
+ }),
189
+ )
190
+ // Outside any web app, a finding may only mean the project serves no pages: say how to declare it.
191
+ const inWebApp = createWebAppProbe(ctx.root)
192
+ const resolveSpecifier = createSpecifierResolver()
193
+ // Without a compiler nothing proves an import erased, so every import stays an edge.
194
+ const { compiler } = await loadRuleTypeScript(ctx)
195
+ const facts =
196
+ compiler === undefined
197
+ ? undefined
198
+ : createSourceFacts(compiler, {
199
+ keepsTypeOnlyImports: (file) =>
200
+ resolveSpecifier.keepsTypeOnlyImports(isAbsolute(file) ? file : join(ctx.root, file)),
201
+ })
202
+ const queue: Importer[] = []
203
+ const queued = new Set<string>()
204
+ const follow = (importer: Importer): void => {
205
+ if (queued.has(importer.file) || queued.size >= MAX_UNZONED + files.length) return
206
+ queued.add(importer.file)
207
+ queue.push(importer)
208
+ }
209
+ for (const { file, app, classification } of files) {
210
+ const zone = classification.zone
211
+ if (zone !== "error" && zone !== "library" && zone !== "generated")
212
+ follow({ file, app, zone })
213
+ }
214
+ // The browser entry imports the app's `clientModule`, so the build classifies it like any file it
215
+ // loads, and loads what it imports.
216
+ for (const app of appDirs(ctx.sources.files)) {
217
+ const entry = clientModuleRoot(ctx.root, app, resolveSpecifier, compiler)
218
+ if (entry === undefined) continue
219
+ let classification: Classification
220
+ try {
221
+ classification = classifierFor(app).classify(entry.file)
222
+ } catch (error) {
223
+ if (error instanceof ZoneConfigError) continue
224
+ throw error
225
+ }
226
+ const name = display(ctx.root, entry.file)
227
+ const denial = browserDenial(classification)
228
+ if (denial !== undefined) {
229
+ findings.push(
230
+ diagnostic({
231
+ code: "NF-C028",
232
+ severity: "error",
233
+ file: entry.config,
234
+ line: entry.line,
235
+ message: `clientModule ${JSON.stringify(entry.specifier)} puts ${name} in the browser bundle: ${denial}`,
236
+ evidence: [`clientModule: ${entry.specifier}`, `zone: ${classification.zone}`],
237
+ verify: "nifra check",
238
+ }),
239
+ )
240
+ }
241
+ if (classification.zone === "error" && classification.package === undefined)
242
+ follow({ file: name, app, zone: "error" })
243
+ }
244
+
245
+ // An undeclared package is one fact, however many files import it.
246
+ const reportedPackages = new Set<string>()
247
+ // An array iterator reads the length on every step, so files `follow` appends are visited too.
248
+ for (const { file, app, zone: from } of queue) {
109
249
  const content = ctx.sources.read(file)
110
250
  if (content === undefined) continue
111
- for (const { specifier, line } of importSites(content, file)) {
112
- const target = resolveRelative(ctx.root, file, specifier)
113
- if (target === undefined) continue
114
- const to = classifierFor(app).classify(target)
115
- const targetName = display(ctx.root, target)
251
+ for (const { specifier, line } of importSites(content, file, facts)) {
252
+ let target = resolveSpecifier(join(ctx.root, file), specifier)
253
+ let to: Classification
254
+ if (target === undefined) {
255
+ // An installed package is judged here only by the entry it names, when it gives each
256
+ // entry its own side; a package-wide side is NF-C004's in browser code.
257
+ const name = specifierPackage(specifier)
258
+ const manifest =
259
+ name === undefined ? undefined : installedManifest(join(ctx.root, file), name)
260
+ if (manifest === undefined) continue
261
+ to = classifierFor(app).classify(manifest, specifier)
262
+ if (to.entry === undefined) continue
263
+ target = manifest
264
+ } else to = classifierFor(app).classify(target, specifier)
265
+ const targetName = to.entry === undefined ? display(ctx.root, target) : specifier
266
+ if (
267
+ ["frontend", "route-frontend", "shared"].includes(from) &&
268
+ (receipts.has(target) || receipts.has(targetName))
269
+ )
270
+ continue
116
271
  let message: string
117
- if (to.zone === "error") message = `${file} imports ${targetName}: ${to.reason}`
118
- else if (
119
- (SERVER.has(from.zone) && FRONTEND.has(to.zone)) ||
120
- // Shared code reaching backend code is NF-C004's, reported there with the chain.
121
- (from.zone === "shared" && !importAllowed(from.zone, to.zone) && !BACKEND.has(to.zone))
272
+ if (to.zone === "error") {
273
+ if (to.package !== undefined) {
274
+ if (reportedPackages.has(to.package)) continue
275
+ reportedPackages.add(to.package)
276
+ } else if (to.entry === undefined) {
277
+ // A file in no zone loads what it imports too: the build refuses each one it reaches.
278
+ follow({ file: targetName, app, zone: "error" })
279
+ }
280
+ message = `${file} imports ${targetName}: ${to.reason}`
281
+ } else if (
282
+ from !== "error" &&
283
+ ((SERVER.has(from) && FRONTEND.has(to.zone)) ||
284
+ // Shared code reaching backend code is NF-C004's, reported there with the chain.
285
+ (from === "shared" && !importAllowed(from, to.zone) && !BACKEND.has(to.zone)))
122
286
  ) {
123
- message = importRuleMessage(file, from.zone, targetName, to.zone)
287
+ message = importRuleMessage(file, from, targetName, to.zone)
124
288
  } else continue
289
+ if (!inWebApp(app)) message = withApiKindHint(message)
125
290
  findings.push(
126
291
  diagnostic({
127
292
  code: "NF-C028",
@@ -129,7 +294,7 @@ export const zoneImportRule: CheckRule = {
129
294
  file,
130
295
  line,
131
296
  message,
132
- evidence: [`import: ${specifier}`, `from zone: ${from.zone}`, `to zone: ${to.zone}`],
297
+ evidence: [`import: ${specifier}`, `from zone: ${from}`, `to zone: ${to.zone}`],
133
298
  verify: "nifra check",
134
299
  }),
135
300
  )
@@ -141,6 +306,31 @@ export const zoneImportRule: CheckRule = {
141
306
  },
142
307
  }
143
308
 
309
+ /** NF-C037: `"nifra": { "kind": "api" }` governing a directory that holds web config. */
310
+ export const apiKindRule: CheckRule = {
311
+ code: "NF-C037",
312
+ title: "API-only declaration over a web app",
313
+ async scan(ctx) {
314
+ const kindOf = createKindProbe(ctx.root)
315
+ const findings: Diagnostic[] = []
316
+ for (const app of appDirs(ctx.sources.files)) {
317
+ const declared = kindOf(app)
318
+ if (declared.kind !== "api" || !hasWebConfig(ctx.root, app)) continue
319
+ const where = app === "" ? "the project root" : `${app}/`
320
+ findings.push(
321
+ diagnostic({
322
+ code: "NF-C037",
323
+ severity: "error",
324
+ file: declared.manifest ?? "package.json",
325
+ message: `"nifra": { "kind": "api" } in ${declared.manifest ?? "package.json"} governs ${where}, which has web config (${app === "" ? "" : `${app}/`}${existsSync(join(ctx.root, app, FRAMEWORK_FILE)) ? FRAMEWORK_FILE : CONFIG_FILE}). An app that serves pages keeps its zone checks; remove "kind", or give the web app its own package.json`,
326
+ verify: "nifra check",
327
+ }),
328
+ )
329
+ }
330
+ return findings
331
+ },
332
+ }
333
+
144
334
  const PREFIX_EXPORT =
145
335
  /\bexport\s+const\s+publicEnvPrefix\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')/
146
336
 
@@ -164,7 +354,7 @@ export const privateEnvRule: CheckRule = {
164
354
  const findings: Diagnostic[] = []
165
355
  for (const { file, app, classification } of files) {
166
356
  if (!BROWSER_CODE.has(classification.zone)) continue
167
- const content = ctx.sources.read(file)
357
+ const content = buildText(ctx, file)
168
358
  if (content === undefined) continue
169
359
  let prefix = prefixes.get(app)
170
360
  if (prefix === undefined) {
@@ -192,4 +382,4 @@ export const privateEnvRule: CheckRule = {
192
382
  },
193
383
  }
194
384
 
195
- export const zoneRules = Object.freeze([zoneImportRule, privateEnvRule])
385
+ export const zoneRules = Object.freeze([zoneImportRule, privateEnvRule, apiKindRule])
package/src/scaffold.ts CHANGED
@@ -29,7 +29,7 @@ const EXT: Record<Framework, string> = {
29
29
  solid: "tsx",
30
30
  vue: "vue",
31
31
  svelte: "svelte",
32
- vanilla: "ts",
32
+ vanilla: "tsx",
33
33
  }
34
34
 
35
35
  /** Derive the framework from a `clientModule` like `@nifrajs/web-react/client`. Defaults to react. */
@@ -93,7 +93,7 @@ export function routePathToFile(urlPath: string, ext: string): string {
93
93
 
94
94
  const ROUTE_CONTRACT = `A route is two files:
95
95
  - The page, \`routes/x.<ext>\` - ships to the browser: \`export default function Page({ data }: Route.ComponentProps)\` and \`export const meta = { title, meta: […] }\`. It never imports backend code.
96
- - Its backend half, \`routes/x.backend.ts\` - never reaches the browser: \`export const loaderOutput = t.object({…})\` with \`export async function loader({ params, api }: Route.LoaderArgs)\` (data for SSR - only what the schema declares is sent), \`actionOutput\` with \`action\` (the form POST), and \`hydrate\`, \`islandScripts\`, \`revalidate\`, \`middleware\`. Reach the backend through the typed \`api\`.
96
+ - Its backend half, \`routes/x.backend.ts\` - never reaches the browser: \`export const loaderOutput = t.object({…})\` with \`export const loader: Route.Loader = async ({ params, api }) => …\` (data for SSR - only what the schema declares is sent, and \`Route.Loader\` type-checks what it returns against that schema), \`actionOutput\` with \`export const action: Route.Action\` (the form POST), and \`hydrate\`, \`islandScripts\`, \`revalidate\`, \`middleware\`. Reach the backend through the typed \`api\`.
97
97
  Both import \`type { Route } from "./+types/<name>"\`, which \`nifra dev\`, \`nifra build\`, \`nifra check\` and \`nifra types\` generate. Path params are typed on \`params\`.`
98
98
 
99
99
  interface RouteParam {
@@ -120,11 +120,13 @@ function backendStub(file: string, params: readonly RouteParam[], vanilla: boole
120
120
  const last = params.at(-1)
121
121
  const loader =
122
122
  last === undefined
123
- ? `export async function loader(_args: Route.LoaderArgs) {
123
+ ? `// Route.Loader checks what it returns against loaderOutput.
124
+ export const loader: Route.Loader = async () => {
124
125
  // Load through the typed backend client, \`api\` in these args; see nifra_example("loader").
125
126
  return { title: "TODO" }
126
127
  }`
127
- : `export async function loader({ params }: Route.LoaderArgs) {
128
+ : `// Route.Loader checks what it returns against loaderOutput.
129
+ export const loader: Route.Loader = async ({ params }) => {
128
130
  // Load by params.${last.name} through the typed backend client, \`api\` in these args; see nifra_example("loader").
129
131
  return { title: \`TODO: \${params.${last.name}${last.optional ? ' ?? ""' : ""}}\` }
130
132
  }`
package/src/sdk.ts CHANGED
@@ -381,7 +381,10 @@ function pythonModelFields(
381
381
  if (properties === undefined) return " pass"
382
382
  const required = schemaRequired(schema)
383
383
  const used = new Set<string>()
384
+ // A dataclass rejects a field without a default after one with a default, so required fields
385
+ // lead and each group keeps the schema's order.
384
386
  const fields: string[] = []
387
+ const optionalFields: string[] = []
385
388
  for (const [jsonName, property] of Object.entries(properties)) {
386
389
  let fieldName = pythonIdentifier(jsonName, "field")
387
390
  while (used.has(fieldName)) fieldName += "_"
@@ -396,11 +399,12 @@ function pythonModelFields(
396
399
  )
397
400
  const optional = !required.has(jsonName)
398
401
  if (optional && !type.includes("None")) type += " | None"
402
+ const group = optional ? optionalFields : fields
399
403
  if (fieldName === jsonName) {
400
- fields.push(` ${fieldName}: ${type}${optional ? " = None" : ""}`)
404
+ group.push(` ${fieldName}: ${type}${optional ? " = None" : ""}`)
401
405
  } else {
402
406
  const metadata = `{"json_name": ${JSON.stringify(jsonName)}}`
403
- fields.push(
407
+ group.push(
404
408
  " " +
405
409
  fieldName +
406
410
  ": " +
@@ -413,6 +417,7 @@ function pythonModelFields(
413
417
  )
414
418
  }
415
419
  }
420
+ fields.push(...optionalFields)
416
421
  return fields.length === 0 ? " pass" : fields.join("\n")
417
422
  }
418
423
 
@@ -430,9 +435,11 @@ function renderPythonModels(catalog: SchemaCatalog, tracker: OpaqueTracker): str
430
435
  pythonModelFields(schema, name, catalog, tracker),
431
436
  )
432
437
  } else {
438
+ // A `type` statement evaluates lazily, so an alias may name a model declared below it.
433
439
  rendered.push(
434
- name +
435
- ": TypeAlias = " +
440
+ "type " +
441
+ name +
442
+ " = " +
436
443
  pythonType(schema, name, catalog, tracker, "schema", `model ${name}`),
437
444
  )
438
445
  }
@@ -628,7 +635,7 @@ function pythonSdk(document: OpenAPIDocument, options: SdkRenderOptions): string
628
635
  "import urllib.parse",
629
636
  "import urllib.request",
630
637
  "from dataclasses import dataclass, field, fields, is_dataclass",
631
- "from typing import Any, Generic, Literal, Mapping, Never, TypeAlias, TypeVar, get_args, get_origin, get_type_hints",
638
+ "from typing import Any, Generic, Literal, Mapping, Never, TypeAliasType, TypeVar, get_args, get_origin, get_type_hints",
632
639
  "",
633
640
  'T = TypeVar("T")',
634
641
  "",
@@ -639,6 +646,8 @@ function pythonSdk(document: OpenAPIDocument, options: SdkRenderOptions): string
639
646
  " self.body = body",
640
647
  "",
641
648
  "def _decode(value: Any, target: Any) -> Any:",
649
+ " if isinstance(target, TypeAliasType):",
650
+ " return _decode(value, target.__value__)",
642
651
  " if target is None or target is Any or target is object:",
643
652
  " return value",
644
653
  " origin = get_origin(target)",