@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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +969 -0
- package/CONSUMING.md +23 -9
- package/MIGRATIONS.md +603 -0
- package/README.md +2 -0
- package/UPGRADING.md +222 -0
- package/codemods/hds-prefix.mjs +28 -13
- package/codemods/jsx-fold.mjs +28 -13
- package/codemods/lib/atomic-write.mjs +28 -0
- package/codemods/lib/installed-version.mjs +354 -0
- package/codemods/lib/project.mjs +395 -0
- package/codemods/lib/record.mjs +110 -0
- package/codemods/lib/scan.mjs +323 -0
- package/codemods/lib/semver.mjs +57 -0
- package/codemods/not-found-pattern.mjs +3 -3
- package/codemods/patterns-subpath.mjs +28 -12
- package/codemods/registry.mjs +67 -0
- package/codemods/tile-grid.mjs +3 -3
- package/codemods/upgrade.mjs +909 -0
- package/dist/hirobius-ui.js +1 -1
- package/dist/manifest.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/tokens.css +1 -1
- package/dist/types/src/app/components/alert-dialog.d.ts +1 -1
- package/dist/types/src/app/components/alert.d.ts +1 -1
- package/dist/types/src/app/components/asset-img.d.ts +1 -1
- package/dist/types/src/app/components/avatar-group.d.ts +1 -1
- package/dist/types/src/app/components/avatar.d.ts +1 -1
- package/dist/types/src/app/components/badge.d.ts +1 -1
- package/dist/types/src/app/components/blockquote.d.ts +1 -1
- package/dist/types/src/app/components/breadcrumb.d.ts +1 -1
- package/dist/types/src/app/components/button.d.ts +1 -1
- package/dist/types/src/app/components/callout.d.ts +1 -1
- package/dist/types/src/app/components/card.d.ts +1 -1
- package/dist/types/src/app/components/checkbox.d.ts +1 -1
- package/dist/types/src/app/components/code-block.d.ts +1 -1
- package/dist/types/src/app/components/combobox.d.ts +1 -1
- package/dist/types/src/app/components/data-table-section.d.ts +1 -1
- package/dist/types/src/app/components/destructive-section.d.ts +1 -1
- package/dist/types/src/app/components/dialog.d.ts +1 -1
- package/dist/types/src/app/components/disclosure.d.ts +1 -1
- package/dist/types/src/app/components/divider.d.ts +1 -1
- package/dist/types/src/app/components/empty-state.d.ts +1 -1
- package/dist/types/src/app/components/field.d.ts +1 -1
- package/dist/types/src/app/components/form-actions.d.ts +1 -1
- package/dist/types/src/app/components/form.d.ts +1 -1
- package/dist/types/src/app/components/hds-tooltip.d.ts +1 -1
- package/dist/types/src/app/components/icon.d.ts +1 -1
- package/dist/types/src/app/components/inline-link.d.ts +1 -1
- package/dist/types/src/app/components/input.d.ts +1 -1
- package/dist/types/src/app/components/kbd.d.ts +1 -1
- package/dist/types/src/app/components/menu.d.ts +1 -1
- package/dist/types/src/app/components/metadata-list.d.ts +1 -1
- package/dist/types/src/app/components/metric-tiles.d.ts +1 -1
- package/dist/types/src/app/components/pagination.d.ts +1 -1
- package/dist/types/src/app/components/popover.d.ts +1 -1
- package/dist/types/src/app/components/progress.d.ts +1 -1
- package/dist/types/src/app/components/radio.d.ts +1 -1
- package/dist/types/src/app/components/select.d.ts +1 -1
- package/dist/types/src/app/components/skeleton.d.ts +1 -1
- package/dist/types/src/app/components/slider.d.ts +1 -1
- package/dist/types/src/app/components/spinner.d.ts +1 -1
- package/dist/types/src/app/components/stat.d.ts +1 -1
- package/dist/types/src/app/components/status-list-item.d.ts +1 -1
- package/dist/types/src/app/components/status-tile.d.ts +1 -1
- package/dist/types/src/app/components/table.d.ts +1 -1
- package/dist/types/src/app/components/tabs.d.ts +1 -1
- package/dist/types/src/app/components/tag.d.ts +1 -1
- package/dist/types/src/app/components/text.d.ts +1 -1
- package/dist/types/src/app/components/textarea.d.ts +1 -1
- package/dist/types/src/app/components/timestamp.d.ts +1 -1
- package/dist/types/src/app/components/toast.d.ts +1 -1
- package/dist/types/src/app/components/toggle.d.ts +1 -1
- package/dist/types/src/index.d.ts +79 -32
- package/hirobius.tokens.json +86 -39
- package/mcp/catalog.mjs +10 -7
- package/mcp/core-set.mjs +66 -0
- package/mcp/guide.mjs +9 -52
- package/package.json +23 -7
- package/public/hds-manifest.json +109 -109
- package/src/app/data/component-api.json +54 -54
- package/upgrade/index.json +208 -0
- package/upgrade/releases/0.17.0.json +962 -0
- package/upgrade/releases/0.18.0.json +8 -0
- package/upgrade/releases/0.19.0.json +233 -0
- package/upgrade/releases/0.19.1.json +8 -0
- package/upgrade/releases/0.20.0.json +5108 -0
- package/upgrade/releases/0.21.0.json +538 -0
- package/upgrade/releases/0.22.0.json +94 -0
- package/upgrade/schema.json +989 -0
|
@@ -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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|
package/codemods/tile-grid.mjs
CHANGED
|
@@ -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)) {
|