@nifrajs/cli 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
@@ -8,8 +8,9 @@
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,
@@ -17,11 +18,23 @@ import {
17
18
  privateEnvReads,
18
19
  privateEnvReason,
19
20
  type ZoneClassifier,
21
+ ZoneConfigError,
20
22
  } from "@nifrajs/web/zones"
21
23
  import { CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
22
- import { importSites, stripComments } from "../check-scan.ts"
24
+ import { COMPONENT_FILE, importSites, stripComments } from "../check-scan.ts"
25
+ import { appDirs, clientModuleRoot } from "../client-module.ts"
26
+ import { componentCheckReceipts } from "../component-check.ts"
23
27
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
24
28
  import { codeUnitOrder } from "../internal/code-unit-order.ts"
29
+ import {
30
+ createApiOnlyProbe,
31
+ createKindProbe,
32
+ createNifraProjectProbe,
33
+ createWebAppProbe,
34
+ hasWebConfig,
35
+ withApiKindHint,
36
+ } from "../internal/nifra-project.ts"
37
+ import { createSpecifierResolver } from "../internal/project-specifiers.ts"
25
38
  import type { CheckRule, RuleContext } from "./index.ts"
26
39
 
27
40
  const ZONE_FOLDER = new Set(["routes", "frontend", "backend", "shared"])
@@ -31,11 +44,35 @@ const BACKEND = new Set(["backend", "route-backend"])
31
44
  const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
32
45
  const CODE_FILE = /\.(?:[cm]?[jt]sx?|svelte|vue|mdx)$/
33
46
 
34
- /** The app a project file belongs to: the path before its first zone folder, or `undefined`. */
35
- function appOf(file: string): string | undefined {
47
+ /**
48
+ * The app a project file belongs to: the path before its first zone folder, else the deepest app in
49
+ * `apps` that encloses it, since a folder `nifra.zones` declares or a `.frontend`-style suffix zones a
50
+ * file anywhere in its app. `undefined` for a file in no app.
51
+ */
52
+ function appOf(file: string, apps: readonly string[]): string | undefined {
36
53
  const parts = file.split("/")
37
54
  const at = parts.findIndex((part, i) => i < parts.length - 1 && ZONE_FOLDER.has(part))
38
- return at === -1 ? undefined : parts.slice(0, at).join("/")
55
+ if (at !== -1) return parts.slice(0, at).join("/")
56
+ let nearest: string | undefined
57
+ for (const app of apps) {
58
+ if (app !== "" && !file.startsWith(`${app}/`)) continue
59
+ if (nearest === undefined || app.length > nearest.length) nearest = app
60
+ }
61
+ return nearest
62
+ }
63
+
64
+ /**
65
+ * A file's text as the build compiles it. The source index holds a `.svelte`/`.vue` file as its script
66
+ * blocks only, for the parsers; its markup carries expressions the build compiles too, so a rule that
67
+ * reads components itself gets the whole file.
68
+ */
69
+ export function buildText(ctx: RuleContext, file: string): string | undefined {
70
+ if (!COMPONENT_FILE.test(file)) return ctx.sources.read(file)
71
+ try {
72
+ return readFileSync(join(ctx.root, file), "utf8")
73
+ } catch {
74
+ return undefined
75
+ }
39
76
  }
40
77
 
41
78
  interface ZonedFile {
@@ -48,28 +85,62 @@ interface ZonedFile {
48
85
  export function zonedFiles(ctx: RuleContext): {
49
86
  readonly files: readonly ZonedFile[]
50
87
  readonly classifierFor: (app: string) => ZoneClassifier
88
+ /** Settings that cannot be applied (`nifra.zones`, `nifra.kind`); their apps' files are left out. */
89
+ readonly configErrors: ReadonlyArray<{ readonly file: string; readonly message: string }>
51
90
  } {
52
- const classifiers = new Map<string, ZoneClassifier>()
53
- const classifierFor = (app: string): ZoneClassifier => {
91
+ const classifiers = new Map<string, ZoneClassifier | ZoneConfigError>()
92
+ const load = (app: string): ZoneClassifier | ZoneConfigError => {
54
93
  let classifier = classifiers.get(app)
55
94
  if (classifier === undefined) {
56
95
  const appRoot = join(ctx.root, app)
57
- classifier = createZoneClassifier({
58
- appRoot,
59
- generatedFiles: [join(appRoot, "server-manifest.ts")],
60
- })
96
+ try {
97
+ classifier = createZoneClassifier({
98
+ appRoot,
99
+ generatedFiles: [join(appRoot, "server-manifest.ts")],
100
+ })
101
+ } catch (error) {
102
+ if (!(error instanceof ZoneConfigError)) throw error
103
+ classifier = error
104
+ }
61
105
  classifiers.set(app, classifier)
62
106
  }
63
107
  return classifier
64
108
  }
109
+ const classifierFor = (app: string): ZoneClassifier => {
110
+ const classifier = load(app)
111
+ if (classifier instanceof ZoneConfigError) throw classifier
112
+ return classifier
113
+ }
65
114
  const files: ZonedFile[] = []
115
+ const configErrors: Array<{ file: string; message: string }> = []
116
+ const reportOnce = (file: string, message: string): void => {
117
+ if (!configErrors.some((entry) => entry.message === message))
118
+ configErrors.push({ file, message })
119
+ }
120
+ // Another framework's app keeps folders named `routes/` too; nifra builds none of it.
121
+ const inNifraProject = createNifraProjectProbe(ctx.root)
122
+ const kindOf = createKindProbe(ctx.root)
123
+ const apiOnly = createApiOnlyProbe(ctx.root)
124
+ const apps = appDirs(ctx.sources.files)
66
125
  for (const file of ctx.sources.files) {
67
126
  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)) })
127
+ const app = appOf(file, apps)
128
+ if (app === undefined || !inNifraProject(app)) continue
129
+ const declared = kindOf(app)
130
+ if (declared.invalid !== undefined) {
131
+ reportOnce(declared.manifest ?? "package.json", declared.invalid)
132
+ continue
133
+ }
134
+ // An API-only project has no browser, so nothing in it is zoned.
135
+ if (apiOnly(app)) continue
136
+ const classifier = load(app)
137
+ if (classifier instanceof ZoneConfigError) {
138
+ reportOnce(app === "" ? "package.json" : `${app}/package.json`, classifier.message)
139
+ continue
140
+ }
141
+ files.push({ file, app, classification: classifier.classify(join(ctx.root, file)) })
71
142
  }
72
- return { files, classifierFor }
143
+ return { files, classifierFor, configErrors }
73
144
  }
74
145
 
75
146
  const real = (path: string): string => {
@@ -87,41 +158,112 @@ function display(root: string, file: string): string {
87
158
  return rel.startsWith("../") || isAbsolute(rel) ? file : rel
88
159
  }
89
160
 
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
- }
161
+ // The unzoned files one check follows through, at most: each is a build refusal of its own.
162
+ const MAX_UNZONED = 5000
163
+
164
+ interface Importer {
165
+ readonly file: string
166
+ readonly app: string
167
+ /** `"error"` for a file in no zone, reached from code a build loads. */
168
+ readonly zone: Classification["zone"]
99
169
  }
100
170
 
101
171
  export const zoneImportRule: CheckRule = {
102
172
  code: "NF-C028",
103
173
  title: "Zone import check",
104
174
  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
175
+ const { files, classifierFor, configErrors } = zonedFiles(ctx)
176
+ const { receipts } = await componentCheckReceipts(ctx.root, ctx.sources)
177
+ const findings: Diagnostic[] = configErrors.map(({ file, message }) =>
178
+ diagnostic({
179
+ code: "NF-C028",
180
+ severity: "error",
181
+ file,
182
+ message,
183
+ verify: "nifra check",
184
+ }),
185
+ )
186
+ // Outside any web app, a finding may only mean the project serves no pages: say how to declare it.
187
+ const inWebApp = createWebAppProbe(ctx.root)
188
+ const resolveSpecifier = createSpecifierResolver()
189
+ const queue: Importer[] = []
190
+ const queued = new Set<string>()
191
+ const follow = (importer: Importer): void => {
192
+ if (queued.has(importer.file) || queued.size >= MAX_UNZONED + files.length) return
193
+ queued.add(importer.file)
194
+ queue.push(importer)
195
+ }
196
+ for (const { file, app, classification } of files) {
197
+ const zone = classification.zone
198
+ if (zone !== "error" && zone !== "library" && zone !== "generated")
199
+ follow({ file, app, zone })
200
+ }
201
+ // The browser entry imports the app's `clientModule`, so the build classifies it like any file it
202
+ // loads, and loads what it imports.
203
+ for (const app of appDirs(ctx.sources.files)) {
204
+ const entry = clientModuleRoot(ctx.root, app, resolveSpecifier)
205
+ if (entry === undefined) continue
206
+ let classification: Classification
207
+ try {
208
+ classification = classifierFor(app).classify(entry.file)
209
+ } catch (error) {
210
+ if (error instanceof ZoneConfigError) continue
211
+ throw error
212
+ }
213
+ const name = display(ctx.root, entry.file)
214
+ const denial = browserDenial(classification)
215
+ if (denial !== undefined) {
216
+ findings.push(
217
+ diagnostic({
218
+ code: "NF-C028",
219
+ severity: "error",
220
+ file: entry.config,
221
+ line: entry.line,
222
+ message: `clientModule ${JSON.stringify(entry.specifier)} puts ${name} in the browser bundle: ${denial}`,
223
+ evidence: [`clientModule: ${entry.specifier}`, `zone: ${classification.zone}`],
224
+ verify: "nifra check",
225
+ }),
226
+ )
227
+ }
228
+ if (classification.zone === "error" && classification.package === undefined)
229
+ follow({ file: name, app, zone: "error" })
230
+ }
231
+
232
+ // An undeclared package is one fact, however many files import it.
233
+ const reportedPackages = new Set<string>()
234
+ // An array iterator reads the length on every step, so files `follow` appends are visited too.
235
+ for (const { file, app, zone: from } of queue) {
109
236
  const content = ctx.sources.read(file)
110
237
  if (content === undefined) continue
111
238
  for (const { specifier, line } of importSites(content, file)) {
112
- const target = resolveRelative(ctx.root, file, specifier)
239
+ const target = resolveSpecifier(join(ctx.root, file), specifier)
113
240
  if (target === undefined) continue
114
241
  const to = classifierFor(app).classify(target)
115
242
  const targetName = display(ctx.root, target)
243
+ if (
244
+ ["frontend", "route-frontend", "shared"].includes(from) &&
245
+ (receipts.has(target) || receipts.has(targetName))
246
+ )
247
+ continue
116
248
  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))
249
+ if (to.zone === "error") {
250
+ if (to.package !== undefined) {
251
+ if (reportedPackages.has(to.package)) continue
252
+ reportedPackages.add(to.package)
253
+ } else {
254
+ // A file in no zone loads what it imports too: the build refuses each one it reaches.
255
+ follow({ file: targetName, app, zone: "error" })
256
+ }
257
+ message = `${file} imports ${targetName}: ${to.reason}`
258
+ } else if (
259
+ from !== "error" &&
260
+ ((SERVER.has(from) && FRONTEND.has(to.zone)) ||
261
+ // Shared code reaching backend code is NF-C004's, reported there with the chain.
262
+ (from === "shared" && !importAllowed(from, to.zone) && !BACKEND.has(to.zone)))
122
263
  ) {
123
- message = importRuleMessage(file, from.zone, targetName, to.zone)
264
+ message = importRuleMessage(file, from, targetName, to.zone)
124
265
  } else continue
266
+ if (!inWebApp(app)) message = withApiKindHint(message)
125
267
  findings.push(
126
268
  diagnostic({
127
269
  code: "NF-C028",
@@ -129,7 +271,7 @@ export const zoneImportRule: CheckRule = {
129
271
  file,
130
272
  line,
131
273
  message,
132
- evidence: [`import: ${specifier}`, `from zone: ${from.zone}`, `to zone: ${to.zone}`],
274
+ evidence: [`import: ${specifier}`, `from zone: ${from}`, `to zone: ${to.zone}`],
133
275
  verify: "nifra check",
134
276
  }),
135
277
  )
@@ -141,6 +283,31 @@ export const zoneImportRule: CheckRule = {
141
283
  },
142
284
  }
143
285
 
286
+ /** NF-C037: `"nifra": { "kind": "api" }` governing a directory that holds web config. */
287
+ export const apiKindRule: CheckRule = {
288
+ code: "NF-C037",
289
+ title: "API-only declaration over a web app",
290
+ async scan(ctx) {
291
+ const kindOf = createKindProbe(ctx.root)
292
+ const findings: Diagnostic[] = []
293
+ for (const app of appDirs(ctx.sources.files)) {
294
+ const declared = kindOf(app)
295
+ if (declared.kind !== "api" || !hasWebConfig(ctx.root, app)) continue
296
+ const where = app === "" ? "the project root" : `${app}/`
297
+ findings.push(
298
+ diagnostic({
299
+ code: "NF-C037",
300
+ severity: "error",
301
+ file: declared.manifest ?? "package.json",
302
+ 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`,
303
+ verify: "nifra check",
304
+ }),
305
+ )
306
+ }
307
+ return findings
308
+ },
309
+ }
310
+
144
311
  const PREFIX_EXPORT =
145
312
  /\bexport\s+const\s+publicEnvPrefix\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')/
146
313
 
@@ -164,7 +331,7 @@ export const privateEnvRule: CheckRule = {
164
331
  const findings: Diagnostic[] = []
165
332
  for (const { file, app, classification } of files) {
166
333
  if (!BROWSER_CODE.has(classification.zone)) continue
167
- const content = ctx.sources.read(file)
334
+ const content = buildText(ctx, file)
168
335
  if (content === undefined) continue
169
336
  let prefix = prefixes.get(app)
170
337
  if (prefix === undefined) {
@@ -192,4 +359,4 @@ export const privateEnvRule: CheckRule = {
192
359
  },
193
360
  }
194
361
 
195
- export const zoneRules = Object.freeze([zoneImportRule, privateEnvRule])
362
+ 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)",
@@ -927,17 +927,144 @@ const lineOf = (source: string, offset: number): number => {
927
927
  return line
928
928
  }
929
929
 
930
- const isLikelyJsxAttribute = (source: string, offset: number): boolean => {
931
- const tagStart = source.lastIndexOf("<", offset)
932
- const tagEnd = source.lastIndexOf(">", offset)
933
- return tagStart > tagEnd && !source.slice(tagStart, offset).includes("<!--")
930
+ /** Words after which an expression - so a regex literal or a JSX element - may start. */
931
+ const EXPRESSION_KEYWORDS = new Set([
932
+ "return",
933
+ "typeof",
934
+ "instanceof",
935
+ "in",
936
+ "of",
937
+ "new",
938
+ "delete",
939
+ "void",
940
+ "throw",
941
+ "case",
942
+ "do",
943
+ "else",
944
+ "yield",
945
+ "await",
946
+ ])
947
+
948
+ const WORD_CHAR = /[\w$]/
949
+
950
+ /** `<` at `offset` opens an element unless it starts a type parameter list (`<T,>`, `<T extends U>`). */
951
+ const opensElement = (source: string, offset: number): boolean => {
952
+ const name = /^<([A-Za-z_$][\w$.:-]*)?(\s*)/.exec(source.slice(offset, offset + 256))
953
+ if (name === null) return false
954
+ if (name[1] === undefined) return source[offset + 1] === ">"
955
+ const after = source.slice(offset + name[0].length, offset + name[0].length + 8)
956
+ return !after.startsWith(",") && !(name[2] !== "" && /^extends\b/.test(after))
957
+ }
958
+
959
+ /**
960
+ * Which offsets of `source` sit inside a JSX tag, where an attribute can be. A small lexer, not a
961
+ * parser: it steps over strings, template literals, comments and regex literals, and follows JSX by
962
+ * the usual rule that `<` where an expression may start opens an element. A `className=` anywhere
963
+ * else - HTML in a template literal, a comment, a string, JSX text - is not an attribute.
964
+ */
965
+ function jsxTagMask(source: string, jsx: boolean): Uint8Array {
966
+ const mask = new Uint8Array(source.length)
967
+ const stack: ("code" | "template" | "tag" | "children")[] = ["code"]
968
+ const length = source.length
969
+ let expression = true
970
+ let index = 0
971
+ const skipQuoted = (quote: string, multiline: boolean): void => {
972
+ index += 1
973
+ while (index < length && source[index] !== quote && (multiline || source[index] !== "\n"))
974
+ index += source[index] === "\\" && !multiline ? 2 : 1
975
+ index += 1
976
+ }
977
+ while (index < length) {
978
+ const frame = stack[stack.length - 1]
979
+ const char = source[index] ?? ""
980
+ const next = source[index + 1]
981
+ if (frame === "template") {
982
+ if (char === "\\") index += 2
983
+ else if (char === "`") {
984
+ stack.pop()
985
+ index += 1
986
+ expression = false
987
+ } else if (char === "$" && next === "{") {
988
+ stack.push("code")
989
+ index += 2
990
+ expression = true
991
+ } else index += 1
992
+ } else if (frame === "children") {
993
+ if (char === "{") {
994
+ stack.push("code")
995
+ expression = true
996
+ } else if (char === "<" && next === "/") {
997
+ const close = source.indexOf(">", index)
998
+ index = close === -1 ? length : close
999
+ stack.pop()
1000
+ expression = false
1001
+ } else if (char === "<") stack.push("tag")
1002
+ index += 1
1003
+ } else if (frame === "tag") {
1004
+ mask[index] = 1
1005
+ if (char === '"' || char === "'") skipQuoted(char, true)
1006
+ else if (char === "{") {
1007
+ stack.push("code")
1008
+ expression = true
1009
+ index += 1
1010
+ } else if (char === "/" && next === ">") {
1011
+ stack.pop()
1012
+ expression = false
1013
+ index += 2
1014
+ } else {
1015
+ if (char === ">") stack[stack.length - 1] = "children"
1016
+ index += 1
1017
+ }
1018
+ } else if (char === "/" && next === "/") {
1019
+ const end = source.indexOf("\n", index)
1020
+ index = end === -1 ? length : end
1021
+ } else if (char === "/" && next === "*") {
1022
+ const end = source.indexOf("*/", index + 2)
1023
+ index = end === -1 ? length : end + 2
1024
+ } else if (char === '"' || char === "'") {
1025
+ skipQuoted(char, false)
1026
+ expression = false
1027
+ } else if (char === "`") {
1028
+ stack.push("template")
1029
+ index += 1
1030
+ } else if (char === "/" && expression) {
1031
+ let inClass = false
1032
+ index += 1
1033
+ while (index < length && source[index] !== "\n") {
1034
+ const current = source[index]
1035
+ if (current === "\\") index += 1
1036
+ else if (current === "[") inClass = true
1037
+ else if (current === "]") inClass = false
1038
+ else if (current === "/" && !inClass) break
1039
+ index += 1
1040
+ }
1041
+ index += 1
1042
+ while (index < length && WORD_CHAR.test(source[index] ?? "")) index += 1
1043
+ expression = false
1044
+ } else if (char === "<" && expression && jsx && opensElement(source, index)) {
1045
+ stack.push("tag")
1046
+ index += 1
1047
+ } else if (WORD_CHAR.test(char)) {
1048
+ const start = index
1049
+ while (index < length && WORD_CHAR.test(source[index] ?? "")) index += 1
1050
+ expression = EXPRESSION_KEYWORDS.has(source.slice(start, index))
1051
+ } else {
1052
+ if (char === "{") stack.push("code")
1053
+ else if (char === "}" && stack.length > 1) stack.pop()
1054
+ if (char.trim() !== "") expression = char !== ")" && char !== "]"
1055
+ index += 1
1056
+ }
1057
+ }
1058
+ return mask
934
1059
  }
935
1060
 
936
- const classAttributes = (source: string): readonly ClassAttribute[] => {
1061
+ const classAttributes = (source: string, file: string): readonly ClassAttribute[] => {
937
1062
  const attributes: ClassAttribute[] = []
1063
+ // A TypeScript module that is not `.tsx` cannot hold JSX, and its `<T>` would read as an element.
1064
+ const inTag = jsxTagMask(source, !/\.[cm]?ts$/.test(file))
938
1065
  for (const match of source.matchAll(CLASS_ATTRIBUTE)) {
939
1066
  const start = match.index ?? 0
940
- if (!isLikelyJsxAttribute(source, start)) continue
1067
+ if (inTag[start] !== 1) continue
941
1068
  const end = start + match[0].length
942
1069
  const value = match[1] ?? match[2] ?? match[3] ?? match[4]
943
1070
  attributes.push(
@@ -997,7 +1124,7 @@ const insertionOffset = (source: string): number => {
997
1124
 
998
1125
  /** Transform one source file. The returned source is unchanged when every match is unsupported. */
999
1126
  export function migrateTailwindSource(source: string, file: string): SourceMigration {
1000
- const attributes = classAttributes(source)
1127
+ const attributes = classAttributes(source, file)
1001
1128
  if (attributes.length === 0) return { source, changed: false, replacements: 0, issues: [] }
1002
1129
 
1003
1130
  const replacements: { start: number; end: number; text: string }[] = []