@nifrajs/cli 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +7 -2
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +4 -2
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts +12 -2
  6. package/dist/assure.d.ts.map +1 -1
  7. package/dist/assure.js +59 -8
  8. package/dist/assure.js.map +1 -1
  9. package/dist/capabilities-tool.d.ts.map +1 -1
  10. package/dist/capabilities-tool.js +24 -7
  11. package/dist/capabilities-tool.js.map +1 -1
  12. package/dist/check-diagnostics.d.ts +2 -0
  13. package/dist/check-diagnostics.d.ts.map +1 -1
  14. package/dist/check-diagnostics.js.map +1 -1
  15. package/dist/check-scan.d.ts +8 -2
  16. package/dist/check-scan.d.ts.map +1 -1
  17. package/dist/check-scan.js +43 -10
  18. package/dist/check-scan.js.map +1 -1
  19. package/dist/check.d.ts.map +1 -1
  20. package/dist/check.js +39 -33
  21. package/dist/check.js.map +1 -1
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +106 -48
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client-module.d.ts +5 -3
  27. package/dist/client-module.d.ts.map +1 -1
  28. package/dist/client-module.js +34 -10
  29. package/dist/client-module.js.map +1 -1
  30. package/dist/command-catalog.d.ts +9 -1
  31. package/dist/command-catalog.d.ts.map +1 -1
  32. package/dist/command-catalog.js +67 -22
  33. package/dist/command-catalog.js.map +1 -1
  34. package/dist/dev-feed-tool.d.ts +18 -1
  35. package/dist/dev-feed-tool.d.ts.map +1 -1
  36. package/dist/dev-feed-tool.js +93 -0
  37. package/dist/dev-feed-tool.js.map +1 -1
  38. package/dist/fix-recipes.d.ts +2 -0
  39. package/dist/fix-recipes.d.ts.map +1 -1
  40. package/dist/fix-recipes.js +93 -12
  41. package/dist/fix-recipes.js.map +1 -1
  42. package/dist/init-agents.d.ts +16 -0
  43. package/dist/init-agents.d.ts.map +1 -1
  44. package/dist/init-agents.js +3 -2
  45. package/dist/init-agents.js.map +1 -1
  46. package/dist/internal/config-paths.d.ts +16 -0
  47. package/dist/internal/config-paths.d.ts.map +1 -0
  48. package/dist/internal/config-paths.js +124 -0
  49. package/dist/internal/config-paths.js.map +1 -0
  50. package/dist/internal/installed-package.d.ts +4 -0
  51. package/dist/internal/installed-package.d.ts.map +1 -0
  52. package/dist/internal/installed-package.js +17 -0
  53. package/dist/internal/installed-package.js.map +1 -0
  54. package/dist/internal/project-specifiers.d.ts +14 -1
  55. package/dist/internal/project-specifiers.d.ts.map +1 -1
  56. package/dist/internal/project-specifiers.js +54 -15
  57. package/dist/internal/project-specifiers.js.map +1 -1
  58. package/dist/internal/source-facts.d.ts +6 -1
  59. package/dist/internal/source-facts.d.ts.map +1 -1
  60. package/dist/internal/source-facts.js +11 -5
  61. package/dist/internal/source-facts.js.map +1 -1
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +5 -3
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +9 -0
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +32 -1
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/levels-tool.d.ts.map +1 -1
  70. package/dist/levels-tool.js +5 -8
  71. package/dist/levels-tool.js.map +1 -1
  72. package/dist/load.d.ts +2 -2
  73. package/dist/load.d.ts.map +1 -1
  74. package/dist/load.js +23 -3
  75. package/dist/load.js.map +1 -1
  76. package/dist/mcp-context.d.ts.map +1 -1
  77. package/dist/mcp-context.js +6 -21
  78. package/dist/mcp-context.js.map +1 -1
  79. package/dist/mcp-delegate.d.ts +2 -2
  80. package/dist/mcp-delegate.d.ts.map +1 -1
  81. package/dist/mcp-delegate.js +19 -7
  82. package/dist/mcp-delegate.js.map +1 -1
  83. package/dist/mcp-exec.d.ts +1 -0
  84. package/dist/mcp-exec.d.ts.map +1 -1
  85. package/dist/mcp-exec.js +8 -3
  86. package/dist/mcp-exec.js.map +1 -1
  87. package/dist/mcp-http.js +1 -1
  88. package/dist/mcp-io.d.ts +13 -0
  89. package/dist/mcp-io.d.ts.map +1 -1
  90. package/dist/mcp-io.js +23 -0
  91. package/dist/mcp-io.js.map +1 -1
  92. package/dist/mcp-path.d.ts +2 -1
  93. package/dist/mcp-path.d.ts.map +1 -1
  94. package/dist/mcp-path.js +9 -24
  95. package/dist/mcp-path.js.map +1 -1
  96. package/dist/mcp.d.ts.map +1 -1
  97. package/dist/mcp.js +40 -17
  98. package/dist/mcp.js.map +1 -1
  99. package/dist/migrate-layout.d.ts +40 -1
  100. package/dist/migrate-layout.d.ts.map +1 -1
  101. package/dist/migrate-layout.js +1316 -228
  102. package/dist/migrate-layout.js.map +1 -1
  103. package/dist/output-schemas.d.ts +34 -0
  104. package/dist/output-schemas.d.ts.map +1 -1
  105. package/dist/output-schemas.js +132 -21
  106. package/dist/output-schemas.js.map +1 -1
  107. package/dist/project-path.d.ts +6 -0
  108. package/dist/project-path.d.ts.map +1 -1
  109. package/dist/project-path.js +48 -14
  110. package/dist/project-path.js.map +1 -1
  111. package/dist/release-verification.d.ts +5 -0
  112. package/dist/release-verification.d.ts.map +1 -1
  113. package/dist/release-verification.js +14 -0
  114. package/dist/release-verification.js.map +1 -1
  115. package/dist/review.d.ts.map +1 -1
  116. package/dist/review.js +5 -0
  117. package/dist/review.js.map +1 -1
  118. package/dist/rules/codes.d.ts +3 -1
  119. package/dist/rules/codes.d.ts.map +1 -1
  120. package/dist/rules/codes.js +3 -1
  121. package/dist/rules/codes.js.map +1 -1
  122. package/dist/rules/data-guard.d.ts +6 -0
  123. package/dist/rules/data-guard.d.ts.map +1 -1
  124. package/dist/rules/data-guard.js +37 -0
  125. package/dist/rules/data-guard.js.map +1 -1
  126. package/dist/rules/legacy.d.ts.map +1 -1
  127. package/dist/rules/legacy.js +20 -1
  128. package/dist/rules/legacy.js.map +1 -1
  129. package/dist/rules/pages.d.ts +29 -3
  130. package/dist/rules/pages.d.ts.map +1 -1
  131. package/dist/rules/pages.js +236 -22
  132. package/dist/rules/pages.js.map +1 -1
  133. package/dist/rules/security.d.ts +3 -2
  134. package/dist/rules/security.d.ts.map +1 -1
  135. package/dist/rules/security.js +42 -11
  136. package/dist/rules/security.js.map +1 -1
  137. package/dist/rules/zones.d.ts.map +1 -1
  138. package/dist/rules/zones.js +31 -9
  139. package/dist/rules/zones.js.map +1 -1
  140. package/dist/stylex-migrate.d.ts.map +1 -1
  141. package/dist/stylex-migrate.js +127 -17
  142. package/dist/stylex-migrate.js.map +1 -1
  143. package/dist/test-tool.d.ts.map +1 -1
  144. package/dist/test-tool.js +3 -9
  145. package/dist/test-tool.js.map +1 -1
  146. package/dist/upgrade.d.ts +20 -5
  147. package/dist/upgrade.d.ts.map +1 -1
  148. package/dist/upgrade.js +86 -9
  149. package/dist/upgrade.js.map +1 -1
  150. package/dist/verification-plan.d.ts.map +1 -1
  151. package/dist/verification-plan.js +5 -2
  152. package/dist/verification-plan.js.map +1 -1
  153. package/dist/version-drift.d.ts.map +1 -1
  154. package/dist/version-drift.js +55 -3
  155. package/dist/version-drift.js.map +1 -1
  156. package/docs/examples.json +2 -2
  157. package/docs/llms-full.txt +481 -61
  158. package/docs/types.json +147 -70
  159. package/package.json +11 -11
  160. package/src/assure-hydration.ts +4 -1
  161. package/src/assure.ts +88 -10
  162. package/src/capabilities-tool.ts +33 -7
  163. package/src/check-diagnostics.ts +2 -0
  164. package/src/check-scan.ts +56 -9
  165. package/src/check.ts +55 -39
  166. package/src/cli.ts +104 -35
  167. package/src/client-module.ts +45 -9
  168. package/src/command-catalog.ts +77 -21
  169. package/src/dev-feed-tool.ts +112 -1
  170. package/src/fix-recipes.ts +101 -10
  171. package/src/init-agents.ts +4 -3
  172. package/src/internal/config-paths.ts +154 -0
  173. package/src/internal/installed-package.ts +15 -0
  174. package/src/internal/project-specifiers.ts +69 -16
  175. package/src/internal/source-facts.ts +23 -5
  176. package/src/internal/typescript-import.ts +5 -3
  177. package/src/introspect.ts +39 -1
  178. package/src/levels-tool.ts +7 -9
  179. package/src/load.ts +29 -3
  180. package/src/mcp-context.ts +5 -18
  181. package/src/mcp-delegate.ts +22 -10
  182. package/src/mcp-exec.ts +9 -2
  183. package/src/mcp-http.ts +1 -1
  184. package/src/mcp-io.ts +33 -0
  185. package/src/mcp-path.ts +8 -22
  186. package/src/mcp.ts +48 -19
  187. package/src/migrate-layout.ts +1496 -237
  188. package/src/output-schemas.ts +179 -23
  189. package/src/project-path.ts +44 -13
  190. package/src/release-verification.ts +20 -0
  191. package/src/review.ts +5 -0
  192. package/src/rules/codes.ts +3 -1
  193. package/src/rules/data-guard.ts +40 -0
  194. package/src/rules/legacy.ts +22 -1
  195. package/src/rules/pages.ts +266 -24
  196. package/src/rules/security.ts +49 -11
  197. package/src/rules/zones.ts +30 -7
  198. package/src/stylex-migrate.ts +117 -17
  199. package/src/test-tool.ts +3 -9
  200. package/src/upgrade.ts +96 -8
  201. package/src/verification-plan.ts +9 -2
  202. package/src/version-drift.ts +53 -2
@@ -12,16 +12,18 @@
12
12
  * the server side -> `backend/`, both -> `shared/`. Imports through tsconfig `paths`/`baseUrl` and
13
13
  * package.json `imports` count as the bundler reads them;
14
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`;
15
+ * through the same alias when one of its targets covers the new place and as a relative path
16
+ * otherwise, each checked to resolve to its target after the moves; the config's `clientModule`
17
+ * and `componentPipeline.entries` paths; `@nifrajs/web/server-only` to
18
+ * `@nifrajs/web/backend-only`; and `ServerOnly` to `BackendOnly`;
17
19
  * 6. adds the zone folders moved TypeScript lands in to tsconfig `include`, and `.nifra/` to
18
20
  * `.gitignore`.
19
21
  *
20
22
  * Data files (JSON, YAML, CSV, ...) and folders the app zones in package.json stay where they are.
21
23
  *
22
24
  * It is a dry run unless `write` is set, and it reports what it could not decide (a frontend that uses
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.
25
+ * a moved name at runtime, a helper both halves need, a loader with no output schema, an import no
26
+ * specifier reaches after the moves, a specifier that names nothing) instead of guessing.
25
27
  */
26
28
  import {
27
29
  existsSync,
@@ -42,10 +44,13 @@ import { parseZoneConfig, type ZoneConfig } from "@nifrajs/web/zones"
42
44
  import type * as TS from "typescript"
43
45
  import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.ts"
44
46
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
47
+ import { configPathReader, type StaticConfigPath } from "./internal/config-paths.ts"
45
48
  import { createKindProbe, hasWebConfig } from "./internal/nifra-project.ts"
46
- import { createSpecifierResolver } from "./internal/project-specifiers.ts"
49
+ import { createSpecifierResolver, isFile } from "./internal/project-specifiers.ts"
47
50
  import { importTypeScript } from "./internal/typescript-import.ts"
51
+ import { type OutputSchemaEntry, planOutputSchemas } from "./output-schemas.ts"
48
52
  import { missingOutputSchemas } from "./rules/data-guard.ts"
53
+ import { apiPrefixFix } from "./rules/pages.ts"
49
54
 
50
55
  /** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
51
56
  const BACKEND_EXPORTS = BACKEND_ROUTE_EXPORTS
@@ -75,6 +80,8 @@ const TOOLING_FILE =
75
80
  const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/
76
81
  const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"])
77
82
  const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"])
83
+ type FolderSide = "frontend" | "backend" | "shared"
84
+ const FOLDER_SIDES: readonly FolderSide[] = ["frontend", "backend", "shared"]
78
85
  /** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
79
86
  const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/
80
87
  /** Test-runner calls that name a module by path, as an import does. */
@@ -91,13 +98,34 @@ const MOCK_CALLS: ReadonlySet<string> = new Set([
91
98
  "jest.unstable_mockModule",
92
99
  ])
93
100
  const TAILWIND_CONFIG = /^tailwind\.config\.[cm]?[jt]s$/
101
+ /** What a `x.test.ts` beside `x.<ext>` tests. */
102
+ const SUBJECT_EXTENSIONS = [
103
+ ".ts",
104
+ ".tsx",
105
+ ".mts",
106
+ ".cts",
107
+ ".js",
108
+ ".jsx",
109
+ ".mjs",
110
+ ".cjs",
111
+ ".svelte",
112
+ ".vue",
113
+ ]
94
114
 
95
115
  export interface LayoutMigrationIssue {
96
116
  readonly file: string
97
117
  readonly reason: string
98
118
  }
99
119
 
120
+ /** A step left for after the migration that a command takes: it needs no decision, so `ok` ignores it. */
121
+ export interface LayoutMigrationFollowUp {
122
+ readonly file: string
123
+ readonly reason: string
124
+ readonly command: string
125
+ }
126
+
100
127
  export interface LayoutMigrationResult {
128
+ /** No issue needs a decision; follow-ups may remain. */
101
129
  readonly ok: boolean
102
130
  readonly write: boolean
103
131
  /** `"api"` for an app that serves no pages: only its backend entry moves, nothing is split or zoned. */
@@ -107,6 +135,8 @@ export interface LayoutMigrationResult {
107
135
  readonly file: string
108
136
  readonly backend: string
109
137
  readonly moved: readonly string[]
138
+ /** The module beside the route both halves import constant data from, when the split made one. */
139
+ readonly shared?: string
110
140
  }>
111
141
  /** Files that moved, app-relative. */
112
142
  readonly moves: ReadonlyArray<{ readonly from: string; readonly to: string }>
@@ -114,11 +144,24 @@ export interface LayoutMigrationResult {
114
144
  readonly rewritten: readonly string[]
115
145
  /** Tooling config the migration updated so it still covers what moved. */
116
146
  readonly tooling: ReadonlyArray<{ readonly file: string; readonly change: string }>
147
+ /**
148
+ * Output schemas drafted for the backend halves the split wrote, from their return types:
149
+ * `missing` in a dry run, `generated` once written. Each is a draft the author reviews.
150
+ */
151
+ readonly schemas: readonly OutputSchemaEntry[]
117
152
  readonly issues: readonly LayoutMigrationIssue[]
153
+ /** Commands to run after the migration, each with what it settles. */
154
+ readonly followUps: readonly LayoutMigrationFollowUp[]
118
155
  }
119
156
 
120
157
  export interface LayoutMigrationOptions {
121
158
  readonly write?: boolean
159
+ /**
160
+ * Leave the modules the layout would move into `frontend/`, `backend/` or `shared/` where they are,
161
+ * and list them in the app's `"nifra": { "zones": ... }` instead: the widest folder whose files all
162
+ * belong to one side, else the file itself. Route splits and the root entry moves still happen.
163
+ */
164
+ readonly zones?: boolean
122
165
  /** Inject the TypeScript compiler (tests); defaults to the CLI's own. */
123
166
  readonly typescript?: typeof TS
124
167
  }
@@ -128,6 +171,15 @@ type Files = Map<string, string>
128
171
 
129
172
  const toPosix = (path: string): string => path.replaceAll("\\", "/")
130
173
 
174
+ /** A specifier without its `?query` or `#hash`; a leading `#` names a package.json import. Cut by
175
+ * index, since `/[?#].*$/` retries from every `#` and is quadratic on a long run of them. */
176
+ const withoutQuery = (specifier: string): string => {
177
+ const query = specifier.indexOf("?", 1)
178
+ const hash = specifier.indexOf("#", 1)
179
+ const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash)
180
+ return cut === -1 ? specifier : specifier.slice(0, cut)
181
+ }
182
+
131
183
  /** Every first-party file under `root`, app-relative and posix. */
