@nifrajs/cli 4.0.2 → 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 (323) 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 +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  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 +21 -2
  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 +778 -64
  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/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
@@ -8,29 +8,42 @@
8
8
  * 2. folds each `_middleware.ts` into its directory's `_layout.backend.ts` as `middleware`;
9
9
  * 3. moves `backend.ts` to `backend/app.ts` and `framework.ts` to `backend/framework.ts`;
10
10
  * 4. moves each `x.server.ts` under `backend/` without the retired suffix, and zones every other module
11
- * by who imports it at runtime: only route frontends -> `frontend/`, only the server side ->
12
- * `backend/`, both -> `shared/`;
13
- * 5. rewrites every relative import in the app to the new locations, `@nifrajs/web/server-only` to
14
- * `@nifrajs/web/backend-only`, and `ServerOnly` to `BackendOnly`.
11
+ * by who imports it at runtime: only route frontends and the `clientModule` -> `frontend/`, only
12
+ * the server side -> `backend/`, both -> `shared/`. Imports through tsconfig `paths`/`baseUrl` and
13
+ * package.json `imports` count as the bundler reads them;
14
+ * 5. rewrites every import in the app to the new locations (test-runner mocks included), an alias
15
+ * through the same alias, the config's `clientModule` and `componentPipeline.entries` paths,
16
+ * `@nifrajs/web/server-only` to `@nifrajs/web/backend-only`, and `ServerOnly` to `BackendOnly`;
17
+ * 6. adds the zone folders moved TypeScript lands in to tsconfig `include`, and `.nifra/` to
18
+ * `.gitignore`.
19
+ *
20
+ * Data files (JSON, YAML, CSV, ...) and folders the app zones in package.json stay where they are.
15
21
  *
16
22
  * It is a dry run unless `write` is set, and it reports what it could not decide (a frontend that uses
17
- * a moved name at runtime, a helper both halves need, a loader with no output schema) instead of
18
- * guessing.
23
+ * a moved name at runtime, a helper both halves need, a loader with no output schema, an alias no
24
+ * target covers after a move, a specifier that names nothing) instead of guessing.
19
25
  */
20
26
  import {
21
27
  existsSync,
22
28
  mkdirSync,
23
29
  readdirSync,
24
30
  readFileSync,
31
+ realpathSync,
25
32
  renameSync,
33
+ rmdirSync,
26
34
  rmSync,
27
35
  statSync,
28
36
  writeFileSync,
29
37
  } from "node:fs"
30
- import { dirname, extname, join, posix, relative, resolve } from "node:path"
38
+ import { builtinModules } from "node:module"
39
+ import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"
31
40
  import { BACKEND_ROUTE_EXPORTS } from "@nifrajs/web/route-manifest"
41
+ import { parseZoneConfig, type ZoneConfig } from "@nifrajs/web/zones"
32
42
  import type * as TS from "typescript"
43
+ import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.ts"
33
44
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
45
+ import { createKindProbe, hasWebConfig } from "./internal/nifra-project.ts"
46
+ import { createSpecifierResolver } from "./internal/project-specifiers.ts"
34
47
  import { importTypeScript } from "./internal/typescript-import.ts"
35
48
  import { missingOutputSchemas } from "./rules/data-guard.ts"
36
49
 
@@ -62,6 +75,22 @@ const TOOLING_FILE =
62
75
  const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/
63
76
  const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"])
64
77
  const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"])
78
+ /** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
79
+ const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/
80
+ /** Test-runner calls that name a module by path, as an import does. */
81
+ const MOCK_CALLS: ReadonlySet<string> = new Set([
82
+ "mock.module",
83
+ "vi.mock",
84
+ "vi.doMock",
85
+ "vi.importActual",
86
+ "vi.importMock",
87
+ "jest.mock",
88
+ "jest.doMock",
89
+ "jest.requireActual",
90
+ "jest.requireMock",
91
+ "jest.unstable_mockModule",
92
+ ])
93
+ const TAILWIND_CONFIG = /^tailwind\.config\.[cm]?[jt]s$/
65
94
 
