@hirobius/design-system 0.20.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 (130) hide show
  1. package/AGENTS.md +125 -0
  2. package/CHANGELOG.md +969 -0
  3. package/CONSUMING.md +27 -11
  4. package/DESIGN.md +28 -17
  5. package/MIGRATIONS.md +603 -0
  6. package/NOTICE.md +1 -1
  7. package/README.md +39 -5
  8. package/UPGRADING.md +222 -0
  9. package/codemods/hds-prefix.mjs +28 -13
  10. package/codemods/jsx-fold.mjs +28 -13
  11. package/codemods/lib/atomic-write.mjs +28 -0
  12. package/codemods/lib/installed-version.mjs +354 -0
  13. package/codemods/lib/project.mjs +395 -0
  14. package/codemods/lib/record.mjs +110 -0
  15. package/codemods/lib/scan.mjs +323 -0
  16. package/codemods/lib/semver.mjs +57 -0
  17. package/codemods/not-found-pattern.mjs +3 -3
  18. package/codemods/patterns-subpath.mjs +28 -12
  19. package/codemods/registry.mjs +67 -0
  20. package/codemods/tile-grid.mjs +3 -3
  21. package/codemods/upgrade.mjs +909 -0
  22. package/dist/chunks/empty-state-DNoThgfV.js +1227 -0
  23. package/dist/chunks/form-By82qZcC.js +66 -0
  24. package/dist/fonts/geist-mono-400.woff2 +0 -0
  25. package/dist/fonts/satoshi-400.woff2 +0 -0
  26. package/dist/fonts/satoshi-500.woff2 +0 -0
  27. package/dist/fonts/satoshi-700.woff2 +0 -0
  28. package/dist/fonts.css +53 -0
  29. package/dist/form.js +1 -1
  30. package/dist/hirobius-ui.js +876 -877
  31. package/dist/manifest.js +3 -3
  32. package/dist/patterns.js +189 -170
  33. package/dist/styles.css +2 -2
  34. package/dist/tokens.css +1 -1
  35. package/dist/tokens.js +136 -57
  36. package/dist/types/src/app/components/alert-dialog.d.ts +5 -0
  37. package/dist/types/src/app/components/alert.d.ts +1 -1
  38. package/dist/types/src/app/components/asset-img.d.ts +1 -0
  39. package/dist/types/src/app/components/avatar-group.d.ts +1 -0
  40. package/dist/types/src/app/components/avatar.d.ts +1 -1
  41. package/dist/types/src/app/components/badge.d.ts +2 -2
  42. package/dist/types/src/app/components/blockquote.d.ts +1 -0
  43. package/dist/types/src/app/components/breadcrumb.d.ts +1 -1
  44. package/dist/types/src/app/components/button.d.ts +3 -1
  45. package/dist/types/src/app/components/callout.d.ts +1 -1
  46. package/dist/types/src/app/components/card.d.ts +4 -3
  47. package/dist/types/src/app/components/checkbox.d.ts +1 -1
  48. package/dist/types/src/app/components/code-block.d.ts +1 -1
  49. package/dist/types/src/app/components/combobox.d.ts +1 -1
  50. package/dist/types/src/app/components/data-table-section.d.ts +1 -0
  51. package/dist/types/src/app/components/destructive-section.d.ts +1 -0
  52. package/dist/types/src/app/components/dialog.d.ts +2 -1
  53. package/dist/types/src/app/components/disclosure.d.ts +1 -1
  54. package/dist/types/src/app/components/divider.d.ts +1 -1
  55. package/dist/types/src/app/components/empty-state.d.ts +1 -1
  56. package/dist/types/src/app/components/error-pattern.d.ts +6 -1
  57. package/dist/types/src/app/components/field.d.ts +1 -1
  58. package/dist/types/src/app/components/form-actions.d.ts +1 -0
  59. package/dist/types/src/app/components/form-control.d.ts +10 -0
  60. package/dist/types/src/app/components/form.d.ts +1 -1
  61. package/dist/types/src/app/components/grid.d.ts +5 -0
  62. package/dist/types/src/app/components/hds-tooltip.d.ts +1 -1
  63. package/dist/types/src/app/components/icon.d.ts +1 -0
  64. package/dist/types/src/app/components/inline-link.d.ts +1 -1
  65. package/dist/types/src/app/components/input.d.ts +1 -1
  66. package/dist/types/src/app/components/kbd.d.ts +1 -0
  67. package/dist/types/src/app/components/menu.d.ts +1 -1
  68. package/dist/types/src/app/components/metadata-list.d.ts +1 -0
  69. package/dist/types/src/app/components/metric-tiles.d.ts +1 -0
  70. package/dist/types/src/app/components/pagination.d.ts +1 -1
  71. package/dist/types/src/app/components/popover.d.ts +1 -1
  72. package/dist/types/src/app/components/progress.d.ts +2 -2
  73. package/dist/types/src/app/components/radio.d.ts +1 -1
  74. package/dist/types/src/app/components/select.d.ts +1 -1
  75. package/dist/types/src/app/components/skeleton.d.ts +2 -2
  76. package/dist/types/src/app/components/slider.d.ts +1 -1
  77. package/dist/types/src/app/components/spinner.d.ts +1 -1
  78. package/dist/types/src/app/components/stat.d.ts +5 -4
  79. package/dist/types/src/app/components/status-list-item.d.ts +1 -1
  80. package/dist/types/src/app/components/status-tile.d.ts +4 -3
  81. package/dist/types/src/app/components/table.d.ts +6 -1
  82. package/dist/types/src/app/components/tabs.d.ts +1 -1
  83. package/dist/types/src/app/components/tag.d.ts +1 -1
  84. package/dist/types/src/app/components/text.d.ts +53 -6
  85. package/dist/types/src/app/components/textarea.d.ts +1 -1
  86. package/dist/types/src/app/components/timestamp.d.ts +1 -0
  87. package/dist/types/src/app/components/toast.d.ts +1 -1
  88. package/dist/types/src/app/components/toggle.d.ts +1 -1
  89. package/dist/types/src/app/data/manifest-types.d.ts +4 -0
  90. package/dist/types/src/app/design-system/generated-token-refs.d.ts +37 -28
  91. package/dist/types/src/app/design-system/generated-token-values.d.ts +3 -0
  92. package/dist/types/src/app/design-system/tokens.d.ts +117 -15
  93. package/dist/types/src/index.d.ts +122 -72
  94. package/dist/variables.css +41 -33
  95. package/docs/CONSUMING.md +63 -39
  96. package/hirobius.tokens.json +163 -112
  97. package/llms.txt +158 -50
  98. package/mcp/catalog.mjs +369 -0
  99. package/mcp/core-set.mjs +66 -0
  100. package/mcp/guide.mjs +272 -0
  101. package/mcp/hds-mcp.mjs +49 -0
  102. package/mcp/server.mjs +94 -0
  103. package/package.json +53 -11
  104. package/public/hds-manifest.json +430 -336
  105. package/public/llms/components.txt +4 -3
  106. package/public/llms/layout.txt +1 -1
  107. package/public/llms-full.txt +190 -70
  108. package/public/llms.txt +158 -50
  109. package/scripts/eslint-plugin-hds/index.d.mts +29 -0
  110. package/scripts/eslint-plugin-hds/index.mjs +55 -0
  111. package/scripts/eslint-plugin-hds/package.json +30 -0
  112. package/scripts/eslint-plugin-hds/rules/no-raw-controls.mjs +47 -0
  113. package/scripts/eslint-plugin-hds/rules/no-raw-hex.mjs +70 -0
  114. package/scripts/eslint-plugin-hds/rules/no-raw-px-spacing.mjs +125 -0
  115. package/scripts/eslint-plugin-hds/rules/prefer-hds-layout-primitive.mjs +54 -0
  116. package/scripts/eslint-plugin-hds/rules/sx-token-first.mjs +111 -0
  117. package/scripts/eslint-plugin-hds/rules/utils.mjs +66 -0
  118. package/src/app/data/component-api.json +154 -93
  119. package/upgrade/index.json +208 -0
  120. package/upgrade/releases/0.17.0.json +962 -0
  121. package/upgrade/releases/0.18.0.json +8 -0
  122. package/upgrade/releases/0.19.0.json +233 -0
  123. package/upgrade/releases/0.19.1.json +8 -0
  124. package/upgrade/releases/0.20.0.json +5108 -0
  125. package/upgrade/releases/0.21.0.json +538 -0
  126. package/upgrade/releases/0.22.0.json +94 -0
  127. package/upgrade/schema.json +989 -0
  128. package/dist/chunks/empty-state-BBqhCx0S.js +0 -1217
  129. package/dist/chunks/form-COP3vK9_.js +0 -66
  130. package/dist/types/src/app/components/status-dot.d.ts +0 -39