132
184
  function listFiles(root: string): string[] {
133
185
  const out: string[] = []
@@ -156,12 +208,29 @@ interface StatementInfo {
156
208
  readonly exports: ReadonlyMap<string, readonly string[]>
157
209
  readonly valueRefs: ReadonlySet<string>
158
210
  readonly typeRefs: ReadonlySet<string>
211
+ /** A local `export { a, b as c }`: each entry, which the reachability of its own half follows. */
212
+ readonly listed: ReadonlyArray<ListedExport>
213
+ }
214
+
215
+ interface ListedExport {
216
+ readonly exported: string
217
+ readonly local: string
218
+ /** `export type { X }` or `export { type X }`: a read in a type position. */
219
+ readonly type: boolean
220
+ }
221
+
222
+ /** The names one half reads, by position. */
223
+ interface Uses {
224
+ readonly value: ReadonlySet<string>
225
+ readonly type: ReadonlySet<string>
159
226
  }
160
227
 
161
228
  export interface SplitResult {
162
229
  readonly frontend: string
163
230
  /** `undefined` when the file exports nothing for the server. */
164
231
  readonly backend: string | undefined
232
+ /** The module beside the route that holds constant data both halves import, when there is any. */
233
+ readonly shared?: string
165
234
  readonly moved: readonly string[]
166
235
  readonly issues: readonly string[]
167
236
  }
@@ -247,6 +316,7 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
247
316
  return sf.statements.map((node) => {
248
317
  const declares = new Set<string>()
249
318
  const exports = new Map<string, string[]>()
319
+ const listed: ListedExport[] = []
250
320
  const exported = hasModifier(ts, node, ts.SyntaxKind.ExportKeyword)
251
321
  const isDefault = hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)
252
322
  const addExport = (local: string, as: string): void => {
@@ -286,10 +356,10 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
286
356
  ts.isNamedExports(node.exportClause)
287
357
  ) {
288
358
  for (const el of node.exportClause.elements) {
289
- addExport(
290
- node.moduleSpecifier ? `\0${el.name.text}` : (el.propertyName ?? el.name).text,
291
- el.name.text,
292
- )
359
+ const local = (el.propertyName ?? el.name).text
360
+ addExport(node.moduleSpecifier ? `\0${el.name.text}` : local, el.name.text)
361
+ if (node.moduleSpecifier === undefined)
362
+ listed.push({ exported: el.name.text, local, type: node.isTypeOnly || el.isTypeOnly })
293
363
  }
294
364
  }
295
365
  const refs = ts.isImportDeclaration(node)
@@ -306,34 +376,72 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
306
376
  exports,
307
377
  valueRefs: refs.value,
308
378
  typeRefs: refs.type,
379
+ listed,
309
380
  }
310
381
  })
311
382
  }
312
383
 
