@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,19 +8,30 @@
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
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
21
- import { dirname, extname, join, posix, relative, resolve } from "node:path";
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
27
+ import { builtinModules } from "node:module";
28
+ import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
22
29
  import { BACKEND_ROUTE_EXPORTS } from "@nifrajs/web/route-manifest";
30
+ import { parseZoneConfig } from "@nifrajs/web/zones";
31
+ import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.js";
23
32
  import { codeUnitOrder } from "./internal/code-unit-order.js";
33
+ import { createKindProbe, hasWebConfig } from "./internal/nifra-project.js";
34
+ import { createSpecifierResolver } from "./internal/project-specifiers.js";
24
35
  import { importTypeScript } from "./internal/typescript-import.js";
25
36
  import { missingOutputSchemas } from "./rules/data-guard.js";
26
37
  /** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
@@ -49,6 +60,22 @@ const TOOLING_FILE = /(?:\.(?:json|jsonc|ya?ml|toml|sh|bash|zsh|ps1)|(?:^|\/)(?:
49
60
  const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/;
50
61
  const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"]);
51
62
  const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"]);
63
+ /** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
64
+ const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/;
65
+ /** Test-runner calls that name a module by path, as an import does. */
66
+ const MOCK_CALLS = new Set([
67
+ "mock.module",
68
+ "vi.mock",
69
+ "vi.doMock",
70
+ "vi.importActual",
71
+ "vi.importMock",
72
+ "jest.mock",
73
+ "jest.doMock",
74
+ "jest.requireActual",
75
+ "jest.requireMock",
76
+ "jest.unstable_mockModule",
77
+ ]);
78
+ const TAILWIND_CONFIG = /^tailwind\.config\.[cm]?[jt]s$/;
52
79
  const toPosix = (path) => path.replaceAll("\\", "/");
53
80
  /** Every first-party file under `root`, app-relative and posix. */