66
95
  export interface LayoutMigrationIssue {
67
96
  readonly file: string
@@ -71,6 +100,8 @@ export interface LayoutMigrationIssue {
71
100
  export interface LayoutMigrationResult {
72
101
  readonly ok: boolean
73
102
  readonly write: boolean
103
+ /** `"api"` for an app that serves no pages: only its backend entry moves, nothing is split or zoned. */
104
+ readonly mode: "web" | "api"
74
105
  /** Route files whose backend exports moved, and where they went. */
75
106
  readonly splits: ReadonlyArray<{
76
107
  readonly file: string
@@ -81,6 +112,8 @@ export interface LayoutMigrationResult {
81
112
  readonly moves: ReadonlyArray<{ readonly from: string; readonly to: string }>
82
113
  /** Files whose imports were rewritten (and were not otherwise created). */
83
114
  readonly rewritten: readonly string[]
115
+ /** Tooling config the migration updated so it still covers what moved. */
116
+ readonly tooling: ReadonlyArray<{ readonly file: string; readonly change: string }>
84
117
  readonly issues: readonly LayoutMigrationIssue[]
85
118
  }
86
119
 
@@ -145,14 +178,42 @@ const hasModifier = (ts: typeof TS, node: TS.Node, kind: TS.SyntaxKind): boolean
145
178
 
146
179
  /** Identifiers a statement reads, split into value and type positions. Over-approximates on purpose:
147
180
  * an extra name only keeps a declaration in a half that did not need it. */
148
- function references(ts: typeof TS, node: TS.Node): { value: Set<string>; type: Set<string> } {
181
+ interface JsxBindings {
182
+ readonly element: readonly string[]
183
+ readonly fragment: readonly string[]
184
+ }
185
+
186
+ /**
187
+ * The bindings classic JSX compiles to calls of, which no identifier in the source names: the roots
188
+ * of the `@jsx`/`@jsxFrag` pragmas, else TypeScript's default `React`. A file that names its own
189
+ * `@jsxImportSource` imports the runtime itself.
190
+ */
191
+ function jsxBindings(ts: typeof TS, source: string): JsxBindings {
192
+ const comments = (ts.getLeadingCommentRanges(source, 0) ?? [])
193
+ .map(({ pos, end }) => source.slice(pos, end))
194
+ .join("\n")
195
+ if (/@jsxImportSource\s/.test(comments)) return { element: [], fragment: [] }
196
+ const root = (pragma: string): string | undefined =>
197
+ new RegExp(`@${pragma}\\s+([A-Za-z_$][\\w$]*)`).exec(comments)?.[1]
198
+ const element = root("jsx") ?? "React"
199
+ return { element: [element], fragment: [root("jsxFrag") ?? element] }
200
+ }
201
+
202
+ function references(
203
+ ts: typeof TS,
204
+ node: TS.Node,
205
+ jsx: JsxBindings,
206
+ ): { value: Set<string>; type: Set<string> } {
149
207
  const value = new Set<string>()
150
208
  const type = new Set<string>()
151
209
  const visit = (current: TS.Node, inType: boolean): void => {
210
+ if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current))
211
+ for (const name of jsx.element) value.add(name)
212
+ else if (ts.isJsxFragment(current)) for (const name of jsx.fragment) value.add(name)
152
213
  const typed =
153
214
  inType ||
154
215
  ts.isTypeNode(current) ||
155
- // @nifra-gate-reviewed: `token` is the heritage clause's syntax kind, not a credential.
216
+ // nifra-expect NF-S002: `token` is the heritage clause's syntax kind, not a credential.
156
217
  (ts.isHeritageClause(current) && current.token === ts.SyntaxKind.ImplementsKeyword)
157
218
  if (ts.isIdentifier(current)) {
158
219
  const parent = current.parent
@@ -182,6 +243,7 @@ function references(ts: typeof TS, node: TS.Node): { value: Set<string>; type: S
182
243
  }
183
244
 
184
245
  function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
246
+ const jsx = jsxBindings(ts, sf.text)
185
247
  return sf.statements.map((node) => {
186
248
  const declares = new Set<string>()
187
249
  const exports = new Map<string, string[]>()
@@ -232,7 +294,7 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
232
294
  }
233
295
  const refs = ts.isImportDeclaration(node)
234
296
  ? { value: new Set<string>(), type: new Set<string>() }
235
- : references(ts, node)
297
+ : references(ts, node, jsx)
236
298
  for (const name of declares) {
237
299
  refs.value.delete(name)
238
300
  refs.type.delete(name)
@@ -271,9 +333,13 @@ function filterImport(
271
333
  if (parts.length === 0) return undefined
272
334
  const typeOnly = clause.isTypeOnly ? "type " : ""
273
335
  const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
274
- return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}`
336
+ return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}${terminatorOf(sf, node)}`
275
337
  }
276
338
 
339
+ /** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
340
+ const terminatorOf = (sf: TS.SourceFile, node: TS.Node): string =>
341
+ node.getText(sf).endsWith(";") ? ";" : ""
342
+
277
343
  /** Rebuild `export { a, b as c }` keeping only the exported names `keep` accepts. */
278
344
  function filterExportList(
279
345
  ts: typeof TS,
@@ -287,7 +353,7 @@ function filterExportList(
287
353
  if (kept.length === 0) return undefined
288
354
  const from = node.moduleSpecifier ? ` from ${node.moduleSpecifier.getText(sf)}` : ""
289
355
  const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
290
- return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}`
356
+ return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}${terminatorOf(sf, node)}`
291
357
  }
292
358
 
293
359
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
@@ -453,10 +519,11 @@ export function splitRouteSource(
453
519
  }
454
520
  // After the other imports: the nearest relative path sorts last.
455
521
  if (typeImports.size > 0) {
522
+ const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : ""
456
523
  frontendTexts.splice(
457
524
  afterImports,
458
525
  0,
459
- `\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"`,
526
+ `\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"${semi}`,
460
527
  )
461
528
  }
462
529
  // A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
@@ -497,7 +564,7 @@ export function middlewareSource(ts: typeof TS, source: string, fileName: string
497
564
  // Edit from the end so earlier offsets stay valid.
498
565
  for (const node of [...sf.statements].reverse()) {
499
566
  if (ts.isExportAssignment(node) && !node.isExportEquals) {
500
- out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${out.slice(node.getEnd())}`
567
+ out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${terminatorOf(sf, node)}${out.slice(node.getEnd())}`
501
568
  } else if (
502
569
  ts.isFunctionDeclaration(node) &&
503
570
  hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)
@@ -515,7 +582,8 @@ export function middlewareSource(ts: typeof TS, source: string, fileName: string
515
582
  out = `${out.slice(0, first.getStart(sf))}export${text}${out.slice(node.getEnd())}`
516
583
  } else {
517
584
  out = `${out.slice(0, first.getStart(sf))}${out.slice(last.getEnd()).replace(/^\s+/, "")}`
518
- out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }\n`
585
+ const semi = sf.statements.some((s) => terminatorOf(sf, s) !== "") ? ";" : ""
586
+ out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }${semi}\n`
519
587
  }
520
588
  }
521
589
  }
@@ -579,6 +647,16 @@ interface ImportRef {
579
647
  readonly typeOnly: boolean
580
648
  }
581
649
 
650
+ /** The path argument of `mock.module("./x", ...)` or a vitest/jest twin, when it is a literal. */
651
+ function mockedSpecifier(ts: typeof TS, node: TS.CallExpression): TS.StringLiteralLike | undefined {
652
+ const callee = node.expression
653
+ if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression))
654
+ return undefined
655
+ if (!MOCK_CALLS.has(`${callee.expression.text}.${callee.name.text}`)) return undefined
656
+ const arg = node.arguments[0]
657
+ return arg !== undefined && ts.isStringLiteralLike(arg) ? arg : undefined
658
+ }
659
+
582
660
  function importsOf(ts: typeof TS, file: string, text: string): ImportRef[] {
583
661
  if (/\.(?:svelte|vue)$/.test(file)) {
584
662
  return scriptBlocks(text).flatMap((block) =>
@@ -635,6 +713,9 @@ function importsOf(ts: typeof TS, file: string, text: string): ImportRef[] {
635
713
  const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require"
636
714
  if ((isImport || isRequire) && arg !== undefined && ts.isStringLiteralLike(arg))
637
715
  refs.push({ specifier: arg.text, typeOnly: false })
716
+ // A test's mock names the module without loading it into the app: no runtime edge.
717
+ const mocked = mockedSpecifier(ts, node)
718
+ if (mocked !== undefined) refs.push({ specifier: mocked.text, typeOnly: true })
638
719
  } else if (
639
720
  ts.isImportEqualsDeclaration(node) &&
640
721
  ts.isExternalModuleReference(node.moduleReference)
@@ -846,6 +927,164 @@ function rebase(specifier: string, from: string, newFrom: string): string | unde
846
927
  return rel.startsWith(".") ? rel : `./${rel}`
847
928
  }
848
929
 
930
+ /** A split route as an importer sees it: the names that left it, and how this importer spells the
931
+ * backend half they went to (`undefined` when an alias cannot name it). */
932
+ interface SplitTarget {
933
+ readonly backend: string
934
+ readonly moved: ReadonlySet<string>
935
+ readonly specifier: string | undefined
936
+ }
937
+
938
+ /** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
939
+ function backendSpecifier(specifier: string, route: string, backend: string, from: string): string {
940
+ const query = /[?#].*$/.exec(specifier)?.[0] ?? ""
941
+ const written = extname(specifier.slice(0, specifier.length - query.length))
942
+ const stem = backend.slice(0, -extname(backend).length)
943
+ const target =
944
+ written === ""
945
+ ? stem
946
+ : written === extname(route)
947
+ ? backend
948
+ : `${stem}${written === ".jsx" ? ".js" : written}`
949
+ const rel = posix.relative(posix.dirname(from), target)
950
+ return (rel.startsWith(".") ? rel : `./${rel}`) + query
951
+ }
952
+
953
+ /**
954
+ * Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
955
+ * backend half, splitting a statement that also names what stayed. What cannot be rewritten (a
956
+ * namespace or dynamic import that reads a moved name, `export *`, an alias with no target there) is
957
+ * returned as an issue.
958
+ */
959
+ function redirectMovedImports(
960
+ ts: typeof TS,
961
+ file: string,
962
+ text: string,
963
+ splitOf: (specifier: string) => SplitTarget | undefined,
964
+ ): {
965
+ readonly text: string
966
+ readonly issues: readonly string[]
967
+ readonly redirects: ReadonlyArray<{ names: string[]; from: string; to: string }>
968
+ } {
969
+ const redirects: Array<{ names: string[]; from: string; to: string }> = []
970
+ const sf = ts.createSourceFile(
971
+ file,
972
+ text,
973
+ ts.ScriptTarget.Latest,
974
+ true,
975
+ file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
976
+ )
977
+ const edits: Array<{ start: number; end: number; text: string }> = []
978
+ const issues: string[] = []
979
+ const mentioned = (split: SplitTarget, pattern: (name: string) => RegExp): string[] =>
980
+ [...split.moved].filter((name) => pattern(escapeRegExp(name)).test(text)).sort()
981
+ const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(", ")
982
+ const visit = (node: TS.Node): void => {
983
+ if (
984
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
985
+ node.moduleSpecifier !== undefined &&
986
+ ts.isStringLiteral(node.moduleSpecifier)
987
+ ) {
988
+ const from = node.moduleSpecifier.text
989
+ const split = splitOf(from)
990
+ if (split === undefined) return
991
+ const clause = ts.isImportDeclaration(node) ? node.importClause : undefined
992
+ const named = ts.isImportDeclaration(node) ? clause?.namedBindings : node.exportClause
993
+ if (named !== undefined && (ts.isNamespaceImport(named) || ts.isNamespaceExport(named))) {
994
+ const reads = mentioned(split, (name) => new RegExp(`\\b${named.name.text}\\.${name}\\b`))
995
+ if (reads.length > 0)
996
+ issues.push(
997
+ `reads ${quoted(reads)} through a namespace import of "${from}", but they moved to "${split.backend}"; import them from there by hand`,
998
+ )
999
+ return
1000
+ }
1001
+ if (named === undefined) {
1002
+ if (ts.isExportDeclaration(node))
1003
+ issues.push(
1004
+ `re-exports everything from "${from}", but ${quoted([...split.moved].sort())} moved to "${split.backend}"; re-export them from there by hand`,
1005
+ )
1006
+ return
1007
+ }
1008
+ const elements: readonly (TS.ImportSpecifier | TS.ExportSpecifier)[] = named.elements
1009
+ const isMoved = (el: TS.ImportSpecifier | TS.ExportSpecifier): boolean =>
1010
+ split.moved.has((el.propertyName ?? el.name).text)
1011
+ const moved = elements.filter(isMoved)
1012
+ if (moved.length === 0) return
1013
+ if (split.specifier === undefined) {
1014
+ issues.push(
1015
+ `imports ${quoted(moved.map((el) => (el.propertyName ?? el.name).text))} from "${from}", but they moved to "${split.backend}" and no target of that alias covers it; point the import there by hand`,
1016
+ )
1017
+ return
1018
+ }
1019
+ redirects.push({
1020
+ names: moved.map((el) => (el.propertyName ?? el.name).text),
1021
+ from,
1022
+ to: split.specifier,
1023
+ })
1024
+ const kept = elements.filter((el) => !isMoved(el)).map((el) => el.getText(sf))
1025
+ const quote = node.moduleSpecifier.getText(sf)[0] ?? '"'
1026
+ const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : ""
1027
+ const tail = (specifier: string): string =>
1028
+ ` from ${quote}${specifier}${quote}${attributes}${terminatorOf(sf, node)}`
1029
+ const keyword = ts.isImportDeclaration(node)
1030
+ ? `import ${clause?.isTypeOnly ? "type " : ""}`
1031
+ : `export ${node.isTypeOnly ? "type " : ""}`
1032
+ const list = (names: readonly string[]): string => `{ ${names.join(", ")} }`
1033
+ const backend = `${keyword}${list(moved.map((el) => el.getText(sf)))}${tail(split.specifier)}`
1034
+ const stays = [
1035
+ ...(clause?.name === undefined ? [] : [clause.name.text]),
1036
+ ...(kept.length === 0 ? [] : [list(kept)]),
1037
+ ]
1038
+ edits.push({
1039
+ start: node.getStart(sf),
1040
+ end: node.getEnd(),
1041
+ text:
1042
+ stays.length === 0 ? backend : `${keyword}${stays.join(", ")}${tail(from)}\n${backend}`,
1043
+ })
1044
+ return
1045
+ }
1046
+ if (
1047
+ ts.isCallExpression(node) &&
1048
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
1049
+ node.arguments[0] !== undefined &&
1050
+ ts.isStringLiteralLike(node.arguments[0])
1051
+ ) {
1052
+ const from = node.arguments[0].text
1053
+ const split = splitOf(from)
1054
+ const reads =
1055
+ split === undefined ? [] : mentioned(split, (name) => new RegExp(`\\b${name}\\b`))
1056
+ if (split !== undefined && reads.length > 0)
1057
+ issues.push(
1058
+ `loads "${from}" with import() and mentions ${quoted(reads)}, which moved to "${split.backend}"; load the backend half for them by hand`,
1059
+ )
1060
+ }
1061
+ ts.forEachChild(node, visit)
1062
+ }
1063
+ visit(sf)
1064
+ let out = text
1065
+ for (const edit of edits.sort((a, b) => b.start - a.start))
1066
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
1067
+ return { text: out, issues, redirects }
1068
+ }
1069
+
1070
+ /** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
1071
+ * up and never a workspace root, so the scan beside it stays inside one package. */
1072
+ function enclosingPackage(root: string): string | undefined {
1073
+ if (existsSync(join(root, "package.json"))) return undefined
1074
+ for (const dir of [dirname(root), dirname(dirname(root))]) {
1075
+ const manifest = join(dir, "package.json")
1076
+ if (dir === root || !existsSync(manifest)) continue
1077
+ try {
1078
+ const parsed: unknown = JSON.parse(readFileSync(manifest, "utf8"))
1079
+ const workspace = typeof parsed === "object" && parsed !== null && "workspaces" in parsed
1080
+ return workspace ? undefined : dir
1081
+ } catch {
1082
+ return undefined
1083
+ }
1084
+ }
1085
+ return undefined
1086
+ }
1087
+
849
1088
  function rewriteImports(
850
1089
  ts: typeof TS,
851
1090
  file: string,
@@ -880,7 +1119,7 @@ function rewriteImports(
880
1119
  ) {
881
1120
  const arg = node.arguments[0]
882
1121
  if (arg !== undefined && ts.isStringLiteralLike(arg)) literal = arg
883
- }
1122
+ } else if (ts.isCallExpression(node)) literal = mockedSpecifier(ts, node)
884
1123
  if (literal !== undefined) {
885
1124
  const next = rewrite(literal.text)
886
1125
  if (next !== undefined && next !== literal.text) {
@@ -976,16 +1215,153 @@ function renameRetiredTypes(ts: typeof TS, file: string, text: string): string {
976
1215
  // The migration
977
1216
  // ---------------------------------------------------------------------------------------------------
978
1217
 
979
- const ROOT_MOVES: Readonly<Record<string, string>> = {
980
- "backend.ts": "backend/app.ts",
981
- "framework.ts": "backend/framework.ts",
982
- }
983
1218
  /** Root files that stay where they are: the CLI's config, and scripts no build loads. */
984
- const isRootScript = (file: string): boolean =>
985
- !file.includes("/") && SOURCE_FILE.test(file) && ROOT_MOVES[file] === undefined
1219
+ /** The config files the loader reads, old layout included; a path field may live in any of them. */
1220
+ const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"]
1221
+
1222
+ interface ConfigEntry {
1223
+ /** `clientModule`, or `componentPipeline.entries.<name>`. */
1224
+ readonly field: string
1225
+ readonly role: "frontend" | "backend"
1226
+ readonly value: string
1227
+ /** The literal's text, quotes excluded: [start, end). */
1228
+ readonly start: number
1229
+ readonly end: number
1230
+ }
1231
+
1232
+ /**
1233
+ * The module paths a config exports as string literals: `clientModule` and the component pipeline's
1234
+ * `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
1235
+ */
1236
+ function configEntries(ts: typeof TS, file: string, text: string): ConfigEntry[] {
1237
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
1238
+ const bare = (node: TS.Expression | undefined): TS.Expression | undefined => {
1239
+ let current = node
1240
+ while (
1241
+ current !== undefined &&
1242
+ (ts.isAsExpression(current) ||
1243
+ ts.isSatisfiesExpression(current) ||
1244
+ ts.isParenthesizedExpression(current))
1245
+ )
1246
+ current = current.expression
1247
+ return current
1248
+ }
1249
+ const out: ConfigEntry[] = []
1250
+ const literal = (field: string, role: ConfigEntry["role"], node: TS.Expression | undefined) => {
1251
+ const value = bare(node)
1252
+ if (value === undefined || !ts.isStringLiteralLike(value)) return
1253
+ out.push({
1254
+ field,
1255
+ role,
1256
+ value: value.text,
1257
+ start: value.getStart(sf) + 1,
1258
+ end: value.getEnd() - 1,
1259
+ })
1260
+ }
1261
+ for (const statement of sf.statements) {
1262
+ if (
1263
+ !ts.isVariableStatement(statement) ||
1264
+ !hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword)
1265
+ )
1266
+ continue
1267
+ for (const declaration of statement.declarationList.declarations) {
1268
+ if (!ts.isIdentifier(declaration.name)) continue
1269
+ if (declaration.name.text === "clientModule")
1270
+ literal("clientModule", "frontend", declaration.initializer)
1271
+ if (declaration.name.text !== "componentPipeline") continue
1272
+ const pipeline = bare(declaration.initializer)
1273
+ if (pipeline === undefined || !ts.isObjectLiteralExpression(pipeline)) continue
1274
+ for (const property of pipeline.properties) {
1275
+ if (!ts.isPropertyAssignment(property) || property.name.getText(sf) !== "entries") continue
1276
+ const entries = bare(property.initializer)
1277
+ if (entries === undefined || !ts.isObjectLiteralExpression(entries)) continue
1278
+ for (const entry of entries.properties) {
1279
+ if (!ts.isPropertyAssignment(entry)) continue
1280
+ const name = entry.name.getText(sf).replace(/^["']|["']$/g, "")
1281
+ if (name === "client" || name === "ssr" || name === "rsc")
1282
+ literal(
1283
+ `componentPipeline.entries.${name}`,
1284
+ name === "client" ? "frontend" : "backend",
1285
+ entry.initializer,
1286
+ )
1287
+ }
1288
+ }
1289
+ }
1290
+ }
1291
+ return out
1292
+ }
1293
+
1294
+ /** The package an import names: `@scope/pkg` or `pkg`, without a subpath. */
1295
+ const packageName = (specifier: string): string => {
1296
+ const parts = specifier.split("/")
1297
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? "")
1298
+ }
1299
+
1300
+ const isRootScript = (file: string, rootMoves: Readonly<Record<string, string>>): boolean =>
1301
+ !file.includes("/") && SOURCE_FILE.test(file) && rootMoves[file] === undefined
986
1302
  const isTestFile = (file: string): boolean =>
987
1303
  /(?:^|\/)(?:test|tests|__tests__)\//.test(file) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)
988
1304
 
1305
+ /** The web config an app has, old root `framework.ts` included, or `undefined`. */
1306
+ const webConfigFile = (
1307
+ root: string,
1308
+ rootMoves: Readonly<Record<string, string>>,
1309
+ ): string | undefined =>
1310
+ rootMoves["framework.ts"] !== undefined
1311
+ ? "framework.ts"
1312
+ : existsSync(join(root, FRAMEWORK_FILE))
1313
+ ? FRAMEWORK_FILE
1314
+ : hasWebConfig(root, ".")
1315
+ ? CONFIG_FILE
1316
+ : undefined
1317
+
1318
+ /**
1319
+ * `"api"` when the app declares `"nifra": { "kind": "api" }`, or has no web config, no `routes/` and a
1320
+ * backend; `"web"` when it has `routes/`. Web config without `routes/` could be either, so it throws.
1321
+ */
1322
+ function layoutMode(root: string, rootMoves: Readonly<Record<string, string>>): "web" | "api" {
1323
+ const declared = createKindProbe(root)(root)
1324
+ if (declared.invalid !== undefined) throw new Error(declared.invalid)
1325
+ const config = webConfigFile(root, rootMoves)
1326
+ if (declared.kind === "api") {
1327
+ if (config === undefined) return "api"
1328
+ throw new Error(
1329
+ `[nifra] ${declared.manifest} declares "nifra": { "kind": "api" }, but ${root} has web config (${config}); remove "kind" to migrate it as a web app, or remove the web config`,
1330
+ )
1331
+ }
1332
+ if (existsSync(join(root, "routes"))) return "web"
1333
+ const nested = nestedAppHint(root)
1334
+ if (config !== undefined) {
1335
+ throw new Error(
1336
+ `[nifra] no routes/ directory in ${root}, but it has web config (${config}). A web app is file-routed under routes/; if this one serves no pages, remove the web config and declare "nifra": { "kind": "api" } in package.json${nested}`,
1337
+ )
1338
+ }
1339
+ if (declared.kind === "web") throw new Error(`[nifra] no routes/ directory in ${root}${nested}`)
1340
+ if (rootMoves["backend.ts"] === undefined && !existsSync(join(root, BACKEND_APP_FILE))) {
1341
+ throw new Error(
1342
+ `[nifra] ${root} has no routes/, no web config and no backend (a backend.ts that exports \`backend\`, or ${BACKEND_APP_FILE}); there is nothing to migrate${nested}`,
1343
+ )
1344
+ }
1345
+ return "api"
1346
+ }
1347
+
1348
+ /** An app one folder down (`src/routes/`): the `--dir` that migrates it, as a sentence to append. */
1349
+ function nestedAppHint(root: string): string {
1350
+ const found = readdirSync(root, { withFileTypes: true })
1351
+ .filter(
1352
+ (entry) =>
1353
+ entry.isDirectory() &&
1354
+ !entry.name.startsWith(".") &&
1355
+ !SKIP_DIRS.has(entry.name) &&
1356
+ existsSync(join(root, entry.name, "routes")),
1357
+ )
1358
+ .map((entry) => entry.name)
1359
+ .sort(codeUnitOrder)
1360
+ const first = found[0]
1361
+ if (first === undefined) return ""
1362
+ return `. ${found.map((name) => `${name}/routes/`).join(", ")} exists: run \`nifra migrate layout --dir ${first}\` to migrate the app there`
1363
+ }
1364
+
989
1365
  export async function migrateLayout(
990
1366
  appRoot: string,
991
1367
  options: LayoutMigrationOptions = {},
@@ -994,7 +1370,15 @@ export async function migrateLayout(
994
1370
  if (ts === undefined)
995
1371
  throw new Error("[nifra] `nifra migrate layout` needs TypeScript: bun add -d typescript")
996
1372
  const root = resolve(appRoot)
997
- if (!existsSync(join(root, "routes"))) throw new Error(`[nifra] no routes/ directory in ${root}`)
1373
+ // Only a root file that exports what the old layout read moves; any other backend.ts is a module.
1374
+ const rootMoves: Readonly<Record<string, string>> = Object.fromEntries(retiredRootFiles(root))
1375
+ const mode = layoutMode(root, rootMoves)
1376
+ // An API-only app has no browser: nothing to split, and every file is server code where it is.
1377
+ const api = mode === "api"
1378
+ const zones = appZones(root)
1379
+ const configured = [...(zones.backend ?? []), ...(zones.frontend ?? []), ...(zones.shared ?? [])]
1380
+ const inConfiguredFolder = (file: string): boolean =>
1381
+ configured.some((folder) => file === folder || file.startsWith(`${folder}/`))
998
1382
  const original: Files = new Map()
999
1383
  const everyFile = listFiles(root)
1000
1384
  for (const file of everyFile) {
@@ -1011,7 +1395,7 @@ export async function migrateLayout(
1011
1395
  // 1-2. Route halves and middleware.
1012
1396
  const backendHalves = new Map<string, string>()
1013
1397
  for (const [file, text] of original) {
1014
- if (!file.startsWith("routes/") || file.includes(".backend.")) continue
1398
+ if (api || !file.startsWith("routes/") || file.includes(".backend.")) continue
1015
1399
  if (/(?:^|\/)_middleware\.[cm]?[jt]sx?$/.test(file)) {
1016
1400
  const target = posix.join(posix.dirname(file), "_layout.backend.ts")
1017
1401
  backendHalves.set(
@@ -1066,12 +1450,98 @@ export async function migrateLayout(
1066
1450
  }
1067
1451
 
1068
1452
  // 3-4. Zoning by runtime reachability, on the post-split graph.
1069
- for (const [from, to] of Object.entries(ROOT_MOVES)) if (next.has(from)) moveMap.set(from, to)
1453
+ for (const [from, to] of Object.entries(rootMoves)) if (next.has(from)) moveMap.set(from, to)
1070
1454
  // Assets (images, fonts) resolve too, so they move with the modules that import them.
1071
1455
  const known = new Set([...next.keys(), ...everyFile])
1456
+ const resolver = createSpecifierResolver()
1457
+ const realRoot = (() => {
1458
+ try {
1459
+ return realpathSync(root)
1460
+ } catch {
1461
+ return root
1462
+ }
1463
+ })()
1464
+ /** A non-relative specifier the app's tsconfig `paths`/`baseUrl` or package.json `imports` maps to
1465
+ * one of its files, read from disk as the bundler reads it. */
1466
+ const viaAlias = (file: string, specifier: string): string | undefined => {
1467
+ if (specifier.startsWith(".") || isAbsolute(specifier)) return undefined
1468
+ const found = resolver(join(root, file), specifier)
1469
+ if (found === undefined) return undefined
1470
+ const rel = toPosix(relative(realRoot, found))
1471
+ return rel.startsWith("../") || isAbsolute(rel) || !known.has(rel) ? undefined : rel
1472
+ }
1473
+ const resolveAny = (file: string, specifier: string): string | undefined =>
1474
+ resolveIn(known, file, specifier) ?? viaAlias(file, specifier)
1475
+ const ownName = (() => {
1476
+ try {
1477
+ const name: unknown = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name
1478
+ return typeof name === "string" ? name : undefined
1479
+ } catch {
1480
+ return undefined
1481
+ }
1482
+ })()
1483
+ const installed = new Map<string, boolean>()
1484
+ /** Whether a bare specifier names something outside the app that exists: a built-in, a URL or a
1485
+ * scheme the bundler owns, the app's own package name, or a package installed at or above it. */
1486
+ const external = (specifier: string): boolean => {
1487
+ if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun") return true
1488
+ const name = packageName(specifier.replace(/[?#].*$/, ""))
1489
+ if (builtinModules.includes(name) || name === ownName) return true
1490
+ let found = installed.get(name)
1491
+ if (found === undefined) {
1492
+ const types = `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`
1493
+ found = false
1494
+ for (let dir = root; ; dir = dirname(dir)) {
1495
+ if (
1496
+ existsSync(join(dir, "node_modules", name)) ||
1497
+ existsSync(join(dir, "node_modules", types))
1498
+ ) {
1499
+ found = true
1500
+ break
1501
+ }
1502
+ if (dirname(dir) === dir) break
1503
+ }
1504
+ installed.set(name, found)
1505
+ }
1506
+ return found
1507
+ }
1508
+ // A test (or any module) that imports `loader` from a page would import a name the page no longer
1509
+ // exports: it reads it from the backend half instead.
1510
+ const splitRoutes = new Map<string, { backend: string; moved: ReadonlySet<string> }>()
1511
+ for (const split of splits)
1512
+ if (next.has(split.file)) splitRoutes.set(split.file, { ...split, moved: new Set(split.moved) })
1513
+ /** `base` is where `file` is relative to: the app root, or the package folder around it. */
1514
+ const splitTarget =
1515
+ (base: string, file: string, resolve: (specifier: string) => string | undefined) =>
1516
+ (specifier: string): SplitTarget | undefined => {
1517
+ const route = resolve(specifier)
1518
+ const split = route === undefined ? undefined : splitRoutes.get(route)
1519
+ if (route === undefined || split === undefined) return undefined
1520
+ const inBase = (path: string): string => toPosix(relative(base, join(root, path)))
1521
+ const respelled = specifier.startsWith(".")
1522
+ ? backendSpecifier(specifier, inBase(route), inBase(split.backend), file)
1523
+ : resolver.respell(join(base, file), specifier, join(root, split.backend))
1524
+ return { backend: split.backend, moved: split.moved, specifier: respelled }
1525
+ }
1526
+ const redirectedFiles = new Set<string>()
1527
+ for (const [file, text] of splitRoutes.size === 0 ? [] : next) {
1528
+ if (splitRoutes.has(file) || !/\.[cm]?[jt]sx?$/.test(file)) continue
1529
+ const redirected = redirectMovedImports(
1530
+ ts,
1531
+ file,
1532
+ text,
1533
+ splitTarget(root, file, (specifier) => resolveAny(file, specifier)),
1534
+ )
1535
+ if (redirected.text !== text) {
1536
+ next.set(file, redirected.text)
1537
+ redirectedFiles.add(file)
1538
+ }
1539
+ for (const reason of redirected.issues) issues.push({ file, reason })
1540
+ }
1072
1541
  const edges = new Map<string, Array<{ target: string; typeOnly: boolean }>>()
1073
- for (const [file, text] of next) {
1542
+ for (const [file, text] of api ? [] : next) {
1074
1543
  const refs: Array<{ target: string; typeOnly: boolean }> = []
1544
+ const reported = new Set<string>()
1075
1545
  for (const ref of importsOf(ts, file, text)) {
1076
1546
  if (ref.specifier === "@nifrajs/web/plugins/vite-server-only") {
1077
1547
  issues.push({
@@ -1080,23 +1550,52 @@ export async function migrateLayout(
1080
1550
  "imports the retired @nifrajs/web/plugins/vite-server-only; remove it: nifra's Vite builds and dev server enforce the zones themselves (a hand-built Vite config uses viteLeakGuard from @nifrajs/web/plugins/vite-leak-guard)",
1081
1551
  })
1082
1552
  }
1083
- const target = resolveIn(known, file, ref.specifier)
1553
+ const target = resolveAny(file, ref.specifier)
1084
1554
  if (target !== undefined) refs.push({ target, typeOnly: ref.typeOnly })
1085
- else if (/^[@~]\//.test(ref.specifier))
1555
+ else if (
1556
+ !ref.specifier.startsWith(".") &&
1557
+ !isAbsolute(ref.specifier) &&
1558
+ RETIRED_SPECIFIERS[ref.specifier] === undefined &&
1559
+ resolver(join(root, file), ref.specifier) === undefined &&
1560
+ !external(ref.specifier) &&
1561
+ !reported.has(ref.specifier)
1562
+ ) {
1563
+ reported.add(ref.specifier)
1086
1564
  issues.push({
1087
1565
  file,
1088
- reason: `the path alias "${ref.specifier}" is left as written; point it at the new location by hand`,
1566
+ reason: `"${ref.specifier}" names no installed package and no file through the app's tsconfig paths or package.json imports, so the migration cannot tell where it belongs; check it by hand`,
1089
1567
  })
1568
+ }
1090
1569
  }
1091
1570
  edges.set(file, refs)
1092
1571
  }
1093
- const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])]
1094
- const frontendRoots = files.filter(
1095
- (f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend."),
1572
+ // The config's module paths are entries too: the browser loads `clientModule`.
1573
+ const entries = CONFIG_FILES.filter((file) => next.has(file)).flatMap((file) =>
1574
+ configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
1575
+ ...entry,
1576
+ file,
1577
+ // Relative to the app root, as the loader reads it, whichever config file holds it.
1578
+ target: resolveAny("nifra.config.ts", entry.value),
1579
+ })),
1096
1580
  )
1581
+ const entryTargets = (role: ConfigEntry["role"]): Set<string> =>
1582
+ new Set(
1583
+ entries.flatMap((entry) =>
1584
+ entry.role === role && entry.target !== undefined ? [entry.target] : [],
1585
+ ),
1586
+ )
1587
+ const browserEntries = entryTargets("frontend")
1588
+ const serverEntries = entryTargets("backend")
1589
+ const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])]
1590
+ const frontendRoots = [
1591
+ ...files.filter(
1592
+ (f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend."),
1593
+ ),
1594
+ ...browserEntries,
1595
+ ]
1097
1596
  // A route page the server imports - a generated `server-manifest.ts` imports them all - is rendered
1098
1597
  // there, not run as server code, so the server's walk stops at it: what only pages use stays frontend.
1099
- const pages = new Set(frontendRoots)
1598
+ const pages = new Set(frontendRoots.filter((f) => !browserEntries.has(f)))
1100
1599
  const reach = (roots: readonly string[], stopAt?: ReadonlySet<string>): Set<string> => {
1101
1600
  const seen = new Set<string>()
1102
1601
  const queue = [...roots]
@@ -1109,23 +1608,31 @@ export async function migrateLayout(
1109
1608
  }
1110
1609
  return seen
1111
1610
  }
1112
- const backendRoots = files.filter(
1113
- (f) =>
1114
- (f.startsWith("routes/") && f.includes(".backend.")) ||
1115
- f.startsWith("backend/") ||
1116
- ROOT_MOVES[f] !== undefined ||
1117
- (isRootScript(f) && f !== "nifra.config.ts" && !isTestFile(f)),
1118
- )
1611
+ const backendRoots = [
1612
+ ...files.filter(
1613
+ (f) =>
1614
+ (f.startsWith("routes/") && f.includes(".backend.")) ||
1615
+ f.startsWith("backend/") ||
1616
+ rootMoves[f] !== undefined ||
1617
+ (isRootScript(f, rootMoves) &&
1618
+ f !== "nifra.config.ts" &&
1619
+ !isTestFile(f) &&
1620
+ !browserEntries.has(f)),
1621
+ ),
1622
+ ...serverEntries,
1623
+ ]
1119
1624
  const browser = reach(frontendRoots)
1120
1625
  const server = reach(backendRoots, pages)
1121
1626
  // What the app's own server modules reach, without the root scripts: a root file in here is a module
1122
1627
  // the app imports, not an entry, so it moves into a zone.
1123
1628
  const appServer = reach(
1124
- backendRoots.filter((f) => f.includes("/") || ROOT_MOVES[f] !== undefined),
1629
+ backendRoots.filter(
1630
+ (f) => f.includes("/") || rootMoves[f] !== undefined || serverEntries.has(f),
1631
+ ),
1125
1632
  pages,
1126
1633
  )
1127
1634
  // A `.server` module ran empty in the browser, so only server code used it: it moves under backend/.
1128
- for (const file of files) {
1635
+ for (const file of api ? [] : files) {
1129
1636
  if (!LEGACY_SERVER.test(file) || isTestFile(file)) continue
1130
1637
  const parts = file.replace(LEGACY_SERVER, "$1").split("/")
1131
1638
  if (parts.length > 1 && ZONE_DIRS.has(parts[0] ?? "")) parts.shift()
@@ -1140,12 +1647,13 @@ export async function migrateLayout(
1140
1647
  }
1141
1648
  const zoneOf = (file: string): "frontend" | "backend" | "shared" =>
1142
1649
  browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend"
1143
- for (const file of files) {
1650
+ for (const file of api ? [] : files) {
1144
1651
  if (
1145
1652
  moveMap.has(file) ||
1146
1653
  isTestFile(file) ||
1147
1654
  file.endsWith(".d.ts") ||
1148
- file === "nifra.config.ts"
1655
+ file === "nifra.config.ts" ||
1656
+ inConfiguredFolder(file)
1149
1657
  )
1150
1658
  continue
1151
1659
  if (/\.(?:frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.test(file)) continue
@@ -1153,7 +1661,16 @@ export async function migrateLayout(
1153
1661
  if (file.includes("/")) {
1154
1662
  if (ZONE_DIRS.has(top) || (!browser.has(file) && !server.has(file))) continue
1155
1663
  } else if (!browser.has(file) && !appServer.has(file)) continue
1156
- moveMap.set(file, `${zoneOf(file)}/${file}`)
1664
+ const zone = zoneOf(file)
1665
+ if (DATA_FILE.test(file)) {
1666
+ const folder = file.includes("/") ? posix.dirname(file) : undefined
1667
+ issues.push({
1668
+ file,
1669
+ reason: `is data ${zone === "shared" ? "both sides import" : `${zone} code imports`}, and a build only loads files in a zone; it stays here because other tools may read it by path. Move it to "${zone}/${file}" by hand, or ${folder === undefined ? "into a folder listed" : `list "${folder}"`} under "nifra": { "zones": { "${zone}": [...] } } in package.json`,
1670
+ })
1671
+ continue
1672
+ }
1673
+ moveMap.set(file, `${zone}/${file}`)
1157
1674
  }
1158
1675
  const claimed = new Map<string, string>()
1159
1676
  for (const [from, to] of moveMap) {
@@ -1181,19 +1698,60 @@ export async function migrateLayout(
1181
1698
  const newPath = (file: string): string => moveMap.get(file) ?? file
1182
1699
  const final: Files = new Map()
1183
1700
  const rewritten: string[] = []
1701
+ const unspelled = new Set<string>()
1702
+ /** An alias for a target that moves, through the same alias, or an issue when none covers it. */
1703
+ const respellAlias = (file: string, specifier: string, target: string): string | undefined => {
1704
+ if (newPath(target) === target) return undefined
1705
+ const respelled = resolver.respell(join(root, file), specifier, join(root, newPath(target)))
1706
+ if (respelled === undefined && !unspelled.has(`${file}\0${specifier}`)) {
1707
+ unspelled.add(`${file}\0${specifier}`)
1708
+ issues.push({
1709
+ file,
1710
+ reason: `the path alias "${specifier}" names "${target}", which moves to "${newPath(target)}", and no target of that alias covers the new location; point it there by hand`,
1711
+ })
1712
+ }
1713
+ return respelled
1714
+ }
1184
1715
  for (const [file, text] of next) {
1185
1716
  const imports = rewriteImports(ts, file, text, (specifier) => {
1186
1717
  const retired = RETIRED_SPECIFIERS[specifier]
1187
1718
  if (retired !== undefined) return retired
1188
- const target = resolveIn(known, file, specifier)
1719
+ const target = resolveAny(file, specifier)
1189
1720
  // Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
1190
1721
  if (target === undefined) return rebase(specifier, file, newPath(file))
1722
+ if (!specifier.startsWith(".")) return respellAlias(file, specifier, target)
1191
1723
  if (newPath(target) === target && newPath(file) === file) return undefined
1192
1724
  return respecify(specifier, target, newPath(file), newPath(target))
1193
1725
  })
1194
1726
  const updated = renameRetiredTypes(ts, file, imports)
1195
1727
  final.set(newPath(file), updated)
1196
- if (updated !== text && !moveMap.has(file) && original.get(file) === text) rewritten.push(file)
1728
+ if (
1729
+ !moveMap.has(file) &&
1730
+ (redirectedFiles.has(file) || (updated !== text && original.get(file) === text))
1731
+ )
1732
+ rewritten.push(file)
1733
+ }
1734
+
1735
+ // A config path names its module from the app root, so it follows the module, never the config file.
1736
+ for (const configFile of CONFIG_FILES) {
1737
+ const at = newPath(configFile)
1738
+ const text = final.get(at)
1739
+ if (text === undefined || !next.has(configFile)) continue
1740
+ const edits: Array<{ start: number; end: number; text: string }> = []
1741
+ for (const entry of configEntries(ts, at, text)) {
1742
+ const target = resolveAny("nifra.config.ts", entry.value)
1743
+ if (target === undefined || newPath(target) === target) continue
1744
+ const value = entry.value.startsWith(".")
1745
+ ? respecify(entry.value, target, "nifra.config.ts", newPath(target))
1746
+ : respellAlias(configFile, entry.value, target)
1747
+ if (value !== undefined) edits.push({ start: entry.start, end: entry.end, text: value })
1748
+ }
1749
+ if (edits.length === 0) continue
1750
+ let updated = text
1751
+ for (const edit of edits.sort((a, b) => b.start - a.start))
1752
+ updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end)
1753
+ final.set(at, updated)
1754
+ if (!moveMap.has(configFile) && !rewritten.includes(configFile)) rewritten.push(configFile)
1197
1755
  }
1198
1756
 
1199
1757
  const moves = [...moveMap]
@@ -1249,6 +1807,44 @@ export async function migrateLayout(
1249
1807
  }
1250
1808
  }
1251
1809
 
1810
+ // Code beside an app one folder down (a package's test/ when the app is its src/) is not the
1811
+ // migration's to edit, so each import there that stops resolving to what it named is reported.
1812
+ const pkg = enclosingPackage(root)
1813
+ if (pkg !== undefined) {
1814
+ const prefix = toPosix(relative(pkg, root))
1815
+ const inApp = new Set([...known].map((file) => `${prefix}/${file}`))
1816
+ const appPath = (file: string, specifier: string): string | undefined =>
1817
+ resolveIn(inApp, file, specifier)?.slice(prefix.length + 1)
1818
+ const outside = ` - it is outside ${prefix}/, so the migration does not rewrite it`
1819
+ for (const file of listFiles(pkg)) {
1820
+ if (file.startsWith(`${prefix}/`) || !/\.[cm]?[jt]sx?$/.test(file)) continue
1821
+ const text = readFileSync(join(pkg, file), "utf8")
1822
+ const at = toPosix(relative(root, join(pkg, file)))
1823
+ for (const { specifier } of importsOf(ts, file, text)) {
1824
+ const target = appPath(file, specifier)
1825
+ const to = target === undefined ? undefined : moveMap.get(target)
1826
+ if (to !== undefined)
1827
+ issues.push({
1828
+ file: at,
1829
+ reason: `imports "${specifier}", which moves to "${to}"${outside}`,
1830
+ })
1831
+ }
1832
+ const redirected = redirectMovedImports(
1833
+ ts,
1834
+ file,
1835
+ text,
1836
+ splitTarget(pkg, file, (specifier) => appPath(file, specifier)),
1837
+ )
1838
+ for (const { names, from, to } of redirected.redirects) {
1839
+ issues.push({
1840
+ file: at,
1841
+ reason: `imports ${names.map((name) => `"${name}"`).join(", ")} from "${from}"; import it from "${to}"${outside}`,
1842
+ })
1843
+ }
1844
+ for (const reason of redirected.issues) issues.push({ file: at, reason })
1845
+ }
1846
+ }
1847
+
1252
1848
  // Paths a module finds at run time are not imports either: one built from the module's own location
1253
1849
  // now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
1254
1850
  const leaving = new Map<string, Array<{ from: string; to: string }>>()
@@ -1261,7 +1857,8 @@ export async function migrateLayout(
1261
1857
  })
1262
1858
  }
1263
1859
  const dir = posix.dirname(move.from)
1264
- if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? "")) continue
1860
+ if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? "") || inConfiguredFolder(move.from))
1861
+ continue
1265
1862
  leaving.set(dir, [...(leaving.get(dir) ?? []), move])
1266
1863
  }
1267
1864
  for (const [dir, group] of leaving) {
@@ -1288,51 +1885,276 @@ export async function migrateLayout(
1288
1885
  for (const { name, schema } of missingOutputSchemas(text)) {
1289
1886
  issues.push({
1290
1887
  file,
1291
- reason: `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request. Declare what the browser may see: export const ${schema} = t.object({ ... })`,
1888
+ reason: `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request. Declare what the browser may see: export const ${schema} = t.object({ ... }), or run \`nifra schemas --write\` after the migration to draft it from the ${name}'s return type`,
1292
1889
  })
1293
1890
  }
1294
1891
  }
1295
1892
 
1893
+ // Tooling that lists folders or globs, not files: the import rewrite above never sees it.
1894
+ const tooling: Array<{ file: string; change: string }> = []
1895
+ const toolingWrites = new Map<string, string>()
1896
+ const tsconfig = coverMovedFiles(ts, root, moves)
1897
+ if (tsconfig?.issue !== undefined) issues.push({ file: "tsconfig.json", reason: tsconfig.issue })
1898
+ if (tsconfig?.text !== undefined) {
1899
+ toolingWrites.set("tsconfig.json", tsconfig.text)
1900
+ tooling.push({ file: "tsconfig.json", change: `include += ${tsconfig.added.join(", ")}` })
1901
+ }
1902
+ for (const file of everyFile) {
1903
+ if (!TAILWIND_CONFIG.test(file)) continue
1904
+ const reason = staleContentGlobs(ts, readFileSync(join(root, file), "utf8"), moves)
1905
+ if (reason !== undefined) issues.push({ file, reason })
1906
+ }
1907
+ const gitignore = ignoreNifraState(root)
1908
+ if (gitignore !== undefined) {
1909
+ toolingWrites.set(".gitignore", gitignore)
1910
+ tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" })
1911
+ }
1912
+ // `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
1913
+ const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {}
1914
+ if (kind.issue !== undefined) issues.push({ file: "package.json", reason: kind.issue })
1915
+ if (kind.text !== undefined) {
1916
+ toolingWrites.set("package.json", kind.text)
1917
+ tooling.push({ file: "package.json", change: '"nifra": { "kind": "api" }' })
1918
+ }
1919
+
1296
1920
  if (options.write === true) {
1921
+ for (const [file, text] of toolingWrites) writeFileSync(join(root, file), text)
1297
1922
  for (const [file, text] of final) {
1298
1923
  if (original.get(file) === text) continue
1299
1924
  const path = join(root, file)
1300
1925
  mkdirSync(dirname(path), { recursive: true })
1301
1926
  writeFileSync(path, text)
1302
1927
  }
1928
+ const vacated: string[] = []
1303
1929
  for (const file of original.keys()) {
1304
- if (!final.has(file)) rmSync(join(root, file), { force: true })
1930
+ if (final.has(file)) continue
1931
+ rmSync(join(root, file), { force: true })
1932
+ vacated.push(file)
1305
1933
  }
1306
1934
  for (const { from, to } of moves) {
1307
1935
  if (original.has(from) || !existsSync(join(root, from))) continue
1308
1936
  mkdirSync(dirname(join(root, to)), { recursive: true })
1309
1937
  renameSync(join(root, from), join(root, to))
1938
+ vacated.push(from)
1310
1939
  }
1311
- pruneEmptyDirs(root)
1940
+ pruneVacatedDirs(root, vacated)
1312
1941
  }
1313
1942
  return {
1314
1943
  ok: issues.length === 0,
1315
1944
  write: options.write === true,
1945
+ mode,
1316
1946
  splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
1317
1947
  moves,
1318
1948
  rewritten: rewritten.sort(),
1949
+ tooling,
1319
1950
  issues,
1320
1951
  }
1321
1952
  }
1322
1953
 
1323
- function pruneEmptyDirs(root: string): void {
1324
- const prune = (dir: string): boolean => {
1325
- let empty = true
1326
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1327
- if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
1328
- empty = false
1329
- continue
1954
+ /** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
1955
+ function appZones(root: string): ZoneConfig {
1956
+ const manifest = join(root, "package.json")
1957
+ if (!existsSync(manifest)) return {}
1958
+ let parsed: unknown
1959
+ try {
1960
+ parsed = JSON.parse(readFileSync(manifest, "utf8"))
1961
+ } catch {
1962
+ return {}
1963
+ }
1964
+ const nifra =
1965
+ typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "nifra") : undefined
1966
+ const zones =
1967
+ typeof nifra === "object" && nifra !== null ? Reflect.get(nifra, "zones") : undefined
1968
+ return zones === undefined ? {} : parseZoneConfig(zones, manifest)
1969
+ }
1970
+
1971
+ /** Whether a tsconfig `include` entry takes in `file` (app-relative), the way tsc reads it. */
1972
+ function includes(pattern: string, file: string): boolean {
1973
+ let spec = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "")
1974
+ if (spec === "" || spec === ".") return true
1975
+ if (!/[*?]/.test(spec)) return file === spec || file.startsWith(`${spec}/`)
1976
+ if (spec.endsWith("**")) spec = `${spec}/*`
1977
+ else if (!/[*?.][^/]*$/.test(spec.slice(spec.lastIndexOf("/") + 1))) spec = `${spec}/**/*`
1978
+ return new Bun.Glob(spec).match(file)
1979
+ }
1980
+
1981
+ /**
1982
+ * Add each zone folder that receives moved TypeScript to tsconfig `include`, editing only that array
1983
+ * so comments and layout survive. A tsconfig without `include` (or with only `extends`) is left
1984
+ * alone: tsc's default, or the base config, decides, and `nifra check` reports any folder left out.
1985
+ */
1986
+ function coverMovedFiles(
1987
+ ts: typeof TS,
1988
+ root: string,
1989
+ moves: ReadonlyArray<{ readonly from: string; readonly to: string }>,
1990
+ ): { text?: string; added: string[]; issue?: string } | undefined {
1991
+ const path = join(root, "tsconfig.json")
1992
+ if (!existsSync(path)) return undefined
1993
+ const text = readFileSync(path, "utf8")
1994
+ const source = ts.parseJsonText(path, text)
1995
+ const top = source.statements[0]?.expression
1996
+ if (top === undefined || !ts.isObjectLiteralExpression(top))
1997
+ return { added: [], issue: "is not a JSON object; add the zone folders to its include by hand" }
1998
+ const include = jsonProperty(ts, top, "include")
1999
+ if (include === undefined) return undefined
2000
+ const array = include.initializer
2001
+ if (!ts.isArrayLiteralExpression(array))
2002
+ return { added: [], issue: '"include" is not an array; add the zone folders to it by hand' }
2003
+ const patterns = array.elements.filter(ts.isStringLiteral).map((element) => element.text)
2004
+ const added: string[] = []
2005
+ for (const { to } of moves) {
2006
+ if (!/\.(?:[cm]?ts|tsx)$/.test(to) || /\.d\.[cm]?ts$/.test(to)) continue
2007
+ const folder = to.split("/")[0] ?? to
2008
+ if (added.includes(folder) || patterns.some((pattern) => includes(pattern, to))) continue
2009
+ added.push(folder)
2010
+ }
2011
+ if (added.length === 0) return undefined
2012
+ added.sort(codeUnitOrder)
2013
+ const quoted = added.map((folder) => JSON.stringify(folder))
2014
+ const last = array.elements[array.elements.length - 1]
2015
+ if (last === undefined) {
2016
+ const at = array.getStart(source) + 1
2017
+ return { text: `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`, added }
2018
+ }
2019
+ let at = last.end
2020
+ const trailing = /^\s*,/.exec(text.slice(at))
2021
+ if (trailing !== null) at += trailing[0].length
2022
+ // A multi-line array gets one entry per line at the last element's indent, below any comment that
2023
+ // closes the last element's line; anything else gets them inline.
2024
+ if (text.slice(array.getStart(source), last.getStart(source)).includes("\n")) {
2025
+ const lineStart = text.lastIndexOf("\n", last.getStart(source)) + 1
2026
+ const indent = text.slice(lineStart, last.getStart(source)).match(/^\s*/)?.[0] ?? ""
2027
+ const newline = text.indexOf("\n", at)
2028
+ const eol = newline === -1 ? text.length : newline
2029
+ if (/^\s*(?:\/\/.*|\/\*.*\*\/\s*)?$/.test(text.slice(at, eol))) {
2030
+ const head =
2031
+ trailing !== null
2032
+ ? text.slice(0, eol)
2033
+ : `${text.slice(0, last.end)},${text.slice(last.end, eol)}`
2034
+ const entries = quoted.join(`,\n${indent}`)
2035
+ return {
2036
+ text: `${head}\n${indent}${entries}${trailing !== null ? "," : ""}${text.slice(eol)}`,
2037
+ added,
1330
2038
  }
1331
- const full = join(dir, entry.name)
1332
- if (entry.isDirectory() ? !prune(full) : true) empty = false
1333
2039
  }
1334
- if (empty && dir !== root) rmSync(dir, { recursive: true, force: true })
1335
- return empty
1336
2040
  }
1337
- prune(root)
2041
+ const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`
2042
+ return { text: `${text.slice(0, at)}${insert}${text.slice(at)}`, added }
2043
+ }
2044
+
2045
+ const jsonProperty = (
2046
+ ts: typeof TS,
2047
+ object: TS.ObjectLiteralExpression,
2048
+ key: string,
2049
+ ): TS.PropertyAssignment | undefined =>
2050
+ object.properties.find(
2051
+ (property): property is TS.PropertyAssignment =>
2052
+ ts.isPropertyAssignment(property) &&
2053
+ ts.isStringLiteral(property.name) &&
2054
+ property.name.text === key,
2055
+ )
2056
+
2057
+ /** `text` with `entry` as the last property of `object`, on its own line when the object spans lines. */
2058
+ function insertProperty(
2059
+ source: TS.JsonSourceFile,
2060
+ text: string,
2061
+ object: TS.ObjectLiteralExpression,
2062
+ entry: string,
2063
+ ): string {
2064
+ const open = object.getStart(source)
2065
+ const last = object.properties[object.properties.length - 1]
2066
+ if (last === undefined) return `${text.slice(0, open)}{ ${entry} }${text.slice(object.end)}`
2067
+ const start = last.getStart(source)
2068
+ if (!text.slice(open, start).includes("\n")) {
2069
+ return `${text.slice(0, last.end)}, ${entry}${text.slice(last.end)}`
2070
+ }
2071
+ const indent = /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", start) + 1, start))?.[0] ?? ""
2072
+ return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`
2073
+ }
2074
+
2075
+ /** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
2076
+ function declareApiKind(ts: typeof TS, root: string): { text?: string; issue?: string } {
2077
+ const path = join(root, "package.json")
2078
+ if (!existsSync(path)) {
2079
+ return {
2080
+ issue:
2081
+ 'there is none in the app; declare "nifra": { "kind": "api" } in the package.json that governs it',
2082
+ }
2083
+ }
2084
+ const text = readFileSync(path, "utf8")
2085
+ const source = ts.parseJsonText(path, text)
2086
+ const top = source.statements[0]?.expression
2087
+ if (top === undefined || !ts.isObjectLiteralExpression(top))
2088
+ return { issue: 'is not a JSON object; add "nifra": { "kind": "api" } to it by hand' }
2089
+ const nifra = jsonProperty(ts, top, "nifra")
2090
+ if (nifra === undefined) {
2091
+ return { text: insertProperty(source, text, top, '"nifra": { "kind": "api" }') }
2092
+ }
2093
+ if (!ts.isObjectLiteralExpression(nifra.initializer))
2094
+ return { issue: '"nifra" is not an object; add "kind": "api" to it by hand' }
2095
+ return { text: insertProperty(source, text, nifra.initializer, '"kind": "api"') }
2096
+ }
2097
+
2098
+ /** Tailwind `content` globs that matched a moved file at its old path and match nothing at its new one. */
2099
+ function staleContentGlobs(
2100
+ ts: typeof TS,
2101
+ text: string,
2102
+ moves: ReadonlyArray<{ readonly from: string; readonly to: string }>,
2103
+ ): string | undefined {
2104
+ const globs: string[] = []
2105
+ const source = ts.createSourceFile("tailwind.config.ts", text, ts.ScriptTarget.Latest, true)
2106
+ const visit = (node: TS.Node): void => {
2107
+ if (ts.isStringLiteralLike(node) && /[*/]/.test(node.text) && !node.text.startsWith("!"))
2108
+ globs.push(node.text.replace(/^\.\//, ""))
2109
+ ts.forEachChild(node, visit)
2110
+ }
2111
+ visit(source)
2112
+ const matchers = globs.map((glob) => ({ glob, matcher: new Bun.Glob(glob) }))
2113
+ const lost = moves.filter(
2114
+ ({ from, to }) =>
2115
+ matchers.some(({ matcher }) => matcher.match(from)) &&
2116
+ !matchers.some(({ matcher }) => matcher.match(to)),
2117
+ )
2118
+ const first = lost[0]
2119
+ if (first === undefined) return undefined
2120
+ const folders = [...new Set(lost.map(({ to }) => to.split("/")[0] ?? to))].sort(codeUnitOrder)
2121
+ return `its content globs matched ${lost.length} file(s) that move, but none of their new paths (such as "${first.from}" -> "${first.to}"), so Tailwind would stop seeing their classes. Add globs for ${folders.map((folder) => `"./${folder}/**/*"`).join(", ")} (with the extensions you use)`
2122
+ }
2123
+
2124
+ /**
2125
+ * `.nifra/` holds generated types, dev state and the dev server's launch token; none of it belongs in
2126
+ * git. The new `.gitignore` text when it is not ignored yet, else `undefined`.
2127
+ */
2128
+ function ignoreNifraState(root: string): string | undefined {
2129
+ let exitCode: number | null = null
2130
+ try {
2131
+ exitCode = Bun.spawnSync(["git", "check-ignore", "-q", ".nifra/types"], {
2132
+ cwd: root,
2133
+ stdout: "ignore",
2134
+ stderr: "ignore",
2135
+ }).exitCode
2136
+ } catch {
2137
+ // No git installed: the same as no checkout.
2138
+ }
2139
+ if (exitCode === 0) return undefined
2140
+ const path = join(root, ".gitignore")
2141
+ const current = existsSync(path) ? readFileSync(path, "utf8") : ""
2142
+ // Outside a git checkout `check-ignore` cannot answer; the app's own file decides.
2143
+ if (exitCode !== 1 && /^\/?\.nifra(?:\/|\*|\/\*\*)?\s*$/m.test(current)) return undefined
2144
+ const separator = current === "" || current.endsWith("\n") ? "" : "\n"
2145
+ return `${current}${separator}.nifra/\n`
2146
+ }
2147
+
2148
+ /** Remove each directory the run emptied, then each parent that leaves empty. A directory that was
2149
+ * empty before the run is the app's own, not the migration's to remove. */
2150
+ function pruneVacatedDirs(root: string, vacated: Iterable<string>): void {
2151
+ const dirs = new Set<string>()
2152
+ for (const file of vacated) dirs.add(dirname(join(root, file)))
2153
+ // Deepest first, so a parent is judged after its emptied children are gone.
2154
+ for (const start of [...dirs].sort((a, b) => b.length - a.length)) {
2155
+ for (let dir = start; dir !== root && dir.startsWith(`${root}${sep}`); dir = dirname(dir)) {
2156
+ if (!existsSync(dir) || readdirSync(dir).length > 0) break
2157
+ rmdirSync(dir)
2158
+ }
2159
+ }
1338
2160
  }