313
- /** Rebuild an import keeping only the bindings `keep` names. `undefined` when none remain. */
384
+ /**
385
+ * Rebuild an import keeping only the bindings `uses` names; `undefined` when none remain. Bindings
386
+ * read only in types become `import type`, so no bundler loads the module for them: always in a
387
+ * `fresh` module, and in the page only where the split narrowed the import.
388
+ */
314
389
  function filterImport(
315
390
  ts: typeof TS,
316
391
  sf: TS.SourceFile,
317
392
  node: TS.ImportDeclaration,
318
- keep: (name: string) => boolean,
393
+ uses: Uses,
394
+ fresh: boolean,
319
395
  ): string | undefined {
320
396
  const clause = node.importClause
321
397
  const spec = node.moduleSpecifier.getText(sf)
322
398
  const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : ""
323
399
  if (clause === undefined) return node.getFullText(sf)
324
- const parts: string[] = []
325
- if (clause.name && keep(clause.name.text)) parts.push(clause.name.text)
400
+ interface Part {
401
+ readonly text: string
402
+ /** Without an inline `type`, for an `import type`. */
403
+ readonly bare: string
404
+ readonly typeOnly: boolean
405
+ }
406
+ const heads: Part[] = []
407
+ const named: Part[] = []
408
+ let total = 0
409
+ const add = (into: Part[], name: string, text: string, bare: string): void => {
410
+ total++
411
+ if (uses.value.has(name) || uses.type.has(name))
412
+ into.push({ text, bare, typeOnly: !uses.value.has(name) })
413
+ }
414
+ if (clause.name) add(heads, clause.name.text, clause.name.text, clause.name.text)
326
415
  const bindings = clause.namedBindings
327
- if (bindings && ts.isNamespaceImport(bindings) && keep(bindings.name.text))
328
- parts.push(`* as ${bindings.name.text}`)
416
+ if (bindings && ts.isNamespaceImport(bindings)) {
417
+ const text = `* as ${bindings.name.text}`
418
+ add(heads, bindings.name.text, text, text)
419
+ }
329
420
  if (bindings && ts.isNamedImports(bindings)) {
330
- const named = bindings.elements.filter((el) => keep(el.name.text)).map((el) => el.getText(sf))
331
- if (named.length > 0) parts.push(`{ ${named.join(", ")} }`)
421
+ for (const el of bindings.elements) {
422
+ const renamed = el.propertyName ? `${el.propertyName.getText(sf)} as ` : ""
423
+ add(named, el.name.text, el.getText(sf), `${renamed}${el.name.text}`)
424
+ }
332
425
  }
333
- if (parts.length === 0) return undefined
334
- const typeOnly = clause.isTypeOnly ? "type " : ""
426
+ const kept = [...heads, ...named]
427
+ if (kept.length === 0) return undefined
335
428
  const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
336
- return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}${terminatorOf(sf, node)}`
429
+ const statement = (typeOnly: boolean, body: string): string =>
430
+ `import ${typeOnly ? "type " : ""}${body} from ${spec}${attributes}${terminatorOf(sf, node)}`
431
+ const list = (parts: readonly Part[], text: (part: Part) => string): string[] =>
432
+ parts.length === 0 ? [] : [`{ ${parts.map(text).join(", ")} }`]
433
+ if (
434
+ !clause.isTypeOnly &&
435
+ node.attributes === undefined &&
436
+ (fresh || kept.length < total) &&
437
+ kept.every((part) => part.typeOnly)
438
+ ) {
439
+ // `import type` takes one default, namespace or list, so each goes in its own statement.
440
+ const bodies = [...heads.map((part) => part.bare), ...list(named, (part) => part.bare)]
441
+ return `${leading}${bodies.map((body) => statement(true, body)).join("\n")}`
442
+ }
443
+ const body = [...heads.map((part) => part.text), ...list(named, (part) => part.text)]
444
+ return `${leading}${statement(clause.isTypeOnly, body.join(", "))}`
337
445
  }
338
446
 
339
447
  /** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
@@ -363,6 +471,126 @@ const joinStatements = (texts: readonly string[]): string => {
363
471
  return body.endsWith("\n") ? body : `${body}\n`
364
472
  }
365
473
 
474
+ /** Options for {@link splitRouteSource}. */
475
+ export interface SplitOptions {
476
+ /**
477
+ * The file name of a module beside the route (`index.shared.ts`) to hold constant data both halves
478
+ * read, which each half then imports. Without it, that data is copied into each half.
479
+ */
480
+ readonly sharedFile?: string
481
+ }
482
+
483
+ const PURE_OBJECT_CALLS = new Set(["keys", "values", "entries", "freeze", "fromEntries"])
484
+
485
+ /**
486
+ * Whether `node` builds constant data: literals, arrays and objects of it, operators over it,
487
+ * `Object.keys`-style reads, and the file's own top-level names, which go into `reads`. `undefined`
488
+ * when it is data; else the import it reads, or `""` for anything else (a call, a function, a global).
489
+ */
490
+ function dataBlocker(
491
+ ts: typeof TS,
492
+ node: TS.Node,
493
+ isLocal: (name: string) => boolean,
494
+ reads: Set<string>,
495
+ ): string | undefined {
496
+ const data = (n: TS.Node): string | undefined => {
497
+ if (
498
+ ts.isStringLiteral(n) ||
499
+ ts.isNumericLiteral(n) ||
500
+ ts.isBigIntLiteral(n) ||
501
+ ts.isNoSubstitutionTemplateLiteral(n) ||
502
+ n.kind === ts.SyntaxKind.TrueKeyword ||
503
+ n.kind === ts.SyntaxKind.FalseKeyword ||
504
+ n.kind === ts.SyntaxKind.NullKeyword
505
+ )
506
+ return undefined
507
+ if (ts.isIdentifier(n)) {
508
+ if (isLocal(n.text)) {
509
+ reads.add(n.text)
510
+ return undefined
511
+ }
512
+ return n.text === "undefined" ? undefined : n.text
513
+ }
514
+ const every = (nodes: readonly TS.Node[]): string | undefined => {
515
+ for (const child of nodes) {
516
+ const blocker = data(child)
517
+ if (blocker !== undefined) return blocker
518
+ }
519
+ return undefined
520
+ }
521
+ if (ts.isTemplateExpression(n)) return every(n.templateSpans.map((span) => span.expression))
522
+ if (ts.isPrefixUnaryExpression(n))
523
+ return n.operator === ts.SyntaxKind.PlusPlusToken ||
524
+ n.operator === ts.SyntaxKind.MinusMinusToken
525
+ ? ""
526
+ : data(n.operand)
527
+ if (ts.isBinaryExpression(n)) {
528
+ const operator = n.operatorToken.kind
529
+ const assigns =
530
+ operator >= ts.SyntaxKind.FirstAssignment && operator <= ts.SyntaxKind.LastAssignment
531
+ if (
532
+ assigns ||
533
+ operator === ts.SyntaxKind.CommaToken ||
534
+ operator === ts.SyntaxKind.InKeyword ||
535
+ operator === ts.SyntaxKind.InstanceOfKeyword
536
+ )
537
+ return ""
538
+ return every([n.left, n.right])
539
+ }
540
+ if (ts.isConditionalExpression(n)) return every([n.condition, n.whenTrue, n.whenFalse])
541
+ if (ts.isArrayLiteralExpression(n))
542
+ return every(
543
+ n.elements.flatMap((element) =>
544
+ ts.isOmittedExpression(element)
545
+ ? []
546
+ : [ts.isSpreadElement(element) ? element.expression : element],
547
+ ),
548
+ )
549
+ if (ts.isObjectLiteralExpression(n)) {
550
+ for (const property of n.properties) {
551
+ const blocker = ts.isPropertyAssignment(property)
552
+ ? ((ts.isComputedPropertyName(property.name)
553
+ ? data(property.name.expression)
554
+ : undefined) ?? data(property.initializer))
555
+ : ts.isShorthandPropertyAssignment(property)
556
+ ? property.objectAssignmentInitializer === undefined
557
+ ? data(property.name)
558
+ : ""
559
+ : ts.isSpreadAssignment(property)
560
+ ? data(property.expression)
561
+ : ""
562
+ if (blocker !== undefined) return blocker
563
+ }
564
+ return undefined
565
+ }
566
+ if (
567
+ ts.isParenthesizedExpression(n) ||
568
+ ts.isAsExpression(n) ||
569
+ ts.isSatisfiesExpression(n) ||
570
+ ts.isNonNullExpression(n) ||
571
+ ts.isTypeAssertionExpression(n)
572
+ )
573
+ return data(n.expression)
574
+ if (ts.isPropertyAccessExpression(n))
575
+ return ts.isPrivateIdentifier(n.name) ? "" : data(n.expression)
576
+ if (ts.isElementAccessExpression(n)) return every([n.expression, n.argumentExpression])
577
+ if (ts.isCallExpression(n)) {
578
+ const callee = n.expression
579
+ const objectRead =
580
+ ts.isPropertyAccessExpression(callee) &&
581
+ ts.isIdentifier(callee.expression) &&
582
+ callee.expression.text === "Object" &&
583
+ !isLocal("Object") &&
584
+ PURE_OBJECT_CALLS.has(callee.name.text) &&
585
+ n.questionDotToken === undefined &&
586
+ n.typeArguments === undefined
587
+ return objectRead ? every(n.arguments) : ""
588
+ }
589
+ return ""
590
+ }
591
+ return data(node)
592
+ }
593
+
366
594
  /**
367
595
  * Split one route module's source into its frontend and backend halves. `backendSpecifier` is how the
368
596
  * frontend refers to the backend half in an `import type` (`./index.backend.ts`).
@@ -372,6 +600,7 @@ export function splitRouteSource(
372
600
  source: string,
373
601
  fileName: string,
374
602
  backendSpecifier: string,
603
+ options: SplitOptions = {},
375
604
  ): SplitResult {
376
605
  const kind = fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
377
606
  const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, kind)
@@ -384,70 +613,177 @@ export function splitRouteSource(
384
613
  if (moved.length === 0) return { frontend: source, backend: undefined, moved: [], issues: [] }
385
614
 
386
615
  const declaredBy = new Map<string, StatementInfo>()
616
+ const importedBy = new Map<string, StatementInfo>()
387
617
  for (const info of statements) {
388
- if (ts.isImportDeclaration(info.node)) continue
389
- for (const name of info.declares) declaredBy.set(name, info)
390
- }
391
-
392
- // The backend half: its export statements plus every top-level declaration they reach.
393
- const backendSet = new Set<StatementInfo>()
394
- const queue: StatementInfo[] = statements.filter(isBackendStatement)
395
- for (const info of queue) backendSet.add(info)
396
- while (queue.length > 0) {
397
- const info = queue.pop() as StatementInfo
398
- for (const name of [...info.valueRefs, ...info.typeRefs]) {
399
- const target = declaredBy.get(name)
400
- if (target !== undefined && !backendSet.has(target)) {
401
- backendSet.add(target)
402
- queue.push(target)
403
- }
404
- }
405
- // An exported-name statement (`export { loader }`) reaches the declaration it names.
406
- for (const local of info.exports.keys()) {
407
- const target = declaredBy.get(local)
408
- if (target !== undefined && !backendSet.has(target)) {
409
- backendSet.add(target)
410
- queue.push(target)
411
- }
618
+ const into = ts.isImportDeclaration(info.node) ? importedBy : declaredBy
619
+ for (const name of info.declares) into.set(name, info)
620
+ }
621
+ const isTypeDeclaration = (info: StatementInfo): boolean =>
622
+ ts.isInterfaceDeclaration(info.node) || ts.isTypeAliasDeclaration(info.node)
623
+ /** What `info` reads for one half: an export list reads only the locals it exports there. */
624
+ const readsFor = (info: StatementInfo, backend: boolean): Uses => {
625
+ if (info.listed.length === 0) return { value: info.valueRefs, type: info.typeRefs }
626
+ const mine = info.listed.filter((entry) => BACKEND_EXPORTS.has(entry.exported) === backend)
627
+ return {
628
+ value: new Set(mine.filter((entry) => !entry.type).map((entry) => entry.local)),
629
+ type: new Set(mine.filter((entry) => entry.type).map((entry) => entry.local)),
412
630
  }
413
631
  }
414
-
415
- // The frontend half: everything that is not a backend export statement, minus declarations that only
416
- // the backend half needs.
417
632
  const movedLocals = new Set<string>()
418
633
  for (const info of statements) {
419
634
  for (const [local, names] of info.exports)
420
635
  if (names.some((name) => BACKEND_EXPORTS.has(name))) movedLocals.add(local)
421
636
  }
422
- const frontendCandidates = statements.filter((info) => !ts.isImportDeclaration(info.node))
423
- const frontendKeep = new Set<StatementInfo>()
424
- const seed = frontendCandidates.filter(
425
- (info) =>
426
- !backendSet.has(info) ||
427
- (!isBackendStatement(info) && exportsOf(info).length > 0) ||
428
- // a statement with no declaration (a side effect) stays where it ran
429
- (info.declares.size === 0 && info.exports.size === 0),
430
- )
431
- const fqueue = [...seed]
432
- for (const info of fqueue) frontendKeep.add(info)
433
- while (fqueue.length > 0) {
434
- const info = fqueue.pop() as StatementInfo
435
- for (const name of info.valueRefs) {
436
- if (movedLocals.has(name) && !info.declares.has(name)) continue
637
+
638
+ /** Each half's statements. What `shared` holds, both import, so neither half keeps a copy. */
639
+ const partition = (
640
+ shared: ReadonlySet<StatementInfo>,
641
+ ): { backendSet: Set<StatementInfo>; frontendKeep: Set<StatementInfo> } => {
642
+ const local = (name: string): StatementInfo | undefined => {
437
643
  const target = declaredBy.get(name)
438
- if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
439
- frontendKeep.add(target)
440
- fqueue.push(target)
644
+ return target === undefined || shared.has(target) ? undefined : target
645
+ }
646
+ // The backend half: its export statements plus every top-level declaration they reach.
647
+ const backendSet = new Set<StatementInfo>()
648
+ const queue: StatementInfo[] = statements.filter(isBackendStatement)
649
+ for (const info of queue) backendSet.add(info)
650
+ while (queue.length > 0) {
651
+ const info = queue.pop() as StatementInfo
652
+ const reads = readsFor(info, true)
653
+ for (const name of [...reads.value, ...reads.type]) {
654
+ const target = local(name)
655
+ if (target !== undefined && !backendSet.has(target)) {
656
+ backendSet.add(target)
657
+ queue.push(target)
658
+ }
441
659
  }
442
660
  }
443
- for (const name of info.typeRefs) {
444
- if (movedLocals.has(name)) continue
445
- const target = declaredBy.get(name)
446
- if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
447
- frontendKeep.add(target)
448
- fqueue.push(target)
661
+
662
+ // The frontend half: everything that is not a backend export statement, minus declarations that
663
+ // only the backend half needs. An export list that also names backend exports keeps the rest.
664
+ const frontendKeep = new Set<StatementInfo>()
665
+ const seed = statements.filter(
666
+ (info) =>
667
+ !ts.isImportDeclaration(info.node) &&
668
+ !shared.has(info) &&
669
+ (!backendSet.has(info) ||
670
+ exportsOf(info).some((name) => !BACKEND_EXPORTS.has(name)) ||
671
+ // a statement with no declaration (a side effect) stays where it ran
672
+ (info.declares.size === 0 && info.exports.size === 0)),
673
+ )
674
+ const fqueue = [...seed]
675
+ for (const info of fqueue) frontendKeep.add(info)
676
+ while (fqueue.length > 0) {
677
+ const info = fqueue.pop() as StatementInfo
678
+ const reads = readsFor(info, false)
679
+ for (const name of reads.value) {
680
+ if (movedLocals.has(name) && !info.declares.has(name)) continue
681
+ const target = local(name)
682
+ if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
683
+ frontendKeep.add(target)
684
+ fqueue.push(target)
685
+ }
686
+ }
687
+ for (const name of reads.type) {
688
+ if (movedLocals.has(name)) continue
689
+ const target = local(name)
690
+ if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
691
+ frontendKeep.add(target)
692
+ fqueue.push(target)
693
+ }
694
+ }
695
+ }
696
+ return { backendSet, frontendKeep }
697
+ }
698
+ let { backendSet, frontendKeep } = partition(new Set())
699
+ const copies = (): StatementInfo[] =>
700
+ [...frontendKeep].filter(
701
+ (info) =>
702
+ backendSet.has(info) &&
703
+ !isBackendStatement(info) &&
704
+ info.declares.size > 0 &&
705
+ !isTypeDeclaration(info),
706
+ )
707
+
708
+ // Constant data both halves read moves to a module beside the route, with the declarations it
709
+ // needs: one definition, which neither half's bundle can drift from.
710
+ const shared = new Set<StatementInfo>()
711
+ const sharedTypes = new Set<StatementInfo>()
712
+ const sharedImports = new Set<string>()
713
+ const unshared = new Map<StatementInfo, string>()
714
+ if (options.sharedFile !== undefined) {
715
+ interface Closure {
716
+ readonly values: Set<StatementInfo>
717
+ readonly types: Set<StatementInfo>
718
+ readonly imports: Set<string>
719
+ }
720
+ /** Why `info` cannot move to the shared module, or `undefined` once it and what it needs are in `closure`. */
721
+ const gather = (info: StatementInfo, closure: Closure): string | undefined => {
722
+ if (closure.values.has(info) || shared.has(info)) return undefined
723
+ const node = info.node
724
+ const reads = new Set<string>()
725
+ let blocker: string | undefined = ""
726
+ if (
727
+ !isBackendStatement(info) &&
728
+ ts.isVariableStatement(node) &&
729
+ (node.declarationList.flags & ts.NodeFlags.Const) !== 0 &&
730
+ !hasModifier(ts, node, ts.SyntaxKind.DeclareKeyword)
731
+ ) {
732
+ blocker = undefined
733
+ for (const declaration of node.declarationList.declarations) {
734
+ blocker =
735
+ ts.isIdentifier(declaration.name) && declaration.initializer !== undefined
736
+ ? dataBlocker(ts, declaration.initializer, (name) => declaredBy.has(name), reads)
737
+ : ""
738
+ if (blocker !== undefined) break
739
+ }
740
+ }
741
+ if (blocker !== undefined)
742
+ return blocker !== "" && importedBy.has(blocker)
743
+ ? `it reads the import "${blocker}"`
744
+ : "it is not constant data"
745
+ closure.values.add(info)
746
+ for (const name of reads) {
747
+ const target = declaredBy.get(name)
748
+ const why = target === undefined ? undefined : gather(target, closure)
749
+ if (why !== undefined) return `it reads "${name}", which ${why.slice(3)}`
750
+ }
751
+ for (const name of info.typeRefs) {
752
+ const why = gatherType(name, closure)
753
+ if (why !== undefined) return why
754
+ }
755
+ return undefined
756
+ }
757
+ const gatherType = (name: string, closure: Closure): string | undefined => {
758
+ const local = declaredBy.get(name)
759
+ if (local === undefined) {
760
+ if (importedBy.has(name)) closure.imports.add(name)
761
+ return undefined
762
+ }
763
+ if (!isTypeDeclaration(local)) {
764
+ const why = gather(local, closure)
765
+ return why === undefined ? undefined : `its type reads "${name}", which ${why.slice(3)}`
766
+ }
767
+ if (closure.types.has(local) || sharedTypes.has(local)) return undefined
768
+ closure.types.add(local)
769
+ for (const ref of [...local.typeRefs, ...local.valueRefs]) {
770
+ const why = gatherType(ref, closure)
771
+ if (why !== undefined) return why
449
772
  }
773
+ return undefined
450
774
  }
775
+ for (const info of copies()) {
776
+ const closure: Closure = { values: new Set(), types: new Set(), imports: new Set() }
777
+ const why = gather(info, closure)
778
+ if (why !== undefined) {
779
+ unshared.set(info, why)
780
+ continue
781
+ }
782
+ for (const value of closure.values) shared.add(value)
783
+ for (const type of closure.types) sharedTypes.add(type)
784
+ for (const name of closure.imports) sharedImports.add(name)
785
+ }
786
+ if (shared.size > 0) ({ backendSet, frontendKeep } = partition(shared))
451
787
  }
452
788
 
453
789
  // A moved name the frontend still mentions: in a type it becomes `import type`; at runtime it is an
@@ -455,44 +791,88 @@ export function splitRouteSource(
455
791
  const typeImports = new Set<string>()
456
792
  for (const info of frontendKeep) {
457
793
  if (isBackendStatement(info)) continue
794
+ const reads = readsFor(info, false)
458
795
  for (const name of movedLocals) {
459
796
  if (info.declares.has(name)) continue
460
- if (info.valueRefs.has(name))
797
+ if (reads.value.has(name))
461
798
  issues.push(`the frontend uses "${name}" at runtime, but it runs on the server only`)
462
- if (info.typeRefs.has(name) || info.valueRefs.has(name)) typeImports.add(name)
799
+ if (reads.type.has(name) || reads.value.has(name)) typeImports.add(name)
463
800
  }
464
801
  }
465
- for (const info of frontendKeep) {
466
- if (!backendSet.has(info) || isBackendStatement(info) || info.declares.size === 0) continue
467
- const values = [...info.declares].filter(
468
- () => !ts.isInterfaceDeclaration(info.node) && !ts.isTypeAliasDeclaration(info.node),
802
+ for (const info of copies()) {
803
+ const why = unshared.get(info)
804
+ issues.push(
805
+ `"${[...info.declares].join(", ")}" is needed by both halves and was copied into each${why === undefined ? "" : `, since ${why}`}; consider moving it to shared/`,
469
806
  )
470
- if (values.length > 0)
471
- issues.push(
472
- `"${values.join(", ")}" is needed by both halves and was copied into each; consider moving it to shared/`,
473
- )
474
807
  }
475
808
 
476
- const used = (infos: Iterable<StatementInfo>): Set<string> => {
477
- const names = new Set<string>()
478
- for (const info of infos)
479
- for (const name of [...info.valueRefs, ...info.typeRefs]) names.add(name)
480
- return names
481
- }
482
- const backendUses = used(backendSet)
483
- const frontendUses = used([...frontendKeep].filter((info) => !isBackendStatement(info)))
809
+ const uses = (infos: Iterable<StatementInfo>, backend: boolean): Uses => {
810
+ const value = new Set<string>()
811
+ const type = new Set<string>()
812
+ for (const info of infos) {
813
+ const reads = readsFor(info, backend)
814
+ for (const name of reads.value) value.add(name)
815
+ for (const name of reads.type) type.add(name)
816
+ }
817
+ return { value, type }
818
+ }
819
+ const backendUses = uses(backendSet, true)
820
+ const frontendUses = uses(frontendKeep, false)
821
+
822
+ const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : ""
823
+ // The imports the split writes quote the way the file's own imports do.
824
+ const firstImport = statements.find((info) => ts.isImportDeclaration(info.node))?.node
825
+ const quote =
826
+ firstImport !== undefined && ts.isImportDeclaration(firstImport)
827
+ ? (firstImport.moduleSpecifier.getText(sf)[0] ?? '"')
828
+ : '"'
829
+ const sharedNames = [...shared].flatMap((info) => [...info.declares])
830
+ const sharedSpecifier = (() => {
831
+ const file = options.sharedFile ?? ""
832
+ // The halves name the module the way the route names its other relative imports.
833
+ const relative = statements.flatMap((info) =>
834
+ ts.isImportDeclaration(info.node) &&
835
+ ts.isStringLiteral(info.node.moduleSpecifier) &&
836
+ info.node.moduleSpecifier.text.startsWith(".")
837
+ ? [info.node.moduleSpecifier.text]
838
+ : [],
839
+ )
840
+ const extension = relative
841
+ .map((specifier) => /\.(ts|tsx|js|jsx)$/.exec(specifier)?.[1])
842
+ .find((found) => found !== undefined)
843
+ const suffix = extension === undefined ? "" : extension.startsWith("t") ? ".ts" : ".js"
844
+ return `./${file.replace(/\.ts$/, "")}${suffix}`
845
+ })()
846
+ const sharedImport = (half: Uses): string[] => {
847
+ const names = sharedNames
848
+ .filter((name) => half.value.has(name) || half.type.has(name))
849
+ .sort(codeUnitOrder)
850
+ if (names.length === 0) return []
851
+ const typeOnly = names.every((name) => !half.value.has(name))
852
+ return [
853
+ `\nimport ${typeOnly ? "type " : ""}{ ${names.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`,
854
+ ]
855
+ }
856
+ // The page still exports what it exported, now from the shared module.
857
+ const reexported = [...shared].flatMap((info) =>
858
+ [...info.exports].flatMap(([local, names]) =>
859
+ names.map((name) => (name === local ? local : `${local} as ${name}`)),
860
+ ),
861
+ )
484
862
 
485
863
  const backendTexts: string[] = []
486
864
  const frontendTexts: string[] = []
487
865
  let afterImports = 0
866
+ let backendAfterImports = 0
488
867
  for (const info of statements) {
489
868
  const node = info.node
490
869
  if (ts.isImportDeclaration(node)) {
491
- const back = filterImport(ts, sf, node, (name) => backendUses.has(name))
870
+ const back = filterImport(ts, sf, node, backendUses, true)
492
871
  if (back !== undefined) backendTexts.push(node.importClause ? back : "")
493
- const front = filterImport(ts, sf, node, (name) => frontendUses.has(name))
872
+ const front = filterImport(ts, sf, node, frontendUses, false)
494
873
  if (front !== undefined) frontendTexts.push(front)
495
874
  afterImports = frontendTexts.length
875
+ backendAfterImports = backendTexts.length
496
876
  continue
497
877
  }
498
878
  if (isBackendStatement(info)) {
@@ -518,14 +898,17 @@ export function splitRouteSource(
518
898
  if (frontendKeep.has(info)) frontendTexts.push(info.text)
519
899
  }
520
900
  // After the other imports: the nearest relative path sorts last.
521
- if (typeImports.size > 0) {
522
- const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : ""
523
- frontendTexts.splice(
524
- afterImports,
525
- 0,
526
- `\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"${semi}`,
901
+ const frontendImports = sharedImport(frontendUses)
902
+ if (reexported.length > 0)
903
+ frontendImports.push(
904
+ `\nexport { ${reexported.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`,
527
905
  )
528
- }
906
+ if (typeImports.size > 0)
907
+ frontendImports.push(
908
+ `\nimport type { ${[...typeImports].sort().join(", ")} } from ${quote}${backendSpecifier}${quote}${semi}`,
909
+ )
910
+ frontendTexts.splice(afterImports, 0, ...frontendImports)
911
+ backendTexts.splice(backendAfterImports, 0, ...sharedImport(backendUses))
529
912
  // A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
530
913
  const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
531
914
  .map((range) => source.slice(range.pos, range.end))
@@ -536,12 +919,34 @@ export function splitRouteSource(
536
919
  text,
537
920
  )
538
921
  const frontend = lift(joinStatements(frontendTexts))
539
- return {
922
+ let sharedText: string | undefined
923
+ if (shared.size > 0) {
924
+ const texts: string[] = []
925
+ const typeUses: Uses = { value: new Set(), type: sharedImports }
926
+ for (const info of statements) {
927
+ if (ts.isImportDeclaration(info.node)) {
928
+ const kept = filterImport(ts, sf, info.node, typeUses, true)
929
+ if (kept !== undefined && info.node.importClause !== undefined) texts.push(kept)
930
+ } else if (shared.has(info) || sharedTypes.has(info)) {
931
+ texts.push(withExport(ts, sf, info))
932
+ }
933
+ }
934
+ sharedText = lift(joinStatements(texts))
935
+ }
936
+ const result: SplitResult = {
540
937
  frontend: pragmas.length > 0 ? `${pragmas.join("\n")}\n${frontend}` : frontend,
541
938
  backend: lift(joinStatements(backendTexts)),
542
939
  moved: [...new Set(moved)].sort(),
543
940
  issues,
544
941
  }
942
+ return sharedText === undefined ? result : { ...result, shared: sharedText }
943
+ }
944
+
945
+ /** A declaration the shared module holds is exported, so either half can import it. */
946
+ function withExport(ts: typeof TS, sf: TS.SourceFile, info: StatementInfo): string {
947
+ if (hasModifier(ts, info.node, ts.SyntaxKind.ExportKeyword)) return info.text
948
+ const at = info.node.getStart(sf) - info.node.getFullStart()
949
+ return `${info.text.slice(0, at)}export ${info.text.slice(at)}`
545
950
  }
546
951
 
547
952
  /** A helper the backend half copies keeps its declaration; an `export` on it would add a backend export. */
@@ -736,7 +1141,7 @@ function resolveIn(
736
1141
  specifier: string,
737
1142
  ): string | undefined {
738
1143
  if (!specifier.startsWith(".")) return undefined
739
- const clean = specifier.replace(/[?#].*$/, "")
1144
+ const clean = withoutQuery(specifier)
740
1145
  const base = posix.normalize(posix.join(posix.dirname(from), clean))
741
1146
  const candidates = [
742
1147
  base,
@@ -806,15 +1211,24 @@ function rebaseOwnPaths(
806
1211
  const quote = node.getText(sf)[0] ?? '"'
807
1212
  return `${quote}${value}${quote}`
808
1213
  }
809
- // The path `relative` names from the module's old folder, named from its new folder.
810
- const rebased = (relative: string): string => {
811
- const target = locate(posix.normalize(posix.join(posix.dirname(from), relative)))
1214
+ // The path `relative` names from the module's old folder, named from its new folder; `undefined`
1215
+ // when neither moved, so a module that stays keeps its spelling.
1216
+ const rebased = (relative: string): string | undefined => {
1217
+ const before = posix.normalize(posix.join(posix.dirname(from), relative))
1218
+ const target = locate(before)
1219
+ if (from === to && target === before) return undefined
812
1220
  const next = posix.relative(posix.dirname(to), target) || "."
813
1221
  return relative.endsWith("/") && next !== "." ? `${next}/` : next
814
1222
  }
815
- const follow = (anchor: TS.Node, start: number, end: number, replacement: string): void => {
1223
+ const follow = (
1224
+ anchor: TS.Node,
1225
+ start: number,
1226
+ end: number,
1227
+ replacement: string | undefined,
1228
+ ): void => {
816
1229
  followed.push([anchor.getStart(sf), anchor.getEnd()])
817
- if (text.slice(start, end) !== replacement) edits.push({ start, end, text: replacement })
1230
+ if (replacement !== undefined && text.slice(start, end) !== replacement)
1231
+ edits.push({ start, end, text: replacement })
818
1232
  }
819
1233
 
820
1234
  const visit = (node: TS.Node): void => {
@@ -832,8 +1246,12 @@ function rebaseOwnPaths(
832
1246
  !(callee.endsWith("resolve") && parts.some((part) => part.text.startsWith("/")))
833
1247
  ) {
834
1248
  const next = rebased(posix.join(...parts.map((part) => part.text)))
835
- const segments = parts.length === 1 ? [next] : next.split("/").filter((s) => s !== "")
836
- const replacement = segments.map((segment) => quoted(first, segment)).join(", ")
1249
+ const replacement =
1250
+ next === undefined
1251
+ ? undefined
1252
+ : (parts.length === 1 ? [next] : next.split("/").filter((s) => s !== ""))
1253
+ .map((segment) => quoted(first, segment))
1254
+ .join(", ")
837
1255
  follow(dir, first.getStart(sf), last.getEnd(), replacement)
838
1256
  return
839
1257
  }
@@ -850,7 +1268,7 @@ function rebaseOwnPaths(
850
1268
  node.left,
851
1269
  node.right.getStart(sf),
852
1270
  node.right.getEnd(),
853
- quoted(node.right, `/${next}`),
1271
+ next === undefined ? undefined : quoted(node.right, `/${next}`),
854
1272
  )
855
1273
  return
856
1274
  }
@@ -858,7 +1276,8 @@ function rebaseOwnPaths(
858
1276
  const span = node.templateSpans[0]
859
1277
  if (span !== undefined && isOwnDir(span.expression) && span.literal.text.startsWith("/")) {
860
1278
  const next = rebased(span.literal.text.slice(1) || ".")
861
- const replacement = `\`\${${span.expression.getText(sf)}}/${next}\``
1279
+ const replacement =
1280
+ next === undefined ? undefined : `\`\${${span.expression.getText(sf)}}/${next}\``
862
1281
  follow(span.expression, node.getStart(sf), node.getEnd(), replacement)
863
1282
  return
864
1283
  }
@@ -872,7 +1291,8 @@ function rebaseOwnPaths(
872
1291
  const path = node.arguments[0]
873
1292
  if (isLiteral(path) && !/^(?:\/|[a-z][a-z\d+.-]*:)/i.test(path.text)) {
874
1293
  const next = rebased(path.text)
875
- const replacement = quoted(path, next.startsWith("..") ? next : `./${next}`)
1294
+ const replacement =
1295
+ next === undefined ? undefined : quoted(path, next.startsWith("..") ? next : `./${next}`)
876
1296
  follow(node.arguments[1], path.getStart(sf), path.getEnd(), replacement)
877
1297
  return
878
1298
  }
@@ -898,7 +1318,7 @@ function respecify(
898
1318
  newFrom: string,
899
1319
  newTarget: string,
900
1320
  ): string {
901
- const query = /[?#].*$/.exec(original)?.[0] ?? ""
1321
+ const query = original.slice(withoutQuery(original).length)
902
1322
  const clean = original.slice(0, original.length - query.length)
903
1323
  let target = newTarget
904
1324
  const writtenExt = extname(clean)
@@ -928,16 +1348,16 @@ function rebase(specifier: string, from: string, newFrom: string): string | unde
928
1348
  }
929
1349
 
930
1350
  /** 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). */
1351
+ * backend half they went to. */
932
1352
  interface SplitTarget {
933
1353
  readonly backend: string
934
1354
  readonly moved: ReadonlySet<string>
935
- readonly specifier: string | undefined
1355
+ readonly specifier: string
936
1356
  }
937
1357
 
938
1358
  /** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
939
1359
  function backendSpecifier(specifier: string, route: string, backend: string, from: string): string {
940
- const query = /[?#].*$/.exec(specifier)?.[0] ?? ""
1360
+ const query = specifier.slice(withoutQuery(specifier).length)
941
1361
  const written = extname(specifier.slice(0, specifier.length - query.length))
942
1362
  const stem = backend.slice(0, -extname(backend).length)
943
1363
  const target =
@@ -953,8 +1373,7 @@ function backendSpecifier(specifier: string, route: string, backend: string, fro
953
1373
  /**
954
1374
  * Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
955
1375
  * 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.
1376
+ * namespace or dynamic import that reads a moved name, `export *`) is returned as an issue.
958
1377
  */
959
1378
  function redirectMovedImports(
960
1379
  ts: typeof TS,
@@ -1010,12 +1429,6 @@ function redirectMovedImports(
1010
1429
  split.moved.has((el.propertyName ?? el.name).text)
1011
1430
  const moved = elements.filter(isMoved)
1012
1431
  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
1432
  redirects.push({
1020
1433
  names: moved.map((el) => (el.propertyName ?? el.name).text),
1021
1434
  from,
@@ -1049,14 +1462,29 @@ function redirectMovedImports(
1049
1462
  node.arguments[0] !== undefined &&
1050
1463
  ts.isStringLiteralLike(node.arguments[0])
1051
1464
  ) {
1052
- const from = node.arguments[0].text
1465
+ const literal = node.arguments[0]
1466
+ const from = literal.text
1053
1467
  const split = splitOf(from)
1054
1468
  const reads =
1055
1469
  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
- )
1470
+ if (split !== undefined && reads.length > 0) {
1471
+ const names = dynamicReads(ts, sf, node)
1472
+ if (
1473
+ names !== undefined &&
1474
+ names.length > 0 &&
1475
+ names.every((name) => split.moved.has(name))
1476
+ ) {
1477
+ edits.push({
1478
+ start: literal.getStart(sf) + 1,
1479
+ end: literal.getEnd() - 1,
1480
+ text: split.specifier,
1481
+ })
1482
+ redirects.push({ names, from, to: split.specifier })
1483
+ } else
1484
+ issues.push(
1485
+ `loads "${from}" with import() and mentions ${quoted(reads)}, which moved to "${split.backend}"; load the backend half for them by hand`,
1486
+ )
1487
+ }
1060
1488
  }
1061
1489
  ts.forEachChild(node, visit)
1062
1490
  }
@@ -1067,6 +1495,193 @@ function redirectMovedImports(
1067
1495
  return { text: out, issues, redirects }
1068
1496
  }
1069
1497
 
1498
+ /**
1499
+ * The exports code reads from what `import()` loads: off the awaited module (`(await import(x)).a`),
1500
+ * through a destructuring, or through a variable used only as `mod.a`. `undefined` when the module
1501
+ * goes anywhere else, so its reads cannot be listed.
1502
+ */
1503
+ function dynamicReads(
1504
+ ts: typeof TS,
1505
+ sf: TS.SourceFile,
1506
+ call: TS.CallExpression,
1507
+ ): string[] | undefined {
1508
+ let node: TS.Node = call
1509
+ if (!ts.isAwaitExpression(node.parent)) return undefined
1510
+ node = node.parent
1511
+ while (ts.isParenthesizedExpression(node.parent)) node = node.parent
1512
+ const parent = node.parent
1513
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) return [parent.name.text]
1514
+ if (!ts.isVariableDeclaration(parent) || parent.initializer !== node) return undefined
1515
+ const binding = parent.name
1516
+ if (ts.isObjectBindingPattern(binding)) {
1517
+ const names: string[] = []
1518
+ for (const element of binding.elements) {
1519
+ const key = element.propertyName ?? element.name
1520
+ if (
1521
+ element.dotDotDotToken !== undefined ||
1522
+ !(ts.isIdentifier(key) || ts.isStringLiteral(key))
1523
+ )
1524
+ return undefined
1525
+ names.push(key.text)
1526
+ }
1527
+ return names
1528
+ }
1529
+ if (!ts.isIdentifier(binding)) return undefined
1530
+ const names = new Set<string>()
1531
+ let escapes = false
1532
+ const visit = (current: TS.Node): void => {
1533
+ if (ts.isIdentifier(current) && current.text === binding.text && current !== binding) {
1534
+ const owner = current.parent
1535
+ if (ts.isPropertyAccessExpression(owner) && owner.expression === current)
1536
+ names.add(owner.name.text)
1537
+ else if (
1538
+ !(
1539
+ (ts.isPropertyAccessExpression(owner) ||
1540
+ ts.isPropertyAssignment(owner) ||
1541
+ ts.isPropertyDeclaration(owner) ||
1542
+ ts.isMethodDeclaration(owner)) &&
1543
+ owner.name === current
1544
+ )
1545
+ )
1546
+ escapes = true
1547
+ }
1548
+ ts.forEachChild(current, visit)
1549
+ }
1550
+ visit(sf)
1551
+ return escapes ? undefined : [...names].sort(codeUnitOrder)
1552
+ }
1553
+
1554
+ /** A `workspaces` glob as a matcher over package-relative folder paths. */
1555
+ function workspacePattern(glob: string): RegExp {
1556
+ const source = glob
1557
+ .replace(/^\.\//, "")
1558
+ .replace(/\/+$/, "")
1559
+ .split("/")
1560
+ .map((segment) =>
1561
+ segment === "**"
1562
+ ? "(?:[^/]+/)*?[^/]*"
1563
+ : segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*"),
1564
+ )
1565
+ .join("/")
1566
+ return new RegExp(`^${source}$`)
1567
+ }
1568
+
1569
+ /**
1570
+ * The workspace an app belongs to: the nearest folder above it whose package.json declares
1571
+ * `workspaces` that include the app, and the package folders those globs (less any `!` exclusion)
1572
+ * name, other than the app and any folder that holds it. `undefined` outside one.
1573
+ */
1574
+ function workspaceSiblings(root: string): readonly string[] | undefined {
1575
+ for (let dir = dirname(root), depth = 0; depth < 8; dir = dirname(dir), depth += 1) {
1576
+ let declared: unknown
1577
+ try {
1578
+ declared = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).workspaces
1579
+ } catch {
1580
+ declared = undefined
1581
+ }
1582
+ const listed = Array.isArray(declared)
1583
+ ? declared
1584
+ : typeof declared === "object" && declared !== null
1585
+ ? Reflect.get(declared, "packages")
1586
+ : undefined
1587
+ if (Array.isArray(listed)) {
1588
+ const globs = listed.filter((glob): glob is string => typeof glob === "string")
1589
+ const include = globs.filter((glob) => !glob.startsWith("!")).map(workspacePattern)
1590
+ const exclude = globs
1591
+ .filter((glob) => glob.startsWith("!"))
1592
+ .map((glob) => workspacePattern(glob.slice(1)))
1593
+ const member = (rel: string): boolean =>
1594
+ include.some((pattern) => pattern.test(rel)) &&
1595
+ !exclude.some((pattern) => pattern.test(rel))
1596
+ // The nearest declaration decides, as package managers read it.
1597
+ if (!member(toPosix(relative(dir, root)))) return undefined
1598
+ const packages: string[] = []
1599
+ const walk = (folder: string, depth: number): void => {
1600
+ for (const entry of readdirSync(folder, { withFileTypes: true })) {
1601
+ if (!entry.isDirectory() || entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
1602
+ continue
1603
+ const full = join(folder, entry.name)
1604
+ const rel = toPosix(relative(dir, full))
1605
+ if (exclude.some((pattern) => pattern.test(rel))) continue
1606
+ if (member(rel) && existsSync(join(full, "package.json"))) {
1607
+ const inside = toPosix(relative(full, root))
1608
+ if (inside.startsWith("../") || isAbsolute(inside)) packages.push(full)
1609
+ continue
1610
+ }
1611
+ if (depth < 4) walk(full, depth + 1)
1612
+ }
1613
+ }
1614
+ walk(dir, 0)
1615
+ return packages.sort()
1616
+ }
1617
+ if (dirname(dir) === dir) return undefined
1618
+ }
1619
+ return undefined
1620
+ }
1621
+
1622
+ /**
1623
+ * Where code in a workspace sibling names an app file that moves: a relative import into the app, or
1624
+ * the file's path in a string or command (`bun -e 'import("./src/x.ts")'` with the app as its working
1625
+ * directory). Only files that mention the app's folder or package name are read further, and a path
1626
+ * the sibling's own package holds is its own file. Report-only: the migration never edits a sibling.
1627
+ */
1628
+ function siblingReferences(
1629
+ ts: typeof TS,
1630
+ root: string,
1631
+ pkg: string,
1632
+ file: string,
1633
+ text: string,
1634
+ moves: ReadonlyMap<string, string>,
1635
+ known: ReadonlySet<string>,
1636
+ ownName: string | undefined,
1637
+ ): Array<{ readonly from: string; readonly to: string; readonly line: number }> {
1638
+ const abs = join(pkg, file)
1639
+ // A process started in the app (`cwd: \`${repo}apps/web\``) reads its paths from the app root.
1640
+ const runsInApp = new RegExp(
1641
+ `\\bcwd\\s*:[^,\\n]*\\b${escapeRegExp(posix.basename(toPosix(root)))}\\b`,
1642
+ ).test(text)
1643
+ const found = new Map<string, { from: string; to: string; line: number }>()
1644
+ const report = (from: string, index: number): void => {
1645
+ const to = moves.get(from)
1646
+ if (to === undefined || found.has(from)) return
1647
+ found.set(from, { from, to, line: text.slice(0, index).split("\n").length })
1648
+ }
1649
+ if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
1650
+ for (const { specifier } of importsOf(ts, file, text)) {
1651
+ const clean = withoutQuery(specifier)
1652
+ const inApp = clean.startsWith(".")
1653
+ ? toPosix(relative(root, resolve(dirname(abs), clean)))
1654
+ : ownName !== undefined && clean.startsWith(`${ownName}/`)
1655
+ ? clean.slice(ownName.length + 1)
1656
+ : undefined
1657
+ if (inApp === undefined || inApp.startsWith("../") || isAbsolute(inApp)) continue
1658
+ const target = resolveIn(known, "index.ts", `./${inApp}`)
1659
+ if (target !== undefined) report(target, text.indexOf(specifier))
1660
+ }
1661
+ }
1662
+ for (const from of moves.keys()) {
1663
+ const stem = SOURCE_FILE.test(from)
1664
+ ? from.slice(0, from.length - extname(from).length)
1665
+ : undefined
1666
+ const pattern = new RegExp(
1667
+ `(?<![\\w.-])(?:\\./)?(?:${escapeRegExp(from)}(?![\\w])${stem === undefined ? "" : `|${escapeRegExp(stem)}(?=["'\`])`})`,
1668
+ "g",
1669
+ )
1670
+ for (const match of text.matchAll(pattern)) {
1671
+ // `packages/x/src/a.ts` names another package's `src/a.ts`; only a prefix that reaches the
1672
+ // app counts, and a path the sibling holds itself is its own.
1673
+ const prefix = /(?:[\w.@-]+\/)+$/.exec(text.slice(0, match.index))?.[0] ?? ""
1674
+ const bases = prefix === "" ? [] : [dirname(abs), pkg, dirname(pkg), dirname(dirname(pkg))]
1675
+ if (prefix !== "" && !bases.some((base) => resolve(base, prefix) === root)) continue
1676
+ const own = existsSync(join(pkg, from)) || existsSync(join(dirname(abs), from))
1677
+ if (prefix === "" && own && !runsInApp) continue
1678
+ report(from, match.index)
1679
+ break
1680
+ }
1681
+ }
1682
+ return [...found.values()]
1683
+ }
1684
+
1070
1685
  /** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
1071
1686
  * up and never a workspace root, so the scan beside it stays inside one package. */
1072
1687
  function enclosingPackage(root: string): string | undefined {
@@ -1219,18 +1834,14 @@ function renameRetiredTypes(ts: typeof TS, file: string, text: string): string {
1219
1834
  /** The config files the loader reads, old layout included; a path field may live in any of them. */
1220
1835
  const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"]
1221
1836
 
1222
- interface ConfigEntry {
1837
+ interface ConfigEntry extends StaticConfigPath {
1223
1838
  /** `clientModule`, or `componentPipeline.entries.<name>`. */
1224
1839
  readonly field: string
1225
1840
  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
1841
  }
1231
1842
 
1232
1843
  /**
1233
- * The module paths a config exports as string literals: `clientModule` and the component pipeline's
1844
+ * The module paths a config exports, read statically: `clientModule` and the component pipeline's
1234
1845
  * `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
1235
1846
  */
1236
1847
  function configEntries(ts: typeof TS, file: string, text: string): ConfigEntry[] {
@@ -1246,17 +1857,11 @@ function configEntries(ts: typeof TS, file: string, text: string): ConfigEntry[]
1246
1857
  current = current.expression
1247
1858
  return current
1248
1859
  }
1860
+ const read = configPathReader(ts, sf)
1249
1861
  const out: ConfigEntry[] = []
1250
1862
  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
- })
1863
+ const path = read(node)
1864
+ if (path !== undefined) out.push({ ...path, field, role })
1260
1865
  }
1261
1866
  for (const statement of sf.statements) {
1262
1867
  if (
@@ -1386,7 +1991,7 @@ export async function migrateLayout(
1386
1991
  original.set(file, readFileSync(join(root, file), "utf8"))
1387
1992
  }
1388
1993
  const issues: LayoutMigrationIssue[] = []
1389
- const splits: Array<{ file: string; backend: string; moved: string[] }> = []
1994
+ const splits: Array<{ file: string; backend: string; moved: string[]; shared?: string }> = []
1390
1995
  /** The app after the migration: new path -> text. */
1391
1996
  const next: Files = new Map(original)
1392
1997
  /** Old path -> new path, for every file that moves. */
@@ -1409,13 +2014,25 @@ export async function migrateLayout(
1409
2014
  if (!ROUTE_FILE.test(file)) continue
1410
2015
  const backendFile = file.replace(ROUTE_FILE, ".backend.ts")
1411
2016
  const specifier = `./${posix.basename(backendFile)}`
2017
+ const sharedFile = file.replace(ROUTE_FILE, ".shared.ts")
2018
+ // A taken name leaves data both halves read copied into each, as reported.
2019
+ const splitOptions: SplitOptions =
2020
+ next.has(sharedFile) || existsSync(join(root, sharedFile))
2021
+ ? {}
2022
+ : { sharedFile: posix.basename(sharedFile) }
1412
2023
  let result: SplitResult
1413
2024
  if (/\.(?:svelte|vue)$/.test(file)) {
1414
2025
  const blocks = scriptBlocks(text)
1415
2026
  const block =
1416
2027
  blocks.find((b) => b.module) ?? blocks.find((b) => !b.setup && file.endsWith(".vue"))
1417
2028
  if (block === undefined) continue
1418
- const inner = splitRouteSource(ts, text.slice(block.start, block.end), "block.ts", specifier)
2029
+ const inner = splitRouteSource(
2030
+ ts,
2031
+ text.slice(block.start, block.end),
2032
+ "block.ts",
2033
+ specifier,
2034
+ splitOptions,
2035
+ )
1419
2036
  result = {
1420
2037
  ...inner,
1421
2038
  frontend: `${text.slice(0, block.start)}\n${inner.frontend}${text.slice(block.end)}`,
@@ -1433,7 +2050,7 @@ export async function migrateLayout(
1433
2050
  }
1434
2051
  continue
1435
2052
  } else {
1436
- result = splitRouteSource(ts, text, file, specifier)
2053
+ result = splitRouteSource(ts, text, file, specifier, splitOptions)
1437
2054
  }
1438
2055
  if (result.backend === undefined) continue
1439
2056
  next.set(file, result.frontend)
@@ -1441,7 +2058,12 @@ export async function migrateLayout(
1441
2058
  backendFile,
1442
2059
  mergeModules(ts, backendHalves.get(backendFile) ?? "", result.backend),
1443
2060
  )
1444
- splits.push({ file, backend: backendFile, moved: [...result.moved] })
2061
+ if (result.shared === undefined)
2062
+ splits.push({ file, backend: backendFile, moved: [...result.moved] })
2063
+ else {
2064
+ next.set(sharedFile, result.shared)
2065
+ splits.push({ file, backend: backendFile, moved: [...result.moved], shared: sharedFile })
2066
+ }
1445
2067
  for (const reason of result.issues) issues.push({ file, reason })
1446
2068
  }
1447
2069
  for (const [file, text] of backendHalves) {
@@ -1453,7 +2075,11 @@ export async function migrateLayout(
1453
2075
  for (const [from, to] of Object.entries(rootMoves)) if (next.has(from)) moveMap.set(from, to)
1454
2076
  // Assets (images, fonts) resolve too, so they move with the modules that import them.
1455
2077
  const known = new Set([...next.keys(), ...everyFile])
1456
- const resolver = createSpecifierResolver()
2078
+ const knownPaths = new Set([...known].map((file) => join(root, file)))
2079
+ // Route halves the split creates resolve before they are written.
2080
+ const resolver = createSpecifierResolver({
2081
+ moduleExists: (path) => knownPaths.has(path) || isFile(path),
2082
+ })
1457
2083
  const realRoot = (() => {
1458
2084
  try {
1459
2085
  return realpathSync(root)
@@ -1461,14 +2087,21 @@ export async function migrateLayout(
1461
2087
  return root
1462
2088
  }
1463
2089
  })()
2090
+ /** An app file as the migration names it, from a path the resolver found (real, or as planned). */
2091
+ const appFile = (path: string | undefined): string | undefined => {
2092
+ if (path === undefined) return undefined
2093
+ for (const base of [realRoot, root]) {
2094
+ const rel = toPosix(relative(base, path))
2095
+ if (!rel.startsWith("../") && !isAbsolute(rel)) return rel
2096
+ }
2097
+ return undefined
2098
+ }
1464
2099
  /** 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. */
2100
+ * one of its files, as the bundler reads it. */
1466
2101
  const viaAlias = (file: string, specifier: string): string | undefined => {
1467
2102
  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
2103
+ const rel = appFile(resolver(join(root, file), withoutQuery(specifier)))
2104
+ return rel === undefined || !known.has(rel) ? undefined : rel
1472
2105
  }
1473
2106
  const resolveAny = (file: string, specifier: string): string | undefined =>
1474
2107
  resolveIn(known, file, specifier) ?? viaAlias(file, specifier)
@@ -1485,7 +2118,7 @@ export async function migrateLayout(
1485
2118
  * scheme the bundler owns, the app's own package name, or a package installed at or above it. */
1486
2119
  const external = (specifier: string): boolean => {
1487
2120
  if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun") return true
1488
- const name = packageName(specifier.replace(/[?#].*$/, ""))
2121
+ const name = packageName(withoutQuery(specifier))
1489
2122
  if (builtinModules.includes(name) || name === ownName) return true
1490
2123
  let found = installed.get(name)
1491
2124
  if (found === undefined) {
@@ -1518,9 +2151,10 @@ export async function migrateLayout(
1518
2151
  const split = route === undefined ? undefined : splitRoutes.get(route)
1519
2152
  if (route === undefined || split === undefined) return undefined
1520
2153
  const inBase = (path: string): string => toPosix(relative(base, join(root, path)))
2154
+ const relativePath = backendSpecifier(specifier, inBase(route), inBase(split.backend), file)
1521
2155
  const respelled = specifier.startsWith(".")
1522
- ? backendSpecifier(specifier, inBase(route), inBase(split.backend), file)
1523
- : resolver.respell(join(base, file), specifier, join(root, split.backend))
2156
+ ? relativePath
2157
+ : (resolver.respell(join(base, file), specifier, join(root, split.backend)) ?? relativePath)
1524
2158
  return { backend: split.backend, moved: split.moved, specifier: respelled }
1525
2159
  }
1526
2160
  const redirectedFiles = new Set<string>()
@@ -1574,8 +2208,12 @@ export async function migrateLayout(
1574
2208
  configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
1575
2209
  ...entry,
1576
2210
  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),
2211
+ // A string is read from the app root, whichever config file holds it; a joined path from the
2212
+ // config's own folder.
2213
+ target:
2214
+ entry.base === "app"
2215
+ ? resolveAny("nifra.config.ts", entry.path)
2216
+ : resolveIn(known, file, entry.path.startsWith("../") ? entry.path : `./${entry.path}`),
1579
2217
  })),
1580
2218
  )
1581
2219
  const entryTargets = (role: ConfigEntry["role"]): Set<string> =>
@@ -1589,7 +2227,8 @@ export async function migrateLayout(
1589
2227
  const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])]
1590
2228
  const frontendRoots = [
1591
2229
  ...files.filter(
1592
- (f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend."),
2230
+ (f) =>
2231
+ f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.") && !isTestFile(f),
1593
2232
  ),
1594
2233
  ...browserEntries,
1595
2234
  ]
@@ -1645,7 +2284,9 @@ export async function migrateLayout(
1645
2284
  })
1646
2285
  }
1647
2286
  }
1648
- const zoneOf = (file: string): "frontend" | "backend" | "shared" =>
2287
+ /** With `zones`, the files that stay where they are and the side package.json gives them. */
2288
+ const inPlace = new Map<string, FolderSide>()
2289
+ const zoneOf = (file: string): FolderSide =>
1649
2290
  browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend"
1650
2291
  for (const file of api ? [] : files) {
1651
2292
  if (
@@ -1662,6 +2303,10 @@ export async function migrateLayout(
1662
2303
  if (ZONE_DIRS.has(top) || (!browser.has(file) && !server.has(file))) continue
1663
2304
  } else if (!browser.has(file) && !appServer.has(file)) continue
1664
2305
  const zone = zoneOf(file)
2306
+ if (DATA_FILE.test(file) && options.zones === true) {
2307
+ inPlace.set(file, zone)
2308
+ continue
2309
+ }
1665
2310
  if (DATA_FILE.test(file)) {
1666
2311
  const folder = file.includes("/") ? posix.dirname(file) : undefined
1667
2312
  issues.push({
@@ -1672,6 +2317,51 @@ export async function migrateLayout(
1672
2317
  }
1673
2318
  moveMap.set(file, `${zone}/${file}`)
1674
2319
  }
2320
+ // A folder whose reached files all move into one zone takes its other files along: a file nothing
2321
+ // imports at runtime (a component not used yet, an asset) has no zone of its own, and leaving it
2322
+ // behind splits the folder for every tool that lists it. Server built-ins never go to the browser.
2323
+ const zonesUnder = new Map<string, Set<string>>()
2324
+ for (const [from, to] of moveMap) {
2325
+ if (isTestFile(from)) continue
2326
+ for (let dir = posix.dirname(from); dir !== "."; dir = posix.dirname(dir)) {
2327
+ zonesUnder.set(dir, (zonesUnder.get(dir) ?? new Set<string>()).add(to.split("/")[0] ?? ""))
2328
+ }
2329
+ }
2330
+ for (const file of api ? [] : files) {
2331
+ if (moveMap.has(file) || browser.has(file) || server.has(file)) continue
2332
+ if (ZONE_DIRS.has(file.split("/")[0] ?? "") || inConfiguredFolder(file)) continue
2333
+ // A test and its snapshot follow their subject below.
2334
+ if (
2335
+ isTestFile(file) ||
2336
+ file.endsWith(".snap") ||
2337
+ file.endsWith(".d.ts") ||
2338
+ DATA_FILE.test(file)
2339
+ )
2340
+ continue
2341
+ let zones: ReadonlySet<string> | undefined
2342
+ for (
2343
+ let dir = posix.dirname(file);
2344
+ dir !== "." && zones === undefined;
2345
+ dir = posix.dirname(dir)
2346
+ ) {
2347
+ // A nested package is its own install, never part of the app's folder.
2348
+ if (existsSync(join(root, dir, "package.json"))) break
2349
+ zones = zonesUnder.get(dir)
2350
+ }
2351
+ const [zone] = zones?.size === 1 ? zones : []
2352
+ if (zone === undefined) continue
2353
+ if (zone !== "backend" && /from\s+["'](?:node:|bun:)/.test(next.get(file) ?? "")) continue
2354
+ moveMap.set(file, `${zone}/${file}`)
2355
+ }
2356
+ if (options.zones === true) {
2357
+ for (const [from, to] of moveMap) {
2358
+ const side = FOLDER_SIDES.find((side) => to === `${side}/${from}`)
2359
+ if (side === undefined) continue
2360
+ inPlace.set(from, side)
2361
+ moveMap.delete(from)
2362
+ }
2363
+ }
2364
+ const declared = zoneDeclarations(inPlace, files, (file) => next.get(file), inConfiguredFolder)
1675
2365
  const claimed = new Map<string, string>()
1676
2366
  for (const [from, to] of moveMap) {
1677
2367
  const holder = claimed.get(to) ?? (known.has(to) && !moveMap.has(to) ? to : undefined)
@@ -1685,32 +2375,120 @@ export async function migrateLayout(
1685
2375
  })
1686
2376
  moveMap.delete(from)
1687
2377
  }
1688
- for (const [from, to] of moveMap) {
1689
- if (to.startsWith("shared/") && /from\s+["'](?:node:|bun:)/.test(next.get(from) ?? "")) {
2378
+ const newPath = (file: string): string => moveMap.get(file) ?? file
2379
+ // A test beside its subject (`x.test.ts` by `x.ts`, or in a `__tests__/` under its folder) moves
2380
+ // with it, snapshots included, once the subject's place is settled; a test never decides a zone.
2381
+ const present = new Set(files)
2382
+ for (const test of files) {
2383
+ const stem = /^(.+?)\.(?:test|spec)\.[cm]?[jt]sx?$/.exec(posix.basename(test))?.[1]
2384
+ if (stem === undefined || moveMap.has(test) || !next.has(test)) continue
2385
+ const dir = posix.dirname(test)
2386
+ const nested = posix.basename(dir) === "__tests__"
2387
+ const home = nested ? posix.dirname(dir) : dir
2388
+ const subjects = SUBJECT_EXTENSIONS.map((ext) => posix.join(home, `${stem}${ext}`)).filter(
2389
+ (file) => present.has(file) && !isTestFile(file),
2390
+ )
2391
+ const subject = subjects.find((file) => moveMap.has(file))
2392
+ if (subject === undefined) continue
2393
+ if (new Set(subjects.map((file) => posix.dirname(newPath(file)))).size > 1) {
2394
+ issues.push({
2395
+ file: test,
2396
+ reason: `tests ${subjects.map((file) => `"${file}"`).join(" and ")}, which end up in different folders, so it stays here; move it by hand`,
2397
+ })
2398
+ continue
2399
+ }
2400
+ const to = posix.join(
2401
+ posix.dirname(newPath(subject)),
2402
+ nested ? "__tests__" : "",
2403
+ posix.basename(test),
2404
+ )
2405
+ const pairs: Array<readonly [string, string]> = [[test, to]]
2406
+ const snapshot = posix.join(dir, "__snapshots__", `${posix.basename(test)}.snap`)
2407
+ if (present.has(snapshot))
2408
+ pairs.push([
2409
+ snapshot,
2410
+ posix.join(posix.dirname(to), "__snapshots__", posix.basename(snapshot)),
2411
+ ])
2412
+ const taken = pairs.find(([, to]) => claimed.has(to) || (known.has(to) && !moveMap.has(to)))
2413
+ if (taken !== undefined) {
2414
+ issues.push({
2415
+ file: test,
2416
+ reason: `would follow "${subject}" to "${taken[1]}", which already exists or another file moves to; move it by hand`,
2417
+ })
2418
+ continue
2419
+ }
2420
+ for (const [from, to] of pairs) {
2421
+ moveMap.set(from, to)
2422
+ claimed.set(to, from)
2423
+ }
2424
+ }
2425
+ for (const [from, to] of [
2426
+ ...moveMap,
2427
+ ...[...inPlace].map(([file, side]) => [file, side] as const),
2428
+ ]) {
2429
+ // A test is never bundled, so its own server imports decide nothing.
2430
+ if (isTestFile(from)) continue
2431
+ if (
2432
+ (to === "shared" || to.startsWith("shared/")) &&
2433
+ /from\s+["'](?:node:|bun:)/.test(next.get(from) ?? "")
2434
+ ) {
1690
2435
  issues.push({
1691
2436
  file: from,
1692
- reason: `both halves import it at runtime, but it uses a server built-in; split it before moving to ${to}`,
2437
+ reason: `both halves import it at runtime, but it uses a server built-in; split it before ${to === "shared" ? "zoning it shared" : `moving to ${to}`}`,
1693
2438
  })
1694
2439
  }
1695
2440
  }
1696
2441
 
1697
2442
  // 5. Rewrite imports to the new locations.
1698
- const newPath = (file: string): string => moveMap.get(file) ?? file
1699
2443
  const final: Files = new Map()
1700
2444
  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
2445
+ // The app as the migration leaves it, where every rewritten specifier must reach its target.
2446
+ const planned = new Set(
2447
+ [...known].filter((file) => next.has(file) || !original.has(file)).map(newPath),
2448
+ )
2449
+ const plannedResolver = createSpecifierResolver({
2450
+ moduleExists: (path) => {
2451
+ const file = appFile(path)
2452
+ if (file !== undefined && (planned.has(file) || known.has(file))) return planned.has(file)
2453
+ return isFile(path)
2454
+ },
2455
+ })
2456
+ /** Whether `specifier`, written in `from` after the moves, loads `target` there. */
2457
+ const reaches = (from: string, specifier: string, target: string): boolean =>
2458
+ specifier.startsWith(".")
2459
+ ? resolveIn(planned, from, specifier) === target
2460
+ : appFile(plannedResolver(join(root, from), withoutQuery(specifier))) === target
2461
+ const unreached = new Set<string>()
2462
+ const unreachable = (file: string, specifier: string, target: string, from: string): void => {
2463
+ if (unreached.has(`${file}\0${specifier}`)) return
2464
+ unreached.add(`${file}\0${specifier}`)
2465
+ issues.push({
2466
+ file,
2467
+ reason: `"${specifier}" names "${target}", which moves to "${newPath(target)}", and no specifier the migration can write from "${from}" reaches it there; point it there by hand`,
2468
+ })
2469
+ }
2470
+ /**
2471
+ * How `from` names `target` after the moves, for the alias `specifier` that `file` writes: as written
2472
+ * while it still reaches it, through the same alias when one of its targets covers the new place,
2473
+ * else as a relative path. `undefined` leaves it as written.
2474
+ */
2475
+ const aliasTo = (
2476
+ file: string,
2477
+ specifier: string,
2478
+ target: string,
2479
+ from = newPath(file),
2480
+ ): string | undefined => {
2481
+ const to = newPath(target)
2482
+ if (to === target && from === file) return undefined
2483
+ const written = withoutQuery(specifier)
2484
+ if (reaches(from, written, to)) return undefined
2485
+ const respelled = resolver.respell(join(root, file), written, join(root, to))
2486
+ if (respelled !== undefined && reaches(from, respelled, to))
2487
+ return `${respelled}${specifier.slice(written.length)}`
2488
+ const relativePath = respecify(specifier, target, from, to)
2489
+ if (reaches(from, relativePath, to)) return relativePath
2490
+ unreachable(file, specifier, target, from)
2491
+ return undefined
1714
2492
  }
1715
2493
  for (const [file, text] of next) {
1716
2494
  const imports = rewriteImports(ts, file, text, (specifier) => {
@@ -1719,9 +2497,12 @@ export async function migrateLayout(
1719
2497
  const target = resolveAny(file, specifier)
1720
2498
  // Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
1721
2499
  if (target === undefined) return rebase(specifier, file, newPath(file))
1722
- if (!specifier.startsWith(".")) return respellAlias(file, specifier, target)
2500
+ if (!specifier.startsWith(".")) return aliasTo(file, specifier, target)
1723
2501
  if (newPath(target) === target && newPath(file) === file) return undefined
1724
- return respecify(specifier, target, newPath(file), newPath(target))
2502
+ const relativePath = respecify(specifier, target, newPath(file), newPath(target))
2503
+ if (!reaches(newPath(file), relativePath, newPath(target)))
2504
+ unreachable(file, specifier, target, newPath(file))
2505
+ return relativePath
1725
2506
  })
1726
2507
  const updated = renameRetiredTypes(ts, file, imports)
1727
2508
  final.set(newPath(file), updated)
@@ -1732,19 +2513,21 @@ export async function migrateLayout(
1732
2513
  rewritten.push(file)
1733
2514
  }
1734
2515
 
1735
- // A config path names its module from the app root, so it follows the module, never the config file.
2516
+ // A config string names its module from the app root, so it follows the module, never the config
2517
+ // file; a path joined onto the config's folder is re-pointed with the module's own paths below.
1736
2518
  for (const configFile of CONFIG_FILES) {
1737
2519
  const at = newPath(configFile)
1738
2520
  const text = final.get(at)
1739
2521
  if (text === undefined || !next.has(configFile)) continue
1740
2522
  const edits: Array<{ start: number; end: number; text: string }> = []
1741
2523
  for (const entry of configEntries(ts, at, text)) {
1742
- const target = resolveAny("nifra.config.ts", entry.value)
2524
+ if (entry.span === undefined) continue
2525
+ const target = resolveAny("nifra.config.ts", entry.path)
1743
2526
  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 })
2527
+ const value = entry.path.startsWith(".")
2528
+ ? respecify(entry.path, target, "nifra.config.ts", newPath(target))
2529
+ : aliasTo(configFile, entry.path, target, "nifra.config.ts")
2530
+ if (value !== undefined) edits.push({ start: entry.span[0], end: entry.span[1], text: value })
1748
2531
  }
1749
2532
  if (edits.length === 0) continue
1750
2533
  let updated = text
@@ -1774,11 +2557,33 @@ export async function migrateLayout(
1774
2557
  final.set(to, result.text)
1775
2558
  if (result.unfollowed !== undefined) unfollowed.set(to, result.unfollowed)
1776
2559
  }
2560
+ // A module that stays still names files from its own folder (a config's `clientModule` joined onto
2561
+ // `import.meta.dir`): each that moved is re-pointed.
2562
+ const landed = new Set(moveMap.values())
2563
+ for (const [file, text] of final) {
2564
+ if (landed.has(file) || !/\.[cm]?[jt]sx?$/.test(file) || !SELF_LOCATION.test(text)) continue
2565
+ const result = rebaseOwnPaths(ts, file, file, text, (target) =>
2566
+ known.has(target) ? newPath(target) : target,
2567
+ )
2568
+ if (result.text === text) continue
2569
+ final.set(file, result.text)
2570
+ if (original.has(file) && !rewritten.includes(file)) rewritten.push(file)
2571
+ }
1777
2572
 
1778
2573
  // A path in a string or comment is not an import, so nothing above rewrote it: a build script that
1779
2574
  // writes `data/x.json` would keep writing to the old place, and so would a deploy script or
1780
2575
  // `package.json` beside the code.
1781
2576
  const settled = new Set([...known].map(newPath))
2577
+ // A new backend half starts where its route was, so it keeps that route's place in each program.
2578
+ const splitFrom = new Map(
2579
+ splits
2580
+ .filter((split) => !original.has(split.backend))
2581
+ .map((split) => [split.backend, split.file]),
2582
+ )
2583
+ const layout = [...known]
2584
+ .filter((file) => next.has(file) || !original.has(file))
2585
+ .map((file) => ({ from: splitFrom.get(file) ?? file, to: newPath(file) }))
2586
+ const tsconfigs = followTsconfigs(ts, root, moveMap, layout)
1782
2587
  const scanned: Array<readonly [string, string]> = [...final]
1783
2588
  for (const file of everyFile) {
1784
2589
  if (
@@ -1786,7 +2591,10 @@ export async function migrateLayout(
1786
2591
  TOOLING_FILE.test(file) &&
1787
2592
  statSync(join(root, file)).size <= 256_000
1788
2593
  )
1789
- scanned.push([newPath(file), readFileSync(join(root, file), "utf8")])
2594
+ scanned.push([
2595
+ newPath(file),
2596
+ tsconfigs.files.get(file)?.text ?? readFileSync(join(root, file), "utf8"),
2597
+ ])
1790
2598
  }
1791
2599
  for (const [file, text] of scanned) {
1792
2600
  let live: ReadonlyArray<readonly [number, number]> | undefined
@@ -1845,6 +2653,36 @@ export async function migrateLayout(
1845
2653
  }
1846
2654
  }
1847
2655
 
2656
+ // A workspace sibling is not the migration's to edit either: each file that names a moved app file,
2657
+ // by import or by path, is reported with the line and both paths.
2658
+ const appName = posix.basename(toPosix(root))
2659
+ const mentionsApp = new RegExp(`(?:^|[/'"\`])${escapeRegExp(appName)}(?:[/'"\`]|$)`, "m")
2660
+ for (const sibling of moveMap.size === 0 ? [] : (workspaceSiblings(root) ?? [])) {
2661
+ for (const file of listFiles(sibling)) {
2662
+ if (!/\.[cm]?[jt]sx?$/.test(file) && !TOOLING_FILE.test(file)) continue
2663
+ if (statSync(join(sibling, file)).size > 256_000) continue
2664
+ const text = readFileSync(join(sibling, file), "utf8")
2665
+ if (!mentionsApp.test(text) && (ownName === undefined || !text.includes(`${ownName}/`)))
2666
+ continue
2667
+ const at = toPosix(relative(root, join(sibling, file)))
2668
+ for (const { from, to, line } of siblingReferences(
2669
+ ts,
2670
+ root,
2671
+ sibling,
2672
+ file,
2673
+ text,
2674
+ moveMap,
2675
+ known,
2676
+ ownName,
2677
+ )) {
2678
+ issues.push({
2679
+ file: at,
2680
+ reason: `names "${from}" on line ${line}, and it moves to "${to}" - it is outside the app, so the migration does not rewrite it`,
2681
+ })
2682
+ }
2683
+ }
2684
+ }
2685
+
1848
2686
  // Paths a module finds at run time are not imports either: one built from the module's own location
1849
2687
  // now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
1850
2688
  const leaving = new Map<string, Array<{ from: string; to: string }>>()
@@ -1878,14 +2716,46 @@ export async function migrateLayout(
1878
2716
  })
1879
2717
  }
1880
2718
 
1881
- // The server refuses data a loader or action returns without an output schema; only the author
1882
- // knows which fields the browser may see.
2719
+ // The server refuses data a loader or action returns without an output schema. Each half the split
2720
+ // wrote gets a draft from its return type, read from the layout as planned so the dry run shows
2721
+ // what --write adds; only the author knows which fields the browser may see, so it is a draft.
2722
+ const halves = [...new Set(splits.map((split) => split.backend))].filter((file) =>
2723
+ final.has(file),
2724
+ )
2725
+ const unwritten = new Map<string, string>()
2726
+ for (const [file, text] of final) if (original.get(file) !== text) unwritten.set(file, text)
2727
+ const schemaPlan =
2728
+ halves.length === 0
2729
+ ? undefined
2730
+ : await planOutputSchemas(root, halves, (file) => final.get(file), {
2731
+ overlay: unwritten,
2732
+ // What NF-C030 would flag: a schema for data the function returns, not for `return null`.
2733
+ draft: (file, schema) =>
2734
+ missingOutputSchemas(final.get(file) ?? "").some((found) => found.schema === schema),
2735
+ })
2736
+ for (const [file, text] of schemaPlan?.edits ?? []) final.set(file, text)
2737
+ const schemas = (schemaPlan?.entries ?? []).filter((entry) => entry.status === "missing")
2738
+ const followUps: LayoutMigrationFollowUp[] = []
1883
2739
  for (const [file, text] of final) {
1884
2740
  if (!file.startsWith("routes/") || !/\.backend\.[cm]?[jt]s$/.test(file)) continue
1885
2741
  for (const { name, schema } of missingOutputSchemas(text)) {
2742
+ const why = schemaPlan?.entries.find(
2743
+ (entry) => entry.file === file && entry.schema === schema && entry.reason !== undefined,
2744
+ )?.reason
2745
+ const missing = `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request`
2746
+ // A half this run did not write keeps the schema it had: drafting one is the next command, not a
2747
+ // decision the migration left open.
2748
+ if (why === undefined && (schemaPlan === undefined || schemaPlan.warnings.length === 0)) {
2749
+ followUps.push({
2750
+ file,
2751
+ reason: `${missing}; draft it from the ${name}'s return type`,
2752
+ command: "nifra schemas --write",
2753
+ })
2754
+ continue
2755
+ }
1886
2756
  issues.push({
1887
2757
  file,
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`,
2758
+ reason: `${missing}. Declare what the browser may see: export const ${schema} = t.object({ ... }); no draft was written: ${why ?? schemaPlan?.warnings.join("; ")}`,
1889
2759
  })
1890
2760
  }
1891
2761
  }
@@ -1893,12 +2763,11 @@ export async function migrateLayout(
1893
2763
  // Tooling that lists folders or globs, not files: the import rewrite above never sees it.
1894
2764
  const tooling: Array<{ file: string; change: string }> = []
1895
2765
  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(", ")}` })
2766
+ for (const [file, { text, changes }] of tsconfigs.files) {
2767
+ toolingWrites.set(file, text)
2768
+ for (const change of changes) tooling.push({ file, change })
1901
2769
  }
2770
+ issues.push(...tsconfigs.issues)
1902
2771
  for (const file of everyFile) {
1903
2772
  if (!TAILWIND_CONFIG.test(file)) continue
1904
2773
  const reason = staleContentGlobs(ts, readFileSync(join(root, file), "utf8"), moves)
@@ -1909,6 +2778,33 @@ export async function migrateLayout(
1909
2778
  toolingWrites.set(".gitignore", gitignore)
1910
2779
  tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" })
1911
2780
  }
2781
+ // A root backend.ts was served at /api by default until 5.0, which mounts it only where apiPrefix says.
2782
+ if (!api && rootMoves["backend.ts"] !== undefined) {
2783
+ const mount = apiPrefixFix({
2784
+ framework: final.get(FRAMEWORK_FILE),
2785
+ config: final.get(CONFIG_FILE),
2786
+ })
2787
+ for (const [file, text] of [
2788
+ [FRAMEWORK_FILE, mount.framework],
2789
+ [CONFIG_FILE, mount.config],
2790
+ ] as const) {
2791
+ if (text === undefined) continue
2792
+ final.set(file, text)
2793
+ tooling.push({
2794
+ file,
2795
+ change: 'export const apiPrefix = "/api", the mount 4.x made by default',
2796
+ })
2797
+ }
2798
+ }
2799
+ if (declared.size > 0) {
2800
+ const zoned = declareZones(ts, root, declared)
2801
+ if (zoned.issue !== undefined) issues.push({ file: "package.json", reason: zoned.issue })
2802
+ if (zoned.text !== undefined) {
2803
+ toolingWrites.set("package.json", zoned.text)
2804
+ for (const [path, side] of declared)
2805
+ tooling.push({ file: "package.json", change: `"nifra.zones" lists "${path}" as ${side}` })
2806
+ }
2807
+ }
1912
2808
  // `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
1913
2809
  const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {}
1914
2810
  if (kind.issue !== undefined) issues.push({ file: "package.json", reason: kind.issue })
@@ -1943,14 +2839,57 @@ export async function migrateLayout(
1943
2839
  ok: issues.length === 0,
1944
2840
  write: options.write === true,
1945
2841
  mode,
2842
+ followUps,
1946
2843
  splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
1947
2844
  moves,
1948
2845
  rewritten: rewritten.sort(),
1949
2846
  tooling,
2847
+ schemas: schemas.map((entry) =>
2848
+ options.write === true && entry.reason === undefined
2849
+ ? { ...entry, status: "generated" }
2850
+ : entry,
2851
+ ),
1950
2852
  issues,
1951
2853
  }
1952
2854
  }
1953
2855
 
2856
+ /**
2857
+ * Split one route file in place: its server-only exports, and the code only they use, move into its
2858
+ * `x.backend.ts`, created or merged into. Returns the files written, app-relative; nothing for a file
2859
+ * that exports nothing for the server. Refuses, naming why, a split that would need a decision.
2860
+ */
2861
+ export async function splitRouteFile(
2862
+ appRoot: string,
2863
+ file: string,
2864
+ options: Pick<LayoutMigrationOptions, "typescript"> = {},
2865
+ ): Promise<readonly string[]> {
2866
+ if (!/\.[jt]sx$/.test(file)) return []
2867
+ const ts = options.typescript ?? (await importTypeScript())
2868
+ if (ts === undefined)
2869
+ throw new Error("[nifra] splitting a route needs TypeScript: bun add -d typescript")
2870
+ const root = resolve(appRoot)
2871
+ const backendFile = file.replace(ROUTE_FILE, ".backend.ts")
2872
+ const sharedFile = file.replace(ROUTE_FILE, ".shared.ts")
2873
+ const result = splitRouteSource(
2874
+ ts,
2875
+ readFileSync(join(root, file), "utf8"),
2876
+ file,
2877
+ `./${posix.basename(backendFile)}`,
2878
+ existsSync(join(root, sharedFile)) ? {} : { sharedFile: posix.basename(sharedFile) },
2879
+ )
2880
+ if (result.backend === undefined) return []
2881
+ if (result.issues.length > 0) {
2882
+ throw new Error(`[nifra] ${file} was not split: ${result.issues.join("; ")}`)
2883
+ }
2884
+ const backendPath = join(root, backendFile)
2885
+ const existing = existsSync(backendPath) ? readFileSync(backendPath, "utf8") : ""
2886
+ writeFileSync(join(root, file), result.frontend)
2887
+ writeFileSync(backendPath, mergeModules(ts, existing, result.backend))
2888
+ if (result.shared === undefined) return [file, backendFile]
2889
+ writeFileSync(join(root, sharedFile), result.shared)
2890
+ return [file, backendFile, sharedFile]
2891
+ }
2892
+
1954
2893
  /** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
1955
2894
  function appZones(root: string): ZoneConfig {
1956
2895
  const manifest = join(root, "package.json")
@@ -1968,53 +2907,271 @@ function appZones(root: string): ZoneConfig {
1968
2907
  return zones === undefined ? {} : parseZoneConfig(zones, manifest)
1969
2908
  }
1970
2909
 
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)
2910
+ const matchers = new Map<string, (file: string) => boolean>()
2911
+
2912
+ /** Whether a tsconfig `include` or `exclude` entry takes in `file` (app-relative), the way tsc reads it. */
2913
+ function matches(pattern: string, file: string): boolean {
2914
+ let match = matchers.get(pattern)
2915
+ if (match === undefined) {
2916
+ let spec = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "")
2917
+ if (spec === "" || spec === ".") match = () => true
2918
+ else if (!/[*?]/.test(spec)) {
2919
+ const folder = `${spec}/`
2920
+ match = (path) => path === spec || path.startsWith(folder)
2921
+ } else {
2922
+ if (spec.endsWith("**")) spec = `${spec}/*`
2923
+ else if (!/[*?.][^/]*$/.test(spec.slice(spec.lastIndexOf("/") + 1))) spec = `${spec}/**/*`
2924
+ const glob = new Bun.Glob(spec)
2925
+ match = (path) => glob.match(path)
2926
+ }
2927
+ matchers.set(pattern, match)
2928
+ }
2929
+ return match(file)
2930
+ }
2931
+
2932
+ const TSCONFIG = /^tsconfig(?:\.[\w-]+)?\.json$/
2933
+ const TS_FILE = /\.(?:[cm]?ts|tsx)$/
2934
+
2935
+ interface ProgramLists {
2936
+ readonly include: readonly string[]
2937
+ readonly exclude: readonly string[]
2938
+ readonly files: readonly string[]
2939
+ }
2940
+
2941
+ const inProgram = (lists: ProgramLists, file: string): boolean =>
2942
+ lists.files.includes(file) ||
2943
+ (lists.include.some((pattern) => matches(pattern, file)) &&
2944
+ !lists.exclude.some((pattern) => matches(pattern, file)))
2945
+
2946
+ /** `pattern` under the zone folder `to` landed in, when that reaches `to`; else `to` itself. */
2947
+ function twinEntry(pattern: string | undefined, to: string): string {
2948
+ const slash = to.indexOf("/")
2949
+ if (pattern === undefined || slash === -1) return to
2950
+ const bare = pattern.replace(/^\.\//, "")
2951
+ const twin = `${to.slice(0, slash)}/${bare}`
2952
+ if (!matches(twin, to)) return to
2953
+ return bare === pattern ? twin : `./${twin}`
1979
2954
  }
1980
2955
 
1981
2956
  /**
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.
2957
+ * Keep every TypeScript file in, or out of, each app-root `tsconfig*.json` program as it was. An entry
2958
+ * naming a moved file follows it; a folder or glob that took a moved file in gains its twin under the
2959
+ * zone folder (`src` -> `frontend/src`), and one that kept a file out does the same in `exclude`. So a
2960
+ * test run under its own config's types, or a folder left out on purpose, stays out after the move,
2961
+ * and code the config checked stays checked. An `include` or `exclude` a config inherits is not edited.
1985
2962
  */
1986
- function coverMovedFiles(
2963
+ function followTsconfigs(
1987
2964
  ts: typeof TS,
1988
2965
  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))
2966
+ moves: ReadonlyMap<string, string>,
2967
+ layout: ReadonlyArray<{ readonly from: string; readonly to: string }>,
2968
+ ): {
2969
+ readonly files: Map<string, { readonly text: string; readonly changes: readonly string[] }>
2970
+ readonly issues: LayoutMigrationIssue[]
2971
+ } {
2972
+ const files = new Map<string, { readonly text: string; readonly changes: readonly string[] }>()
2973
+ const issues: LayoutMigrationIssue[] = []
2974
+ const typescript = layout.filter(({ to }) => TS_FILE.test(to))
2975
+ const names = readdirSync(root)
2976
+ .filter((name) => TSCONFIG.test(name) && isFile(join(root, name)))
2977
+ .sort(codeUnitOrder)
2978
+ // Each config's lists before and after the move, for the configs that extend it.
2979
+ const settled = new Map<string, { readonly before: ProgramLists; readonly after: ProgramLists }>()
2980
+ const pending = [...names]
2981
+ for (let progress = true; progress && pending.length > 0; ) {
2982
+ progress = false
2983
+ for (const name of [...pending]) {
2984
+ const text = readFileSync(join(root, name), "utf8")
2985
+ const source = ts.parseJsonText(name, text)
2986
+ const top = source.statements[0]?.expression
2987
+ if (top === undefined || !ts.isObjectLiteralExpression(top)) {
2988
+ pending.splice(pending.indexOf(name), 1)
2989
+ progress = true
2990
+ if (moves.size > 0)
2991
+ issues.push({
2992
+ file: name,
2993
+ reason: "is not a JSON object; keep the moved files in its include and exclude by hand",
2994
+ })
2995
+ continue
2996
+ }
2997
+ const base = jsonProperty(ts, top, "extends")?.initializer
2998
+ let parent: string | undefined
2999
+ if (base !== undefined && ts.isStringLiteral(base) && base.text.startsWith(".")) {
3000
+ const path = posix.normalize(base.text)
3001
+ const file = path.endsWith(".json") ? path : `${path}.json`
3002
+ if (names.includes(file) && file !== name) parent = file
3003
+ }
3004
+ if (parent !== undefined && pending.includes(parent)) continue
3005
+ pending.splice(pending.indexOf(name), 1)
3006
+ progress = true
3007
+ const inherited = parent === undefined ? undefined : settled.get(parent)
3008
+ const extendsOther = base !== undefined && inherited === undefined
3009
+
3010
+ const edits: Array<{ start: number; end: number; text: string }> = []
3011
+ const own = new Map<string, { before: string[]; after: string[] }>()
3012
+ let malformed: string | undefined
3013
+ for (const key of ["include", "exclude", "files"]) {
3014
+ const array = jsonProperty(ts, top, key)?.initializer
3015
+ if (array === undefined) continue
3016
+ if (!ts.isArrayLiteralExpression(array)) {
3017
+ malformed = key
3018
+ continue
3019
+ }
3020
+ const lists: { before: string[]; after: string[] } = { before: [], after: [] }
3021
+ for (const element of array.elements) {
3022
+ if (!ts.isStringLiteral(element)) continue
3023
+ const to = moves.get(posix.normalize(element.text))
3024
+ lists.before.push(element.text)
3025
+ if (to === undefined) {
3026
+ lists.after.push(element.text)
3027
+ continue
3028
+ }
3029
+ const spelled = element.text.startsWith("./") ? `./${to}` : to
3030
+ lists.after.push(spelled)
3031
+ edits.push({
3032
+ start: element.getStart(source) + 1,
3033
+ end: element.getEnd() - 1,
3034
+ text: spelled,
3035
+ })
3036
+ }
3037
+ own.set(key, lists)
3038
+ }
3039
+ let updated = text
3040
+ for (const edit of edits.sort((a, b) => b.start - a.start))
3041
+ updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end)
3042
+ const changes: string[] = []
3043
+ if (edits.length > 0) changes.push(`${edits.length} file path(s) follow their moved files`)
3044
+
3045
+ const list = (key: "include" | "exclude", when: "before" | "after"): string[] | undefined => {
3046
+ const mine = own.get(key)
3047
+ if (mine !== undefined) return mine[when]
3048
+ if (inherited !== undefined) return [...inherited[when][key]]
3049
+ if (extendsOther) return undefined
3050
+ // tsc's default: everything below the config, unless `files` lists the program.
3051
+ return key === "exclude" ? [] : own.has("files") ? [] : ["**/*"]
3052
+ }
3053
+ const filesOf = (when: "before" | "after"): string[] =>
3054
+ (own.get("files")?.[when] ?? []).map((entry) => posix.normalize(entry))
3055
+ const beforeInclude = list("include", "before")
3056
+ const beforeExclude = list("exclude", "before")
3057
+ const afterInclude = list("include", "after")
3058
+ const afterExclude = list("exclude", "after")
3059
+ if (
3060
+ malformed !== undefined ||
3061
+ beforeInclude === undefined ||
3062
+ beforeExclude === undefined ||
3063
+ afterInclude === undefined ||
3064
+ afterExclude === undefined
3065
+ ) {
3066
+ if (malformed !== undefined && moves.size > 0)
3067
+ issues.push({
3068
+ file: name,
3069
+ reason: `"${malformed}" is not an array; keep the moved files in its program by hand`,
3070
+ })
3071
+ if (edits.length > 0) files.set(name, { text: updated, changes })
3072
+ continue
3073
+ }
3074
+ const before: ProgramLists = {
3075
+ include: beforeInclude,
3076
+ exclude: beforeExclude,
3077
+ files: filesOf("before"),
3078
+ }
3079
+ const after = { include: afterInclude, exclude: afterExclude, files: filesOf("after") }
3080
+ const added: { include: string[]; exclude: string[] } = { include: [], exclude: [] }
3081
+ let stranded: { readonly from: string; readonly to: string; readonly key: string } | undefined
3082
+ let strandedCount = 0
3083
+ const quiet = new Map<string, boolean>()
3084
+ // The whole zone folder is the entry that keeps covering code added there later, but only when
3085
+ // it takes in no file the config left out.
3086
+ const takesInOnlyChecked = (folder: string): boolean => {
3087
+ let safe = quiet.get(folder)
3088
+ if (safe === undefined) {
3089
+ const wider = { ...after, include: [...after.include, folder] }
3090
+ safe = typescript.every(
3091
+ (file) =>
3092
+ !matches(folder, file.to) ||
3093
+ inProgram(before, file.from) ||
3094
+ !inProgram(wider, file.to),
3095
+ )
3096
+ quiet.set(folder, safe)
3097
+ }
3098
+ return safe
3099
+ }
3100
+ for (const { from, to } of typescript) {
3101
+ if (!inProgram(before, from) || inProgram(after, to)) continue
3102
+ if (after.include.some((pattern) => matches(pattern, to))) {
3103
+ // Its new place sits under an `exclude` that did not hold the old one.
3104
+ stranded ??= { from, to, key: "exclude" }
3105
+ strandedCount++
3106
+ continue
3107
+ }
3108
+ const zone = to.includes("/") ? to.slice(0, to.indexOf("/")) : undefined
3109
+ const entry =
3110
+ zone !== undefined && takesInOnlyChecked(zone)
3111
+ ? zone
3112
+ : twinEntry(
3113
+ before.include.find((pattern) => matches(pattern, from)),
3114
+ to,
3115
+ )
3116
+ added.include.push(entry)
3117
+ after.include.push(entry)
3118
+ }
3119
+ for (const { from, to } of typescript) {
3120
+ if (inProgram(before, from) || !inProgram(after, to)) continue
3121
+ let entry = twinEntry(
3122
+ before.exclude.find((pattern) => matches(pattern, from)),
3123
+ to,
3124
+ )
3125
+ // A twin that would also drop a file the config checked is too wide: name the file.
3126
+ if (
3127
+ entry !== to &&
3128
+ typescript.some((other) => matches(entry, other.to) && inProgram(before, other.from))
3129
+ )
3130
+ entry = to
3131
+ added.exclude.push(entry)
3132
+ after.exclude.push(entry)
3133
+ }
3134
+ for (const key of ["include", "exclude"] as const) {
3135
+ const entries = [...new Set(added[key])].sort(codeUnitOrder)
3136
+ if (entries.length === 0) continue
3137
+ const reparsed = ts.parseJsonText(name, updated)
3138
+ const object = reparsed.statements[0]?.expression
3139
+ const array =
3140
+ object !== undefined && ts.isObjectLiteralExpression(object)
3141
+ ? jsonProperty(ts, object, key)?.initializer
3142
+ : undefined
3143
+ if (array === undefined || !ts.isArrayLiteralExpression(array)) {
3144
+ stranded ??= { from: "", to: entries[0] ?? "", key }
3145
+ strandedCount += entries.length
3146
+ continue
3147
+ }
3148
+ updated = appendToArray(reparsed, updated, array, entries)
3149
+ changes.push(`${key} += ${entries.join(", ")}`)
3150
+ }
3151
+ if (stranded !== undefined)
3152
+ issues.push({
3153
+ file: name,
3154
+ reason: `cannot keep ${strandedCount} moved file(s) in or out of its program as before (first: "${stranded.to}"); its "${stranded.key}" is inherited or would hide them, so set it by hand`,
3155
+ })
3156
+ settled.set(name, { before, after })
3157
+ if (changes.length > 0) files.set(name, { text: updated, changes })
3158
+ }
3159
+ }
3160
+ return { files, issues }
3161
+ }
3162
+
3163
+ /** `text` with `entries` appended to `array`: one per line in a multi-line array, else inline. */
3164
+ function appendToArray(
3165
+ source: TS.JsonSourceFile,
3166
+ text: string,
3167
+ array: TS.ArrayLiteralExpression,
3168
+ entries: readonly string[],
3169
+ ): string {
3170
+ const quoted = entries.map((entry) => JSON.stringify(entry))
2014
3171
  const last = array.elements[array.elements.length - 1]
2015
3172
  if (last === undefined) {
2016
3173
  const at = array.getStart(source) + 1
2017
- return { text: `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`, added }
3174
+ return `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`
2018
3175
  }
2019
3176
  let at = last.end
2020
3177
  const trailing = /^\s*,/.exec(text.slice(at))
@@ -2031,15 +3188,12 @@ function coverMovedFiles(
2031
3188
  trailing !== null
2032
3189
  ? text.slice(0, eol)
2033
3190
  : `${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,
2038
- }
3191
+ const lines = quoted.join(`,\n${indent}`)
3192
+ return `${head}\n${indent}${lines}${trailing !== null ? "," : ""}${text.slice(eol)}`
2039
3193
  }
2040
3194
  }
2041
3195
  const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`
2042
- return { text: `${text.slice(0, at)}${insert}${text.slice(at)}`, added }
3196
+ return `${text.slice(0, at)}${insert}${text.slice(at)}`
2043
3197
  }
2044
3198
 
2045
3199
  const jsonProperty = (
@@ -2072,6 +3226,111 @@ function insertProperty(
2072
3226
  return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`
2073
3227
  }
2074
3228
 
3229
+ /**
3230
+ * The `nifra.zones` entries that keep `inPlace` files where they are: for each, the widest folder
3231
+ * above it whose zoned files all take its side (a nested package stops the climb), else the file
3232
+ * itself. A file another side's suffix or a server built-in claims counts against a folder.
3233
+ */
3234
+ function zoneDeclarations(
3235
+ inPlace: ReadonlyMap<string, FolderSide>,
3236
+ files: readonly string[],
3237
+ text: (file: string) => string | undefined,
3238
+ configured: (file: string) => boolean,
3239
+ ): Map<string, FolderSide> {
3240
+ const sides = new Map<string, Set<string>>()
3241
+ const packages = new Set<string>()
3242
+ for (const file of files) {
3243
+ const top = file.split("/")[0] ?? ""
3244
+ if (!file.includes("/") || ZONE_DIRS.has(top)) continue
3245
+ if (posix.basename(file) === "package.json") {
3246
+ for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir)) packages.add(dir)
3247
+ continue
3248
+ }
3249
+ // A configured path is more specific than any folder above it, so it never counts against one.
3250
+ if (isTestFile(file) || file.endsWith(".d.ts") || configured(file)) continue
3251
+ const suffix = /\.(frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.exec(file)?.[1]
3252
+ const side =
3253
+ inPlace.get(file) ??
3254
+ (suffix === "fn" ? "backend" : suffix) ??
3255
+ (SOURCE_FILE.test(file) && /from\s+["'](?:node:|bun:)/.test(text(file) ?? "")
3256
+ ? "backend"
3257
+ : undefined)
3258
+ if (side === undefined) continue
3259
+ for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir))
3260
+ sides.set(dir, (sides.get(dir) ?? new Set<string>()).add(side))
3261
+ }
3262
+ const declared = new Map<string, FolderSide>()
3263
+ for (const [file, side] of [...inPlace].sort(([a], [b]) => codeUnitOrder(a, b))) {
3264
+ const parts = file.split("/")
3265
+ let path = file
3266
+ for (let depth = 1; depth < parts.length; depth++) {
3267
+ const dir = parts.slice(0, depth).join("/")
3268
+ const found = sides.get(dir)
3269
+ if (!packages.has(dir) && found?.size === 1 && found.has(side)) {
3270
+ path = dir
3271
+ break
3272
+ }
3273
+ }
3274
+ declared.set(path, side)
3275
+ }
3276
+ return declared
3277
+ }
3278
+
3279
+ /** The app's package.json with `paths` added to `"nifra": { "zones": ... }`, editing only where they go. */
3280
+ function declareZones(
3281
+ ts: typeof TS,
3282
+ root: string,
3283
+ paths: ReadonlyMap<string, FolderSide>,
3284
+ ): { text?: string; issue?: string } {
3285
+ const listed = (side: FolderSide): string[] =>
3286
+ [...paths].flatMap(([path, of]) => (of === side ? [path] : []))
3287
+ const setting = `{ ${FOLDER_SIDES.filter((side) => listed(side).length > 0)
3288
+ .map((side) => `"${side}": ${JSON.stringify(listed(side))}`)
3289
+ .join(", ")} }`
3290
+ const path = join(root, "package.json")
3291
+ if (!existsSync(path))
3292
+ return { issue: `there is none in the app; declare "nifra": { "zones": ${setting} } in it` }
3293
+ let text = readFileSync(path, "utf8")
3294
+ // Parsed again for each edit: an edit moves every offset after it.
3295
+ const find = (keys: readonly string[]) => {
3296
+ const source = ts.parseJsonText(path, text)
3297
+ let node: TS.Expression | undefined = source.statements[0]?.expression
3298
+ for (const key of keys)
3299
+ node =
3300
+ node !== undefined && ts.isObjectLiteralExpression(node)
3301
+ ? jsonProperty(ts, node, key)?.initializer
3302
+ : undefined
3303
+ return { source, node }
3304
+ }
3305
+ const top = find([])
3306
+ if (top.node === undefined || !ts.isObjectLiteralExpression(top.node))
3307
+ return { issue: `is not a JSON object; add "nifra": { "zones": ${setting} } to it by hand` }
3308
+ const nifra = find(["nifra"])
3309
+ if (nifra.node === undefined)
3310
+ return { text: insertProperty(top.source, text, top.node, `"nifra": { "zones": ${setting} }`) }
3311
+ if (!ts.isObjectLiteralExpression(nifra.node))
3312
+ return { issue: `"nifra" is not an object; add "zones": ${setting} to it by hand` }
3313
+ const zones = find(["nifra", "zones"])
3314
+ if (zones.node === undefined)
3315
+ return { text: insertProperty(nifra.source, text, nifra.node, `"zones": ${setting}`) }
3316
+ for (const side of FOLDER_SIDES) {
3317
+ const add = listed(side)
3318
+ if (add.length === 0) continue
3319
+ const { source, node } = find(["nifra", "zones"])
3320
+ if (node === undefined || !ts.isObjectLiteralExpression(node))
3321
+ return { issue: `"nifra.zones" is not an object; add ${setting} to it by hand` }
3322
+ const array = jsonProperty(ts, node, side)?.initializer
3323
+ if (array === undefined)
3324
+ text = insertProperty(source, text, node, `"${side}": ${JSON.stringify(add)}`)
3325
+ else if (ts.isArrayLiteralExpression(array)) text = appendToArray(source, text, array, add)
3326
+ else
3327
+ return {
3328
+ issue: `"nifra.zones.${side}" is not an array; add ${JSON.stringify(add)} to it by hand`,
3329
+ }
3330
+ }
3331
+ return { text }
3332
+ }
3333
+
2075
3334
  /** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
2076
3335
  function declareApiKind(ts: typeof TS, root: string): { text?: string; issue?: string } {
2077
3336
  const path = join(root, "package.json")