54
81
  function listFiles(root) {
@@ -76,15 +103,34 @@ function bindingNames(ts, name, into) {
76
103
  bindingNames(ts, element.name, into);
77
104
  }
78
105
  const hasModifier = (ts, node, kind) => (ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : []).some((m) => m.kind === kind);
79
- /** Identifiers a statement reads, split into value and type positions. Over-approximates on purpose:
80
- * an extra name only keeps a declaration in a half that did not need it. */
81
- function references(ts, node) {
106
+ /**
107
+ * The bindings classic JSX compiles to calls of, which no identifier in the source names: the roots
108
+ * of the `@jsx`/`@jsxFrag` pragmas, else TypeScript's default `React`. A file that names its own
109
+ * `@jsxImportSource` imports the runtime itself.
110
+ */
111
+ function jsxBindings(ts, source) {
112
+ const comments = (ts.getLeadingCommentRanges(source, 0) ?? [])
113
+ .map(({ pos, end }) => source.slice(pos, end))
114
+ .join("\n");
115
+ if (/@jsxImportSource\s/.test(comments))
116
+ return { element: [], fragment: [] };
117
+ const root = (pragma) => new RegExp(`@${pragma}\\s+([A-Za-z_$][\\w$]*)`).exec(comments)?.[1];
118
+ const element = root("jsx") ?? "React";
119
+ return { element: [element], fragment: [root("jsxFrag") ?? element] };
120
+ }
121
+ function references(ts, node, jsx) {
82
122
  const value = new Set();
83
123
  const type = new Set();
84
124
  const visit = (current, inType) => {
125
+ if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current))
126
+ for (const name of jsx.element)
127
+ value.add(name);
128
+ else if (ts.isJsxFragment(current))
129
+ for (const name of jsx.fragment)
130
+ value.add(name);
85
131
  const typed = inType ||
86
132
  ts.isTypeNode(current) ||
87
- // @nifra-gate-reviewed: `token` is the heritage clause's syntax kind, not a credential.
133
+ // nifra-expect NF-S002: `token` is the heritage clause's syntax kind, not a credential.
88
134
  (ts.isHeritageClause(current) && current.token === ts.SyntaxKind.ImplementsKeyword);
89
135
  if (ts.isIdentifier(current)) {
90
136
  const parent = current.parent;
@@ -114,6 +160,7 @@ function references(ts, node) {
114
160
  return { value, type };
115
161
  }
116
162
  function describeStatements(ts, sf) {
163
+ const jsx = jsxBindings(ts, sf.text);
117
164
  return sf.statements.map((node) => {
118
165
  const declares = new Set();
119
166
  const exports = new Map();
@@ -167,7 +214,7 @@ function describeStatements(ts, sf) {
167
214
  }
168
215
  const refs = ts.isImportDeclaration(node)
169
216
  ? { value: new Set(), type: new Set() }
170
- : references(ts, node);
217
+ : references(ts, node, jsx);
171
218
  for (const name of declares) {
172
219
  refs.value.delete(name);
173
220
  refs.type.delete(name);
@@ -204,8 +251,10 @@ function filterImport(ts, sf, node, keep) {
204
251
  return undefined;
205
252
  const typeOnly = clause.isTypeOnly ? "type " : "";
206
253
  const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart());
207
- return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}`;
254
+ return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}${terminatorOf(sf, node)}`;
208
255
  }
256
+ /** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
257
+ const terminatorOf = (sf, node) => node.getText(sf).endsWith(";") ? ";" : "";
209
258
  /** Rebuild `export { a, b as c }` keeping only the exported names `keep` accepts. */
210
259
  function filterExportList(ts, sf, node, keep) {
211
260
  const clause = node.exportClause;
@@ -216,7 +265,7 @@ function filterExportList(ts, sf, node, keep) {
216
265
  return undefined;
217
266
  const from = node.moduleSpecifier ? ` from ${node.moduleSpecifier.getText(sf)}` : "";
218
267
  const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart());
219
- return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}`;
268
+ return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}${terminatorOf(sf, node)}`;
220
269
  }
221
270
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
222
271
  const joinStatements = (texts) => {
@@ -371,7 +420,8 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
371
420
  }
372
421
  // After the other imports: the nearest relative path sorts last.
373
422
  if (typeImports.size > 0) {
374
- frontendTexts.splice(afterImports, 0, `\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"`);
423
+ const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : "";
424
+ frontendTexts.splice(afterImports, 0, `\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"${semi}`);
375
425
  }
376
426
  // A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
377
427
  const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
@@ -405,7 +455,7 @@ export function middlewareSource(ts, source, fileName) {
405
455
  // Edit from the end so earlier offsets stay valid.
406
456
  for (const node of [...sf.statements].reverse()) {
407
457
  if (ts.isExportAssignment(node) && !node.isExportEquals) {
408
- out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${out.slice(node.getEnd())}`;
458
+ out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${terminatorOf(sf, node)}${out.slice(node.getEnd())}`;
409
459
  }
410
460
  else if (ts.isFunctionDeclaration(node) &&
411
461
  hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)) {
@@ -422,7 +472,8 @@ export function middlewareSource(ts, source, fileName) {
422
472
  }
423
473
  else {
424
474
  out = `${out.slice(0, first.getStart(sf))}${out.slice(last.getEnd()).replace(/^\s+/, "")}`;
425
- out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }\n`;
475
+ const semi = sf.statements.some((s) => terminatorOf(sf, s) !== "") ? ";" : "";
476
+ out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }${semi}\n`;
426
477
  }
427
478
  }
428
479
  }
@@ -470,6 +521,16 @@ function scriptBlocks(source) {
470
521
  }
471
522
  return blocks;
472
523
  }
524
+ /** The path argument of `mock.module("./x", ...)` or a vitest/jest twin, when it is a literal. */
525
+ function mockedSpecifier(ts, node) {
526
+ const callee = node.expression;
527
+ if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression))
528
+ return undefined;
529
+ if (!MOCK_CALLS.has(`${callee.expression.text}.${callee.name.text}`))
530
+ return undefined;
531
+ const arg = node.arguments[0];
532
+ return arg !== undefined && ts.isStringLiteralLike(arg) ? arg : undefined;
533
+ }
473
534
  function importsOf(ts, file, text) {
474
535
  if (/\.(?:svelte|vue)$/.test(file)) {
475
536
  return scriptBlocks(text).flatMap((block) => importsOf(ts, "block.ts", text.slice(block.start, block.end)));
@@ -519,6 +580,10 @@ function importsOf(ts, file, text) {
519
580
  const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
520
581
  if ((isImport || isRequire) && arg !== undefined && ts.isStringLiteralLike(arg))
521
582
  refs.push({ specifier: arg.text, typeOnly: false });
583
+ // A test's mock names the module without loading it into the app: no runtime edge.
584
+ const mocked = mockedSpecifier(ts, node);
585
+ if (mocked !== undefined)
586
+ refs.push({ specifier: mocked.text, typeOnly: true });
522
587
  }
523
588
  else if (ts.isImportEqualsDeclaration(node) &&
524
589
  ts.isExternalModuleReference(node.moduleReference)) {
@@ -682,6 +747,125 @@ function rebase(specifier, from, newFrom) {
682
747
  const rel = posix.relative(posix.dirname(newFrom), target);
683
748
  return rel.startsWith(".") ? rel : `./${rel}`;
684
749
  }
750
+ /** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
751
+ function backendSpecifier(specifier, route, backend, from) {
752
+ const query = /[?#].*$/.exec(specifier)?.[0] ?? "";
753
+ const written = extname(specifier.slice(0, specifier.length - query.length));
754
+ const stem = backend.slice(0, -extname(backend).length);
755
+ const target = written === ""
756
+ ? stem
757
+ : written === extname(route)
758
+ ? backend
759
+ : `${stem}${written === ".jsx" ? ".js" : written}`;
760
+ const rel = posix.relative(posix.dirname(from), target);
761
+ return (rel.startsWith(".") ? rel : `./${rel}`) + query;
762
+ }
763
+ /**
764
+ * Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
765
+ * backend half, splitting a statement that also names what stayed. What cannot be rewritten (a
766
+ * namespace or dynamic import that reads a moved name, `export *`, an alias with no target there) is
767
+ * returned as an issue.
768
+ */
769
+ function redirectMovedImports(ts, file, text, splitOf) {
770
+ const redirects = [];
771
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
772
+ const edits = [];
773
+ const issues = [];
774
+ const mentioned = (split, pattern) => [...split.moved].filter((name) => pattern(escapeRegExp(name)).test(text)).sort();
775
+ const quoted = (names) => names.map((name) => `"${name}"`).join(", ");
776
+ const visit = (node) => {
777
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
778
+ node.moduleSpecifier !== undefined &&
779
+ ts.isStringLiteral(node.moduleSpecifier)) {
780
+ const from = node.moduleSpecifier.text;
781
+ const split = splitOf(from);
782
+ if (split === undefined)
783
+ return;
784
+ const clause = ts.isImportDeclaration(node) ? node.importClause : undefined;
785
+ const named = ts.isImportDeclaration(node) ? clause?.namedBindings : node.exportClause;
786
+ if (named !== undefined && (ts.isNamespaceImport(named) || ts.isNamespaceExport(named))) {
787
+ const reads = mentioned(split, (name) => new RegExp(`\\b${named.name.text}\\.${name}\\b`));
788
+ if (reads.length > 0)
789
+ issues.push(`reads ${quoted(reads)} through a namespace import of "${from}", but they moved to "${split.backend}"; import them from there by hand`);
790
+ return;
791
+ }
792
+ if (named === undefined) {
793
+ if (ts.isExportDeclaration(node))
794
+ issues.push(`re-exports everything from "${from}", but ${quoted([...split.moved].sort())} moved to "${split.backend}"; re-export them from there by hand`);
795
+ return;
796
+ }
797
+ const elements = named.elements;
798
+ const isMoved = (el) => split.moved.has((el.propertyName ?? el.name).text);
799
+ const moved = elements.filter(isMoved);
800
+ if (moved.length === 0)
801
+ return;
802
+ if (split.specifier === undefined) {
803
+ issues.push(`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`);
804
+ return;
805
+ }
806
+ redirects.push({
807
+ names: moved.map((el) => (el.propertyName ?? el.name).text),
808
+ from,
809
+ to: split.specifier,
810
+ });
811
+ const kept = elements.filter((el) => !isMoved(el)).map((el) => el.getText(sf));
812
+ const quote = node.moduleSpecifier.getText(sf)[0] ?? '"';
813
+ const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : "";
814
+ const tail = (specifier) => ` from ${quote}${specifier}${quote}${attributes}${terminatorOf(sf, node)}`;
815
+ const keyword = ts.isImportDeclaration(node)
816
+ ? `import ${clause?.isTypeOnly ? "type " : ""}`
817
+ : `export ${node.isTypeOnly ? "type " : ""}`;
818
+ const list = (names) => `{ ${names.join(", ")} }`;
819
+ const backend = `${keyword}${list(moved.map((el) => el.getText(sf)))}${tail(split.specifier)}`;
820
+ const stays = [
821
+ ...(clause?.name === undefined ? [] : [clause.name.text]),
822
+ ...(kept.length === 0 ? [] : [list(kept)]),
823
+ ];
824
+ edits.push({
825
+ start: node.getStart(sf),
826
+ end: node.getEnd(),
827
+ text: stays.length === 0 ? backend : `${keyword}${stays.join(", ")}${tail(from)}\n${backend}`,
828
+ });
829
+ return;
830
+ }
831
+ if (ts.isCallExpression(node) &&
832
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
833
+ node.arguments[0] !== undefined &&
834
+ ts.isStringLiteralLike(node.arguments[0])) {
835
+ const from = node.arguments[0].text;
836
+ const split = splitOf(from);
837
+ const reads = split === undefined ? [] : mentioned(split, (name) => new RegExp(`\\b${name}\\b`));
838
+ if (split !== undefined && reads.length > 0)
839
+ issues.push(`loads "${from}" with import() and mentions ${quoted(reads)}, which moved to "${split.backend}"; load the backend half for them by hand`);
840
+ }
841
+ ts.forEachChild(node, visit);
842
+ };
843
+ visit(sf);
844
+ let out = text;
845
+ for (const edit of edits.sort((a, b) => b.start - a.start))
846
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
847
+ return { text: out, issues, redirects };
848
+ }
849
+ /** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
850
+ * up and never a workspace root, so the scan beside it stays inside one package. */
851
+ function enclosingPackage(root) {
852
+ if (existsSync(join(root, "package.json")))
853
+ return undefined;
854
+ for (const dir of [dirname(root), dirname(dirname(root))]) {
855
+ const manifest = join(dir, "package.json");
856
+ if (dir === root || !existsSync(manifest))
857
+ continue;
858
+ try {
859
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
860
+ const workspace = typeof parsed === "object" && parsed !== null && "workspaces" in parsed;
861
+ return workspace ? undefined : dir;
862
+ }
863
+ catch {
864
+ return undefined;
865
+ }
866
+ }
867
+ return undefined;
868
+ }
685
869
  function rewriteImports(ts, file, text, rewrite) {
686
870
  const replace = (code, offset, out, kind) => {
687
871
  const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind);
@@ -702,6 +886,8 @@ function rewriteImports(ts, file, text, rewrite) {
702
886
  if (arg !== undefined && ts.isStringLiteralLike(arg))
703
887
  literal = arg;
704
888
  }
889
+ else if (ts.isCallExpression(node))
890
+ literal = mockedSpecifier(ts, node);
705
891
  if (literal !== undefined) {
706
892
  const next = rewrite(literal.text);
707
893
  if (next !== undefined && next !== literal.text) {
@@ -801,20 +987,138 @@ function renameRetiredTypes(ts, file, text) {
801
987
  // ---------------------------------------------------------------------------------------------------
802
988
  // The migration
803
989
  // ---------------------------------------------------------------------------------------------------
804
- const ROOT_MOVES = {
805
- "backend.ts": "backend/app.ts",
806
- "framework.ts": "backend/framework.ts",
807
- };
808
990
  /** Root files that stay where they are: the CLI's config, and scripts no build loads. */
809
- const isRootScript = (file) => !file.includes("/") && SOURCE_FILE.test(file) && ROOT_MOVES[file] === undefined;
991
+ /** The config files the loader reads, old layout included; a path field may live in any of them. */
992
+ const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"];
993
+ /**
994
+ * The module paths a config exports as string literals: `clientModule` and the component pipeline's
995
+ * `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
996
+ */
997
+ function configEntries(ts, file, text) {
998
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
999
+ const bare = (node) => {
1000
+ let current = node;
1001
+ while (current !== undefined &&
1002
+ (ts.isAsExpression(current) ||
1003
+ ts.isSatisfiesExpression(current) ||
1004
+ ts.isParenthesizedExpression(current)))
1005
+ current = current.expression;
1006
+ return current;
1007
+ };
1008
+ const out = [];
1009
+ const literal = (field, role, node) => {
1010
+ const value = bare(node);
1011
+ if (value === undefined || !ts.isStringLiteralLike(value))
1012
+ return;
1013
+ out.push({
1014
+ field,
1015
+ role,
1016
+ value: value.text,
1017
+ start: value.getStart(sf) + 1,
1018
+ end: value.getEnd() - 1,
1019
+ });
1020
+ };
1021
+ for (const statement of sf.statements) {
1022
+ if (!ts.isVariableStatement(statement) ||
1023
+ !hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword))
1024
+ continue;
1025
+ for (const declaration of statement.declarationList.declarations) {
1026
+ if (!ts.isIdentifier(declaration.name))
1027
+ continue;
1028
+ if (declaration.name.text === "clientModule")
1029
+ literal("clientModule", "frontend", declaration.initializer);
1030
+ if (declaration.name.text !== "componentPipeline")
1031
+ continue;
1032
+ const pipeline = bare(declaration.initializer);
1033
+ if (pipeline === undefined || !ts.isObjectLiteralExpression(pipeline))
1034
+ continue;
1035
+ for (const property of pipeline.properties) {
1036
+ if (!ts.isPropertyAssignment(property) || property.name.getText(sf) !== "entries")
1037
+ continue;
1038
+ const entries = bare(property.initializer);
1039
+ if (entries === undefined || !ts.isObjectLiteralExpression(entries))
1040
+ continue;
1041
+ for (const entry of entries.properties) {
1042
+ if (!ts.isPropertyAssignment(entry))
1043
+ continue;
1044
+ const name = entry.name.getText(sf).replace(/^["']|["']$/g, "");
1045
+ if (name === "client" || name === "ssr" || name === "rsc")
1046
+ literal(`componentPipeline.entries.${name}`, name === "client" ? "frontend" : "backend", entry.initializer);
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ return out;
1052
+ }
1053
+ /** The package an import names: `@scope/pkg` or `pkg`, without a subpath. */
1054
+ const packageName = (specifier) => {
1055
+ const parts = specifier.split("/");
1056
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? "");
1057
+ };
1058
+ const isRootScript = (file, rootMoves) => !file.includes("/") && SOURCE_FILE.test(file) && rootMoves[file] === undefined;
810
1059
  const isTestFile = (file) => /(?:^|\/)(?:test|tests|__tests__)\//.test(file) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file);
1060
+ /** The web config an app has, old root `framework.ts` included, or `undefined`. */
1061
+ const webConfigFile = (root, rootMoves) => rootMoves["framework.ts"] !== undefined
1062
+ ? "framework.ts"
1063
+ : existsSync(join(root, FRAMEWORK_FILE))
1064
+ ? FRAMEWORK_FILE
1065
+ : hasWebConfig(root, ".")
1066
+ ? CONFIG_FILE
1067
+ : undefined;
1068
+ /**
1069
+ * `"api"` when the app declares `"nifra": { "kind": "api" }`, or has no web config, no `routes/` and a
1070
+ * backend; `"web"` when it has `routes/`. Web config without `routes/` could be either, so it throws.
1071
+ */
1072
+ function layoutMode(root, rootMoves) {
1073
+ const declared = createKindProbe(root)(root);
1074
+ if (declared.invalid !== undefined)
1075
+ throw new Error(declared.invalid);
1076
+ const config = webConfigFile(root, rootMoves);
1077
+ if (declared.kind === "api") {
1078
+ if (config === undefined)
1079
+ return "api";
1080
+ throw new Error(`[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`);
1081
+ }
1082
+ if (existsSync(join(root, "routes")))
1083
+ return "web";
1084
+ const nested = nestedAppHint(root);
1085
+ if (config !== undefined) {
1086
+ throw new Error(`[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}`);
1087
+ }
1088
+ if (declared.kind === "web")
1089
+ throw new Error(`[nifra] no routes/ directory in ${root}${nested}`);
1090
+ if (rootMoves["backend.ts"] === undefined && !existsSync(join(root, BACKEND_APP_FILE))) {
1091
+ throw new Error(`[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}`);
1092
+ }
1093
+ return "api";
1094
+ }
1095
+ /** An app one folder down (`src/routes/`): the `--dir` that migrates it, as a sentence to append. */
1096
+ function nestedAppHint(root) {
1097
+ const found = readdirSync(root, { withFileTypes: true })
1098
+ .filter((entry) => entry.isDirectory() &&
1099
+ !entry.name.startsWith(".") &&
1100
+ !SKIP_DIRS.has(entry.name) &&
1101
+ existsSync(join(root, entry.name, "routes")))
1102
+ .map((entry) => entry.name)
1103
+ .sort(codeUnitOrder);
1104
+ const first = found[0];
1105
+ if (first === undefined)
1106
+ return "";
1107
+ return `. ${found.map((name) => `${name}/routes/`).join(", ")} exists: run \`nifra migrate layout --dir ${first}\` to migrate the app there`;
1108
+ }
811
1109
  export async function migrateLayout(appRoot, options = {}) {
812
1110
  const ts = options.typescript ?? (await importTypeScript());
813
1111
  if (ts === undefined)
814
1112
  throw new Error("[nifra] `nifra migrate layout` needs TypeScript: bun add -d typescript");
815
1113
  const root = resolve(appRoot);
816
- if (!existsSync(join(root, "routes")))
817
- throw new Error(`[nifra] no routes/ directory in ${root}`);
1114
+ // Only a root file that exports what the old layout read moves; any other backend.ts is a module.
1115
+ const rootMoves = Object.fromEntries(retiredRootFiles(root));
1116
+ const mode = layoutMode(root, rootMoves);
1117
+ // An API-only app has no browser: nothing to split, and every file is server code where it is.
1118
+ const api = mode === "api";
1119
+ const zones = appZones(root);
1120
+ const configured = [...(zones.backend ?? []), ...(zones.frontend ?? []), ...(zones.shared ?? [])];
1121
+ const inConfiguredFolder = (file) => configured.some((folder) => file === folder || file.startsWith(`${folder}/`));
818
1122
  const original = new Map();
819
1123
  const everyFile = listFiles(root);
820
1124
  for (const file of everyFile) {
@@ -830,7 +1134,7 @@ export async function migrateLayout(appRoot, options = {}) {
830
1134
  // 1-2. Route halves and middleware.
831
1135
  const backendHalves = new Map();
832
1136
  for (const [file, text] of original) {
833
- if (!file.startsWith("routes/") || file.includes(".backend."))
1137
+ if (api || !file.startsWith("routes/") || file.includes(".backend."))
834
1138
  continue;
835
1139
  if (/(?:^|\/)_middleware\.[cm]?[jt]sx?$/.test(file)) {
836
1140
  const target = posix.join(posix.dirname(file), "_layout.backend.ts");
@@ -880,14 +1184,101 @@ export async function migrateLayout(appRoot, options = {}) {
880
1184
  next.set(file, existing === undefined ? text : mergeModules(ts, existing, text));
881
1185
  }
882
1186
  // 3-4. Zoning by runtime reachability, on the post-split graph.
883
- for (const [from, to] of Object.entries(ROOT_MOVES))
1187
+ for (const [from, to] of Object.entries(rootMoves))
884
1188
  if (next.has(from))
885
1189
  moveMap.set(from, to);
886
1190
  // Assets (images, fonts) resolve too, so they move with the modules that import them.
887
1191
  const known = new Set([...next.keys(), ...everyFile]);
1192
+ const resolver = createSpecifierResolver();
1193
+ const realRoot = (() => {
1194
+ try {
1195
+ return realpathSync(root);
1196
+ }
1197
+ catch {
1198
+ return root;
1199
+ }
1200
+ })();
1201
+ /** A non-relative specifier the app's tsconfig `paths`/`baseUrl` or package.json `imports` maps to
1202
+ * one of its files, read from disk as the bundler reads it. */
1203
+ const viaAlias = (file, specifier) => {
1204
+ if (specifier.startsWith(".") || isAbsolute(specifier))
1205
+ return undefined;
1206
+ const found = resolver(join(root, file), specifier);
1207
+ if (found === undefined)
1208
+ return undefined;
1209
+ const rel = toPosix(relative(realRoot, found));
1210
+ return rel.startsWith("../") || isAbsolute(rel) || !known.has(rel) ? undefined : rel;
1211
+ };
1212
+ const resolveAny = (file, specifier) => resolveIn(known, file, specifier) ?? viaAlias(file, specifier);
1213
+ const ownName = (() => {
1214
+ try {
1215
+ const name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name;
1216
+ return typeof name === "string" ? name : undefined;
1217
+ }
1218
+ catch {
1219
+ return undefined;
1220
+ }
1221
+ })();
1222
+ const installed = new Map();
1223
+ /** Whether a bare specifier names something outside the app that exists: a built-in, a URL or a
1224
+ * scheme the bundler owns, the app's own package name, or a package installed at or above it. */
1225
+ const external = (specifier) => {
1226
+ if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun")
1227
+ return true;
1228
+ const name = packageName(specifier.replace(/[?#].*$/, ""));
1229
+ if (builtinModules.includes(name) || name === ownName)
1230
+ return true;
1231
+ let found = installed.get(name);
1232
+ if (found === undefined) {
1233
+ const types = `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`;
1234
+ found = false;
1235
+ for (let dir = root;; dir = dirname(dir)) {
1236
+ if (existsSync(join(dir, "node_modules", name)) ||
1237
+ existsSync(join(dir, "node_modules", types))) {
1238
+ found = true;
1239
+ break;
1240
+ }
1241
+ if (dirname(dir) === dir)
1242
+ break;
1243
+ }
1244
+ installed.set(name, found);
1245
+ }
1246
+ return found;
1247
+ };
1248
+ // A test (or any module) that imports `loader` from a page would import a name the page no longer
1249
+ // exports: it reads it from the backend half instead.
1250
+ const splitRoutes = new Map();
1251
+ for (const split of splits)
1252
+ if (next.has(split.file))
1253
+ splitRoutes.set(split.file, { ...split, moved: new Set(split.moved) });
1254
+ /** `base` is where `file` is relative to: the app root, or the package folder around it. */
1255
+ const splitTarget = (base, file, resolve) => (specifier) => {
1256
+ const route = resolve(specifier);
1257
+ const split = route === undefined ? undefined : splitRoutes.get(route);
1258
+ if (route === undefined || split === undefined)
1259
+ return undefined;
1260
+ const inBase = (path) => toPosix(relative(base, join(root, path)));
1261
+ const respelled = specifier.startsWith(".")
1262
+ ? backendSpecifier(specifier, inBase(route), inBase(split.backend), file)
1263
+ : resolver.respell(join(base, file), specifier, join(root, split.backend));
1264
+ return { backend: split.backend, moved: split.moved, specifier: respelled };
1265
+ };
1266
+ const redirectedFiles = new Set();
1267
+ for (const [file, text] of splitRoutes.size === 0 ? [] : next) {
1268
+ if (splitRoutes.has(file) || !/\.[cm]?[jt]sx?$/.test(file))
1269
+ continue;
1270
+ const redirected = redirectMovedImports(ts, file, text, splitTarget(root, file, (specifier) => resolveAny(file, specifier)));
1271
+ if (redirected.text !== text) {
1272
+ next.set(file, redirected.text);
1273
+ redirectedFiles.add(file);
1274
+ }
1275
+ for (const reason of redirected.issues)
1276
+ issues.push({ file, reason });
1277
+ }
888
1278
  const edges = new Map();
889
- for (const [file, text] of next) {
1279
+ for (const [file, text] of api ? [] : next) {
890
1280
  const refs = [];
1281
+ const reported = new Set();
891
1282
  for (const ref of importsOf(ts, file, text)) {
892
1283
  if (ref.specifier === "@nifrajs/web/plugins/vite-server-only") {
893
1284
  issues.push({
@@ -895,22 +1286,42 @@ export async function migrateLayout(appRoot, options = {}) {
895
1286
  reason: "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)",
896
1287
  });
897
1288
  }
898
- const target = resolveIn(known, file, ref.specifier);
1289
+ const target = resolveAny(file, ref.specifier);
899
1290
  if (target !== undefined)
900
1291
  refs.push({ target, typeOnly: ref.typeOnly });
901
- else if (/^[@~]\//.test(ref.specifier))
1292
+ else if (!ref.specifier.startsWith(".") &&
1293
+ !isAbsolute(ref.specifier) &&
1294
+ RETIRED_SPECIFIERS[ref.specifier] === undefined &&
1295
+ resolver(join(root, file), ref.specifier) === undefined &&
1296
+ !external(ref.specifier) &&
1297
+ !reported.has(ref.specifier)) {
1298
+ reported.add(ref.specifier);
902
1299
  issues.push({
903
1300
  file,
904
- reason: `the path alias "${ref.specifier}" is left as written; point it at the new location by hand`,
1301
+ 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`,
905
1302
  });
1303
+ }
906
1304
  }
907
1305
  edges.set(file, refs);
908
1306
  }
1307
+ // The config's module paths are entries too: the browser loads `clientModule`.
1308
+ const entries = CONFIG_FILES.filter((file) => next.has(file)).flatMap((file) => configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
1309
+ ...entry,
1310
+ file,
1311
+ // Relative to the app root, as the loader reads it, whichever config file holds it.
1312
+ target: resolveAny("nifra.config.ts", entry.value),
1313
+ })));
1314
+ const entryTargets = (role) => new Set(entries.flatMap((entry) => entry.role === role && entry.target !== undefined ? [entry.target] : []));
1315
+ const browserEntries = entryTargets("frontend");
1316
+ const serverEntries = entryTargets("backend");
909
1317
  const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])];
910
- const frontendRoots = files.filter((f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend."));
1318
+ const frontendRoots = [
1319
+ ...files.filter((f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.")),
1320
+ ...browserEntries,
1321
+ ];
911
1322
  // A route page the server imports - a generated `server-manifest.ts` imports them all - is rendered
912
1323
  // there, not run as server code, so the server's walk stops at it: what only pages use stays frontend.
913
- const pages = new Set(frontendRoots);
1324
+ const pages = new Set(frontendRoots.filter((f) => !browserEntries.has(f)));
914
1325
  const reach = (roots, stopAt) => {
915
1326
  const seen = new Set();
916
1327
  const queue = [...roots];
@@ -927,17 +1338,23 @@ export async function migrateLayout(appRoot, options = {}) {
927
1338
  }
928
1339
  return seen;
929
1340
  };
930
- const backendRoots = files.filter((f) => (f.startsWith("routes/") && f.includes(".backend.")) ||
931
- f.startsWith("backend/") ||
932
- ROOT_MOVES[f] !== undefined ||
933
- (isRootScript(f) && f !== "nifra.config.ts" && !isTestFile(f)));
1341
+ const backendRoots = [
1342
+ ...files.filter((f) => (f.startsWith("routes/") && f.includes(".backend.")) ||
1343
+ f.startsWith("backend/") ||
1344
+ rootMoves[f] !== undefined ||
1345
+ (isRootScript(f, rootMoves) &&
1346
+ f !== "nifra.config.ts" &&
1347
+ !isTestFile(f) &&
1348
+ !browserEntries.has(f))),
1349
+ ...serverEntries,
1350
+ ];
934
1351
  const browser = reach(frontendRoots);
935
1352
  const server = reach(backendRoots, pages);
936
1353
  // What the app's own server modules reach, without the root scripts: a root file in here is a module
937
1354
  // the app imports, not an entry, so it moves into a zone.
938
- const appServer = reach(backendRoots.filter((f) => f.includes("/") || ROOT_MOVES[f] !== undefined), pages);
1355
+ const appServer = reach(backendRoots.filter((f) => f.includes("/") || rootMoves[f] !== undefined || serverEntries.has(f)), pages);
939
1356
  // A `.server` module ran empty in the browser, so only server code used it: it moves under backend/.
940
- for (const file of files) {
1357
+ for (const file of api ? [] : files) {
941
1358
  if (!LEGACY_SERVER.test(file) || isTestFile(file))
942
1359
  continue;
943
1360
  const parts = file.replace(LEGACY_SERVER, "$1").split("/");
@@ -953,11 +1370,12 @@ export async function migrateLayout(appRoot, options = {}) {
953
1370
  }
954
1371
  }
955
1372
  const zoneOf = (file) => browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend";
956
- for (const file of files) {
1373
+ for (const file of api ? [] : files) {
957
1374
  if (moveMap.has(file) ||
958
1375
  isTestFile(file) ||
959
1376
  file.endsWith(".d.ts") ||
960
- file === "nifra.config.ts")
1377
+ file === "nifra.config.ts" ||
1378
+ inConfiguredFolder(file))
961
1379
  continue;
962
1380
  if (/\.(?:frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.test(file))
963
1381
  continue;
@@ -968,7 +1386,16 @@ export async function migrateLayout(appRoot, options = {}) {
968
1386
  }
969
1387
  else if (!browser.has(file) && !appServer.has(file))
970
1388
  continue;
971
- moveMap.set(file, `${zoneOf(file)}/${file}`);
1389
+ const zone = zoneOf(file);
1390
+ if (DATA_FILE.test(file)) {
1391
+ const folder = file.includes("/") ? posix.dirname(file) : undefined;
1392
+ issues.push({
1393
+ file,
1394
+ 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`,
1395
+ });
1396
+ continue;
1397
+ }
1398
+ moveMap.set(file, `${zone}/${file}`);
972
1399
  }
973
1400
  const claimed = new Map();
974
1401
  for (const [from, to] of moveMap) {
@@ -995,24 +1422,68 @@ export async function migrateLayout(appRoot, options = {}) {
995
1422
  const newPath = (file) => moveMap.get(file) ?? file;
996
1423
  const final = new Map();
997
1424
  const rewritten = [];
1425
+ const unspelled = new Set();
1426
+ /** An alias for a target that moves, through the same alias, or an issue when none covers it. */
1427
+ const respellAlias = (file, specifier, target) => {
1428
+ if (newPath(target) === target)
1429
+ return undefined;
1430
+ const respelled = resolver.respell(join(root, file), specifier, join(root, newPath(target)));
1431
+ if (respelled === undefined && !unspelled.has(`${file}\0${specifier}`)) {
1432
+ unspelled.add(`${file}\0${specifier}`);
1433
+ issues.push({
1434
+ file,
1435
+ 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`,
1436
+ });
1437
+ }
1438
+ return respelled;
1439
+ };
998
1440
  for (const [file, text] of next) {
999
1441
  const imports = rewriteImports(ts, file, text, (specifier) => {
1000
1442
  const retired = RETIRED_SPECIFIERS[specifier];
1001
1443
  if (retired !== undefined)
1002
1444
  return retired;
1003
- const target = resolveIn(known, file, specifier);
1445
+ const target = resolveAny(file, specifier);
1004
1446
  // Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
1005
1447
  if (target === undefined)
1006
1448
  return rebase(specifier, file, newPath(file));
1449
+ if (!specifier.startsWith("."))
1450
+ return respellAlias(file, specifier, target);
1007
1451
  if (newPath(target) === target && newPath(file) === file)
1008
1452
  return undefined;
1009
1453
  return respecify(specifier, target, newPath(file), newPath(target));
1010
1454
  });
1011
1455
  const updated = renameRetiredTypes(ts, file, imports);
1012
1456
  final.set(newPath(file), updated);
1013
- if (updated !== text && !moveMap.has(file) && original.get(file) === text)
1457
+ if (!moveMap.has(file) &&
1458
+ (redirectedFiles.has(file) || (updated !== text && original.get(file) === text)))
1014
1459
  rewritten.push(file);
1015
1460
  }
1461
+ // A config path names its module from the app root, so it follows the module, never the config file.
1462
+ for (const configFile of CONFIG_FILES) {
1463
+ const at = newPath(configFile);
1464
+ const text = final.get(at);
1465
+ if (text === undefined || !next.has(configFile))
1466
+ continue;
1467
+ const edits = [];
1468
+ for (const entry of configEntries(ts, at, text)) {
1469
+ const target = resolveAny("nifra.config.ts", entry.value);
1470
+ if (target === undefined || newPath(target) === target)
1471
+ continue;
1472
+ const value = entry.value.startsWith(".")
1473
+ ? respecify(entry.value, target, "nifra.config.ts", newPath(target))
1474
+ : respellAlias(configFile, entry.value, target);
1475
+ if (value !== undefined)
1476
+ edits.push({ start: entry.start, end: entry.end, text: value });
1477
+ }
1478
+ if (edits.length === 0)
1479
+ continue;
1480
+ let updated = text;
1481
+ for (const edit of edits.sort((a, b) => b.start - a.start))
1482
+ updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
1483
+ final.set(at, updated);
1484
+ if (!moveMap.has(configFile) && !rewritten.includes(configFile))
1485
+ rewritten.push(configFile);
1486
+ }
1016
1487
  const moves = [...moveMap]
1017
1488
  .map(([from, to]) => ({ from, to }))
1018
1489
  .sort((a, b) => codeUnitOrder(a.from, b.from));
@@ -1063,6 +1534,39 @@ export async function migrateLayout(appRoot, options = {}) {
1063
1534
  issues.push({ file, reason: `mentions "${from}" by path; it moved to "${to}"` });
1064
1535
  }
1065
1536
  }
1537
+ // Code beside an app one folder down (a package's test/ when the app is its src/) is not the
1538
+ // migration's to edit, so each import there that stops resolving to what it named is reported.
1539
+ const pkg = enclosingPackage(root);
1540
+ if (pkg !== undefined) {
1541
+ const prefix = toPosix(relative(pkg, root));
1542
+ const inApp = new Set([...known].map((file) => `${prefix}/${file}`));
1543
+ const appPath = (file, specifier) => resolveIn(inApp, file, specifier)?.slice(prefix.length + 1);
1544
+ const outside = ` - it is outside ${prefix}/, so the migration does not rewrite it`;
1545
+ for (const file of listFiles(pkg)) {
1546
+ if (file.startsWith(`${prefix}/`) || !/\.[cm]?[jt]sx?$/.test(file))
1547
+ continue;
1548
+ const text = readFileSync(join(pkg, file), "utf8");
1549
+ const at = toPosix(relative(root, join(pkg, file)));
1550
+ for (const { specifier } of importsOf(ts, file, text)) {
1551
+ const target = appPath(file, specifier);
1552
+ const to = target === undefined ? undefined : moveMap.get(target);
1553
+ if (to !== undefined)
1554
+ issues.push({
1555
+ file: at,
1556
+ reason: `imports "${specifier}", which moves to "${to}"${outside}`,
1557
+ });
1558
+ }
1559
+ const redirected = redirectMovedImports(ts, file, text, splitTarget(pkg, file, (specifier) => appPath(file, specifier)));
1560
+ for (const { names, from, to } of redirected.redirects) {
1561
+ issues.push({
1562
+ file: at,
1563
+ reason: `imports ${names.map((name) => `"${name}"`).join(", ")} from "${from}"; import it from "${to}"${outside}`,
1564
+ });
1565
+ }
1566
+ for (const reason of redirected.issues)
1567
+ issues.push({ file: at, reason });
1568
+ }
1569
+ }
1066
1570
  // Paths a module finds at run time are not imports either: one built from the module's own location
1067
1571
  // now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
1068
1572
  const leaving = new Map();
@@ -1075,7 +1579,7 @@ export async function migrateLayout(appRoot, options = {}) {
1075
1579
  });
1076
1580
  }
1077
1581
  const dir = posix.dirname(move.from);
1078
- if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? ""))
1582
+ if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? "") || inConfiguredFolder(move.from))
1079
1583
  continue;
1080
1584
  leaving.set(dir, [...(leaving.get(dir) ?? []), move]);
1081
1585
  }
@@ -1101,11 +1605,43 @@ export async function migrateLayout(appRoot, options = {}) {
1101
1605
  for (const { name, schema } of missingOutputSchemas(text)) {
1102
1606
  issues.push({
1103
1607
  file,
1104
- 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({ ... })`,
1608
+ 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`,
1105
1609
  });
1106
1610
  }
1107
1611
  }
1612
+ // Tooling that lists folders or globs, not files: the import rewrite above never sees it.
1613
+ const tooling = [];
1614
+ const toolingWrites = new Map();
1615
+ const tsconfig = coverMovedFiles(ts, root, moves);
1616
+ if (tsconfig?.issue !== undefined)
1617
+ issues.push({ file: "tsconfig.json", reason: tsconfig.issue });
1618
+ if (tsconfig?.text !== undefined) {
1619
+ toolingWrites.set("tsconfig.json", tsconfig.text);
1620
+ tooling.push({ file: "tsconfig.json", change: `include += ${tsconfig.added.join(", ")}` });
1621
+ }
1622
+ for (const file of everyFile) {
1623
+ if (!TAILWIND_CONFIG.test(file))
1624
+ continue;
1625
+ const reason = staleContentGlobs(ts, readFileSync(join(root, file), "utf8"), moves);
1626
+ if (reason !== undefined)
1627
+ issues.push({ file, reason });
1628
+ }
1629
+ const gitignore = ignoreNifraState(root);
1630
+ if (gitignore !== undefined) {
1631
+ toolingWrites.set(".gitignore", gitignore);
1632
+ tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" });
1633
+ }
1634
+ // `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
1635
+ const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {};
1636
+ if (kind.issue !== undefined)
1637
+ issues.push({ file: "package.json", reason: kind.issue });
1638
+ if (kind.text !== undefined) {
1639
+ toolingWrites.set("package.json", kind.text);
1640
+ tooling.push({ file: "package.json", change: '"nifra": { "kind": "api" }' });
1641
+ }
1108
1642
  if (options.write === true) {
1643
+ for (const [file, text] of toolingWrites)
1644
+ writeFileSync(join(root, file), text);
1109
1645
  for (const [file, text] of final) {
1110
1646
  if (original.get(file) === text)
1111
1647
  continue;
@@ -1113,43 +1649,221 @@ export async function migrateLayout(appRoot, options = {}) {
1113
1649
  mkdirSync(dirname(path), { recursive: true });
1114
1650
  writeFileSync(path, text);
1115
1651
  }
1652
+ const vacated = [];
1116
1653
  for (const file of original.keys()) {
1117
- if (!final.has(file))
1118
- rmSync(join(root, file), { force: true });
1654
+ if (final.has(file))
1655
+ continue;
1656
+ rmSync(join(root, file), { force: true });
1657
+ vacated.push(file);
1119
1658
  }
1120
1659
  for (const { from, to } of moves) {
1121
1660
  if (original.has(from) || !existsSync(join(root, from)))
1122
1661
  continue;
1123
1662
  mkdirSync(dirname(join(root, to)), { recursive: true });
1124
1663
  renameSync(join(root, from), join(root, to));
1664
+ vacated.push(from);
1125
1665
  }
1126
- pruneEmptyDirs(root);
1666
+ pruneVacatedDirs(root, vacated);
1127
1667
  }
1128
1668
  return {
1129
1669
  ok: issues.length === 0,
1130
1670
  write: options.write === true,
1671
+ mode,
1131
1672
  splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
1132
1673
  moves,
1133
1674
  rewritten: rewritten.sort(),
1675
+ tooling,
1134
1676
  issues,
1135
1677
  };
1136
1678
  }
1137
- function pruneEmptyDirs(root) {
1138
- const prune = (dir) => {
1139
- let empty = true;
1140
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1141
- if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
1142
- empty = false;
1143
- continue;
1144
- }
1145
- const full = join(dir, entry.name);
1146
- if (entry.isDirectory() ? !prune(full) : true)
1147
- empty = false;
1679
+ /** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
1680
+ function appZones(root) {
1681
+ const manifest = join(root, "package.json");
1682
+ if (!existsSync(manifest))
1683
+ return {};
1684
+ let parsed;
1685
+ try {
1686
+ parsed = JSON.parse(readFileSync(manifest, "utf8"));
1687
+ }
1688
+ catch {
1689
+ return {};
1690
+ }
1691
+ const nifra = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "nifra") : undefined;
1692
+ const zones = typeof nifra === "object" && nifra !== null ? Reflect.get(nifra, "zones") : undefined;
1693
+ return zones === undefined ? {} : parseZoneConfig(zones, manifest);
1694
+ }
1695
+ /** Whether a tsconfig `include` entry takes in `file` (app-relative), the way tsc reads it. */
1696
+ function includes(pattern, file) {
1697
+ let spec = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
1698
+ if (spec === "" || spec === ".")
1699
+ return true;
1700
+ if (!/[*?]/.test(spec))
1701
+ return file === spec || file.startsWith(`${spec}/`);
1702
+ if (spec.endsWith("**"))
1703
+ spec = `${spec}/*`;
1704
+ else if (!/[*?.][^/]*$/.test(spec.slice(spec.lastIndexOf("/") + 1)))
1705
+ spec = `${spec}/**/*`;
1706
+ return new Bun.Glob(spec).match(file);
1707
+ }
1708
+ /**
1709
+ * Add each zone folder that receives moved TypeScript to tsconfig `include`, editing only that array
1710
+ * so comments and layout survive. A tsconfig without `include` (or with only `extends`) is left
1711
+ * alone: tsc's default, or the base config, decides, and `nifra check` reports any folder left out.
1712
+ */
1713
+ function coverMovedFiles(ts, root, moves) {
1714
+ const path = join(root, "tsconfig.json");
1715
+ if (!existsSync(path))
1716
+ return undefined;
1717
+ const text = readFileSync(path, "utf8");
1718
+ const source = ts.parseJsonText(path, text);
1719
+ const top = source.statements[0]?.expression;
1720
+ if (top === undefined || !ts.isObjectLiteralExpression(top))
1721
+ return { added: [], issue: "is not a JSON object; add the zone folders to its include by hand" };
1722
+ const include = jsonProperty(ts, top, "include");
1723
+ if (include === undefined)
1724
+ return undefined;
1725
+ const array = include.initializer;
1726
+ if (!ts.isArrayLiteralExpression(array))
1727
+ return { added: [], issue: '"include" is not an array; add the zone folders to it by hand' };
1728
+ const patterns = array.elements.filter(ts.isStringLiteral).map((element) => element.text);
1729
+ const added = [];
1730
+ for (const { to } of moves) {
1731
+ if (!/\.(?:[cm]?ts|tsx)$/.test(to) || /\.d\.[cm]?ts$/.test(to))
1732
+ continue;
1733
+ const folder = to.split("/")[0] ?? to;
1734
+ if (added.includes(folder) || patterns.some((pattern) => includes(pattern, to)))
1735
+ continue;
1736
+ added.push(folder);
1737
+ }
1738
+ if (added.length === 0)
1739
+ return undefined;
1740
+ added.sort(codeUnitOrder);
1741
+ const quoted = added.map((folder) => JSON.stringify(folder));
1742
+ const last = array.elements[array.elements.length - 1];
1743
+ if (last === undefined) {
1744
+ const at = array.getStart(source) + 1;
1745
+ return { text: `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`, added };
1746
+ }
1747
+ let at = last.end;
1748
+ const trailing = /^\s*,/.exec(text.slice(at));
1749
+ if (trailing !== null)
1750
+ at += trailing[0].length;
1751
+ // A multi-line array gets one entry per line at the last element's indent, below any comment that
1752
+ // closes the last element's line; anything else gets them inline.
1753
+ if (text.slice(array.getStart(source), last.getStart(source)).includes("\n")) {
1754
+ const lineStart = text.lastIndexOf("\n", last.getStart(source)) + 1;
1755
+ const indent = text.slice(lineStart, last.getStart(source)).match(/^\s*/)?.[0] ?? "";
1756
+ const newline = text.indexOf("\n", at);
1757
+ const eol = newline === -1 ? text.length : newline;
1758
+ if (/^\s*(?:\/\/.*|\/\*.*\*\/\s*)?$/.test(text.slice(at, eol))) {
1759
+ const head = trailing !== null
1760
+ ? text.slice(0, eol)
1761
+ : `${text.slice(0, last.end)},${text.slice(last.end, eol)}`;
1762
+ const entries = quoted.join(`,\n${indent}`);
1763
+ return {
1764
+ text: `${head}\n${indent}${entries}${trailing !== null ? "," : ""}${text.slice(eol)}`,
1765
+ added,
1766
+ };
1148
1767
  }
1149
- if (empty && dir !== root)
1150
- rmSync(dir, { recursive: true, force: true });
1151
- return empty;
1768
+ }
1769
+ const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`;
1770
+ return { text: `${text.slice(0, at)}${insert}${text.slice(at)}`, added };
1771
+ }
1772
+ const jsonProperty = (ts, object, key) => object.properties.find((property) => ts.isPropertyAssignment(property) &&
1773
+ ts.isStringLiteral(property.name) &&
1774
+ property.name.text === key);
1775
+ /** `text` with `entry` as the last property of `object`, on its own line when the object spans lines. */
1776
+ function insertProperty(source, text, object, entry) {
1777
+ const open = object.getStart(source);
1778
+ const last = object.properties[object.properties.length - 1];
1779
+ if (last === undefined)
1780
+ return `${text.slice(0, open)}{ ${entry} }${text.slice(object.end)}`;
1781
+ const start = last.getStart(source);
1782
+ if (!text.slice(open, start).includes("\n")) {
1783
+ return `${text.slice(0, last.end)}, ${entry}${text.slice(last.end)}`;
1784
+ }
1785
+ const indent = /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", start) + 1, start))?.[0] ?? "";
1786
+ return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`;
1787
+ }
1788
+ /** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
1789
+ function declareApiKind(ts, root) {
1790
+ const path = join(root, "package.json");
1791
+ if (!existsSync(path)) {
1792
+ return {
1793
+ issue: 'there is none in the app; declare "nifra": { "kind": "api" } in the package.json that governs it',
1794
+ };
1795
+ }
1796
+ const text = readFileSync(path, "utf8");
1797
+ const source = ts.parseJsonText(path, text);
1798
+ const top = source.statements[0]?.expression;
1799
+ if (top === undefined || !ts.isObjectLiteralExpression(top))
1800
+ return { issue: 'is not a JSON object; add "nifra": { "kind": "api" } to it by hand' };
1801
+ const nifra = jsonProperty(ts, top, "nifra");
1802
+ if (nifra === undefined) {
1803
+ return { text: insertProperty(source, text, top, '"nifra": { "kind": "api" }') };
1804
+ }
1805
+ if (!ts.isObjectLiteralExpression(nifra.initializer))
1806
+ return { issue: '"nifra" is not an object; add "kind": "api" to it by hand' };
1807
+ return { text: insertProperty(source, text, nifra.initializer, '"kind": "api"') };
1808
+ }
1809
+ /** Tailwind `content` globs that matched a moved file at its old path and match nothing at its new one. */
1810
+ function staleContentGlobs(ts, text, moves) {
1811
+ const globs = [];
1812
+ const source = ts.createSourceFile("tailwind.config.ts", text, ts.ScriptTarget.Latest, true);
1813
+ const visit = (node) => {
1814
+ if (ts.isStringLiteralLike(node) && /[*/]/.test(node.text) && !node.text.startsWith("!"))
1815
+ globs.push(node.text.replace(/^\.\//, ""));
1816
+ ts.forEachChild(node, visit);
1152
1817
  };
1153
- prune(root);
1818
+ visit(source);
1819
+ const matchers = globs.map((glob) => ({ glob, matcher: new Bun.Glob(glob) }));
1820
+ const lost = moves.filter(({ from, to }) => matchers.some(({ matcher }) => matcher.match(from)) &&
1821
+ !matchers.some(({ matcher }) => matcher.match(to)));
1822
+ const first = lost[0];
1823
+ if (first === undefined)
1824
+ return undefined;
1825
+ const folders = [...new Set(lost.map(({ to }) => to.split("/")[0] ?? to))].sort(codeUnitOrder);
1826
+ 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)`;
1827
+ }
1828
+ /**
1829
+ * `.nifra/` holds generated types, dev state and the dev server's launch token; none of it belongs in
1830
+ * git. The new `.gitignore` text when it is not ignored yet, else `undefined`.
1831
+ */
1832
+ function ignoreNifraState(root) {
1833
+ let exitCode = null;
1834
+ try {
1835
+ exitCode = Bun.spawnSync(["git", "check-ignore", "-q", ".nifra/types"], {
1836
+ cwd: root,
1837
+ stdout: "ignore",
1838
+ stderr: "ignore",
1839
+ }).exitCode;
1840
+ }
1841
+ catch {
1842
+ // No git installed: the same as no checkout.
1843
+ }
1844
+ if (exitCode === 0)
1845
+ return undefined;
1846
+ const path = join(root, ".gitignore");
1847
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
1848
+ // Outside a git checkout `check-ignore` cannot answer; the app's own file decides.
1849
+ if (exitCode !== 1 && /^\/?\.nifra(?:\/|\*|\/\*\*)?\s*$/m.test(current))
1850
+ return undefined;
1851
+ const separator = current === "" || current.endsWith("\n") ? "" : "\n";
1852
+ return `${current}${separator}.nifra/\n`;
1853
+ }
1854
+ /** Remove each directory the run emptied, then each parent that leaves empty. A directory that was
1855
+ * empty before the run is the app's own, not the migration's to remove. */
1856
+ function pruneVacatedDirs(root, vacated) {
1857
+ const dirs = new Set();
1858
+ for (const file of vacated)
1859
+ dirs.add(dirname(join(root, file)));
1860
+ // Deepest first, so a parent is judged after its emptied children are gone.
1861
+ for (const start of [...dirs].sort((a, b) => b.length - a.length)) {
1862
+ for (let dir = start; dir !== root && dir.startsWith(`${root}${sep}`); dir = dirname(dir)) {
1863
+ if (!existsSync(dir) || readdirSync(dir).length > 0)
1864
+ break;
1865
+ rmdirSync(dir);
1866
+ }
1867
+ }
1154
1868
  }
1155
1869
  //# sourceMappingURL=migrate-layout.js.map