@hirobius/design-system 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +969 -0
  3. package/CONSUMING.md +23 -9
  4. package/MIGRATIONS.md +603 -0
  5. package/README.md +2 -0
  6. package/UPGRADING.md +222 -0
  7. package/codemods/hds-prefix.mjs +28 -13
  8. package/codemods/jsx-fold.mjs +28 -13
  9. package/codemods/lib/atomic-write.mjs +28 -0
  10. package/codemods/lib/installed-version.mjs +354 -0
  11. package/codemods/lib/project.mjs +395 -0
  12. package/codemods/lib/record.mjs +110 -0
  13. package/codemods/lib/scan.mjs +323 -0
  14. package/codemods/lib/semver.mjs +57 -0
  15. package/codemods/not-found-pattern.mjs +3 -3
  16. package/codemods/patterns-subpath.mjs +28 -12
  17. package/codemods/registry.mjs +67 -0
  18. package/codemods/tile-grid.mjs +3 -3
  19. package/codemods/upgrade.mjs +909 -0
  20. package/dist/hirobius-ui.js +1 -1
  21. package/dist/manifest.js +3 -3
  22. package/dist/styles.css +1 -1
  23. package/dist/tokens.css +1 -1
  24. package/dist/types/src/app/components/alert-dialog.d.ts +1 -1
  25. package/dist/types/src/app/components/alert.d.ts +1 -1
  26. package/dist/types/src/app/components/asset-img.d.ts +1 -1
  27. package/dist/types/src/app/components/avatar-group.d.ts +1 -1
  28. package/dist/types/src/app/components/avatar.d.ts +1 -1
  29. package/dist/types/src/app/components/badge.d.ts +1 -1
  30. package/dist/types/src/app/components/blockquote.d.ts +1 -1
  31. package/dist/types/src/app/components/breadcrumb.d.ts +1 -1
  32. package/dist/types/src/app/components/button.d.ts +1 -1
  33. package/dist/types/src/app/components/callout.d.ts +1 -1
  34. package/dist/types/src/app/components/card.d.ts +1 -1
  35. package/dist/types/src/app/components/checkbox.d.ts +1 -1
  36. package/dist/types/src/app/components/code-block.d.ts +1 -1
  37. package/dist/types/src/app/components/combobox.d.ts +1 -1
  38. package/dist/types/src/app/components/data-table-section.d.ts +1 -1
  39. package/dist/types/src/app/components/destructive-section.d.ts +1 -1
  40. package/dist/types/src/app/components/dialog.d.ts +1 -1
  41. package/dist/types/src/app/components/disclosure.d.ts +1 -1
  42. package/dist/types/src/app/components/divider.d.ts +1 -1
  43. package/dist/types/src/app/components/empty-state.d.ts +1 -1
  44. package/dist/types/src/app/components/field.d.ts +1 -1
  45. package/dist/types/src/app/components/form-actions.d.ts +1 -1
  46. package/dist/types/src/app/components/form.d.ts +1 -1
  47. package/dist/types/src/app/components/hds-tooltip.d.ts +1 -1
  48. package/dist/types/src/app/components/icon.d.ts +1 -1
  49. package/dist/types/src/app/components/inline-link.d.ts +1 -1
  50. package/dist/types/src/app/components/input.d.ts +1 -1
  51. package/dist/types/src/app/components/kbd.d.ts +1 -1
  52. package/dist/types/src/app/components/menu.d.ts +1 -1
  53. package/dist/types/src/app/components/metadata-list.d.ts +1 -1
  54. package/dist/types/src/app/components/metric-tiles.d.ts +1 -1
  55. package/dist/types/src/app/components/pagination.d.ts +1 -1
  56. package/dist/types/src/app/components/popover.d.ts +1 -1
  57. package/dist/types/src/app/components/progress.d.ts +1 -1
  58. package/dist/types/src/app/components/radio.d.ts +1 -1
  59. package/dist/types/src/app/components/select.d.ts +1 -1
  60. package/dist/types/src/app/components/skeleton.d.ts +1 -1
  61. package/dist/types/src/app/components/slider.d.ts +1 -1
  62. package/dist/types/src/app/components/spinner.d.ts +1 -1
  63. package/dist/types/src/app/components/stat.d.ts +1 -1
  64. package/dist/types/src/app/components/status-list-item.d.ts +1 -1
  65. package/dist/types/src/app/components/status-tile.d.ts +1 -1
  66. package/dist/types/src/app/components/table.d.ts +1 -1
  67. package/dist/types/src/app/components/tabs.d.ts +1 -1
  68. package/dist/types/src/app/components/tag.d.ts +1 -1
  69. package/dist/types/src/app/components/text.d.ts +1 -1
  70. package/dist/types/src/app/components/textarea.d.ts +1 -1
  71. package/dist/types/src/app/components/timestamp.d.ts +1 -1
  72. package/dist/types/src/app/components/toast.d.ts +1 -1
  73. package/dist/types/src/app/components/toggle.d.ts +1 -1
  74. package/dist/types/src/index.d.ts +79 -32
  75. package/hirobius.tokens.json +86 -39
  76. package/mcp/catalog.mjs +10 -7
  77. package/mcp/core-set.mjs +66 -0
  78. package/mcp/guide.mjs +9 -52
  79. package/package.json +23 -7
  80. package/public/hds-manifest.json +109 -109
  81. package/src/app/data/component-api.json +54 -54
  82. package/upgrade/index.json +208 -0
  83. package/upgrade/releases/0.17.0.json +962 -0
  84. package/upgrade/releases/0.18.0.json +8 -0
  85. package/upgrade/releases/0.19.0.json +233 -0
  86. package/upgrade/releases/0.19.1.json +8 -0
  87. package/upgrade/releases/0.20.0.json +5108 -0
  88. package/upgrade/releases/0.21.0.json +538 -0
  89. package/upgrade/releases/0.22.0.json +94 -0
  90. package/upgrade/schema.json +989 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * project — what a consumer repo looks like to the upgrade command (hds#452):
3
+ * its package manager, its lockfile, every importer that declares
4
+ * @hirobius/design-system, and the version each one has installed.
5
+ *
6
+ * Node builtins only. An importer is the root package or a workspace package
7
+ * (pnpm-workspace.yaml, or package.json `workspaces` for npm, yarn and bun).
8
+ * The installed version comes from, in order: the lockfile, the importer's
9
+ * node_modules copy, `--from`. A lockfile at git HEAD or at the merge-base with
10
+ * the default branch that holds an older version wins (the dependency was
11
+ * bumped by hand and not yet committed or merged); when the lockfile already
12
+ * says the target, the lockfile's own first-parent history is read too. An
13
+ * explicit `--from` skips git. A version read from history below the floor
14
+ * becomes the floor, with `belowFloor` set: only a version actually installed
15
+ * is refused for it.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
19
+ import { dirname, join, relative, resolve, sep } from 'node:path';
20
+ import { HDS_PACKAGE, lockfileKind, pickLockfile, readLockfile } from './installed-version.mjs';
21
+ import { compareVersions, isVersion } from './semver.mjs';
22
+
23
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
24
+ const SKIP = new Set(['node_modules', '.git']);
25
+
26
+ function readJsonFile(file) {
27
+ try {
28
+ return JSON.parse(readFileSync(file, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** The range an importer's package.json declares for HDS, from its first field that has one. */
35
+ export function declaredRange(pkg) {
36
+ for (const field of DEP_FIELDS) {
37
+ const range = pkg?.[field]?.[HDS_PACKAGE];
38
+ if (typeof range === 'string') return range;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** One YAML scalar: its trailing ` # comment` dropped, then unquoted. */
44
+ function yamlScalar(raw) {
45
+ const text = raw.trim();
46
+ const quoted = /^(['"])(.*?)\1/.exec(text);
47
+ if (quoted) return quoted[2];
48
+ return text.replace(/\s+#.*$/, '').trim();
49
+ }
50
+
51
+ /** `packages:` of pnpm-workspace.yaml, as a block list or a flow list (`[a, b]`), unquoted. */
52
+ function pnpmWorkspaceGlobs(text) {
53
+ const globs = [];
54
+ let inPackages = false;
55
+ for (const line of text.split(/\r?\n/)) {
56
+ const head = /^packages\s*:\s*(.*)$/.exec(line);
57
+ if (head) {
58
+ const rest = head[1].replace(/^#.*$/, '').trim();
59
+ const flow = /^\[(.*)\]/.exec(rest);
60
+ if (flow) {
61
+ for (const item of flow[1].split(',')) {
62
+ const glob = yamlScalar(item);
63
+ if (glob) globs.push(glob);
64
+ }
65
+ return globs;
66
+ }
67
+ inPackages = true;
68
+ continue;
69
+ }
70
+ if (!inPackages) continue;
71
+ if (/^[^\s#-]/.test(line)) break;
72
+ const item = /^\s*-\s*(.+?)\s*$/.exec(line);
73
+ if (item) {
74
+ const glob = yamlScalar(item[1]);
75
+ if (glob) globs.push(glob);
76
+ }
77
+ }
78
+ return globs;
79
+ }
80
+
81
+ /** Directories (relative, posix) under `root` that a workspace glob names. */
82
+ function expandGlob(root, glob) {
83
+ const segments = glob
84
+ .replace(/\/+$/, '')
85
+ .split('/')
86
+ .filter((s) => s && s !== '.');
87
+ const out = new Set();
88
+ const subdirs = (dir) => {
89
+ try {
90
+ return readdirSync(join(root, dir), { withFileTypes: true })
91
+ .filter((e) => e.isDirectory() && !SKIP.has(e.name))
92
+ .map((e) => (dir ? `${dir}/${e.name}` : e.name));
93
+ } catch {
94
+ return [];
95
+ }
96
+ };
97
+ const step = (dir, i) => {
98
+ if (i === segments.length) {
99
+ out.add(dir);
100
+ return;
101
+ }
102
+ const seg = segments[i];
103
+ if (seg === '**') {
104
+ step(dir, i + 1);
105
+ for (const sub of subdirs(dir)) step(sub, i);
106
+ return;
107
+ }
108
+ if (!/[*?]/.test(seg)) {
109
+ const next = dir ? `${dir}/${seg}` : seg;
110
+ if (existsSync(join(root, next))) step(next, i + 1);
111
+ return;
112
+ }
113
+ const re = new RegExp(
114
+ `^${seg
115
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
116
+ .replace(/\*/g, '[^/]*')
117
+ .replace(/\?/g, '[^/]')}$`,
118
+ );
119
+ for (const sub of subdirs(dir))
120
+ if (re.test(sub.slice(sub.lastIndexOf('/') + 1))) step(sub, i + 1);
121
+ };
122
+ step('', 0);
123
+ return [...out];
124
+ }
125
+
126
+ const hasPackageJson = (root, dir) => existsSync(join(root, ...dir.split('/'), 'package.json'));
127
+
128
+ /** The workspace globs a root declares, whichever manager wrote them. */
129
+ function workspaceGlobs(root, rootPkg) {
130
+ const yaml = join(root, 'pnpm-workspace.yaml');
131
+ if (existsSync(yaml)) return pnpmWorkspaceGlobs(readFileSync(yaml, 'utf8'));
132
+ const ws = rootPkg?.workspaces;
133
+ if (Array.isArray(ws)) return ws;
134
+ if (Array.isArray(ws?.packages)) return ws.packages;
135
+ return [];
136
+ }
137
+
138
+ /**
139
+ * Every importer under `root` that declares HDS, root first, then by directory.
140
+ * A workspace package whose package.json cannot be read is never skipped in
141
+ * silence: its path and the reason go on `unreadable`, for the caller to refuse.
142
+ * @param {string} root
143
+ * @param {{ file: string, reason: string }[]} [unreadable]
144
+ * @returns {{ dir: string, abs: string, pkg: any, range: string, nested: string[] }[]}
145
+ */
146
+ export function findImporters(root, unreadable = []) {
147
+ const rootPkg = readJsonFile(join(root, 'package.json'));
148
+ const globs = workspaceGlobs(root, rootPkg);
149
+ const include = new Set();
150
+ const exclude = new Set();
151
+ for (const glob of globs) {
152
+ const negated = glob.startsWith('!');
153
+ for (const dir of expandGlob(root, negated ? glob.slice(1) : glob)) {
154
+ (negated ? exclude : include).add(dir);
155
+ }
156
+ }
157
+ const dirs = [
158
+ ...new Set([
159
+ '.',
160
+ ...[...include].filter((d) => d && !exclude.has(d) && hasPackageJson(root, d)).sort(),
161
+ ]),
162
+ ];
163
+ const absOf = (dir) => (dir === '.' ? root : join(root, ...dir.split('/')));
164
+ const out = [];
165
+ for (const dir of dirs) {
166
+ const abs = absOf(dir);
167
+ let pkg = rootPkg;
168
+ if (dir !== '.') {
169
+ const file = join(abs, 'package.json');
170
+ try {
171
+ pkg = JSON.parse(readFileSync(file, 'utf8'));
172
+ } catch (error) {
173
+ unreadable.push({ file, reason: error.message });
174
+ continue;
175
+ }
176
+ }
177
+ const range = declaredRange(pkg);
178
+ if (range === null) continue;
179
+ // Workspace packages inside this one are importers of their own: never scan them twice.
180
+ const nested = dirs
181
+ .filter((d) => d !== dir && (dir === '.' || d.startsWith(`${dir}/`)))
182
+ .map(absOf);
183
+ out.push({ dir, abs, pkg, range, nested });
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * The workspace that `root` is a package of, when a parent directory declares
190
+ * one (pnpm-workspace.yaml, or package.json `workspaces`) whose globs name it:
191
+ * `{ dir, rel }`, the workspace root and root's path in it (posix), or null.
192
+ * Only the nearest parent that declares a workspace is asked.
193
+ */
194
+ export function enclosingWorkspace(root) {
195
+ const abs = resolve(root);
196
+ for (let dir = dirname(abs); ; dir = dirname(dir)) {
197
+ const pkg = readJsonFile(join(dir, 'package.json'));
198
+ const globs = workspaceGlobs(dir, pkg);
199
+ if (globs.length > 0 || existsSync(join(dir, 'pnpm-workspace.yaml'))) {
200
+ const rel = relative(dir, abs).split(sep).join('/');
201
+ const names = (negated) =>
202
+ new Set(
203
+ globs
204
+ .filter((g) => g.startsWith('!') === negated)
205
+ .flatMap((g) => expandGlob(dir, negated ? g.slice(1) : g)),
206
+ );
207
+ return names(false).has(rel) && !names(true).has(rel) ? { dir, rel } : null;
208
+ }
209
+ if (dirname(dir) === dir) return null;
210
+ }
211
+ }
212
+
213
+ /** The package manager and lockfile at a root (lockfile null when there is none to read). */
214
+ export function detectManager(root, rootPkg) {
215
+ let names = [];
216
+ try {
217
+ names = readdirSync(root);
218
+ } catch {
219
+ // an unreadable root has no lockfile
220
+ }
221
+ const lockfile = pickLockfile(names, rootPkg?.packageManager);
222
+ const declared = String(rootPkg?.packageManager ?? '').split('@')[0];
223
+ const manager =
224
+ (lockfile && lockfileKind(lockfile)) ||
225
+ (['pnpm', 'npm', 'yarn', 'bun'].includes(declared) ? declared : null) ||
226
+ (names.includes('bun.lockb') ? 'bun' : null);
227
+ return { manager, lockfile };
228
+ }
229
+
230
+ /** Read the lockfile at a root, or null; `ranges` feeds yarn v1 (importer dir → declared range). */
231
+ export function readRootLockfile(root, lockfile, importers) {
232
+ if (!lockfile) return null;
233
+ try {
234
+ const ranges = Object.fromEntries(importers.map((i) => [i.dir, i.range]));
235
+ return readLockfile(lockfile, readFileSync(join(root, lockfile), 'utf8'), { ranges });
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /** The version an importer's node_modules (or the hoisted root copy) holds, or null. */
242
+ export function nodeModulesVersion(root, importer) {
243
+ for (const base of [importer.abs, root]) {
244
+ const pkg = readJsonFile(join(base, 'node_modules', ...HDS_PACKAGE.split('/'), 'package.json'));
245
+ if (isVersion(pkg?.version)) return pkg.version;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ // ── git ──────────────────────────────────────────────────────────────────────
251
+
252
+ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
253
+ /** How many commits that touched the lockfile to read back, newest first. */
254
+ const LOG_DEPTH = 200;
255
+
256
+ /** git with GIT_* stripped (a hook's GIT_DIR must not point it elsewhere) and cwd = root. */
257
+ function git(root, args) {
258
+ const env = { ...process.env };
259
+ for (const key of Object.keys(env)) if (key.startsWith('GIT_')) delete env[key];
260
+ try {
261
+ // A real lockfile is often several MiB: the default 1 MiB maxBuffer would
262
+ // kill `git show` and lose the old version without a word.
263
+ const res = spawnSync('git', args, {
264
+ cwd: root,
265
+ env,
266
+ encoding: 'utf8',
267
+ timeout: 60_000,
268
+ maxBuffer: GIT_MAX_BUFFER,
269
+ });
270
+ if (res.error) return null;
271
+ return res.status === 0 ? res.stdout : null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ /** The default branch to take a merge-base with: origin's HEAD, else main or master. */
278
+ function defaultBranch(root) {
279
+ const head = git(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
280
+ if (head) return head.trim().replace(/^refs\/remotes\//, '');
281
+ for (const name of ['origin/main', 'origin/master', 'main', 'master']) {
282
+ if (git(root, ['rev-parse', '--verify', '--quiet', `${name}^{commit}`])) return name;
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * The git revisions to recover an old lockfile from: HEAD, then the
289
+ * merge-base with the default branch (when it differs). `null` when root is
290
+ * not in a git work tree or has no commit.
291
+ * @returns {{ rev: string, source: 'git-head' | 'git-merge-base' }[] | null}
292
+ */
293
+ export function historyRevisions(root) {
294
+ const head = git(root, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'])?.trim();
295
+ if (!head) return null;
296
+ const revs = [{ rev: head, source: 'git-head' }];
297
+ const branch = defaultBranch(root);
298
+ const base = branch ? git(root, ['merge-base', 'HEAD', branch])?.trim() : null;
299
+ if (base && base !== head) revs.push({ rev: base, source: 'git-merge-base' });
300
+ return revs;
301
+ }
302
+
303
+ /**
304
+ * The commits that changed the lockfile, newest first along HEAD's first
305
+ * parents: where the old version is when the bump is already committed on the
306
+ * default branch, so HEAD and the merge-base both say the target.
307
+ */
308
+ function lockfileCommits(root, lockfile) {
309
+ const out = git(root, [
310
+ 'log',
311
+ '--first-parent',
312
+ `--max-count=${LOG_DEPTH}`,
313
+ '--format=%H',
314
+ 'HEAD',
315
+ '--',
316
+ lockfile,
317
+ ]);
318
+ return out ? out.split('\n').filter(Boolean) : [];
319
+ }
320
+
321
+ /** An importer's HDS version in the lockfile as it was at `rev`, or null. */
322
+ export function versionAtRevision(root, rev, lockfile, importer) {
323
+ const text = git(root, ['show', `${rev}:./${lockfile}`]);
324
+ if (text === null) return null;
325
+ let ranges = { [importer.dir]: importer.range };
326
+ if (lockfile === 'yarn.lock') {
327
+ const path = importer.dir === '.' ? 'package.json' : `${importer.dir}/package.json`;
328
+ const old = git(root, ['show', `${rev}:./${path}`]);
329
+ try {
330
+ ranges = { [importer.dir]: declaredRange(JSON.parse(old)) };
331
+ } catch {
332
+ // keep today's range
333
+ }
334
+ }
335
+ try {
336
+ const v = readLockfile(lockfile, text, { ranges }).importers[importer.dir];
337
+ return isVersion(v) ? v : null;
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * The version each importer upgrades from, and where it was read.
345
+ * `noHistory` is set when the version is already the target and nothing says
346
+ * which one it was upgraded from: 'none' (no git, or nothing in it to read) or
347
+ * 'at-target' (every commit of the lockfile says the target). `belowFloor`
348
+ * holds a version read from history older than `floor`, which `from` replaces.
349
+ * @param {{ root: string, lockfile: string|null, lock: any, importers: any[], target: string, floor?: string, from?: string }} ctx
350
+ * @returns {{ from: string|null, source: string, noHistory?: 'none'|'at-target', belowFloor?: string }[]}
351
+ */
352
+ export function installedVersions({ root, lockfile, lock, importers, target, floor, from }) {
353
+ let revisions;
354
+ let touched;
355
+ const history = () =>
356
+ revisions === undefined ? (revisions = historyRevisions(root)) : revisions;
357
+ const commits = () =>
358
+ touched === undefined ? (touched = lockfileCommits(root, lockfile)) : touched;
359
+ const recovered = (old, source) =>
360
+ floor && compareVersions(old, floor) < 0
361
+ ? { from: floor, source, belowFloor: old }
362
+ : { from: old, source };
363
+ return importers.map((importer) => {
364
+ const locked = lock?.importers?.[importer.dir];
365
+ let found = null;
366
+ if (isVersion(locked)) found = { from: locked, source: 'lockfile' };
367
+ else {
368
+ const nm = nodeModulesVersion(root, importer);
369
+ if (nm) found = { from: nm, source: 'node_modules' };
370
+ }
371
+ if (!found)
372
+ return isVersion(from) ? { from, source: 'flag' } : { from: null, source: 'unknown' };
373
+ const atTarget = compareVersions(found.from, target) === 0;
374
+ // An explicit --from beats anything inferred from history.
375
+ if (isVersion(from)) return atTarget ? { from, source: 'flag' } : found;
376
+ const revs = lockfile && found.source === 'lockfile' ? history() : null;
377
+ if (revs) {
378
+ // An older lockfile at HEAD or the merge-base: bumped by hand, not yet committed or merged.
379
+ for (const { rev, source } of revs) {
380
+ const old = versionAtRevision(root, rev, lockfile, importer);
381
+ if (old && compareVersions(old, found.from) < 0) return recovered(old, source);
382
+ }
383
+ if (atTarget) {
384
+ for (const rev of commits()) {
385
+ const old = versionAtRevision(root, rev, lockfile, importer);
386
+ if (old && compareVersions(old, target) < 0) return recovered(old, 'git-log');
387
+ }
388
+ }
389
+ }
390
+ if (!atTarget) return found;
391
+ // No history below the target (or none readable): say so, never a silent from = target.
392
+ const readable = revs !== null && revs !== undefined && commits().length > 0;
393
+ return { ...found, noHistory: readable ? 'at-target' : 'none' };
394
+ });
395
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * record — the upgrade record a copy of @hirobius/design-system ships
3
+ * (upgrade/index.json and upgrade/releases/<version>.json, hds#451), read for
4
+ * the upgrade command (hds#452). Node builtins only.
5
+ *
6
+ * The command reads the record of the package it runs from, never the
7
+ * consumer's installed copy, so `npx @hirobius/design-system@latest upgrade`
8
+ * always brings the newest steps.
9
+ *
10
+ * listOf and the deprecation bookkeeping mirror scripts/upgrade/compile.mjs,
11
+ * which writes UPGRADING.md from the same ledgers; a test holds them equal.
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { compareVersions } from './semver.mjs';
16
+
17
+ const readJson = (file) => JSON.parse(readFileSync(file, 'utf8'));
18
+
19
+ /**
20
+ * @param {string} pkgDir the package root (the directory holding package.json)
21
+ * @returns {{ version: string, index: any, ledgers: any[] }}
22
+ */
23
+ export function loadRecord(pkgDir) {
24
+ const { version } = readJson(join(pkgDir, 'package.json'));
25
+ const index = readJson(join(pkgDir, 'upgrade/index.json'));
26
+ const ledgers = index.versions
27
+ .map((v) => join(pkgDir, 'upgrade/releases', `${v.version}.json`))
28
+ .filter((file) => existsSync(file))
29
+ .map(readJson)
30
+ .sort((a, b) => compareVersions(a.version, b.version));
31
+ return { version, index, ledgers };
32
+ }
33
+
34
+ /** Step kinds that take something public away. */
35
+ const REMOVING = new Set(['removed', 'moved', 'renamed', 'folded']);
36
+
37
+ /**
38
+ * The list a step belongs to, as UPGRADING.md prints it
39
+ * (scripts/upgrade/compile.mjs listOf).
40
+ * @returns {'fixedForYou' | 'looksDifferent' | 'comingNext' | 'doByHand'}
41
+ */
42
+ export function listOf(step) {
43
+ if (step.kind === 'deprecated') return 'comingNext';
44
+ if (step.auto) return 'fixedForYou';
45
+ if (step.impact === 'look' && !REMOVING.has(step.kind) && step.kind !== 'manual') {
46
+ return 'looksDifferent';
47
+ }
48
+ return 'doByHand';
49
+ }
50
+
51
+ const subject = (step) => step.id.split('/').slice(2).join('/');
52
+
53
+ const namedUses = (step) => [
54
+ ...(step.detect?.imports ?? []).flatMap((i) => i.names.map((name) => `import:${i.from}:${name}`)),
55
+ ...(step.detect?.cssVars ?? []).map((name) => `cssVar:${name}`),
56
+ ...(step.detect?.classes ?? []).map((name) => `class:${name}`),
57
+ ];
58
+
59
+ /**
60
+ * The deprecation steps still deprecated at `target`: announced at or before
61
+ * it, not removed by it (removeIn after it), and with some use no later
62
+ * release up to `target` took away.
63
+ * @param {{ version: string, steps: any[] }[]} ledgers oldest first
64
+ * @param {string} target
65
+ */
66
+ export function comingNextSteps(ledgers, target) {
67
+ const upTo = ledgers.filter((l) => compareVersions(l.version, target) <= 0);
68
+ const out = [];
69
+ for (const ledger of upTo) {
70
+ const later = upTo.filter((l) => compareVersions(l.version, ledger.version) > 0);
71
+ for (const step of ledger.steps) {
72
+ if (step.kind !== 'deprecated') continue;
73
+ if (step.removeIn && compareVersions(step.removeIn, target) <= 0) continue;
74
+ const named = namedUses(step);
75
+ const uses = named.length > 0 ? named : [`subject:${subject(step)}`];
76
+ const taken = new Set();
77
+ for (const taker of later.flatMap((l) => l.steps).filter((s) => REMOVING.has(s.kind))) {
78
+ const own = namedUses(taker);
79
+ for (const use of uses) {
80
+ if (own.length === 0 && subject(taker) === subject(step)) taken.add(use);
81
+ else if (own.includes(use) || use === `subject:${subject(taker)}`) taken.add(use);
82
+ }
83
+ }
84
+ if (taken.size === uses.length) continue;
85
+ if (taken.size === 0 || !step.detect) {
86
+ out.push(step);
87
+ continue;
88
+ }
89
+ // Look only for the uses still deprecated: a name a later release took
90
+ // away is a Do by hand step of that release, not Coming next.
91
+ const { imports, cssVars, classes, ...rest } = step.detect;
92
+ const detect = {
93
+ ...rest,
94
+ imports: imports
95
+ ?.map((i) => ({
96
+ ...i,
97
+ names: i.names.filter((n) => !taken.has(`import:${i.from}:${n}`)),
98
+ }))
99
+ .filter((i) => i.names.length > 0),
100
+ cssVars: cssVars?.filter((v) => !taken.has(`cssVar:${v}`)),
101
+ classes: classes?.filter((c) => !taken.has(`class:${c}`)),
102
+ };
103
+ for (const key of ['imports', 'cssVars', 'classes']) {
104
+ if (!detect[key]?.length) delete detect[key];
105
+ }
106
+ out.push({ ...step, detect });
107
+ }
108
+ }
109
+ return out;
110
+ }