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