@zerotal/core 1.7.4 → 1.8.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/CHANGELOG.md +43 -0
- package/api-surface.md +29 -57
- package/package.json +1 -1
- package/src/application/Application.ts +18 -2
- package/src/assets/assets.ts +56 -4
- package/src/assets/index.ts +9 -4
- package/src/build/PackageLinter.ts +56 -0
- package/src/command/Command.ts +52 -13
- package/src/command/CommandRunner.ts +4 -0
- package/src/command/builtin/AssetsBuildCommand.ts +13 -1
- package/src/command/builtin/ServeCommand.ts +8 -1
- package/src/command/builtin/UpgradeCommand.ts +148 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/dev/BuildOutput.ts +149 -27
- package/src/dev/CssPlugins.ts +9 -2
- package/src/dev/DevDeck.ts +33 -7
- package/src/dev/index.ts +1 -1
- package/src/dev/startDevMode.ts +31 -14
- package/src/doctor/AppDoctor.ts +23 -5
- package/src/events/FrameworkEvents.ts +2 -1
- package/src/helpers/response.ts +8 -0
- package/src/upgrade/codemods/deprecated-aliases.ts +111 -0
- package/src/upgrade/codemods/index.ts +15 -0
- package/src/upgrade/runner.ts +148 -0
- package/src/upgrade/types.ts +78 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger #4 — retire the deprecated aliases.
|
|
3
|
+
*
|
|
4
|
+
* Three pairs, each a live alias somebody depends on, which is exactly why they
|
|
5
|
+
* could not be dropped in a minor:
|
|
6
|
+
*
|
|
7
|
+
* - `BaseModel` → `Model`. The same class object under its original name.
|
|
8
|
+
* - `routes:types` → `route:types`. Registered as an alias in `CommandRunner`.
|
|
9
|
+
* - `serve --dev` → `dev`. The flag still works; `dev` is the command it became.
|
|
10
|
+
*
|
|
11
|
+
* ## What this deliberately does not do
|
|
12
|
+
*
|
|
13
|
+
* **It does not touch `BaseModel` in a type position.** `Model` and `BaseModel`
|
|
14
|
+
* are the same runtime class, so a value-position rename is cosmetic and safe.
|
|
15
|
+
* In a generic bound — `<T extends BaseModel>` — the name may be load-bearing
|
|
16
|
+
* for a reader even though it resolves identically, and in framework source it
|
|
17
|
+
* genuinely is. Those are reported for a person instead.
|
|
18
|
+
*
|
|
19
|
+
* That asymmetry is the whole reason this is a codemod rather than a
|
|
20
|
+
* find-and-replace: the mixin-composition script learned it the hard way in
|
|
21
|
+
* 1.3.0, where a blind rename broke import specifiers it had not considered.
|
|
22
|
+
*/
|
|
23
|
+
import type { Change, Codemod, CodemodResult, Manual, SourceFile } from "../types.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `BaseModel` in a class heritage clause — the one place renaming is safe.
|
|
27
|
+
*
|
|
28
|
+
* Anchored on `class Name extends`, not on `extends` alone. A generic bound
|
|
29
|
+
* writes `<T extends BaseModel>` with the same keyword, and matching that was a
|
|
30
|
+
* real bug the tests caught: it rewrote precisely the type positions this file
|
|
31
|
+
* says it will not touch.
|
|
32
|
+
*/
|
|
33
|
+
const BASE_MODEL_VALUE = /\bclass\s+(\w+)(\s*<[^>]*>)?\s+extends\s+BaseModel\b/g;
|
|
34
|
+
|
|
35
|
+
/** `BaseModel` inside a generic bound or a type annotation. Reported, not rewritten. */
|
|
36
|
+
const BASE_MODEL_TYPE = /<[^>]*\bextends\s+BaseModel\b[^>]*>|:\s*BaseModel\b/;
|
|
37
|
+
|
|
38
|
+
/** The import specifier, which has to follow the rename or the file stops compiling. */
|
|
39
|
+
const BASE_MODEL_IMPORT = /(\bimport\s*\{[^}]*?)\bBaseModel\b([^}]*?\}\s*from\s*["'][^"']*["'])/g;
|
|
40
|
+
|
|
41
|
+
const COMMAND_ALIASES: { find: RegExp; replace: string; label: string }[] = [
|
|
42
|
+
{ find: /\broutes:types\b/g, replace: "route:types", label: "`routes:types` → `route:types`" },
|
|
43
|
+
{
|
|
44
|
+
find: /\b(zt|zerotal)\s+serve\s+--dev\b/g,
|
|
45
|
+
replace: "$1 dev",
|
|
46
|
+
label: "`serve --dev` → `dev`",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export const deprecatedAliases: Codemod = {
|
|
51
|
+
version: "2.0.0",
|
|
52
|
+
name: "deprecated-aliases",
|
|
53
|
+
description: "Retire BaseModel, routes:types and serve --dev in favour of their real names",
|
|
54
|
+
ledger: 4,
|
|
55
|
+
|
|
56
|
+
run(files: SourceFile[]): CodemodResult {
|
|
57
|
+
const changes: Change[] = [];
|
|
58
|
+
const manual: Manual[] = [];
|
|
59
|
+
|
|
60
|
+
for (const { file, contents } of files) {
|
|
61
|
+
let next = contents;
|
|
62
|
+
const notes: string[] = [];
|
|
63
|
+
|
|
64
|
+
// Value positions first, then the import that has to agree with them.
|
|
65
|
+
const valueHits = next.match(BASE_MODEL_VALUE)?.length ?? 0;
|
|
66
|
+
if (valueHits > 0) {
|
|
67
|
+
next = next.replace(
|
|
68
|
+
BASE_MODEL_VALUE,
|
|
69
|
+
(_match, name: string, generics = "") => `class ${name}${generics} extends Model`,
|
|
70
|
+
);
|
|
71
|
+
notes.push(`${valueHits} × \`extends BaseModel\` → \`extends Model\``);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (valueHits > 0 && BASE_MODEL_IMPORT.test(next)) {
|
|
75
|
+
BASE_MODEL_IMPORT.lastIndex = 0;
|
|
76
|
+
// `Model` may already be imported alongside it; collapsing to a single
|
|
77
|
+
// `Model` in that case would produce a duplicate specifier.
|
|
78
|
+
next = next.replace(BASE_MODEL_IMPORT, (whole, before: string, after: string) =>
|
|
79
|
+
/\bModel\b\s*[,}]/.test(before + after)
|
|
80
|
+
? whole.replace(/\bBaseModel\b\s*,\s*/, "").replace(/,\s*\bBaseModel\b/, "")
|
|
81
|
+
: `${before}Model${after}`,
|
|
82
|
+
);
|
|
83
|
+
notes.push("import specifier updated");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const { find, replace, label } of COMMAND_ALIASES) {
|
|
87
|
+
const hits = next.match(find)?.length ?? 0;
|
|
88
|
+
if (hits === 0) continue;
|
|
89
|
+
next = next.replace(find, replace);
|
|
90
|
+
notes.push(`${hits} × ${label}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Type positions are a handover, not a rewrite.
|
|
94
|
+
contents.split("\n").forEach((line, i) => {
|
|
95
|
+
if (!BASE_MODEL_TYPE.test(line)) return;
|
|
96
|
+
manual.push({
|
|
97
|
+
file,
|
|
98
|
+
line: i + 1,
|
|
99
|
+
text: line.trim(),
|
|
100
|
+
reason:
|
|
101
|
+
"`BaseModel` in a type position. It resolves to the same class as `Model`, so this " +
|
|
102
|
+
"compiles either way — renaming it is a readability call, not a correctness one.",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (next !== contents) changes.push({ file, summary: notes.join(", "), contents: next });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { changes, manual };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every codemod, in one list.
|
|
3
|
+
*
|
|
4
|
+
* The runner orders by version, so the order here is only what a reader sees.
|
|
5
|
+
* A codemod belongs here the moment its ledger entry is decided, even if the
|
|
6
|
+
* release that needs it is some way off — the roadmap's rule is that every
|
|
7
|
+
* ledger entry which *can* have a codemod has one before 2.0 ships, and the way
|
|
8
|
+
* that rule fails is by everyone assuming there is time.
|
|
9
|
+
*/
|
|
10
|
+
import type { Codemod } from "../types.ts";
|
|
11
|
+
import { deprecatedAliases } from "./deprecated-aliases.ts";
|
|
12
|
+
|
|
13
|
+
export const CODEMODS: Codemod[] = [deprecatedAliases];
|
|
14
|
+
|
|
15
|
+
export { deprecatedAliases };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The upgrade runner: read the tree once, apply the codemods a version gap
|
|
3
|
+
* calls for, and report before writing anything.
|
|
4
|
+
*
|
|
5
|
+
* `scripts/codemod-mixin-composition.ts` proved the shape on a real 1.3.0 break
|
|
6
|
+
* — walk the tree, rewrite call sites, fix the imports a find-and-replace would
|
|
7
|
+
* have broken, offer `--dry`. This generalises that one-off into something a
|
|
8
|
+
* release can hand to users, which is what `zt upgrade` has to be if the 2.0
|
|
9
|
+
* ledger is ever going to be payable.
|
|
10
|
+
*
|
|
11
|
+
* Three properties it keeps:
|
|
12
|
+
*
|
|
13
|
+
* **Nothing is written until the whole plan is known.** Codemods return the new
|
|
14
|
+
* contents rather than writing, so a run that fails halfway leaves no
|
|
15
|
+
* half-upgraded tree, and `--dry` is the same code path as the real thing rather
|
|
16
|
+
* than a separate one that can drift from it.
|
|
17
|
+
*
|
|
18
|
+
* **Running twice is safe.** Every codemod is expected to be idempotent, and the
|
|
19
|
+
* second run is the test: it should report no changes. That matters because the
|
|
20
|
+
* first thing anyone does after an upgrade that printed warnings is run it again.
|
|
21
|
+
*
|
|
22
|
+
* **What it could not do is the headline.** See `Manual` in `./types.ts`.
|
|
23
|
+
*/
|
|
24
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
25
|
+
import { join, extname, relative } from "node:path";
|
|
26
|
+
import type { Change, Codemod, Manual, SourceFile } from "./types.ts";
|
|
27
|
+
import { compareVersions } from "./types.ts";
|
|
28
|
+
|
|
29
|
+
/** Never walked. Build output and dependencies are not the app's source. */
|
|
30
|
+
const SKIP_DIRS = new Set([
|
|
31
|
+
"node_modules",
|
|
32
|
+
".git",
|
|
33
|
+
"dist",
|
|
34
|
+
"build",
|
|
35
|
+
"coverage",
|
|
36
|
+
".next",
|
|
37
|
+
".zerotal",
|
|
38
|
+
".release-tarballs",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".md", ".mdx"];
|
|
42
|
+
|
|
43
|
+
export interface UpgradePlan {
|
|
44
|
+
/** Codemods selected for this version gap, in the order they will run. */
|
|
45
|
+
codemods: Codemod[];
|
|
46
|
+
/** Every rewrite, keyed by file — later codemods see earlier ones' output. */
|
|
47
|
+
changes: Map<string, Change>;
|
|
48
|
+
manual: Manual[];
|
|
49
|
+
/** Files read, for the "scanned N files" line. */
|
|
50
|
+
scanned: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every source file under `root`, relative-pathed and forward-slashed. */
|
|
54
|
+
export async function collectFiles(root: string, extensions: string[]): Promise<SourceFile[]> {
|
|
55
|
+
const wanted = new Set(extensions);
|
|
56
|
+
const out: SourceFile[] = [];
|
|
57
|
+
|
|
58
|
+
async function walk(dir: string): Promise<void> {
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
62
|
+
} catch {
|
|
63
|
+
return; // unreadable directory is not a reason to abandon an upgrade
|
|
64
|
+
}
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const full = join(dir, entry.name);
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
69
|
+
await walk(full);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!wanted.has(extname(entry.name))) continue;
|
|
73
|
+
out.push({
|
|
74
|
+
file: relative(root, full).replace(/\\/g, "/"),
|
|
75
|
+
contents: await readFile(full, "utf8"),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await walk(root);
|
|
81
|
+
return out.sort((a, b) => a.file.localeCompare(b.file));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The codemods that apply to a move from `from` to `to`.
|
|
86
|
+
*
|
|
87
|
+
* Exclusive of `from`, inclusive of `to`: an app already on 1.8.0 has paid
|
|
88
|
+
* 1.8.0's codemods, and an app moving *to* 2.0.0 owes 2.0.0's.
|
|
89
|
+
*/
|
|
90
|
+
export function selectCodemods(all: Codemod[], from: string, to: string): Codemod[] {
|
|
91
|
+
return all
|
|
92
|
+
.filter((c) => compareVersions(c.version, from) > 0 && compareVersions(c.version, to) <= 0)
|
|
93
|
+
.sort((a, b) => compareVersions(a.version, b.version) || a.name.localeCompare(b.name));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the plan without touching disk.
|
|
98
|
+
*
|
|
99
|
+
* Codemods run in sequence over the *accumulated* contents, so a later one sees
|
|
100
|
+
* what an earlier one produced. Two codemods rewriting the same line is a real
|
|
101
|
+
* possibility across a version range, and the alternative — each reading the
|
|
102
|
+
* original — silently drops one of them.
|
|
103
|
+
*/
|
|
104
|
+
export async function planUpgrade(
|
|
105
|
+
root: string,
|
|
106
|
+
codemods: Codemod[],
|
|
107
|
+
from: string,
|
|
108
|
+
to: string,
|
|
109
|
+
): Promise<UpgradePlan> {
|
|
110
|
+
const selected = selectCodemods(codemods, from, to);
|
|
111
|
+
const extensions = [...new Set(selected.flatMap((c) => c.extensions ?? DEFAULT_EXTENSIONS))];
|
|
112
|
+
const files = await collectFiles(root, extensions.length ? extensions : DEFAULT_EXTENSIONS);
|
|
113
|
+
|
|
114
|
+
const current = new Map(files.map((f) => [f.file, f.contents]));
|
|
115
|
+
const changes = new Map<string, Change>();
|
|
116
|
+
const manual: Manual[] = [];
|
|
117
|
+
|
|
118
|
+
for (const codemod of selected) {
|
|
119
|
+
const wanted = new Set(codemod.extensions ?? DEFAULT_EXTENSIONS);
|
|
120
|
+
const input: SourceFile[] = [...current]
|
|
121
|
+
.filter(([file]) => wanted.has(extname(file)))
|
|
122
|
+
.map(([file, contents]) => ({ file, contents }));
|
|
123
|
+
|
|
124
|
+
const result = codemod.run(input);
|
|
125
|
+
for (const change of result.changes) {
|
|
126
|
+
current.set(change.file, change.contents);
|
|
127
|
+
const existing = changes.get(change.file);
|
|
128
|
+
changes.set(change.file, {
|
|
129
|
+
file: change.file,
|
|
130
|
+
// One line per file in the report, so a file touched by two codemods
|
|
131
|
+
// reads as one entry naming both rather than appearing twice.
|
|
132
|
+
summary: existing ? `${existing.summary}; ${change.summary}` : change.summary,
|
|
133
|
+
contents: change.contents,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
manual.push(...result.manual);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { codemods: selected, changes, manual, scanned: files.length };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Write the plan. Separate from building it, so `--dry` shares every other step. */
|
|
143
|
+
export async function applyPlan(root: string, plan: UpgradePlan): Promise<number> {
|
|
144
|
+
for (const change of plan.changes.values()) {
|
|
145
|
+
await writeFile(join(root, change.file), change.contents, "utf8");
|
|
146
|
+
}
|
|
147
|
+
return plan.changes.size;
|
|
148
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an upgrade codemod is, and what it has to report.
|
|
3
|
+
*
|
|
4
|
+
* The shape is built around one belief: **the interesting output of an upgrade
|
|
5
|
+
* tool is what it could not do.** A codemod that rewrites nine call sites and
|
|
6
|
+
* silently walks past a tenth it did not understand is worse than one that
|
|
7
|
+
* rewrites nothing, because the nine give the impression the job is finished.
|
|
8
|
+
* So a codemod returns two lists, and the runner prints the second one louder.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A file the codemod would rewrite, or did. */
|
|
12
|
+
export interface Change {
|
|
13
|
+
/** Repo-relative path, forward slashes. */
|
|
14
|
+
file: string;
|
|
15
|
+
/** What changed, in a line a person can scan — "3 × `BaseModel` → `Model`". */
|
|
16
|
+
summary: string;
|
|
17
|
+
/** The rewritten contents. Held rather than written, so `--dry` costs nothing extra. */
|
|
18
|
+
contents: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Something the codemod recognised and deliberately did not touch.
|
|
23
|
+
*
|
|
24
|
+
* Not an error — a handover. Each one is a place the author has to look, with
|
|
25
|
+
* enough detail to find it and a reason that explains why a machine should not
|
|
26
|
+
* decide.
|
|
27
|
+
*/
|
|
28
|
+
export interface Manual {
|
|
29
|
+
file: string;
|
|
30
|
+
/** 1-based, so it can be clicked. */
|
|
31
|
+
line: number;
|
|
32
|
+
/** The line as it stands. */
|
|
33
|
+
text: string;
|
|
34
|
+
/** Why this one is a person's call. */
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CodemodResult {
|
|
39
|
+
changes: Change[];
|
|
40
|
+
manual: Manual[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One file handed to a codemod. */
|
|
44
|
+
export interface SourceFile {
|
|
45
|
+
file: string;
|
|
46
|
+
contents: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Codemod {
|
|
50
|
+
/**
|
|
51
|
+
* The release that makes this necessary.
|
|
52
|
+
*
|
|
53
|
+
* Codemods run in version order, and only those between the app's current
|
|
54
|
+
* version and the target. A codemod for 2.0.0 does not run on an app moving
|
|
55
|
+
* from 1.6 to 1.7.
|
|
56
|
+
*/
|
|
57
|
+
version: string;
|
|
58
|
+
/** Stable identifier, for `--only` and for reporting. */
|
|
59
|
+
name: string;
|
|
60
|
+
/** One line, shown in the plan before anything is written. */
|
|
61
|
+
description: string;
|
|
62
|
+
/** The ledger entry this pays, if it pays one. */
|
|
63
|
+
ledger?: number;
|
|
64
|
+
/** Files this wants to see. Keeps a codemod from scanning what it cannot use. */
|
|
65
|
+
extensions?: string[];
|
|
66
|
+
run(files: SourceFile[]): CodemodResult;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Compare two `x.y.z` strings numerically. */
|
|
70
|
+
export function compareVersions(a: string, b: string): number {
|
|
71
|
+
const left = a.split(".").map(Number);
|
|
72
|
+
const right = b.split(".").map(Number);
|
|
73
|
+
for (let i = 0; i < 3; i++) {
|
|
74
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
75
|
+
if (diff !== 0) return diff;
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|