@@ -0,0 +1,323 @@
1
+ /**
2
+ * scan — read a consumer's code once and answer a ledger step's `detect`
3
+ * (upgrade/schema.json, $defs.detect) against it (hds#452).
4
+ *
5
+ * Node builtins only: this runs on consumer machines from the published
6
+ * package. Each detector reads the files it can mean something in:
7
+ *
8
+ * imports, jsx, bareImports code (.ts .tsx .js .jsx .mjs .cjs .mts .cts .mdx)
9
+ * classes code (className / class attributes), CSS
10
+ * selectors and @apply, HTML class attributes
11
+ * cssVars, cssVarWrites code, CSS and HTML
12
+ * regex code, CSS and HTML
13
+ *
14
+ * Plain Markdown is not read: a README that mentions a name is not a use.
15
+ */
16
+ import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs';
17
+ import { join, relative, resolve, sep } from 'node:path';
18
+ import { HDS_PACKAGE as HDS } from './installed-version.mjs';
19
+
20
+ /** Directories never read: dependencies, VCS data and build output. */
21
+ export const SKIP_DIRS = new Set([
22
+ 'node_modules',
23
+ '.git',
24
+ 'dist',
25
+ 'build',
26
+ '.next',
27
+ '.vercel',
28
+ '.turbo',
29
+ '.svelte-kit',
30
+ 'coverage',
31
+ 'storybook-static',
32
+ ]);
33
+
34
+ const CODE = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '.mdx']);
35
+ const STYLE = new Set(['.css', '.scss']);
36
+ const MARKUP = new Set(['.html']);
37
+
38
+ /** Files larger than this are generated or vendored, not code a person wrote. */
39
+ export const MAX_BYTES = 1_000_000;
40
+
41
+ const extOf = (name) => {
42
+ const dot = name.lastIndexOf('.');
43
+ return dot === -1 ? '' : name.slice(dot).toLowerCase();
44
+ };
45
+
46
+ /**
47
+ * Every file under `root` a detector reads, with its text.
48
+ * @param {string} root
49
+ * @param {{ skip?: string[], oversized?: { rel: string, abs: string }[] }} [options]
50
+ * skip: absolute directories not to enter (nested importers); oversized:
51
+ * collects the files of a kind a detector reads that are over MAX_BYTES,
52
+ * so the caller can say they were not read instead of passing them over.
53
+ * @returns {{ rel: string, ext: string, kind: 'code'|'style'|'markup', text: string }[]}
54
+ */
55
+ export function collectFiles(root, { skip = [], oversized = null } = {}) {
56
+ const skipped = new Set(skip.map((dir) => resolve(dir)));
57
+ const out = [];
58
+ const walk = (dir) => {
59
+ let entries;
60
+ try {
61
+ entries = readdirSync(dir, { withFileTypes: true });
62
+ } catch {
63
+ return;
64
+ }
65
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
66
+ const full = join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (!SKIP_DIRS.has(entry.name) && !skipped.has(full)) walk(full);
69
+ continue;
70
+ }
71
+ if (!entry.isFile()) continue;
72
+ const ext = extOf(entry.name);
73
+ const kind = CODE.has(ext)
74
+ ? 'code'
75
+ : STYLE.has(ext)
76
+ ? 'style'
77
+ : MARKUP.has(ext)
78
+ ? 'markup'
79
+ : null;
80
+ if (!kind) continue;
81
+ try {
82
+ const rel = relative(root, full).split(sep).join('/');
83
+ if (statSync(full).size > MAX_BYTES) {
84
+ oversized?.push({ rel, abs: full });
85
+ continue;
86
+ }
87
+ out.push({
88
+ rel,
89
+ ext,
90
+ kind,
91
+ text: readFileSync(full, 'utf8'),
92
+ });
93
+ } catch {
94
+ // unreadable: not ours to report
95
+ }
96
+ }
97
+ };
98
+ walk(resolve(root));
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Whether a file holds `needle`, read in chunks so a file of any size costs
104
+ * one buffer (for the files collectFiles leaves out as too big). An
105
+ * unreadable file counts as holding it: the caller cannot rule it out.
106
+ */
107
+ export function fileMentions(file, needle) {
108
+ const CHUNK = 1 << 20;
109
+ const buf = Buffer.alloc(CHUNK);
110
+ const target = Buffer.from(needle);
111
+ let fd;
112
+ try {
113
+ fd = openSync(file, 'r');
114
+ let carry = Buffer.alloc(0);
115
+ for (;;) {
116
+ const n = readSync(fd, buf, 0, CHUNK, null);
117
+ if (n === 0) return false;
118
+ const chunk = Buffer.concat([carry, buf.subarray(0, n)]);
119
+ if (chunk.includes(target)) return true;
120
+ carry = chunk.subarray(Math.max(0, chunk.length - (target.length - 1)));
121
+ }
122
+ } catch {
123
+ return true;
124
+ } finally {
125
+ if (fd !== undefined) closeSync(fd);
126
+ }
127
+ }
128
+
129
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
130
+ const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
131
+
132
+ const NAMED =
133
+ /\b(?:import|export)\s+(?:type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*(['"])([^'"\n]+)\2/g;
134
+
135
+ const DESTRUCTURED =
136
+ /\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+)?(?:require|import)\s*\(\s*(['"])([^'"\n]+)\2\s*\)/g;
137
+
138
+ /**
139
+ * Named imports and re-exports in a file: `{ from, name, local }`. Also the
140
+ * names a `const { a, b: c } = require(...)` or `= await import(...)`
141
+ * destructures, which bind the same exports.
142
+ */
143
+ export function namedImports(text) {
144
+ const out = [];
145
+ for (const m of text.matchAll(DESTRUCTURED)) {
146
+ for (const raw of stripComments(m[1]).split(',')) {
147
+ const spec = raw.trim().replace(/\s*=[\s\S]*$/, '');
148
+ if (!spec || spec.startsWith('...')) continue;
149
+ const [name, local = name] = spec.split(':').map((s) => s.trim());
150
+ if (/^[\w$]+$/.test(name) && /^[\w$]+$/.test(local)) out.push({ from: m[3], name, local });
151
+ }
152
+ }
153
+ for (const m of text.matchAll(NAMED)) {
154
+ for (const raw of stripComments(m[1]).split(',')) {
155
+ const spec = raw.trim().replace(/^type\s+/, '');
156
+ if (!spec) continue;
157
+ const [name, local = name] = spec.split(/\s+as\s+/).map((s) => s.trim());
158
+ out.push({ from: m[3], name, local });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ const NAMESPACE_FORMS = [
165
+ /\bimport\s+(?:type\s+)?(?:[\w$]+\s*,\s*)?\*\s*as\s+([\w$]+)\s+from\s*(['"])([^'"\n]+)\2/g,
166
+ /\bimport\s+(?:type\s+)?([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*(['"])([^'"\n]+)\2/g,
167
+ /\b(?:const|let|var)\s+([\w$]+)\s*=\s*(?:await\s+)?(?:require|import)\s*\(\s*(['"])([^'"\n]+)\2\s*\)/g,
168
+ ];
169
+
170
+ /**
171
+ * Namespace bindings in a file: `import * as X from`, `import X from` and
172
+ * `const X = require(...)` bind the whole module, so `X.Name` is the export
173
+ * Name. `{ from, local }` for each.
174
+ */
175
+ export function namespaceImports(text) {
176
+ const out = [];
177
+ for (const re of NAMESPACE_FORMS) {
178
+ for (const m of text.matchAll(re)) out.push({ from: m[3], local: m[1] });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ const SPECIFIERS = [
184
+ /\bfrom\s*(['"])([^'"\n]+)\1/g,
185
+ /\bimport\s*(['"])([^'"\n]+)\1/g,
186
+ /\b(?:require|import)\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g,
187
+ ];
188
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?(['"])([^'"\n]+)\1/g;
189
+
190
+ /** Every module specifier a file imports or requires. */
191
+ function specifiers(file) {
192
+ const patterns = file.kind === 'style' ? [CSS_IMPORT] : file.kind === 'code' ? SPECIFIERS : [];
193
+ return patterns.flatMap((re) => [...file.text.matchAll(re)].map((m) => m[2]));
194
+ }
195
+
196
+ const CLASS_ATTR =
197
+ /\bclass(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`))/g;
198
+
199
+ /** The class tokens a file's class / className attributes name. */
200
+ function attrClasses(text) {
201
+ const out = new Set();
202
+ for (const m of text.matchAll(CLASS_ATTR)) {
203
+ const value = m.slice(1).find((v) => v !== undefined) ?? '';
204
+ for (const token of value.split(/\s+/)) if (token) out.add(token);
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /** A class name as a CSS selector writes it: `sm:text-4xl` → `sm\:text-4xl`. */
210
+ const cssEscape = (name) => name.replace(/[^\w-]/g, (ch) => `\\${ch}`);
211
+
212
+ function usesClass(file, name) {
213
+ if (file.kind === 'style') {
214
+ const selector = new RegExp(`\\.${escapeRe(cssEscape(name))}(?![\\w-]|\\\\)`);
215
+ if (selector.test(file.text)) return true;
216
+ for (const m of file.text.matchAll(/@apply\s+([^;}]+)/g)) {
217
+ if (m[1].split(/\s+/).includes(name)) return true;
218
+ }
219
+ return false;
220
+ }
221
+ return attrClasses(file.text).has(name);
222
+ }
223
+
224
+ /** The local name each HDS export is bound to in a file (`Card as Panel` → Card: [Panel]). */
225
+ function hdsBindings(file) {
226
+ const out = new Map();
227
+ for (const { from, name, local } of namedImports(file.text)) {
228
+ if (from !== HDS && !from.startsWith(`${HDS}/`)) continue;
229
+ out.set(name, [...(out.get(name) ?? []), local]);
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /** The local names bound to the whole HDS module (or a subpath) in a file. */
235
+ function hdsNamespaces(file) {
236
+ return namespaceImports(file.text)
237
+ .filter(({ from }) => from === HDS || from.startsWith(`${HDS}/`))
238
+ .map(({ local }) => local);
239
+ }
240
+
241
+ function usesJsx(file, tag) {
242
+ const [head, ...rest] = tag.split('.');
243
+ const locals = hdsBindings(file).get(head) ?? [];
244
+ const tail = rest.map((part) => `\\.${escapeRe(part)}`).join('');
245
+ if (locals.some((local) => new RegExp(`<${escapeRe(local)}${tail}(?=[\\s/>])`).test(file.text))) {
246
+ return true;
247
+ }
248
+ // `<X.StatusDot` where X is a namespace binding of the package.
249
+ return hdsNamespaces(file).some((ns) =>
250
+ new RegExp(`<${escapeRe(ns)}\\.${escapeRe(tag)}(?=[\\s/>])`).test(file.text),
251
+ );
252
+ }
253
+
254
+ /** Does the file reach one of `names` of `from` as `X.Name` on a namespace binding? */
255
+ function usesNamespaceMember(file, from, names) {
256
+ return namespaceImports(file.text)
257
+ .filter((ns) => ns.from === from)
258
+ .some((ns) =>
259
+ names.some((name) =>
260
+ new RegExp(`(?<![\\w$.])${escapeRe(ns.local)}\\s*\\.\\s*${escapeRe(name)}(?![\\w$])`).test(
261
+ file.text,
262
+ ),
263
+ ),
264
+ );
265
+ }
266
+
267
+ const compiled = new Map();
268
+ const regexOf = (source) => {
269
+ if (!compiled.has(source)) compiled.set(source, new RegExp(source));
270
+ return compiled.get(source);
271
+ };
272
+
273
+ /** Does one file hold a use the detect describes? Any key matching is a use. */
274
+ function fileMatches(detect, file) {
275
+ const code = file.kind === 'code';
276
+ if (code && detect.imports) {
277
+ const found = namedImports(file.text);
278
+ const hit = detect.imports.some((want) =>
279
+ found.some((f) => f.from === want.from && want.names.includes(f.name)),
280
+ );
281
+ if (hit) return true;
282
+ if (detect.imports.some((want) => usesNamespaceMember(file, want.from, want.names))) {
283
+ return true;
284
+ }
285
+ }
286
+ if (code && detect.jsx?.some((tag) => usesJsx(file, tag))) return true;
287
+ if (detect.bareImports) {
288
+ const specs = specifiers(file);
289
+ const hit = detect.bareImports.some((pkg) =>
290
+ specs.some((s) => s === pkg || s.startsWith(`${pkg}/`)),
291
+ );
292
+ if (hit) return true;
293
+ }
294
+ if (
295
+ detect.cssVars?.some((v) => new RegExp(`var\\(\\s*${escapeRe(v)}(?![\\w-])`).test(file.text))
296
+ ) {
297
+ return true;
298
+ }
299
+ if (
300
+ detect.cssVarWrites?.some((v) =>
301
+ new RegExp(`(?<![\\w-])${escapeRe(v)}['"]?\\s*:`).test(file.text),
302
+ )
303
+ ) {
304
+ return true;
305
+ }
306
+ if (detect.classes?.some((name) => usesClass(file, name))) return true;
307
+ if (detect.regex?.some((source) => regexOf(source).test(file.text))) return true;
308
+ return false;
309
+ }
310
+
311
+ /**
312
+ * The files (relative, sorted) that hold a use `detect` describes.
313
+ * @param {Record<string, any>} detect a ledger step's detect (or done)
314
+ * @param {ReturnType<typeof collectFiles>} files
315
+ * @returns {string[]}
316
+ */
317
+ export function matchDetect(detect, files) {
318
+ if (!detect) return [];
319
+ return files
320
+ .filter((file) => fileMatches(detect, file))
321
+ .map((file) => file.rel)
322
+ .sort();
323
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * semver — the little of semver the upgrade command needs (hds#452), Node
3
+ * builtins only. compareVersions is installed-version.mjs's, so the lockfile
4
+ * readers and the command order versions the same way.
5
+ */
6
+ import { compareVersions } from './installed-version.mjs';
7
+
8
+ export { compareVersions };
9
+
10
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
11
+
12
+ export const isVersion = (value) => typeof value === 'string' && VERSION.test(value);
13
+
14
+ /** One comparator: an optional operator and a full or partial version (`^0.16`, `0.16.0`). */
15
+ const SIMPLE = /^(\^|~|>=|=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?(-[0-9A-Za-z.-]+)?$/;
16
+
17
+ /**
18
+ * The range a package.json declares, moved to `target` with its operator kept:
19
+ * `^0.16.0` → `^0.21.0`, `~0.16.2` → `~0.21.0`, `0.16.0` → `0.21.0`,
20
+ * `>=0.16.0` → `>=0.21.0`; an x-range or partial version keeps its wildcard
21
+ * (`0.16.x` → `0.21.x`, `0.x` stays); an `npm:` alias keeps its prefix. Null for a range
22
+ * it cannot move safely (`workspace:*`, `latest`, a compound range, a URL).
23
+ * @param {string} range
24
+ * @param {string} target
25
+ * @returns {string|null}
26
+ */
27
+ export function bumpRange(range, target) {
28
+ const alias = /^(npm:(?:@[^@/\s]+\/)?[^@\s]+@)(.*)$/.exec(range);
29
+ if (alias) {
30
+ const inner = bumpRange(alias[2], target);
31
+ return inner === null ? null : `${alias[1]}${inner}`;
32
+ }
33
+ const m = SIMPLE.exec(range.trim());
34
+ if (!m) return null;
35
+ const [, op = '', maj, min, pat, pre] = m;
36
+ const wild = (part) => part === undefined || part === 'x' || part === '*';
37
+ if (op === '' && (wild(min) || wild(pat))) {
38
+ // An x-range or a partial version (`0.16.x`, `0.x`, `0.16`) stays a
39
+ // wildcard of the same shape, so the consumer keeps getting patches.
40
+ if (pre) return null;
41
+ const [tMaj, tMin] = target.split('.');
42
+ const parts = [tMaj];
43
+ if (min !== undefined) parts.push(wild(min) ? min : tMin);
44
+ // Here a patch part is a wildcard, or follows one (`0.x.x`): keep it.
45
+ if (pat !== undefined) parts.push(pat);
46
+ return parts.join('.');
47
+ }
48
+ if (op === '=' && (wild(min) || wild(pat))) return null;
49
+ return `${op}${target}`;
50
+ }
51
+
52
+ /**
53
+ * Whether `range` already names `target` (bumpRange would leave it as it is).
54
+ * @param {string} range
55
+ * @param {string} target
56
+ */
57
+ export const rangeIsAt = (range, target) => bumpRange(range, target) === range;
@@ -61,9 +61,9 @@ export function transformSource(source) {
61
61
  return foldComponent(source, RULE);
62
62
  }
63
63
 
64
- /** Scan a directory. Writes only when `write` is true. */
65
- export function runCodemod({ root, write = false }) {
66
- return runFold({ root, write, rule: RULE });
64
+ /** Scan a directory. Writes only when `write` is true; `skip` lists directories not to enter. */
65
+ export function runCodemod({ root, write = false, skip = [] }) {
66
+ return runFold({ root, write, rule: RULE, skip });
67
67
  }
68
68
 
69
69
  if (isEntry(import.meta.url)) {
@@ -41,7 +41,8 @@
41
41
  * ("removed in 0.20.0, use Button iconOnly"). NotFoundPattern and TileGrid
42
42
  * (hds#395) have codemods of their own: hds-not-found-pattern and hds-tile-grid.
43
43
  */
44
- import { readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
44
+ import { readFileSync, readdirSync, realpathSync } from 'node:fs';
45
+ import { writeFileAtomic } from './lib/atomic-write.mjs';
45
46
  import { dirname, join, relative, resolve } from 'node:path';
46
47
  import { fileURLToPath } from 'node:url';
47
48
  import { findUnrewritable as findHidden, maskSource } from './unrewritable.mjs';
@@ -312,30 +313,45 @@ export function transformSource(source, names) {
312
313
  return { source: out, changed: out !== source, sites, moved, edits };
313
314
  }
314
315
 
315
- function* walk(dir) {
316
- for (const entry of readdirSync(dir)) {
317
- if (SKIP_DIRS.has(entry)) continue;
318
- const full = join(dir, entry);
319
- const st = statSync(full);
320
- if (st.isDirectory()) yield* walk(full);
321
- else if (EXTS.has(full.slice(full.lastIndexOf('.')))) yield full;
316
+ /** `skip`: absolute directories not to enter (the upgrade command's nested importers). */
317
+ function* walk(dir, skip = new Set()) {
318
+ let entries;
319
+ try {
320
+ entries = readdirSync(dir, { withFileTypes: true });
321
+ } catch {
322
+ return; // an unreadable directory has nothing to rewrite
323
+ }
324
+ // Symlinks are skipped, never followed: a dangling one cannot be read and a
325
+ // looping one never ends.
326
+ for (const entry of entries) {
327
+ if (SKIP_DIRS.has(entry.name)) continue;
328
+ const full = join(dir, entry.name);
329
+ if (entry.isDirectory()) {
330
+ if (!skip.has(full)) yield* walk(full, skip);
331
+ } else if (entry.isFile() && EXTS.has(full.slice(full.lastIndexOf('.')))) yield full;
322
332
  }
323
333
  }
324
334
 
325
- /** Scan a directory. Writes only when `write` is true. */
335
+ /** Scan a directory. Writes only when `write` is true; `skip` lists directories not to enter. */
326
336
  export function runCodemod({
327
337
  root,
328
338
  write = false,
329
339
  names = loadPatternNames(),
330
340
  removed = loadRemovedNames(),
331
341
  replacements = loadReplacements(),
342
+ skip = [],
332
343
  }) {
333
344
  const files = [];
334
345
  const manual = [];
335
346
  const moved = new Set();
336
347
  let sites = 0;
337
- for (const file of walk(root)) {
338
- const src = readFileSync(file, 'utf8');
348
+ for (const file of walk(root, new Set(skip.map((d) => resolve(d))))) {
349
+ let src;
350
+ try {
351
+ src = readFileSync(file, 'utf8');
352
+ } catch {
353
+ continue; // unreadable: nothing to rewrite
354
+ }
339
355
  if (!src.includes(ROOT_PKG)) continue;
340
356
  for (const stmt of findUnrewritable(src, names))
341
357
  manual.push({ file: relative(root, file), stmt });
@@ -346,7 +362,7 @@ export function runCodemod({
346
362
  files.push({ file: relative(root, file), sites: r.sites, moved: r.moved, edits: r.edits });
347
363
  r.moved.forEach((n) => moved.add(n));
348
364
  sites += r.sites;
349
- if (write) writeFileSync(file, r.source);
365
+ if (write) writeFileAtomic(file, r.source);
350
366
  }
351
367
  return { files, sites, manual, names: [...moved].sort() };
352
368
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * registry — the codemods this package ships, behind one interface (hds#452).
3
+ *
4
+ * A ledger step names its codemod by bin (`auto.codemod`, such as
5
+ * hds-patterns-subpath). The upgrade command looks it up here and runs it
6
+ * in-process, so it can skip nested workspace importers and read back which
7
+ * files changed; `child: true` runs the bin's file as a child process with
8
+ * --root/--check/--dry-run instead, exactly as a person would.
9
+ *
10
+ * await runCodemod('hds-prefix', { root, write: true, skip: [nestedDir] })
11
+ * → { files: ['src/a.tsx'], sites: 1, manual: [{ file, stmt, removed? }] }
12
+ *
13
+ * await runCodemod('hds-prefix', { root, check: true, child: true })
14
+ * → { status: 1, output: '…' }
15
+ *
16
+ * Node builtins only. Each entry's file is the one package.json#bin points at
17
+ * (a test holds them equal).
18
+ */
19
+ import { spawnSync } from 'node:child_process';
20
+ import { dirname, join, sep } from 'node:path';
21
+ import { fileURLToPath, pathToFileURL } from 'node:url';
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+
25
+ /** bin name → the file in codemods/ that implements it. */
26
+ export const CODEMODS = Object.freeze({
27
+ 'hds-patterns-subpath': { file: 'patterns-subpath.mjs' },
28
+ 'hds-prefix': { file: 'hds-prefix.mjs' },
29
+ 'hds-not-found-pattern': { file: 'not-found-pattern.mjs' },
30
+ 'hds-tile-grid': { file: 'tile-grid.mjs' },
31
+ });
32
+
33
+ export const hasCodemod = (name) => Object.hasOwn(CODEMODS, name);
34
+
35
+ const toPosix = (p) => p.split(sep).join('/');
36
+
37
+ /**
38
+ * Run one codemod over `root`.
39
+ * @param {string} name a bin name from CODEMODS
40
+ * @param {{ root: string, write?: boolean, skip?: string[], child?: boolean, check?: boolean, dryRun?: boolean }} options
41
+ * In-process: `write` writes the edits, `skip` lists absolute directories not
42
+ * to enter. As a child: `check` / `dryRun` pass the flags of the same name.
43
+ */
44
+ export async function runCodemod(name, options) {
45
+ if (!hasCodemod(name)) {
46
+ throw new Error(
47
+ `Unknown codemod ${name}: this package ships ${Object.keys(CODEMODS).join(', ')}`,
48
+ );
49
+ }
50
+ const file = join(HERE, CODEMODS[name].file);
51
+ if (options.child) {
52
+ const args = [file, '--root', options.root];
53
+ if (options.check) args.push('--check');
54
+ if (options.dryRun) args.push('--dry-run');
55
+ const res = spawnSync(process.execPath, args, { encoding: 'utf8' });
56
+ return { status: res.status, output: `${res.stdout ?? ''}${res.stderr ?? ''}` };
57
+ }
58
+ const mod = await import(pathToFileURL(file).href);
59
+ const res = mod.runCodemod({
60
+ root: options.root,
61
+ write: options.write === true,
62
+ skip: options.skip ?? [],
63
+ });
64
+ const files = res.files.map((f) => toPosix(f.file));
65
+ const manual = res.manual.map((m) => ({ ...m, file: toPosix(m.file) }));
66
+ return { files, sites: res.sites, manual };
67
+ }
@@ -273,9 +273,9 @@ export function transformSource(source) {
273
273
  return foldComponent(source, RULE);
274
274
  }
275
275
 
276
- /** Scan a directory. Writes only when `write` is true. */
277
- export function runCodemod({ root, write = false }) {
278
- return runFold({ root, write, rule: RULE });
276
+ /** Scan a directory. Writes only when `write` is true; `skip` lists directories not to enter. */
277
+ export function runCodemod({ root, write = false, skip = [] }) {
278
+ return runFold({ root, write, rule: RULE, skip });
279
279
  }
280
280
 
281
281
  if (isEntry(import.meta.url)) {