@nifrajs/cli 4.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +9 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +36 -9
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +22 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +83 -24
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1516 -318
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +203 -70
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +215 -50
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +20 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +73 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +24 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +194 -45
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +102 -2
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +18 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +20 -13
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +29 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +435 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +8 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +22 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +10 -6
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +15 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +87 -25
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +98 -23
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +33 -10
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +60 -10
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +8 -23
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +13 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +56 -14
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +30 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +63 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +48 -19
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +47 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1972 -170
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +128 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +975 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +43 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +12 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +12 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +13 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +356 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +178 -44
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +50 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +436 -4
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +13 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +313 -69
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +218 -44
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +290 -24
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +6 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +84 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +498 -78
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +16 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +94 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +55 -7
- package/docs/llms-full.txt +2043 -222
- package/docs/types.json +1573 -246
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +18 -2
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +47 -9
- package/src/check-diagnostics.ts +75 -9
- package/src/check-scan.ts +1816 -377
- package/src/check.ts +258 -81
- package/src/cli.ts +242 -37
- package/src/client-module.ts +97 -0
- package/src/command-catalog.ts +237 -52
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/dev-feed-tool.ts +112 -1
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +116 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +21 -14
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +477 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +48 -5
- package/src/internal/typescript-import.ts +15 -6
- package/src/introspect.ts +104 -28
- package/src/levels-tool.ts +124 -29
- package/src/load.ts +107 -17
- package/src/mcp-context.ts +12 -25
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +68 -18
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +74 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-path.ts +8 -22
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +56 -22
- package/src/migrate-layout.ts +2257 -176
- package/src/output-schemas.ts +1240 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/project-path.ts +44 -13
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/release-verification.ts +20 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +44 -10
- package/src/rules/codes.ts +12 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +391 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +206 -52
- package/src/rules/pages.ts +481 -4
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +391 -72
- package/src/rules/zones.ts +232 -42
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +251 -24
- package/src/test-tool.ts +12 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +576 -72
- package/src/verification-plan.ts +33 -2
- package/src/version-drift.ts +118 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/migrate-layout.ts
CHANGED
|
@@ -8,31 +8,49 @@
|
|
|
8
8
|
* 2. folds each `_middleware.ts` into its directory's `_layout.backend.ts` as `middleware`;
|
|
9
9
|
* 3. moves `backend.ts` to `backend/app.ts` and `framework.ts` to `backend/framework.ts`;
|
|
10
10
|
* 4. moves each `x.server.ts` under `backend/` without the retired suffix, and zones every other module
|
|
11
|
-
* by who imports it at runtime: only route frontends -> `frontend/`, only
|
|
12
|
-
* `backend/`, both -> `shared
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* by who imports it at runtime: only route frontends and the `clientModule` -> `frontend/`, only
|
|
12
|
+
* the server side -> `backend/`, both -> `shared/`. Imports through tsconfig `paths`/`baseUrl` and
|
|
13
|
+
* package.json `imports` count as the bundler reads them;
|
|
14
|
+
* 5. rewrites every import in the app to the new locations (test-runner mocks included), an alias
|
|
15
|
+
* through the same alias 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`;
|
|
19
|
+
* 6. adds the zone folders moved TypeScript lands in to tsconfig `include`, and `.nifra/` to
|
|
20
|
+
* `.gitignore`.
|
|
21
|
+
*
|
|
22
|
+
* Data files (JSON, YAML, CSV, ...) and folders the app zones in package.json stay where they are.
|
|
15
23
|
*
|
|
16
24
|
* It is a dry run unless `write` is set, and it reports what it could not decide (a frontend that uses
|
|
17
|
-
* a moved name at runtime, a helper both halves need, a loader with no output schema
|
|
18
|
-
* 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.
|
|
19
27
|
*/
|
|
20
28
|
import {
|
|
21
29
|
existsSync,
|
|
22
30
|
mkdirSync,
|
|
23
31
|
readdirSync,
|
|
24
32
|
readFileSync,
|
|
33
|
+
realpathSync,
|
|
25
34
|
renameSync,
|
|
35
|
+
rmdirSync,
|
|
26
36
|
rmSync,
|
|
27
37
|
statSync,
|
|
28
38
|
writeFileSync,
|
|
29
39
|
} from "node:fs"
|
|
30
|
-
import {
|
|
40
|
+
import { builtinModules } from "node:module"
|
|
41
|
+
import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"
|
|
31
42
|
import { BACKEND_ROUTE_EXPORTS } from "@nifrajs/web/route-manifest"
|
|
43
|
+
import { parseZoneConfig, type ZoneConfig } from "@nifrajs/web/zones"
|
|
32
44
|
import type * as TS from "typescript"
|
|
45
|
+
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.ts"
|
|
33
46
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
47
|
+
import { configPathReader, type StaticConfigPath } from "./internal/config-paths.ts"
|
|
48
|
+
import { createKindProbe, hasWebConfig } from "./internal/nifra-project.ts"
|
|
49
|
+
import { createSpecifierResolver, isFile } from "./internal/project-specifiers.ts"
|
|
34
50
|
import { importTypeScript } from "./internal/typescript-import.ts"
|
|
51
|
+
import { type OutputSchemaEntry, planOutputSchemas } from "./output-schemas.ts"
|
|
35
52
|
import { missingOutputSchemas } from "./rules/data-guard.ts"
|
|
53
|
+
import { apiPrefixFix } from "./rules/pages.ts"
|
|
36
54
|
|
|
37
55
|
/** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
|
|
38
56
|
const BACKEND_EXPORTS = BACKEND_ROUTE_EXPORTS
|
|
@@ -62,30 +80,88 @@ const TOOLING_FILE =
|
|
|
62
80
|
const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/
|
|
63
81
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"])
|
|
64
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"]
|
|
85
|
+
/** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
|
|
86
|
+
const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/
|
|
87
|
+
/** Test-runner calls that name a module by path, as an import does. */
|
|
88
|
+
const MOCK_CALLS: ReadonlySet<string> = new Set([
|
|
89
|
+
"mock.module",
|
|
90
|
+
"vi.mock",
|
|
91
|
+
"vi.doMock",
|
|
92
|
+
"vi.importActual",
|
|
93
|
+
"vi.importMock",
|
|
94
|
+
"jest.mock",
|
|
95
|
+
"jest.doMock",
|
|
96
|
+
"jest.requireActual",
|
|
97
|
+
"jest.requireMock",
|
|
98
|
+
"jest.unstable_mockModule",
|
|
99
|
+
])
|
|
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
|
+
]
|
|
65
114
|
|
|
66
115
|
export interface LayoutMigrationIssue {
|
|
67
116
|
readonly file: string
|
|
68
117
|
readonly reason: string
|
|
69
118
|
}
|
|
70
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
|
+
|
|
71
127
|
export interface LayoutMigrationResult {
|
|
128
|
+
/** No issue needs a decision; follow-ups may remain. */
|
|
72
129
|
readonly ok: boolean
|
|
73
130
|
readonly write: boolean
|
|
131
|
+
/** `"api"` for an app that serves no pages: only its backend entry moves, nothing is split or zoned. */
|
|
132
|
+
readonly mode: "web" | "api"
|
|
74
133
|
/** Route files whose backend exports moved, and where they went. */
|
|
75
134
|
readonly splits: ReadonlyArray<{
|
|
76
135
|
readonly file: string
|
|
77
136
|
readonly backend: string
|
|
78
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
|
|
79
140
|
}>
|
|
80
141
|
/** Files that moved, app-relative. */
|
|
81
142
|
readonly moves: ReadonlyArray<{ readonly from: string; readonly to: string }>
|
|
82
143
|
/** Files whose imports were rewritten (and were not otherwise created). */
|
|
83
144
|
readonly rewritten: readonly string[]
|
|
145
|
+
/** Tooling config the migration updated so it still covers what moved. */
|
|
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[]
|
|
84
152
|
readonly issues: readonly LayoutMigrationIssue[]
|
|
153
|
+
/** Commands to run after the migration, each with what it settles. */
|
|
154
|
+
readonly followUps: readonly LayoutMigrationFollowUp[]
|
|
85
155
|
}
|
|
86
156
|
|
|
87
157
|
export interface LayoutMigrationOptions {
|
|
88
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
|
|
89
165
|
/** Inject the TypeScript compiler (tests); defaults to the CLI's own. */
|
|
90
166
|
readonly typescript?: typeof TS
|
|
91
167
|
}
|
|
@@ -95,6 +171,15 @@ type Files = Map<string, string>
|
|
|
95
171
|
|
|
96
172
|
const toPosix = (path: string): string => path.replaceAll("\\", "/")
|
|
97
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
|
+
|
|
98
183
|
/** Every first-party file under `root`, app-relative and posix. */
|
|
99
184
|
function listFiles(root: string): string[] {
|
|
100
185
|
const out: string[] = []
|
|
@@ -123,12 +208,29 @@ interface StatementInfo {
|
|
|
123
208
|
readonly exports: ReadonlyMap<string, readonly string[]>
|
|
124
209
|
readonly valueRefs: ReadonlySet<string>
|
|
125
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>
|
|
126
226
|
}
|
|
127
227
|
|
|
128
228
|
export interface SplitResult {
|
|
129
229
|
readonly frontend: string
|
|
130
230
|
/** `undefined` when the file exports nothing for the server. */
|
|
131
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
|
|
132
234
|
readonly moved: readonly string[]
|
|
133
235
|
readonly issues: readonly string[]
|
|
134
236
|
}
|
|
@@ -145,14 +247,42 @@ const hasModifier = (ts: typeof TS, node: TS.Node, kind: TS.SyntaxKind): boolean
|
|
|
145
247
|
|
|
146
248
|
/** Identifiers a statement reads, split into value and type positions. Over-approximates on purpose:
|
|
147
249
|
* an extra name only keeps a declaration in a half that did not need it. */
|
|
148
|
-
|
|
250
|
+
interface JsxBindings {
|
|
251
|
+
readonly element: readonly string[]
|
|
252
|
+
readonly fragment: readonly string[]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The bindings classic JSX compiles to calls of, which no identifier in the source names: the roots
|
|
257
|
+
* of the `@jsx`/`@jsxFrag` pragmas, else TypeScript's default `React`. A file that names its own
|
|
258
|
+
* `@jsxImportSource` imports the runtime itself.
|
|
259
|
+
*/
|
|
260
|
+
function jsxBindings(ts: typeof TS, source: string): JsxBindings {
|
|
261
|
+
const comments = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
262
|
+
.map(({ pos, end }) => source.slice(pos, end))
|
|
263
|
+
.join("\n")
|
|
264
|
+
if (/@jsxImportSource\s/.test(comments)) return { element: [], fragment: [] }
|
|
265
|
+
const root = (pragma: string): string | undefined =>
|
|
266
|
+
new RegExp(`@${pragma}\\s+([A-Za-z_$][\\w$]*)`).exec(comments)?.[1]
|
|
267
|
+
const element = root("jsx") ?? "React"
|
|
268
|
+
return { element: [element], fragment: [root("jsxFrag") ?? element] }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function references(
|
|
272
|
+
ts: typeof TS,
|
|
273
|
+
node: TS.Node,
|
|
274
|
+
jsx: JsxBindings,
|
|
275
|
+
): { value: Set<string>; type: Set<string> } {
|
|
149
276
|
const value = new Set<string>()
|
|
150
277
|
const type = new Set<string>()
|
|
151
278
|
const visit = (current: TS.Node, inType: boolean): void => {
|
|
279
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current))
|
|
280
|
+
for (const name of jsx.element) value.add(name)
|
|
281
|
+
else if (ts.isJsxFragment(current)) for (const name of jsx.fragment) value.add(name)
|
|
152
282
|
const typed =
|
|
153
283
|
inType ||
|
|
154
284
|
ts.isTypeNode(current) ||
|
|
155
|
-
//
|
|
285
|
+
// nifra-expect NF-S002: `token` is the heritage clause's syntax kind, not a credential.
|
|
156
286
|
(ts.isHeritageClause(current) && current.token === ts.SyntaxKind.ImplementsKeyword)
|
|
157
287
|
if (ts.isIdentifier(current)) {
|
|
158
288
|
const parent = current.parent
|
|
@@ -182,9 +312,11 @@ function references(ts: typeof TS, node: TS.Node): { value: Set<string>; type: S
|
|
|
182
312
|
}
|
|
183
313
|
|
|
184
314
|
function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
|
|
315
|
+
const jsx = jsxBindings(ts, sf.text)
|
|
185
316
|
return sf.statements.map((node) => {
|
|
186
317
|
const declares = new Set<string>()
|
|
187
318
|
const exports = new Map<string, string[]>()
|
|
319
|
+
const listed: ListedExport[] = []
|
|
188
320
|
const exported = hasModifier(ts, node, ts.SyntaxKind.ExportKeyword)
|
|
189
321
|
const isDefault = hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)
|
|
190
322
|
const addExport = (local: string, as: string): void => {
|
|
@@ -224,15 +356,15 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
|
|
|
224
356
|
ts.isNamedExports(node.exportClause)
|
|
225
357
|
) {
|
|
226
358
|
for (const el of node.exportClause.elements) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 })
|
|
231
363
|
}
|
|
232
364
|
}
|
|
233
365
|
const refs = ts.isImportDeclaration(node)
|
|
234
366
|
? { value: new Set<string>(), type: new Set<string>() }
|
|
235
|
-
: references(ts, node)
|
|
367
|
+
: references(ts, node, jsx)
|
|
236
368
|
for (const name of declares) {
|
|
237
369
|
refs.value.delete(name)
|
|
238
370
|
refs.type.delete(name)
|
|
@@ -244,36 +376,78 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
|
|
|
244
376
|
exports,
|
|
245
377
|
valueRefs: refs.value,
|
|
246
378
|
typeRefs: refs.type,
|
|
379
|
+
listed,
|
|
247
380
|
}
|
|
248
381
|
})
|
|
249
382
|
}
|
|
250
383
|
|
|
251
|
-
/**
|
|
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
|
+
*/
|
|
252
389
|
function filterImport(
|
|
253
390
|
ts: typeof TS,
|
|
254
391
|
sf: TS.SourceFile,
|
|
255
392
|
node: TS.ImportDeclaration,
|
|
256
|
-
|
|
393
|
+
uses: Uses,
|
|
394
|
+
fresh: boolean,
|
|
257
395
|
): string | undefined {
|
|
258
396
|
const clause = node.importClause
|
|
259
397
|
const spec = node.moduleSpecifier.getText(sf)
|
|
260
398
|
const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : ""
|
|
261
399
|
if (clause === undefined) return node.getFullText(sf)
|
|
262
|
-
|
|
263
|
-
|
|
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)
|
|
264
415
|
const bindings = clause.namedBindings
|
|
265
|
-
if (bindings && ts.isNamespaceImport(bindings)
|
|
266
|
-
|
|
416
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
417
|
+
const text = `* as ${bindings.name.text}`
|
|
418
|
+
add(heads, bindings.name.text, text, text)
|
|
419
|
+
}
|
|
267
420
|
if (bindings && ts.isNamedImports(bindings)) {
|
|
268
|
-
const
|
|
269
|
-
|
|
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
|
+
}
|
|
270
425
|
}
|
|
271
|
-
|
|
272
|
-
|
|
426
|
+
const kept = [...heads, ...named]
|
|
427
|
+
if (kept.length === 0) return undefined
|
|
273
428
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
|
|
274
|
-
|
|
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(", "))}`
|
|
275
445
|
}
|
|
276
446
|
|
|
447
|
+
/** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
|
|
448
|
+
const terminatorOf = (sf: TS.SourceFile, node: TS.Node): string =>
|
|
449
|
+
node.getText(sf).endsWith(";") ? ";" : ""
|
|
450
|
+
|
|
277
451
|
/** Rebuild `export { a, b as c }` keeping only the exported names `keep` accepts. */
|
|
278
452
|
function filterExportList(
|
|
279
453
|
ts: typeof TS,
|
|
@@ -287,7 +461,7 @@ function filterExportList(
|
|
|
287
461
|
if (kept.length === 0) return undefined
|
|
288
462
|
const from = node.moduleSpecifier ? ` from ${node.moduleSpecifier.getText(sf)}` : ""
|
|
289
463
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
|
|
290
|
-
return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}`
|
|
464
|
+
return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}${terminatorOf(sf, node)}`
|
|
291
465
|
}
|
|
292
466
|
|
|
293
467
|
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
@@ -297,6 +471,126 @@ const joinStatements = (texts: readonly string[]): string => {
|
|
|
297
471
|
return body.endsWith("\n") ? body : `${body}\n`
|
|
298
472
|
}
|
|
299
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
|
+
|
|
300
594
|
/**
|
|
301
595
|
* Split one route module's source into its frontend and backend halves. `backendSpecifier` is how the
|
|
302
596
|
* frontend refers to the backend half in an `import type` (`./index.backend.ts`).
|
|
@@ -306,6 +600,7 @@ export function splitRouteSource(
|
|
|
306
600
|
source: string,
|
|
307
601
|
fileName: string,
|
|
308
602
|
backendSpecifier: string,
|
|
603
|
+
options: SplitOptions = {},
|
|
309
604
|
): SplitResult {
|
|
310
605
|
const kind = fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
311
606
|
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, kind)
|
|
@@ -318,70 +613,177 @@ export function splitRouteSource(
|
|
|
318
613
|
if (moved.length === 0) return { frontend: source, backend: undefined, moved: [], issues: [] }
|
|
319
614
|
|
|
320
615
|
const declaredBy = new Map<string, StatementInfo>()
|
|
616
|
+
const importedBy = new Map<string, StatementInfo>()
|
|
321
617
|
for (const info of statements) {
|
|
322
|
-
|
|
323
|
-
for (const name of info.declares)
|
|
618
|
+
const into = ts.isImportDeclaration(info.node) ? importedBy : declaredBy
|
|
619
|
+
for (const name of info.declares) into.set(name, info)
|
|
324
620
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (target !== undefined && !backendSet.has(target)) {
|
|
335
|
-
backendSet.add(target)
|
|
336
|
-
queue.push(target)
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
// An exported-name statement (`export { loader }`) reaches the declaration it names.
|
|
340
|
-
for (const local of info.exports.keys()) {
|
|
341
|
-
const target = declaredBy.get(local)
|
|
342
|
-
if (target !== undefined && !backendSet.has(target)) {
|
|
343
|
-
backendSet.add(target)
|
|
344
|
-
queue.push(target)
|
|
345
|
-
}
|
|
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)),
|
|
346
630
|
}
|
|
347
631
|
}
|
|
348
|
-
|
|
349
|
-
// The frontend half: everything that is not a backend export statement, minus declarations that only
|
|
350
|
-
// the backend half needs.
|
|
351
632
|
const movedLocals = new Set<string>()
|
|
352
633
|
for (const info of statements) {
|
|
353
634
|
for (const [local, names] of info.exports)
|
|
354
635
|
if (names.some((name) => BACKEND_EXPORTS.has(name))) movedLocals.add(local)
|
|
355
636
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// a statement with no declaration (a side effect) stays where it ran
|
|
363
|
-
(info.declares.size === 0 && info.exports.size === 0),
|
|
364
|
-
)
|
|
365
|
-
const fqueue = [...seed]
|
|
366
|
-
for (const info of fqueue) frontendKeep.add(info)
|
|
367
|
-
while (fqueue.length > 0) {
|
|
368
|
-
const info = fqueue.pop() as StatementInfo
|
|
369
|
-
for (const name of info.valueRefs) {
|
|
370
|
-
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 => {
|
|
371
643
|
const target = declaredBy.get(name)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
}
|
|
375
659
|
}
|
|
376
660
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
383
754
|
}
|
|
755
|
+
return undefined
|
|
384
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
|
|
772
|
+
}
|
|
773
|
+
return undefined
|
|
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))
|
|
385
787
|
}
|
|
386
788
|
|
|
387
789
|
// A moved name the frontend still mentions: in a type it becomes `import type`; at runtime it is an
|
|
@@ -389,44 +791,88 @@ export function splitRouteSource(
|
|
|
389
791
|
const typeImports = new Set<string>()
|
|
390
792
|
for (const info of frontendKeep) {
|
|
391
793
|
if (isBackendStatement(info)) continue
|
|
794
|
+
const reads = readsFor(info, false)
|
|
392
795
|
for (const name of movedLocals) {
|
|
393
796
|
if (info.declares.has(name)) continue
|
|
394
|
-
if (
|
|
797
|
+
if (reads.value.has(name))
|
|
395
798
|
issues.push(`the frontend uses "${name}" at runtime, but it runs on the server only`)
|
|
396
|
-
if (
|
|
799
|
+
if (reads.type.has(name) || reads.value.has(name)) typeImports.add(name)
|
|
397
800
|
}
|
|
398
801
|
}
|
|
399
|
-
for (const info of
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
()
|
|
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/`,
|
|
403
806
|
)
|
|
404
|
-
if (values.length > 0)
|
|
405
|
-
issues.push(
|
|
406
|
-
`"${values.join(", ")}" is needed by both halves and was copied into each; consider moving it to shared/`,
|
|
407
|
-
)
|
|
408
807
|
}
|
|
409
808
|
|
|
410
|
-
const
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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 }
|
|
415
818
|
}
|
|
416
|
-
const backendUses =
|
|
417
|
-
const frontendUses =
|
|
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
|
+
)
|
|
418
862
|
|
|
419
863
|
const backendTexts: string[] = []
|
|
420
864
|
const frontendTexts: string[] = []
|
|
421
865
|
let afterImports = 0
|
|
866
|
+
let backendAfterImports = 0
|
|
422
867
|
for (const info of statements) {
|
|
423
868
|
const node = info.node
|
|
424
869
|
if (ts.isImportDeclaration(node)) {
|
|
425
|
-
const back = filterImport(ts, sf, node,
|
|
870
|
+
const back = filterImport(ts, sf, node, backendUses, true)
|
|
426
871
|
if (back !== undefined) backendTexts.push(node.importClause ? back : "")
|
|
427
|
-
const front = filterImport(ts, sf, node,
|
|
872
|
+
const front = filterImport(ts, sf, node, frontendUses, false)
|
|
428
873
|
if (front !== undefined) frontendTexts.push(front)
|
|
429
874
|
afterImports = frontendTexts.length
|
|
875
|
+
backendAfterImports = backendTexts.length
|
|
430
876
|
continue
|
|
431
877
|
}
|
|
432
878
|
if (isBackendStatement(info)) {
|
|
@@ -452,13 +898,17 @@ export function splitRouteSource(
|
|
|
452
898
|
if (frontendKeep.has(info)) frontendTexts.push(info.text)
|
|
453
899
|
}
|
|
454
900
|
// After the other imports: the nearest relative path sorts last.
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
`\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"`,
|
|
901
|
+
const frontendImports = sharedImport(frontendUses)
|
|
902
|
+
if (reexported.length > 0)
|
|
903
|
+
frontendImports.push(
|
|
904
|
+
`\nexport { ${reexported.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`,
|
|
460
905
|
)
|
|
461
|
-
|
|
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))
|
|
462
912
|
// A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
|
|
463
913
|
const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
464
914
|
.map((range) => source.slice(range.pos, range.end))
|
|
@@ -469,12 +919,34 @@ export function splitRouteSource(
|
|
|
469
919
|
text,
|
|
470
920
|
)
|
|
471
921
|
const frontend = lift(joinStatements(frontendTexts))
|
|
472
|
-
|
|
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 = {
|
|
473
937
|
frontend: pragmas.length > 0 ? `${pragmas.join("\n")}\n${frontend}` : frontend,
|
|
474
938
|
backend: lift(joinStatements(backendTexts)),
|
|
475
939
|
moved: [...new Set(moved)].sort(),
|
|
476
940
|
issues,
|
|
477
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)}`
|
|
478
950
|
}
|
|
479
951
|
|
|
480
952
|
/** A helper the backend half copies keeps its declaration; an `export` on it would add a backend export. */
|
|
@@ -497,7 +969,7 @@ export function middlewareSource(ts: typeof TS, source: string, fileName: string
|
|
|
497
969
|
// Edit from the end so earlier offsets stay valid.
|
|
498
970
|
for (const node of [...sf.statements].reverse()) {
|
|
499
971
|
if (ts.isExportAssignment(node) && !node.isExportEquals) {
|
|
500
|
-
out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${out.slice(node.getEnd())}`
|
|
972
|
+
out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${terminatorOf(sf, node)}${out.slice(node.getEnd())}`
|
|
501
973
|
} else if (
|
|
502
974
|
ts.isFunctionDeclaration(node) &&
|
|
503
975
|
hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)
|
|
@@ -515,7 +987,8 @@ export function middlewareSource(ts: typeof TS, source: string, fileName: string
|
|
|
515
987
|
out = `${out.slice(0, first.getStart(sf))}export${text}${out.slice(node.getEnd())}`
|
|
516
988
|
} else {
|
|
517
989
|
out = `${out.slice(0, first.getStart(sf))}${out.slice(last.getEnd()).replace(/^\s+/, "")}`
|
|
518
|
-
|
|
990
|
+
const semi = sf.statements.some((s) => terminatorOf(sf, s) !== "") ? ";" : ""
|
|
991
|
+
out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }${semi}\n`
|
|
519
992
|
}
|
|
520
993
|
}
|
|
521
994
|
}
|
|
@@ -579,6 +1052,16 @@ interface ImportRef {
|
|
|
579
1052
|
readonly typeOnly: boolean
|
|
580
1053
|
}
|
|
581
1054
|
|
|
1055
|
+
/** The path argument of `mock.module("./x", ...)` or a vitest/jest twin, when it is a literal. */
|
|
1056
|
+
function mockedSpecifier(ts: typeof TS, node: TS.CallExpression): TS.StringLiteralLike | undefined {
|
|
1057
|
+
const callee = node.expression
|
|
1058
|
+
if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression))
|
|
1059
|
+
return undefined
|
|
1060
|
+
if (!MOCK_CALLS.has(`${callee.expression.text}.${callee.name.text}`)) return undefined
|
|
1061
|
+
const arg = node.arguments[0]
|
|
1062
|
+
return arg !== undefined && ts.isStringLiteralLike(arg) ? arg : undefined
|
|
1063
|
+
}
|
|
1064
|
+
|
|
582
1065
|
function importsOf(ts: typeof TS, file: string, text: string): ImportRef[] {
|
|
583
1066
|
if (/\.(?:svelte|vue)$/.test(file)) {
|
|
584
1067
|
return scriptBlocks(text).flatMap((block) =>
|
|
@@ -635,6 +1118,9 @@ function importsOf(ts: typeof TS, file: string, text: string): ImportRef[] {
|
|
|
635
1118
|
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require"
|
|
636
1119
|
if ((isImport || isRequire) && arg !== undefined && ts.isStringLiteralLike(arg))
|
|
637
1120
|
refs.push({ specifier: arg.text, typeOnly: false })
|
|
1121
|
+
// A test's mock names the module without loading it into the app: no runtime edge.
|
|
1122
|
+
const mocked = mockedSpecifier(ts, node)
|
|
1123
|
+
if (mocked !== undefined) refs.push({ specifier: mocked.text, typeOnly: true })
|
|
638
1124
|
} else if (
|
|
639
1125
|
ts.isImportEqualsDeclaration(node) &&
|
|
640
1126
|
ts.isExternalModuleReference(node.moduleReference)
|
|
@@ -655,7 +1141,7 @@ function resolveIn(
|
|
|
655
1141
|
specifier: string,
|
|
656
1142
|
): string | undefined {
|
|
657
1143
|
if (!specifier.startsWith(".")) return undefined
|
|
658
|
-
const clean = specifier
|
|
1144
|
+
const clean = withoutQuery(specifier)
|
|
659
1145
|
const base = posix.normalize(posix.join(posix.dirname(from), clean))
|
|
660
1146
|
const candidates = [
|
|
661
1147
|
base,
|
|
@@ -725,15 +1211,24 @@ function rebaseOwnPaths(
|
|
|
725
1211
|
const quote = node.getText(sf)[0] ?? '"'
|
|
726
1212
|
return `${quote}${value}${quote}`
|
|
727
1213
|
}
|
|
728
|
-
// The path `relative` names from the module's old folder, named from its new folder
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
731
1220
|
const next = posix.relative(posix.dirname(to), target) || "."
|
|
732
1221
|
return relative.endsWith("/") && next !== "." ? `${next}/` : next
|
|
733
1222
|
}
|
|
734
|
-
const follow = (
|
|
1223
|
+
const follow = (
|
|
1224
|
+
anchor: TS.Node,
|
|
1225
|
+
start: number,
|
|
1226
|
+
end: number,
|
|
1227
|
+
replacement: string | undefined,
|
|
1228
|
+
): void => {
|
|
735
1229
|
followed.push([anchor.getStart(sf), anchor.getEnd()])
|
|
736
|
-
if (text.slice(start, end) !== replacement)
|
|
1230
|
+
if (replacement !== undefined && text.slice(start, end) !== replacement)
|
|
1231
|
+
edits.push({ start, end, text: replacement })
|
|
737
1232
|
}
|
|
738
1233
|
|
|
739
1234
|
const visit = (node: TS.Node): void => {
|
|
@@ -751,8 +1246,12 @@ function rebaseOwnPaths(
|
|
|
751
1246
|
!(callee.endsWith("resolve") && parts.some((part) => part.text.startsWith("/")))
|
|
752
1247
|
) {
|
|
753
1248
|
const next = rebased(posix.join(...parts.map((part) => part.text)))
|
|
754
|
-
const
|
|
755
|
-
|
|
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(", ")
|
|
756
1255
|
follow(dir, first.getStart(sf), last.getEnd(), replacement)
|
|
757
1256
|
return
|
|
758
1257
|
}
|
|
@@ -769,7 +1268,7 @@ function rebaseOwnPaths(
|
|
|
769
1268
|
node.left,
|
|
770
1269
|
node.right.getStart(sf),
|
|
771
1270
|
node.right.getEnd(),
|
|
772
|
-
quoted(node.right, `/${next}`),
|
|
1271
|
+
next === undefined ? undefined : quoted(node.right, `/${next}`),
|
|
773
1272
|
)
|
|
774
1273
|
return
|
|
775
1274
|
}
|
|
@@ -777,7 +1276,8 @@ function rebaseOwnPaths(
|
|
|
777
1276
|
const span = node.templateSpans[0]
|
|
778
1277
|
if (span !== undefined && isOwnDir(span.expression) && span.literal.text.startsWith("/")) {
|
|
779
1278
|
const next = rebased(span.literal.text.slice(1) || ".")
|
|
780
|
-
const replacement =
|
|
1279
|
+
const replacement =
|
|
1280
|
+
next === undefined ? undefined : `\`\${${span.expression.getText(sf)}}/${next}\``
|
|
781
1281
|
follow(span.expression, node.getStart(sf), node.getEnd(), replacement)
|
|
782
1282
|
return
|
|
783
1283
|
}
|
|
@@ -791,7 +1291,8 @@ function rebaseOwnPaths(
|
|
|
791
1291
|
const path = node.arguments[0]
|
|
792
1292
|
if (isLiteral(path) && !/^(?:\/|[a-z][a-z\d+.-]*:)/i.test(path.text)) {
|
|
793
1293
|
const next = rebased(path.text)
|
|
794
|
-
const replacement =
|
|
1294
|
+
const replacement =
|
|
1295
|
+
next === undefined ? undefined : quoted(path, next.startsWith("..") ? next : `./${next}`)
|
|
795
1296
|
follow(node.arguments[1], path.getStart(sf), path.getEnd(), replacement)
|
|
796
1297
|
return
|
|
797
1298
|
}
|
|
@@ -817,7 +1318,7 @@ function respecify(
|
|
|
817
1318
|
newFrom: string,
|
|
818
1319
|
newTarget: string,
|
|
819
1320
|
): string {
|
|
820
|
-
const query =
|
|
1321
|
+
const query = original.slice(withoutQuery(original).length)
|
|
821
1322
|
const clean = original.slice(0, original.length - query.length)
|
|
822
1323
|
let target = newTarget
|
|
823
1324
|
const writtenExt = extname(clean)
|
|
@@ -846,6 +1347,359 @@ function rebase(specifier: string, from: string, newFrom: string): string | unde
|
|
|
846
1347
|
return rel.startsWith(".") ? rel : `./${rel}`
|
|
847
1348
|
}
|
|
848
1349
|
|
|
1350
|
+
/** A split route as an importer sees it: the names that left it, and how this importer spells the
|
|
1351
|
+
* backend half they went to. */
|
|
1352
|
+
interface SplitTarget {
|
|
1353
|
+
readonly backend: string
|
|
1354
|
+
readonly moved: ReadonlySet<string>
|
|
1355
|
+
readonly specifier: string
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
|
|
1359
|
+
function backendSpecifier(specifier: string, route: string, backend: string, from: string): string {
|
|
1360
|
+
const query = specifier.slice(withoutQuery(specifier).length)
|
|
1361
|
+
const written = extname(specifier.slice(0, specifier.length - query.length))
|
|
1362
|
+
const stem = backend.slice(0, -extname(backend).length)
|
|
1363
|
+
const target =
|
|
1364
|
+
written === ""
|
|
1365
|
+
? stem
|
|
1366
|
+
: written === extname(route)
|
|
1367
|
+
? backend
|
|
1368
|
+
: `${stem}${written === ".jsx" ? ".js" : written}`
|
|
1369
|
+
const rel = posix.relative(posix.dirname(from), target)
|
|
1370
|
+
return (rel.startsWith(".") ? rel : `./${rel}`) + query
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
|
|
1375
|
+
* backend half, splitting a statement that also names what stayed. What cannot be rewritten (a
|
|
1376
|
+
* namespace or dynamic import that reads a moved name, `export *`) is returned as an issue.
|
|
1377
|
+
*/
|
|
1378
|
+
function redirectMovedImports(
|
|
1379
|
+
ts: typeof TS,
|
|
1380
|
+
file: string,
|
|
1381
|
+
text: string,
|
|
1382
|
+
splitOf: (specifier: string) => SplitTarget | undefined,
|
|
1383
|
+
): {
|
|
1384
|
+
readonly text: string
|
|
1385
|
+
readonly issues: readonly string[]
|
|
1386
|
+
readonly redirects: ReadonlyArray<{ names: string[]; from: string; to: string }>
|
|
1387
|
+
} {
|
|
1388
|
+
const redirects: Array<{ names: string[]; from: string; to: string }> = []
|
|
1389
|
+
const sf = ts.createSourceFile(
|
|
1390
|
+
file,
|
|
1391
|
+
text,
|
|
1392
|
+
ts.ScriptTarget.Latest,
|
|
1393
|
+
true,
|
|
1394
|
+
file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
1395
|
+
)
|
|
1396
|
+
const edits: Array<{ start: number; end: number; text: string }> = []
|
|
1397
|
+
const issues: string[] = []
|
|
1398
|
+
const mentioned = (split: SplitTarget, pattern: (name: string) => RegExp): string[] =>
|
|
1399
|
+
[...split.moved].filter((name) => pattern(escapeRegExp(name)).test(text)).sort()
|
|
1400
|
+
const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(", ")
|
|
1401
|
+
const visit = (node: TS.Node): void => {
|
|
1402
|
+
if (
|
|
1403
|
+
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
1404
|
+
node.moduleSpecifier !== undefined &&
|
|
1405
|
+
ts.isStringLiteral(node.moduleSpecifier)
|
|
1406
|
+
) {
|
|
1407
|
+
const from = node.moduleSpecifier.text
|
|
1408
|
+
const split = splitOf(from)
|
|
1409
|
+
if (split === undefined) return
|
|
1410
|
+
const clause = ts.isImportDeclaration(node) ? node.importClause : undefined
|
|
1411
|
+
const named = ts.isImportDeclaration(node) ? clause?.namedBindings : node.exportClause
|
|
1412
|
+
if (named !== undefined && (ts.isNamespaceImport(named) || ts.isNamespaceExport(named))) {
|
|
1413
|
+
const reads = mentioned(split, (name) => new RegExp(`\\b${named.name.text}\\.${name}\\b`))
|
|
1414
|
+
if (reads.length > 0)
|
|
1415
|
+
issues.push(
|
|
1416
|
+
`reads ${quoted(reads)} through a namespace import of "${from}", but they moved to "${split.backend}"; import them from there by hand`,
|
|
1417
|
+
)
|
|
1418
|
+
return
|
|
1419
|
+
}
|
|
1420
|
+
if (named === undefined) {
|
|
1421
|
+
if (ts.isExportDeclaration(node))
|
|
1422
|
+
issues.push(
|
|
1423
|
+
`re-exports everything from "${from}", but ${quoted([...split.moved].sort())} moved to "${split.backend}"; re-export them from there by hand`,
|
|
1424
|
+
)
|
|
1425
|
+
return
|
|
1426
|
+
}
|
|
1427
|
+
const elements: readonly (TS.ImportSpecifier | TS.ExportSpecifier)[] = named.elements
|
|
1428
|
+
const isMoved = (el: TS.ImportSpecifier | TS.ExportSpecifier): boolean =>
|
|
1429
|
+
split.moved.has((el.propertyName ?? el.name).text)
|
|
1430
|
+
const moved = elements.filter(isMoved)
|
|
1431
|
+
if (moved.length === 0) return
|
|
1432
|
+
redirects.push({
|
|
1433
|
+
names: moved.map((el) => (el.propertyName ?? el.name).text),
|
|
1434
|
+
from,
|
|
1435
|
+
to: split.specifier,
|
|
1436
|
+
})
|
|
1437
|
+
const kept = elements.filter((el) => !isMoved(el)).map((el) => el.getText(sf))
|
|
1438
|
+
const quote = node.moduleSpecifier.getText(sf)[0] ?? '"'
|
|
1439
|
+
const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : ""
|
|
1440
|
+
const tail = (specifier: string): string =>
|
|
1441
|
+
` from ${quote}${specifier}${quote}${attributes}${terminatorOf(sf, node)}`
|
|
1442
|
+
const keyword = ts.isImportDeclaration(node)
|
|
1443
|
+
? `import ${clause?.isTypeOnly ? "type " : ""}`
|
|
1444
|
+
: `export ${node.isTypeOnly ? "type " : ""}`
|
|
1445
|
+
const list = (names: readonly string[]): string => `{ ${names.join(", ")} }`
|
|
1446
|
+
const backend = `${keyword}${list(moved.map((el) => el.getText(sf)))}${tail(split.specifier)}`
|
|
1447
|
+
const stays = [
|
|
1448
|
+
...(clause?.name === undefined ? [] : [clause.name.text]),
|
|
1449
|
+
...(kept.length === 0 ? [] : [list(kept)]),
|
|
1450
|
+
]
|
|
1451
|
+
edits.push({
|
|
1452
|
+
start: node.getStart(sf),
|
|
1453
|
+
end: node.getEnd(),
|
|
1454
|
+
text:
|
|
1455
|
+
stays.length === 0 ? backend : `${keyword}${stays.join(", ")}${tail(from)}\n${backend}`,
|
|
1456
|
+
})
|
|
1457
|
+
return
|
|
1458
|
+
}
|
|
1459
|
+
if (
|
|
1460
|
+
ts.isCallExpression(node) &&
|
|
1461
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
1462
|
+
node.arguments[0] !== undefined &&
|
|
1463
|
+
ts.isStringLiteralLike(node.arguments[0])
|
|
1464
|
+
) {
|
|
1465
|
+
const literal = node.arguments[0]
|
|
1466
|
+
const from = literal.text
|
|
1467
|
+
const split = splitOf(from)
|
|
1468
|
+
const reads =
|
|
1469
|
+
split === undefined ? [] : mentioned(split, (name) => new RegExp(`\\b${name}\\b`))
|
|
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
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
ts.forEachChild(node, visit)
|
|
1490
|
+
}
|
|
1491
|
+
visit(sf)
|
|
1492
|
+
let out = text
|
|
1493
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
1494
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
|
|
1495
|
+
return { text: out, issues, redirects }
|
|
1496
|
+
}
|
|
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
|
+
|
|
1685
|
+
/** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
|
|
1686
|
+
* up and never a workspace root, so the scan beside it stays inside one package. */
|
|
1687
|
+
function enclosingPackage(root: string): string | undefined {
|
|
1688
|
+
if (existsSync(join(root, "package.json"))) return undefined
|
|
1689
|
+
for (const dir of [dirname(root), dirname(dirname(root))]) {
|
|
1690
|
+
const manifest = join(dir, "package.json")
|
|
1691
|
+
if (dir === root || !existsSync(manifest)) continue
|
|
1692
|
+
try {
|
|
1693
|
+
const parsed: unknown = JSON.parse(readFileSync(manifest, "utf8"))
|
|
1694
|
+
const workspace = typeof parsed === "object" && parsed !== null && "workspaces" in parsed
|
|
1695
|
+
return workspace ? undefined : dir
|
|
1696
|
+
} catch {
|
|
1697
|
+
return undefined
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
return undefined
|
|
1701
|
+
}
|
|
1702
|
+
|
|
849
1703
|
function rewriteImports(
|
|
850
1704
|
ts: typeof TS,
|
|
851
1705
|
file: string,
|
|
@@ -880,7 +1734,7 @@ function rewriteImports(
|
|
|
880
1734
|
) {
|
|
881
1735
|
const arg = node.arguments[0]
|
|
882
1736
|
if (arg !== undefined && ts.isStringLiteralLike(arg)) literal = arg
|
|
883
|
-
}
|
|
1737
|
+
} else if (ts.isCallExpression(node)) literal = mockedSpecifier(ts, node)
|
|
884
1738
|
if (literal !== undefined) {
|
|
885
1739
|
const next = rewrite(literal.text)
|
|
886
1740
|
if (next !== undefined && next !== literal.text) {
|
|
@@ -976,16 +1830,143 @@ function renameRetiredTypes(ts: typeof TS, file: string, text: string): string {
|
|
|
976
1830
|
// The migration
|
|
977
1831
|
// ---------------------------------------------------------------------------------------------------
|
|
978
1832
|
|
|
979
|
-
const ROOT_MOVES: Readonly<Record<string, string>> = {
|
|
980
|
-
"backend.ts": "backend/app.ts",
|
|
981
|
-
"framework.ts": "backend/framework.ts",
|
|
982
|
-
}
|
|
983
1833
|
/** Root files that stay where they are: the CLI's config, and scripts no build loads. */
|
|
984
|
-
|
|
985
|
-
|
|
1834
|
+
/** The config files the loader reads, old layout included; a path field may live in any of them. */
|
|
1835
|
+
const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"]
|
|
1836
|
+
|
|
1837
|
+
interface ConfigEntry extends StaticConfigPath {
|
|
1838
|
+
/** `clientModule`, or `componentPipeline.entries.<name>`. */
|
|
1839
|
+
readonly field: string
|
|
1840
|
+
readonly role: "frontend" | "backend"
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* The module paths a config exports, read statically: `clientModule` and the component pipeline's
|
|
1845
|
+
* `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
|
|
1846
|
+
*/
|
|
1847
|
+
function configEntries(ts: typeof TS, file: string, text: string): ConfigEntry[] {
|
|
1848
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
1849
|
+
const bare = (node: TS.Expression | undefined): TS.Expression | undefined => {
|
|
1850
|
+
let current = node
|
|
1851
|
+
while (
|
|
1852
|
+
current !== undefined &&
|
|
1853
|
+
(ts.isAsExpression(current) ||
|
|
1854
|
+
ts.isSatisfiesExpression(current) ||
|
|
1855
|
+
ts.isParenthesizedExpression(current))
|
|
1856
|
+
)
|
|
1857
|
+
current = current.expression
|
|
1858
|
+
return current
|
|
1859
|
+
}
|
|
1860
|
+
const read = configPathReader(ts, sf)
|
|
1861
|
+
const out: ConfigEntry[] = []
|
|
1862
|
+
const literal = (field: string, role: ConfigEntry["role"], node: TS.Expression | undefined) => {
|
|
1863
|
+
const path = read(node)
|
|
1864
|
+
if (path !== undefined) out.push({ ...path, field, role })
|
|
1865
|
+
}
|
|
1866
|
+
for (const statement of sf.statements) {
|
|
1867
|
+
if (
|
|
1868
|
+
!ts.isVariableStatement(statement) ||
|
|
1869
|
+
!hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword)
|
|
1870
|
+
)
|
|
1871
|
+
continue
|
|
1872
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1873
|
+
if (!ts.isIdentifier(declaration.name)) continue
|
|
1874
|
+
if (declaration.name.text === "clientModule")
|
|
1875
|
+
literal("clientModule", "frontend", declaration.initializer)
|
|
1876
|
+
if (declaration.name.text !== "componentPipeline") continue
|
|
1877
|
+
const pipeline = bare(declaration.initializer)
|
|
1878
|
+
if (pipeline === undefined || !ts.isObjectLiteralExpression(pipeline)) continue
|
|
1879
|
+
for (const property of pipeline.properties) {
|
|
1880
|
+
if (!ts.isPropertyAssignment(property) || property.name.getText(sf) !== "entries") continue
|
|
1881
|
+
const entries = bare(property.initializer)
|
|
1882
|
+
if (entries === undefined || !ts.isObjectLiteralExpression(entries)) continue
|
|
1883
|
+
for (const entry of entries.properties) {
|
|
1884
|
+
if (!ts.isPropertyAssignment(entry)) continue
|
|
1885
|
+
const name = entry.name.getText(sf).replace(/^["']|["']$/g, "")
|
|
1886
|
+
if (name === "client" || name === "ssr" || name === "rsc")
|
|
1887
|
+
literal(
|
|
1888
|
+
`componentPipeline.entries.${name}`,
|
|
1889
|
+
name === "client" ? "frontend" : "backend",
|
|
1890
|
+
entry.initializer,
|
|
1891
|
+
)
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return out
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/** The package an import names: `@scope/pkg` or `pkg`, without a subpath. */
|
|
1900
|
+
const packageName = (specifier: string): string => {
|
|
1901
|
+
const parts = specifier.split("/")
|
|
1902
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? "")
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
const isRootScript = (file: string, rootMoves: Readonly<Record<string, string>>): boolean =>
|
|
1906
|
+
!file.includes("/") && SOURCE_FILE.test(file) && rootMoves[file] === undefined
|
|
986
1907
|
const isTestFile = (file: string): boolean =>
|
|
987
1908
|
/(?:^|\/)(?:test|tests|__tests__)\//.test(file) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)
|
|
988
1909
|
|
|
1910
|
+
/** The web config an app has, old root `framework.ts` included, or `undefined`. */
|
|
1911
|
+
const webConfigFile = (
|
|
1912
|
+
root: string,
|
|
1913
|
+
rootMoves: Readonly<Record<string, string>>,
|
|
1914
|
+
): string | undefined =>
|
|
1915
|
+
rootMoves["framework.ts"] !== undefined
|
|
1916
|
+
? "framework.ts"
|
|
1917
|
+
: existsSync(join(root, FRAMEWORK_FILE))
|
|
1918
|
+
? FRAMEWORK_FILE
|
|
1919
|
+
: hasWebConfig(root, ".")
|
|
1920
|
+
? CONFIG_FILE
|
|
1921
|
+
: undefined
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* `"api"` when the app declares `"nifra": { "kind": "api" }`, or has no web config, no `routes/` and a
|
|
1925
|
+
* backend; `"web"` when it has `routes/`. Web config without `routes/` could be either, so it throws.
|
|
1926
|
+
*/
|
|
1927
|
+
function layoutMode(root: string, rootMoves: Readonly<Record<string, string>>): "web" | "api" {
|
|
1928
|
+
const declared = createKindProbe(root)(root)
|
|
1929
|
+
if (declared.invalid !== undefined) throw new Error(declared.invalid)
|
|
1930
|
+
const config = webConfigFile(root, rootMoves)
|
|
1931
|
+
if (declared.kind === "api") {
|
|
1932
|
+
if (config === undefined) return "api"
|
|
1933
|
+
throw new Error(
|
|
1934
|
+
`[nifra] ${declared.manifest} declares "nifra": { "kind": "api" }, but ${root} has web config (${config}); remove "kind" to migrate it as a web app, or remove the web config`,
|
|
1935
|
+
)
|
|
1936
|
+
}
|
|
1937
|
+
if (existsSync(join(root, "routes"))) return "web"
|
|
1938
|
+
const nested = nestedAppHint(root)
|
|
1939
|
+
if (config !== undefined) {
|
|
1940
|
+
throw new Error(
|
|
1941
|
+
`[nifra] no routes/ directory in ${root}, but it has web config (${config}). A web app is file-routed under routes/; if this one serves no pages, remove the web config and declare "nifra": { "kind": "api" } in package.json${nested}`,
|
|
1942
|
+
)
|
|
1943
|
+
}
|
|
1944
|
+
if (declared.kind === "web") throw new Error(`[nifra] no routes/ directory in ${root}${nested}`)
|
|
1945
|
+
if (rootMoves["backend.ts"] === undefined && !existsSync(join(root, BACKEND_APP_FILE))) {
|
|
1946
|
+
throw new Error(
|
|
1947
|
+
`[nifra] ${root} has no routes/, no web config and no backend (a backend.ts that exports \`backend\`, or ${BACKEND_APP_FILE}); there is nothing to migrate${nested}`,
|
|
1948
|
+
)
|
|
1949
|
+
}
|
|
1950
|
+
return "api"
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/** An app one folder down (`src/routes/`): the `--dir` that migrates it, as a sentence to append. */
|
|
1954
|
+
function nestedAppHint(root: string): string {
|
|
1955
|
+
const found = readdirSync(root, { withFileTypes: true })
|
|
1956
|
+
.filter(
|
|
1957
|
+
(entry) =>
|
|
1958
|
+
entry.isDirectory() &&
|
|
1959
|
+
!entry.name.startsWith(".") &&
|
|
1960
|
+
!SKIP_DIRS.has(entry.name) &&
|
|
1961
|
+
existsSync(join(root, entry.name, "routes")),
|
|
1962
|
+
)
|
|
1963
|
+
.map((entry) => entry.name)
|
|
1964
|
+
.sort(codeUnitOrder)
|
|
1965
|
+
const first = found[0]
|
|
1966
|
+
if (first === undefined) return ""
|
|
1967
|
+
return `. ${found.map((name) => `${name}/routes/`).join(", ")} exists: run \`nifra migrate layout --dir ${first}\` to migrate the app there`
|
|
1968
|
+
}
|
|
1969
|
+
|
|
989
1970
|
export async function migrateLayout(
|
|
990
1971
|
appRoot: string,
|
|
991
1972
|
options: LayoutMigrationOptions = {},
|
|
@@ -994,7 +1975,15 @@ export async function migrateLayout(
|
|
|
994
1975
|
if (ts === undefined)
|
|
995
1976
|
throw new Error("[nifra] `nifra migrate layout` needs TypeScript: bun add -d typescript")
|
|
996
1977
|
const root = resolve(appRoot)
|
|
997
|
-
|
|
1978
|
+
// Only a root file that exports what the old layout read moves; any other backend.ts is a module.
|
|
1979
|
+
const rootMoves: Readonly<Record<string, string>> = Object.fromEntries(retiredRootFiles(root))
|
|
1980
|
+
const mode = layoutMode(root, rootMoves)
|
|
1981
|
+
// An API-only app has no browser: nothing to split, and every file is server code where it is.
|
|
1982
|
+
const api = mode === "api"
|
|
1983
|
+
const zones = appZones(root)
|
|
1984
|
+
const configured = [...(zones.backend ?? []), ...(zones.frontend ?? []), ...(zones.shared ?? [])]
|
|
1985
|
+
const inConfiguredFolder = (file: string): boolean =>
|
|
1986
|
+
configured.some((folder) => file === folder || file.startsWith(`${folder}/`))
|
|
998
1987
|
const original: Files = new Map()
|
|
999
1988
|
const everyFile = listFiles(root)
|
|
1000
1989
|
for (const file of everyFile) {
|
|
@@ -1002,7 +1991,7 @@ export async function migrateLayout(
|
|
|
1002
1991
|
original.set(file, readFileSync(join(root, file), "utf8"))
|
|
1003
1992
|
}
|
|
1004
1993
|
const issues: LayoutMigrationIssue[] = []
|
|
1005
|
-
const splits: Array<{ file: string; backend: string; moved: string[] }> = []
|
|
1994
|
+
const splits: Array<{ file: string; backend: string; moved: string[]; shared?: string }> = []
|
|
1006
1995
|
/** The app after the migration: new path -> text. */
|
|
1007
1996
|
const next: Files = new Map(original)
|
|
1008
1997
|
/** Old path -> new path, for every file that moves. */
|
|
@@ -1011,7 +2000,7 @@ export async function migrateLayout(
|
|
|
1011
2000
|
// 1-2. Route halves and middleware.
|
|
1012
2001
|
const backendHalves = new Map<string, string>()
|
|
1013
2002
|
for (const [file, text] of original) {
|
|
1014
|
-
if (!file.startsWith("routes/") || file.includes(".backend.")) continue
|
|
2003
|
+
if (api || !file.startsWith("routes/") || file.includes(".backend.")) continue
|
|
1015
2004
|
if (/(?:^|\/)_middleware\.[cm]?[jt]sx?$/.test(file)) {
|
|
1016
2005
|
const target = posix.join(posix.dirname(file), "_layout.backend.ts")
|
|
1017
2006
|
backendHalves.set(
|
|
@@ -1025,13 +2014,25 @@ export async function migrateLayout(
|
|
|
1025
2014
|
if (!ROUTE_FILE.test(file)) continue
|
|
1026
2015
|
const backendFile = file.replace(ROUTE_FILE, ".backend.ts")
|
|
1027
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) }
|
|
1028
2023
|
let result: SplitResult
|
|
1029
2024
|
if (/\.(?:svelte|vue)$/.test(file)) {
|
|
1030
2025
|
const blocks = scriptBlocks(text)
|
|
1031
2026
|
const block =
|
|
1032
2027
|
blocks.find((b) => b.module) ?? blocks.find((b) => !b.setup && file.endsWith(".vue"))
|
|
1033
2028
|
if (block === undefined) continue
|
|
1034
|
-
const inner = splitRouteSource(
|
|
2029
|
+
const inner = splitRouteSource(
|
|
2030
|
+
ts,
|
|
2031
|
+
text.slice(block.start, block.end),
|
|
2032
|
+
"block.ts",
|
|
2033
|
+
specifier,
|
|
2034
|
+
splitOptions,
|
|
2035
|
+
)
|
|
1035
2036
|
result = {
|
|
1036
2037
|
...inner,
|
|
1037
2038
|
frontend: `${text.slice(0, block.start)}\n${inner.frontend}${text.slice(block.end)}`,
|
|
@@ -1049,7 +2050,7 @@ export async function migrateLayout(
|
|
|
1049
2050
|
}
|
|
1050
2051
|
continue
|
|
1051
2052
|
} else {
|
|
1052
|
-
result = splitRouteSource(ts, text, file, specifier)
|
|
2053
|
+
result = splitRouteSource(ts, text, file, specifier, splitOptions)
|
|
1053
2054
|
}
|
|
1054
2055
|
if (result.backend === undefined) continue
|
|
1055
2056
|
next.set(file, result.frontend)
|
|
@@ -1057,7 +2058,12 @@ export async function migrateLayout(
|
|
|
1057
2058
|
backendFile,
|
|
1058
2059
|
mergeModules(ts, backendHalves.get(backendFile) ?? "", result.backend),
|
|
1059
2060
|
)
|
|
1060
|
-
|
|
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
|
+
}
|
|
1061
2067
|
for (const reason of result.issues) issues.push({ file, reason })
|
|
1062
2068
|
}
|
|
1063
2069
|
for (const [file, text] of backendHalves) {
|
|
@@ -1066,12 +2072,110 @@ export async function migrateLayout(
|
|
|
1066
2072
|
}
|
|
1067
2073
|
|
|
1068
2074
|
// 3-4. Zoning by runtime reachability, on the post-split graph.
|
|
1069
|
-
for (const [from, to] of Object.entries(
|
|
2075
|
+
for (const [from, to] of Object.entries(rootMoves)) if (next.has(from)) moveMap.set(from, to)
|
|
1070
2076
|
// Assets (images, fonts) resolve too, so they move with the modules that import them.
|
|
1071
2077
|
const known = new Set([...next.keys(), ...everyFile])
|
|
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
|
+
})
|
|
2083
|
+
const realRoot = (() => {
|
|
2084
|
+
try {
|
|
2085
|
+
return realpathSync(root)
|
|
2086
|
+
} catch {
|
|
2087
|
+
return root
|
|
2088
|
+
}
|
|
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
|
+
}
|
|
2099
|
+
/** A non-relative specifier the app's tsconfig `paths`/`baseUrl` or package.json `imports` maps to
|
|
2100
|
+
* one of its files, as the bundler reads it. */
|
|
2101
|
+
const viaAlias = (file: string, specifier: string): string | undefined => {
|
|
2102
|
+
if (specifier.startsWith(".") || isAbsolute(specifier)) return undefined
|
|
2103
|
+
const rel = appFile(resolver(join(root, file), withoutQuery(specifier)))
|
|
2104
|
+
return rel === undefined || !known.has(rel) ? undefined : rel
|
|
2105
|
+
}
|
|
2106
|
+
const resolveAny = (file: string, specifier: string): string | undefined =>
|
|
2107
|
+
resolveIn(known, file, specifier) ?? viaAlias(file, specifier)
|
|
2108
|
+
const ownName = (() => {
|
|
2109
|
+
try {
|
|
2110
|
+
const name: unknown = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name
|
|
2111
|
+
return typeof name === "string" ? name : undefined
|
|
2112
|
+
} catch {
|
|
2113
|
+
return undefined
|
|
2114
|
+
}
|
|
2115
|
+
})()
|
|
2116
|
+
const installed = new Map<string, boolean>()
|
|
2117
|
+
/** Whether a bare specifier names something outside the app that exists: a built-in, a URL or a
|
|
2118
|
+
* scheme the bundler owns, the app's own package name, or a package installed at or above it. */
|
|
2119
|
+
const external = (specifier: string): boolean => {
|
|
2120
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun") return true
|
|
2121
|
+
const name = packageName(withoutQuery(specifier))
|
|
2122
|
+
if (builtinModules.includes(name) || name === ownName) return true
|
|
2123
|
+
let found = installed.get(name)
|
|
2124
|
+
if (found === undefined) {
|
|
2125
|
+
const types = `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`
|
|
2126
|
+
found = false
|
|
2127
|
+
for (let dir = root; ; dir = dirname(dir)) {
|
|
2128
|
+
if (
|
|
2129
|
+
existsSync(join(dir, "node_modules", name)) ||
|
|
2130
|
+
existsSync(join(dir, "node_modules", types))
|
|
2131
|
+
) {
|
|
2132
|
+
found = true
|
|
2133
|
+
break
|
|
2134
|
+
}
|
|
2135
|
+
if (dirname(dir) === dir) break
|
|
2136
|
+
}
|
|
2137
|
+
installed.set(name, found)
|
|
2138
|
+
}
|
|
2139
|
+
return found
|
|
2140
|
+
}
|
|
2141
|
+
// A test (or any module) that imports `loader` from a page would import a name the page no longer
|
|
2142
|
+
// exports: it reads it from the backend half instead.
|
|
2143
|
+
const splitRoutes = new Map<string, { backend: string; moved: ReadonlySet<string> }>()
|
|
2144
|
+
for (const split of splits)
|
|
2145
|
+
if (next.has(split.file)) splitRoutes.set(split.file, { ...split, moved: new Set(split.moved) })
|
|
2146
|
+
/** `base` is where `file` is relative to: the app root, or the package folder around it. */
|
|
2147
|
+
const splitTarget =
|
|
2148
|
+
(base: string, file: string, resolve: (specifier: string) => string | undefined) =>
|
|
2149
|
+
(specifier: string): SplitTarget | undefined => {
|
|
2150
|
+
const route = resolve(specifier)
|
|
2151
|
+
const split = route === undefined ? undefined : splitRoutes.get(route)
|
|
2152
|
+
if (route === undefined || split === undefined) return undefined
|
|
2153
|
+
const inBase = (path: string): string => toPosix(relative(base, join(root, path)))
|
|
2154
|
+
const relativePath = backendSpecifier(specifier, inBase(route), inBase(split.backend), file)
|
|
2155
|
+
const respelled = specifier.startsWith(".")
|
|
2156
|
+
? relativePath
|
|
2157
|
+
: (resolver.respell(join(base, file), specifier, join(root, split.backend)) ?? relativePath)
|
|
2158
|
+
return { backend: split.backend, moved: split.moved, specifier: respelled }
|
|
2159
|
+
}
|
|
2160
|
+
const redirectedFiles = new Set<string>()
|
|
2161
|
+
for (const [file, text] of splitRoutes.size === 0 ? [] : next) {
|
|
2162
|
+
if (splitRoutes.has(file) || !/\.[cm]?[jt]sx?$/.test(file)) continue
|
|
2163
|
+
const redirected = redirectMovedImports(
|
|
2164
|
+
ts,
|
|
2165
|
+
file,
|
|
2166
|
+
text,
|
|
2167
|
+
splitTarget(root, file, (specifier) => resolveAny(file, specifier)),
|
|
2168
|
+
)
|
|
2169
|
+
if (redirected.text !== text) {
|
|
2170
|
+
next.set(file, redirected.text)
|
|
2171
|
+
redirectedFiles.add(file)
|
|
2172
|
+
}
|
|
2173
|
+
for (const reason of redirected.issues) issues.push({ file, reason })
|
|
2174
|
+
}
|
|
1072
2175
|
const edges = new Map<string, Array<{ target: string; typeOnly: boolean }>>()
|
|
1073
|
-
for (const [file, text] of next) {
|
|
2176
|
+
for (const [file, text] of api ? [] : next) {
|
|
1074
2177
|
const refs: Array<{ target: string; typeOnly: boolean }> = []
|
|
2178
|
+
const reported = new Set<string>()
|
|
1075
2179
|
for (const ref of importsOf(ts, file, text)) {
|
|
1076
2180
|
if (ref.specifier === "@nifrajs/web/plugins/vite-server-only") {
|
|
1077
2181
|
issues.push({
|
|
@@ -1080,23 +2184,57 @@ export async function migrateLayout(
|
|
|
1080
2184
|
"imports the retired @nifrajs/web/plugins/vite-server-only; remove it: nifra's Vite builds and dev server enforce the zones themselves (a hand-built Vite config uses viteLeakGuard from @nifrajs/web/plugins/vite-leak-guard)",
|
|
1081
2185
|
})
|
|
1082
2186
|
}
|
|
1083
|
-
const target =
|
|
2187
|
+
const target = resolveAny(file, ref.specifier)
|
|
1084
2188
|
if (target !== undefined) refs.push({ target, typeOnly: ref.typeOnly })
|
|
1085
|
-
else if (
|
|
2189
|
+
else if (
|
|
2190
|
+
!ref.specifier.startsWith(".") &&
|
|
2191
|
+
!isAbsolute(ref.specifier) &&
|
|
2192
|
+
RETIRED_SPECIFIERS[ref.specifier] === undefined &&
|
|
2193
|
+
resolver(join(root, file), ref.specifier) === undefined &&
|
|
2194
|
+
!external(ref.specifier) &&
|
|
2195
|
+
!reported.has(ref.specifier)
|
|
2196
|
+
) {
|
|
2197
|
+
reported.add(ref.specifier)
|
|
1086
2198
|
issues.push({
|
|
1087
2199
|
file,
|
|
1088
|
-
reason: `
|
|
2200
|
+
reason: `"${ref.specifier}" names no installed package and no file through the app's tsconfig paths or package.json imports, so the migration cannot tell where it belongs; check it by hand`,
|
|
1089
2201
|
})
|
|
2202
|
+
}
|
|
1090
2203
|
}
|
|
1091
2204
|
edges.set(file, refs)
|
|
1092
2205
|
}
|
|
1093
|
-
|
|
1094
|
-
const
|
|
1095
|
-
(
|
|
2206
|
+
// The config's module paths are entries too: the browser loads `clientModule`.
|
|
2207
|
+
const entries = CONFIG_FILES.filter((file) => next.has(file)).flatMap((file) =>
|
|
2208
|
+
configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
|
|
2209
|
+
...entry,
|
|
2210
|
+
file,
|
|
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}`),
|
|
2217
|
+
})),
|
|
1096
2218
|
)
|
|
2219
|
+
const entryTargets = (role: ConfigEntry["role"]): Set<string> =>
|
|
2220
|
+
new Set(
|
|
2221
|
+
entries.flatMap((entry) =>
|
|
2222
|
+
entry.role === role && entry.target !== undefined ? [entry.target] : [],
|
|
2223
|
+
),
|
|
2224
|
+
)
|
|
2225
|
+
const browserEntries = entryTargets("frontend")
|
|
2226
|
+
const serverEntries = entryTargets("backend")
|
|
2227
|
+
const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])]
|
|
2228
|
+
const frontendRoots = [
|
|
2229
|
+
...files.filter(
|
|
2230
|
+
(f) =>
|
|
2231
|
+
f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.") && !isTestFile(f),
|
|
2232
|
+
),
|
|
2233
|
+
...browserEntries,
|
|
2234
|
+
]
|
|
1097
2235
|
// A route page the server imports - a generated `server-manifest.ts` imports them all - is rendered
|
|
1098
2236
|
// there, not run as server code, so the server's walk stops at it: what only pages use stays frontend.
|
|
1099
|
-
const pages = new Set(frontendRoots)
|
|
2237
|
+
const pages = new Set(frontendRoots.filter((f) => !browserEntries.has(f)))
|
|
1100
2238
|
const reach = (roots: readonly string[], stopAt?: ReadonlySet<string>): Set<string> => {
|
|
1101
2239
|
const seen = new Set<string>()
|
|
1102
2240
|
const queue = [...roots]
|
|
@@ -1109,23 +2247,31 @@ export async function migrateLayout(
|
|
|
1109
2247
|
}
|
|
1110
2248
|
return seen
|
|
1111
2249
|
}
|
|
1112
|
-
const backendRoots =
|
|
1113
|
-
(
|
|
1114
|
-
(f
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
2250
|
+
const backendRoots = [
|
|
2251
|
+
...files.filter(
|
|
2252
|
+
(f) =>
|
|
2253
|
+
(f.startsWith("routes/") && f.includes(".backend.")) ||
|
|
2254
|
+
f.startsWith("backend/") ||
|
|
2255
|
+
rootMoves[f] !== undefined ||
|
|
2256
|
+
(isRootScript(f, rootMoves) &&
|
|
2257
|
+
f !== "nifra.config.ts" &&
|
|
2258
|
+
!isTestFile(f) &&
|
|
2259
|
+
!browserEntries.has(f)),
|
|
2260
|
+
),
|
|
2261
|
+
...serverEntries,
|
|
2262
|
+
]
|
|
1119
2263
|
const browser = reach(frontendRoots)
|
|
1120
2264
|
const server = reach(backendRoots, pages)
|
|
1121
2265
|
// What the app's own server modules reach, without the root scripts: a root file in here is a module
|
|
1122
2266
|
// the app imports, not an entry, so it moves into a zone.
|
|
1123
2267
|
const appServer = reach(
|
|
1124
|
-
backendRoots.filter(
|
|
2268
|
+
backendRoots.filter(
|
|
2269
|
+
(f) => f.includes("/") || rootMoves[f] !== undefined || serverEntries.has(f),
|
|
2270
|
+
),
|
|
1125
2271
|
pages,
|
|
1126
2272
|
)
|
|
1127
2273
|
// A `.server` module ran empty in the browser, so only server code used it: it moves under backend/.
|
|
1128
|
-
for (const file of files) {
|
|
2274
|
+
for (const file of api ? [] : files) {
|
|
1129
2275
|
if (!LEGACY_SERVER.test(file) || isTestFile(file)) continue
|
|
1130
2276
|
const parts = file.replace(LEGACY_SERVER, "$1").split("/")
|
|
1131
2277
|
if (parts.length > 1 && ZONE_DIRS.has(parts[0] ?? "")) parts.shift()
|
|
@@ -1138,14 +2284,17 @@ export async function migrateLayout(
|
|
|
1138
2284
|
})
|
|
1139
2285
|
}
|
|
1140
2286
|
}
|
|
1141
|
-
|
|
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 =>
|
|
1142
2290
|
browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend"
|
|
1143
|
-
for (const file of files) {
|
|
2291
|
+
for (const file of api ? [] : files) {
|
|
1144
2292
|
if (
|
|
1145
2293
|
moveMap.has(file) ||
|
|
1146
2294
|
isTestFile(file) ||
|
|
1147
2295
|
file.endsWith(".d.ts") ||
|
|
1148
|
-
file === "nifra.config.ts"
|
|
2296
|
+
file === "nifra.config.ts" ||
|
|
2297
|
+
inConfiguredFolder(file)
|
|
1149
2298
|
)
|
|
1150
2299
|
continue
|
|
1151
2300
|
if (/\.(?:frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.test(file)) continue
|
|
@@ -1153,8 +2302,66 @@ export async function migrateLayout(
|
|
|
1153
2302
|
if (file.includes("/")) {
|
|
1154
2303
|
if (ZONE_DIRS.has(top) || (!browser.has(file) && !server.has(file))) continue
|
|
1155
2304
|
} else if (!browser.has(file) && !appServer.has(file)) continue
|
|
1156
|
-
|
|
2305
|
+
const zone = zoneOf(file)
|
|
2306
|
+
if (DATA_FILE.test(file) && options.zones === true) {
|
|
2307
|
+
inPlace.set(file, zone)
|
|
2308
|
+
continue
|
|
2309
|
+
}
|
|
2310
|
+
if (DATA_FILE.test(file)) {
|
|
2311
|
+
const folder = file.includes("/") ? posix.dirname(file) : undefined
|
|
2312
|
+
issues.push({
|
|
2313
|
+
file,
|
|
2314
|
+
reason: `is data ${zone === "shared" ? "both sides import" : `${zone} code imports`}, and a build only loads files in a zone; it stays here because other tools may read it by path. Move it to "${zone}/${file}" by hand, or ${folder === undefined ? "into a folder listed" : `list "${folder}"`} under "nifra": { "zones": { "${zone}": [...] } } in package.json`,
|
|
2315
|
+
})
|
|
2316
|
+
continue
|
|
2317
|
+
}
|
|
2318
|
+
moveMap.set(file, `${zone}/${file}`)
|
|
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
|
+
}
|
|
1157
2363
|
}
|
|
2364
|
+
const declared = zoneDeclarations(inPlace, files, (file) => next.get(file), inConfiguredFolder)
|
|
1158
2365
|
const claimed = new Map<string, string>()
|
|
1159
2366
|
for (const [from, to] of moveMap) {
|
|
1160
2367
|
const holder = claimed.get(to) ?? (known.has(to) && !moveMap.has(to) ? to : undefined)
|
|
@@ -1168,32 +2375,166 @@ export async function migrateLayout(
|
|
|
1168
2375
|
})
|
|
1169
2376
|
moveMap.delete(from)
|
|
1170
2377
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
+
) {
|
|
1173
2435
|
issues.push({
|
|
1174
2436
|
file: from,
|
|
1175
|
-
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}`}`,
|
|
1176
2438
|
})
|
|
1177
2439
|
}
|
|
1178
2440
|
}
|
|
1179
2441
|
|
|
1180
2442
|
// 5. Rewrite imports to the new locations.
|
|
1181
|
-
const newPath = (file: string): string => moveMap.get(file) ?? file
|
|
1182
2443
|
const final: Files = new Map()
|
|
1183
2444
|
const rewritten: string[] = []
|
|
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
|
|
2492
|
+
}
|
|
1184
2493
|
for (const [file, text] of next) {
|
|
1185
2494
|
const imports = rewriteImports(ts, file, text, (specifier) => {
|
|
1186
2495
|
const retired = RETIRED_SPECIFIERS[specifier]
|
|
1187
2496
|
if (retired !== undefined) return retired
|
|
1188
|
-
const target =
|
|
2497
|
+
const target = resolveAny(file, specifier)
|
|
1189
2498
|
// Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
|
|
1190
2499
|
if (target === undefined) return rebase(specifier, file, newPath(file))
|
|
2500
|
+
if (!specifier.startsWith(".")) return aliasTo(file, specifier, target)
|
|
1191
2501
|
if (newPath(target) === target && newPath(file) === file) return undefined
|
|
1192
|
-
|
|
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
|
|
1193
2506
|
})
|
|
1194
2507
|
const updated = renameRetiredTypes(ts, file, imports)
|
|
1195
2508
|
final.set(newPath(file), updated)
|
|
1196
|
-
if (
|
|
2509
|
+
if (
|
|
2510
|
+
!moveMap.has(file) &&
|
|
2511
|
+
(redirectedFiles.has(file) || (updated !== text && original.get(file) === text))
|
|
2512
|
+
)
|
|
2513
|
+
rewritten.push(file)
|
|
2514
|
+
}
|
|
2515
|
+
|
|
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.
|
|
2518
|
+
for (const configFile of CONFIG_FILES) {
|
|
2519
|
+
const at = newPath(configFile)
|
|
2520
|
+
const text = final.get(at)
|
|
2521
|
+
if (text === undefined || !next.has(configFile)) continue
|
|
2522
|
+
const edits: Array<{ start: number; end: number; text: string }> = []
|
|
2523
|
+
for (const entry of configEntries(ts, at, text)) {
|
|
2524
|
+
if (entry.span === undefined) continue
|
|
2525
|
+
const target = resolveAny("nifra.config.ts", entry.path)
|
|
2526
|
+
if (target === undefined || newPath(target) === target) continue
|
|
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 })
|
|
2531
|
+
}
|
|
2532
|
+
if (edits.length === 0) continue
|
|
2533
|
+
let updated = text
|
|
2534
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
2535
|
+
updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end)
|
|
2536
|
+
final.set(at, updated)
|
|
2537
|
+
if (!moveMap.has(configFile) && !rewritten.includes(configFile)) rewritten.push(configFile)
|
|
1197
2538
|
}
|
|
1198
2539
|
|
|
1199
2540
|
const moves = [...moveMap]
|
|
@@ -1216,11 +2557,33 @@ export async function migrateLayout(
|
|
|
1216
2557
|
final.set(to, result.text)
|
|
1217
2558
|
if (result.unfollowed !== undefined) unfollowed.set(to, result.unfollowed)
|
|
1218
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
|
+
}
|
|
1219
2572
|
|
|
1220
2573
|
// A path in a string or comment is not an import, so nothing above rewrote it: a build script that
|
|
1221
2574
|
// writes `data/x.json` would keep writing to the old place, and so would a deploy script or
|
|
1222
2575
|
// `package.json` beside the code.
|
|
1223
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)
|
|
1224
2587
|
const scanned: Array<readonly [string, string]> = [...final]
|
|
1225
2588
|
for (const file of everyFile) {
|
|
1226
2589
|
if (
|
|
@@ -1228,7 +2591,10 @@ export async function migrateLayout(
|
|
|
1228
2591
|
TOOLING_FILE.test(file) &&
|
|
1229
2592
|
statSync(join(root, file)).size <= 256_000
|
|
1230
2593
|
)
|
|
1231
|
-
scanned.push([
|
|
2594
|
+
scanned.push([
|
|
2595
|
+
newPath(file),
|
|
2596
|
+
tsconfigs.files.get(file)?.text ?? readFileSync(join(root, file), "utf8"),
|
|
2597
|
+
])
|
|
1232
2598
|
}
|
|
1233
2599
|
for (const [file, text] of scanned) {
|
|
1234
2600
|
let live: ReadonlyArray<readonly [number, number]> | undefined
|
|
@@ -1249,6 +2615,74 @@ export async function migrateLayout(
|
|
|
1249
2615
|
}
|
|
1250
2616
|
}
|
|
1251
2617
|
|
|
2618
|
+
// Code beside an app one folder down (a package's test/ when the app is its src/) is not the
|
|
2619
|
+
// migration's to edit, so each import there that stops resolving to what it named is reported.
|
|
2620
|
+
const pkg = enclosingPackage(root)
|
|
2621
|
+
if (pkg !== undefined) {
|
|
2622
|
+
const prefix = toPosix(relative(pkg, root))
|
|
2623
|
+
const inApp = new Set([...known].map((file) => `${prefix}/${file}`))
|
|
2624
|
+
const appPath = (file: string, specifier: string): string | undefined =>
|
|
2625
|
+
resolveIn(inApp, file, specifier)?.slice(prefix.length + 1)
|
|
2626
|
+
const outside = ` - it is outside ${prefix}/, so the migration does not rewrite it`
|
|
2627
|
+
for (const file of listFiles(pkg)) {
|
|
2628
|
+
if (file.startsWith(`${prefix}/`) || !/\.[cm]?[jt]sx?$/.test(file)) continue
|
|
2629
|
+
const text = readFileSync(join(pkg, file), "utf8")
|
|
2630
|
+
const at = toPosix(relative(root, join(pkg, file)))
|
|
2631
|
+
for (const { specifier } of importsOf(ts, file, text)) {
|
|
2632
|
+
const target = appPath(file, specifier)
|
|
2633
|
+
const to = target === undefined ? undefined : moveMap.get(target)
|
|
2634
|
+
if (to !== undefined)
|
|
2635
|
+
issues.push({
|
|
2636
|
+
file: at,
|
|
2637
|
+
reason: `imports "${specifier}", which moves to "${to}"${outside}`,
|
|
2638
|
+
})
|
|
2639
|
+
}
|
|
2640
|
+
const redirected = redirectMovedImports(
|
|
2641
|
+
ts,
|
|
2642
|
+
file,
|
|
2643
|
+
text,
|
|
2644
|
+
splitTarget(pkg, file, (specifier) => appPath(file, specifier)),
|
|
2645
|
+
)
|
|
2646
|
+
for (const { names, from, to } of redirected.redirects) {
|
|
2647
|
+
issues.push({
|
|
2648
|
+
file: at,
|
|
2649
|
+
reason: `imports ${names.map((name) => `"${name}"`).join(", ")} from "${from}"; import it from "${to}"${outside}`,
|
|
2650
|
+
})
|
|
2651
|
+
}
|
|
2652
|
+
for (const reason of redirected.issues) issues.push({ file: at, reason })
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
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
|
+
|
|
1252
2686
|
// Paths a module finds at run time are not imports either: one built from the module's own location
|
|
1253
2687
|
// now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
|
|
1254
2688
|
const leaving = new Map<string, Array<{ from: string; to: string }>>()
|
|
@@ -1261,7 +2695,8 @@ export async function migrateLayout(
|
|
|
1261
2695
|
})
|
|
1262
2696
|
}
|
|
1263
2697
|
const dir = posix.dirname(move.from)
|
|
1264
|
-
if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? ""))
|
|
2698
|
+
if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? "") || inConfiguredFolder(move.from))
|
|
2699
|
+
continue
|
|
1265
2700
|
leaving.set(dir, [...(leaving.get(dir) ?? []), move])
|
|
1266
2701
|
}
|
|
1267
2702
|
for (const [dir, group] of leaving) {
|
|
@@ -1281,58 +2716,704 @@ export async function migrateLayout(
|
|
|
1281
2716
|
})
|
|
1282
2717
|
}
|
|
1283
2718
|
|
|
1284
|
-
// The server refuses data a loader or action returns without an output schema
|
|
1285
|
-
//
|
|
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[] = []
|
|
1286
2739
|
for (const [file, text] of final) {
|
|
1287
2740
|
if (!file.startsWith("routes/") || !/\.backend\.[cm]?[jt]s$/.test(file)) continue
|
|
1288
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
|
+
}
|
|
1289
2756
|
issues.push({
|
|
1290
2757
|
file,
|
|
1291
|
-
reason:
|
|
2758
|
+
reason: `${missing}. Declare what the browser may see: export const ${schema} = t.object({ ... }); no draft was written: ${why ?? schemaPlan?.warnings.join("; ")}`,
|
|
2759
|
+
})
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// Tooling that lists folders or globs, not files: the import rewrite above never sees it.
|
|
2764
|
+
const tooling: Array<{ file: string; change: string }> = []
|
|
2765
|
+
const toolingWrites = new Map<string, string>()
|
|
2766
|
+
for (const [file, { text, changes }] of tsconfigs.files) {
|
|
2767
|
+
toolingWrites.set(file, text)
|
|
2768
|
+
for (const change of changes) tooling.push({ file, change })
|
|
2769
|
+
}
|
|
2770
|
+
issues.push(...tsconfigs.issues)
|
|
2771
|
+
for (const file of everyFile) {
|
|
2772
|
+
if (!TAILWIND_CONFIG.test(file)) continue
|
|
2773
|
+
const reason = staleContentGlobs(ts, readFileSync(join(root, file), "utf8"), moves)
|
|
2774
|
+
if (reason !== undefined) issues.push({ file, reason })
|
|
2775
|
+
}
|
|
2776
|
+
const gitignore = ignoreNifraState(root)
|
|
2777
|
+
if (gitignore !== undefined) {
|
|
2778
|
+
toolingWrites.set(".gitignore", gitignore)
|
|
2779
|
+
tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" })
|
|
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',
|
|
1292
2796
|
})
|
|
1293
2797
|
}
|
|
1294
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
|
+
}
|
|
2808
|
+
// `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
|
|
2809
|
+
const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {}
|
|
2810
|
+
if (kind.issue !== undefined) issues.push({ file: "package.json", reason: kind.issue })
|
|
2811
|
+
if (kind.text !== undefined) {
|
|
2812
|
+
toolingWrites.set("package.json", kind.text)
|
|
2813
|
+
tooling.push({ file: "package.json", change: '"nifra": { "kind": "api" }' })
|
|
2814
|
+
}
|
|
1295
2815
|
|
|
1296
2816
|
if (options.write === true) {
|
|
2817
|
+
for (const [file, text] of toolingWrites) writeFileSync(join(root, file), text)
|
|
1297
2818
|
for (const [file, text] of final) {
|
|
1298
2819
|
if (original.get(file) === text) continue
|
|
1299
2820
|
const path = join(root, file)
|
|
1300
2821
|
mkdirSync(dirname(path), { recursive: true })
|
|
1301
2822
|
writeFileSync(path, text)
|
|
1302
2823
|
}
|
|
2824
|
+
const vacated: string[] = []
|
|
1303
2825
|
for (const file of original.keys()) {
|
|
1304
|
-
if (
|
|
2826
|
+
if (final.has(file)) continue
|
|
2827
|
+
rmSync(join(root, file), { force: true })
|
|
2828
|
+
vacated.push(file)
|
|
1305
2829
|
}
|
|
1306
2830
|
for (const { from, to } of moves) {
|
|
1307
2831
|
if (original.has(from) || !existsSync(join(root, from))) continue
|
|
1308
2832
|
mkdirSync(dirname(join(root, to)), { recursive: true })
|
|
1309
2833
|
renameSync(join(root, from), join(root, to))
|
|
2834
|
+
vacated.push(from)
|
|
1310
2835
|
}
|
|
1311
|
-
|
|
2836
|
+
pruneVacatedDirs(root, vacated)
|
|
1312
2837
|
}
|
|
1313
2838
|
return {
|
|
1314
2839
|
ok: issues.length === 0,
|
|
1315
2840
|
write: options.write === true,
|
|
2841
|
+
mode,
|
|
2842
|
+
followUps,
|
|
1316
2843
|
splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
|
|
1317
2844
|
moves,
|
|
1318
2845
|
rewritten: rewritten.sort(),
|
|
2846
|
+
tooling,
|
|
2847
|
+
schemas: schemas.map((entry) =>
|
|
2848
|
+
options.write === true && entry.reason === undefined
|
|
2849
|
+
? { ...entry, status: "generated" }
|
|
2850
|
+
: entry,
|
|
2851
|
+
),
|
|
1319
2852
|
issues,
|
|
1320
2853
|
}
|
|
1321
2854
|
}
|
|
1322
2855
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
+
|
|
2893
|
+
/** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
|
|
2894
|
+
function appZones(root: string): ZoneConfig {
|
|
2895
|
+
const manifest = join(root, "package.json")
|
|
2896
|
+
if (!existsSync(manifest)) return {}
|
|
2897
|
+
let parsed: unknown
|
|
2898
|
+
try {
|
|
2899
|
+
parsed = JSON.parse(readFileSync(manifest, "utf8"))
|
|
2900
|
+
} catch {
|
|
2901
|
+
return {}
|
|
2902
|
+
}
|
|
2903
|
+
const nifra =
|
|
2904
|
+
typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "nifra") : undefined
|
|
2905
|
+
const zones =
|
|
2906
|
+
typeof nifra === "object" && nifra !== null ? Reflect.get(nifra, "zones") : undefined
|
|
2907
|
+
return zones === undefined ? {} : parseZoneConfig(zones, manifest)
|
|
2908
|
+
}
|
|
2909
|
+
|
|
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}`
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
/**
|
|
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.
|
|
2962
|
+
*/
|
|
2963
|
+
function followTsconfigs(
|
|
2964
|
+
ts: typeof TS,
|
|
2965
|
+
root: string,
|
|
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
|
+
})
|
|
1329
2995
|
continue
|
|
1330
2996
|
}
|
|
1331
|
-
const
|
|
1332
|
-
|
|
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))
|
|
3171
|
+
const last = array.elements[array.elements.length - 1]
|
|
3172
|
+
if (last === undefined) {
|
|
3173
|
+
const at = array.getStart(source) + 1
|
|
3174
|
+
return `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`
|
|
3175
|
+
}
|
|
3176
|
+
let at = last.end
|
|
3177
|
+
const trailing = /^\s*,/.exec(text.slice(at))
|
|
3178
|
+
if (trailing !== null) at += trailing[0].length
|
|
3179
|
+
// A multi-line array gets one entry per line at the last element's indent, below any comment that
|
|
3180
|
+
// closes the last element's line; anything else gets them inline.
|
|
3181
|
+
if (text.slice(array.getStart(source), last.getStart(source)).includes("\n")) {
|
|
3182
|
+
const lineStart = text.lastIndexOf("\n", last.getStart(source)) + 1
|
|
3183
|
+
const indent = text.slice(lineStart, last.getStart(source)).match(/^\s*/)?.[0] ?? ""
|
|
3184
|
+
const newline = text.indexOf("\n", at)
|
|
3185
|
+
const eol = newline === -1 ? text.length : newline
|
|
3186
|
+
if (/^\s*(?:\/\/.*|\/\*.*\*\/\s*)?$/.test(text.slice(at, eol))) {
|
|
3187
|
+
const head =
|
|
3188
|
+
trailing !== null
|
|
3189
|
+
? text.slice(0, eol)
|
|
3190
|
+
: `${text.slice(0, last.end)},${text.slice(last.end, eol)}`
|
|
3191
|
+
const lines = quoted.join(`,\n${indent}`)
|
|
3192
|
+
return `${head}\n${indent}${lines}${trailing !== null ? "," : ""}${text.slice(eol)}`
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`
|
|
3196
|
+
return `${text.slice(0, at)}${insert}${text.slice(at)}`
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
const jsonProperty = (
|
|
3200
|
+
ts: typeof TS,
|
|
3201
|
+
object: TS.ObjectLiteralExpression,
|
|
3202
|
+
key: string,
|
|
3203
|
+
): TS.PropertyAssignment | undefined =>
|
|
3204
|
+
object.properties.find(
|
|
3205
|
+
(property): property is TS.PropertyAssignment =>
|
|
3206
|
+
ts.isPropertyAssignment(property) &&
|
|
3207
|
+
ts.isStringLiteral(property.name) &&
|
|
3208
|
+
property.name.text === key,
|
|
3209
|
+
)
|
|
3210
|
+
|
|
3211
|
+
/** `text` with `entry` as the last property of `object`, on its own line when the object spans lines. */
|
|
3212
|
+
function insertProperty(
|
|
3213
|
+
source: TS.JsonSourceFile,
|
|
3214
|
+
text: string,
|
|
3215
|
+
object: TS.ObjectLiteralExpression,
|
|
3216
|
+
entry: string,
|
|
3217
|
+
): string {
|
|
3218
|
+
const open = object.getStart(source)
|
|
3219
|
+
const last = object.properties[object.properties.length - 1]
|
|
3220
|
+
if (last === undefined) return `${text.slice(0, open)}{ ${entry} }${text.slice(object.end)}`
|
|
3221
|
+
const start = last.getStart(source)
|
|
3222
|
+
if (!text.slice(open, start).includes("\n")) {
|
|
3223
|
+
return `${text.slice(0, last.end)}, ${entry}${text.slice(last.end)}`
|
|
3224
|
+
}
|
|
3225
|
+
const indent = /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", start) + 1, start))?.[0] ?? ""
|
|
3226
|
+
return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`
|
|
3227
|
+
}
|
|
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
|
+
|
|
3334
|
+
/** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
|
|
3335
|
+
function declareApiKind(ts: typeof TS, root: string): { text?: string; issue?: string } {
|
|
3336
|
+
const path = join(root, "package.json")
|
|
3337
|
+
if (!existsSync(path)) {
|
|
3338
|
+
return {
|
|
3339
|
+
issue:
|
|
3340
|
+
'there is none in the app; declare "nifra": { "kind": "api" } in the package.json that governs it',
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
const text = readFileSync(path, "utf8")
|
|
3344
|
+
const source = ts.parseJsonText(path, text)
|
|
3345
|
+
const top = source.statements[0]?.expression
|
|
3346
|
+
if (top === undefined || !ts.isObjectLiteralExpression(top))
|
|
3347
|
+
return { issue: 'is not a JSON object; add "nifra": { "kind": "api" } to it by hand' }
|
|
3348
|
+
const nifra = jsonProperty(ts, top, "nifra")
|
|
3349
|
+
if (nifra === undefined) {
|
|
3350
|
+
return { text: insertProperty(source, text, top, '"nifra": { "kind": "api" }') }
|
|
3351
|
+
}
|
|
3352
|
+
if (!ts.isObjectLiteralExpression(nifra.initializer))
|
|
3353
|
+
return { issue: '"nifra" is not an object; add "kind": "api" to it by hand' }
|
|
3354
|
+
return { text: insertProperty(source, text, nifra.initializer, '"kind": "api"') }
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
/** Tailwind `content` globs that matched a moved file at its old path and match nothing at its new one. */
|
|
3358
|
+
function staleContentGlobs(
|
|
3359
|
+
ts: typeof TS,
|
|
3360
|
+
text: string,
|
|
3361
|
+
moves: ReadonlyArray<{ readonly from: string; readonly to: string }>,
|
|
3362
|
+
): string | undefined {
|
|
3363
|
+
const globs: string[] = []
|
|
3364
|
+
const source = ts.createSourceFile("tailwind.config.ts", text, ts.ScriptTarget.Latest, true)
|
|
3365
|
+
const visit = (node: TS.Node): void => {
|
|
3366
|
+
if (ts.isStringLiteralLike(node) && /[*/]/.test(node.text) && !node.text.startsWith("!"))
|
|
3367
|
+
globs.push(node.text.replace(/^\.\//, ""))
|
|
3368
|
+
ts.forEachChild(node, visit)
|
|
3369
|
+
}
|
|
3370
|
+
visit(source)
|
|
3371
|
+
const matchers = globs.map((glob) => ({ glob, matcher: new Bun.Glob(glob) }))
|
|
3372
|
+
const lost = moves.filter(
|
|
3373
|
+
({ from, to }) =>
|
|
3374
|
+
matchers.some(({ matcher }) => matcher.match(from)) &&
|
|
3375
|
+
!matchers.some(({ matcher }) => matcher.match(to)),
|
|
3376
|
+
)
|
|
3377
|
+
const first = lost[0]
|
|
3378
|
+
if (first === undefined) return undefined
|
|
3379
|
+
const folders = [...new Set(lost.map(({ to }) => to.split("/")[0] ?? to))].sort(codeUnitOrder)
|
|
3380
|
+
return `its content globs matched ${lost.length} file(s) that move, but none of their new paths (such as "${first.from}" -> "${first.to}"), so Tailwind would stop seeing their classes. Add globs for ${folders.map((folder) => `"./${folder}/**/*"`).join(", ")} (with the extensions you use)`
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
/**
|
|
3384
|
+
* `.nifra/` holds generated types, dev state and the dev server's launch token; none of it belongs in
|
|
3385
|
+
* git. The new `.gitignore` text when it is not ignored yet, else `undefined`.
|
|
3386
|
+
*/
|
|
3387
|
+
function ignoreNifraState(root: string): string | undefined {
|
|
3388
|
+
let exitCode: number | null = null
|
|
3389
|
+
try {
|
|
3390
|
+
exitCode = Bun.spawnSync(["git", "check-ignore", "-q", ".nifra/types"], {
|
|
3391
|
+
cwd: root,
|
|
3392
|
+
stdout: "ignore",
|
|
3393
|
+
stderr: "ignore",
|
|
3394
|
+
}).exitCode
|
|
3395
|
+
} catch {
|
|
3396
|
+
// No git installed: the same as no checkout.
|
|
3397
|
+
}
|
|
3398
|
+
if (exitCode === 0) return undefined
|
|
3399
|
+
const path = join(root, ".gitignore")
|
|
3400
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : ""
|
|
3401
|
+
// Outside a git checkout `check-ignore` cannot answer; the app's own file decides.
|
|
3402
|
+
if (exitCode !== 1 && /^\/?\.nifra(?:\/|\*|\/\*\*)?\s*$/m.test(current)) return undefined
|
|
3403
|
+
const separator = current === "" || current.endsWith("\n") ? "" : "\n"
|
|
3404
|
+
return `${current}${separator}.nifra/\n`
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
/** Remove each directory the run emptied, then each parent that leaves empty. A directory that was
|
|
3408
|
+
* empty before the run is the app's own, not the migration's to remove. */
|
|
3409
|
+
function pruneVacatedDirs(root: string, vacated: Iterable<string>): void {
|
|
3410
|
+
const dirs = new Set<string>()
|
|
3411
|
+
for (const file of vacated) dirs.add(dirname(join(root, file)))
|
|
3412
|
+
// Deepest first, so a parent is judged after its emptied children are gone.
|
|
3413
|
+
for (const start of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
3414
|
+
for (let dir = start; dir !== root && dir.startsWith(`${root}${sep}`); dir = dirname(dir)) {
|
|
3415
|
+
if (!existsSync(dir) || readdirSync(dir).length > 0) break
|
|
3416
|
+
rmdirSync(dir)
|
|
1333
3417
|
}
|
|
1334
|
-
if (empty && dir !== root) rmSync(dir, { recursive: true, force: true })
|
|
1335
|
-
return empty
|
|
1336
3418
|
}
|
|
1337
|
-
prune(root)
|
|
1338
3419
|
}
|