@lablup/ui-common-cli 0.2.0-alpha.15

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.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * 0.1 -> 0.2 report-only scans. Nothing here edits a file: each finding is
3
+ * something the codemods cannot migrate and a person has to look at.
4
+ *
5
+ * - CSS selectors on 0.1 class names (Astryx renders none of them).
6
+ * - DOM hooks on those classes in scripts (querySelector, closest, classList).
7
+ * - Tests querying them.
8
+ * - `vi.mock` / `jest.mock` of @lablup/ui-common.
9
+ * - Custom properties the project declares that Astryx declares too.
10
+ * - Hard-coded 0.1 stylesheet paths the codemods did not rewrite.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import postcss from "postcss";
17
+ import selectorParser from "postcss-selector-parser";
18
+
19
+ import { dependencyDir, targetUiCommonRoot } from "../../cli/paths.mjs";
20
+ import { keptClassRename } from "./map.mjs";
21
+ import { scanTheme } from "./theme.mjs";
22
+
23
+ const legacy = JSON.parse(
24
+ readFileSync(
25
+ join(dirname(fileURLToPath(import.meta.url)), "legacy-classes.json"),
26
+ "utf8",
27
+ ),
28
+ );
29
+
30
+ /** @type {Map<string, string>} class -> 0.1 component */
31
+ export const LEGACY_CLASSES = new Map();
32
+ for (const [component, classes] of Object.entries(legacy.components)) {
33
+ for (const c of /** @type {string[]} */ (classes)) LEGACY_CLASSES.set(c, component);
34
+ }
35
+
36
+ /**
37
+ * A class 0.1 rendered: every class its stylesheets declared, plus anything
38
+ * under a kept component's renamed BEM block.
39
+ *
40
+ * @param {string} name
41
+ */
42
+ export function isLegacyClass(name) {
43
+ return LEGACY_CLASSES.has(name) || keptClassRename(name) !== null;
44
+ }
45
+
46
+ /**
47
+ * Where a 0.1 class went: a kept component's class was renamed (the map's
48
+ * classRenames), a removed component's is gone.
49
+ *
50
+ * @param {string} name
51
+ */
52
+ export function describeClass(name) {
53
+ const owner = LEGACY_CLASSES.get(name);
54
+ const renamed = keptClassRename(name);
55
+ if (renamed?.to) return `.${name} → .${renamed.to}${owner ? ` (${owner})` : ""}`;
56
+ return `.${name} (${owner ?? "0.1"}): gone`;
57
+ }
58
+
59
+ const COLLISION_PREFIXES = /^--(color|radius|spacing|font|shadow)-/;
60
+
61
+ /** @type {Set<string> | null} */
62
+ let astryxProperties = null;
63
+
64
+ /**
65
+ * Custom properties Astryx declares, read from the installed stylesheets so
66
+ * the list follows the pinned version.
67
+ */
68
+ export function astryxCustomProperties() {
69
+ if (astryxProperties) return astryxProperties;
70
+ const names = new Set();
71
+ const sheets = [];
72
+ // The Astryx the upgrade moves to: the project may still be on 0.1.
73
+ const root = targetUiCommonRoot();
74
+ const core = dependencyDir("@astryxdesign/core", root);
75
+ if (core) sheets.push(join(core, "dist/astryx.css"));
76
+ const neutral = dependencyDir("@astryxdesign/theme-neutral", root);
77
+ if (neutral) sheets.push(join(neutral, "dist/theme.css"));
78
+ for (const sheet of sheets) {
79
+ let text;
80
+ try {
81
+ text = readFileSync(sheet, "utf8");
82
+ } catch {
83
+ continue;
84
+ }
85
+ for (const match of text.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) {
86
+ if (COLLISION_PREFIXES.test(match[1])) names.add(match[1]);
87
+ }
88
+ }
89
+ astryxProperties = names;
90
+ return names;
91
+ }
92
+
93
+ /** @param {string} path */
94
+ export function isTestFile(path) {
95
+ return (
96
+ /(\.|\/)(test|spec)\.[cm]?[jt]sx?$/.test(path) ||
97
+ /(^|\/)(__tests__|e2e|tests?)\//.test(path)
98
+ );
99
+ }
100
+
101
+ /** @param {string} text @param {number} index */
102
+ function lineAt(text, index) {
103
+ return text.slice(0, index).split("\n").length;
104
+ }
105
+
106
+ /** @param {string} selector */
107
+ function classesIn(selector) {
108
+ const found = new Set();
109
+ try {
110
+ selectorParser((s) => {
111
+ s.walkClasses((node) => {
112
+ found.add(node.value);
113
+ });
114
+ }).processSync(selector);
115
+ } catch {
116
+ for (const m of selector.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)) found.add(m[1]);
117
+ }
118
+ return [...found];
119
+ }
120
+
121
+ /**
122
+ * @typedef {{category: string, file: string, line: number, text: string, detail?: string, classes?: string[]}} Finding
123
+ */
124
+
125
+ /**
126
+ * @param {string} file relative path
127
+ * @param {string} source
128
+ * @returns {Finding[]}
129
+ */
130
+ function scanStylesheet(file, source) {
131
+ /** @type {Finding[]} */
132
+ const findings = [];
133
+ const properties = astryxCustomProperties();
134
+ if (file.endsWith(".css")) {
135
+ let root;
136
+ try {
137
+ root = postcss.parse(source, { from: file });
138
+ } catch {
139
+ return findings;
140
+ }
141
+ root.walkRules((rule) => {
142
+ if (
143
+ rule.parent?.type === "atrule" &&
144
+ /keyframes$/.test(/** @type {any} */ (rule.parent).name)
145
+ )
146
+ return;
147
+ const hits = classesIn(rule.selector).filter(isLegacyClass);
148
+ if (hits.length > 0) {
149
+ findings.push({
150
+ category: "css-selector",
151
+ file,
152
+ line: rule.source?.start?.line ?? 0,
153
+ text: rule.selector.replace(/\s+/g, " "),
154
+ detail: hits.map(describeClass).join(", "),
155
+ classes: hits,
156
+ });
157
+ }
158
+ });
159
+ root.walkDecls((decl) => {
160
+ if (properties.has(decl.prop)) {
161
+ const rule = /** @type {any} */ (decl.parent);
162
+ findings.push({
163
+ category: "custom-property",
164
+ file,
165
+ line: decl.source?.start?.line ?? 0,
166
+ text: `${rule?.selector ?? `@${rule?.name ?? ""}`} { ${decl.prop}: ${decl.value} }`,
167
+ detail: decl.prop,
168
+ });
169
+ }
170
+ });
171
+ return findings;
172
+ }
173
+ // Sass / Less: line-level.
174
+ source.split("\n").forEach((line, i) => {
175
+ const code = line.replace(/\/\/.*$/, "");
176
+ const declaration = /^\s*(--[A-Za-z0-9_-]+)\s*:/.exec(code);
177
+ if (declaration && properties.has(declaration[1])) {
178
+ findings.push({
179
+ category: "custom-property",
180
+ file,
181
+ line: i + 1,
182
+ text: line.trim(),
183
+ detail: declaration[1],
184
+ });
185
+ return;
186
+ }
187
+ if (!/[{,]\s*$/.test(code)) return;
188
+ const hits = [...code.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)]
189
+ .map((m) => m[1])
190
+ .filter(isLegacyClass);
191
+ if (hits.length > 0) {
192
+ findings.push({
193
+ category: "css-selector",
194
+ file,
195
+ line: i + 1,
196
+ text: code.trim().replace(/\s*[{,]$/, ""),
197
+ detail: [...new Set(hits)].map(describeClass).join(", "),
198
+ classes: hits,
199
+ });
200
+ }
201
+ });
202
+ return findings;
203
+ }
204
+
205
+ // DOM APIs, and the selector-taking calls of Playwright, Cypress and
206
+ // Testing Library's container queries.
207
+ const SELECTOR_CALL =
208
+ /(?:\b(querySelector(?:All)?|closest|matches|webkitMatchesSelector|locator|waitForSelector)|(?<![\w$])(\$\$?(?:eval)?)|\bcy\.(get|find))\(\s*(["'`])((?:(?!\4)[^\\]|\\.)*)\4/g;
209
+ const CLASS_CALL =
210
+ /\b(classList\.(?:contains|add|remove|toggle|replace)|getElementsByClassName|toHaveClass)\(\s*(["'`])((?:(?!\2)[^\\]|\\.)*)\2/g;
211
+
212
+ /**
213
+ * @param {string} file relative path
214
+ * @param {string} source
215
+ * @returns {Finding[]}
216
+ */
217
+ function scanScript(file, source) {
218
+ /** @type {Finding[]} */
219
+ const findings = [];
220
+ const test = isTestFile(file);
221
+ const lines = source.split("\n");
222
+ const record = (/** @type {RegExpMatchArray} */ m, /** @type {string[]} */ hits) => {
223
+ const line = lineAt(source, m.index ?? 0);
224
+ findings.push({
225
+ category: test ? "test-query" : "dom-hook",
226
+ file,
227
+ line,
228
+ text: (lines[line - 1] ?? "").trim(),
229
+ detail: [...new Set(hits)].map(describeClass).join(", "),
230
+ classes: hits,
231
+ });
232
+ };
233
+ for (const m of source.matchAll(SELECTOR_CALL)) {
234
+ const hits = classesIn(m[5]).filter(isLegacyClass);
235
+ if (hits.length > 0) record(m, hits);
236
+ }
237
+ for (const m of source.matchAll(CLASS_CALL)) {
238
+ const hits = m[3].split(/\s+/).filter(isLegacyClass);
239
+ if (hits.length > 0) record(m, hits);
240
+ }
241
+ for (const m of source.matchAll(
242
+ /\b(vi|jest)\.(mock|doMock|unmock)\(\s*(["'`])(@lablup\/ui-common[^"'`]*)\3/g,
243
+ )) {
244
+ const line = lineAt(source, m.index ?? 0);
245
+ findings.push({
246
+ category: "module-mock",
247
+ file,
248
+ line,
249
+ text: (lines[line - 1] ?? "").trim(),
250
+ detail: m[4],
251
+ });
252
+ }
253
+ const properties = astryxCustomProperties();
254
+ for (const m of source.matchAll(/(["'`])(--[A-Za-z0-9_-]+)\1/g)) {
255
+ if (!properties.has(m[2])) continue;
256
+ const line = lineAt(source, m.index ?? 0);
257
+ const text = (lines[line - 1] ?? "").trim();
258
+ // Declarations (setProperty, style objects), not reads through var().
259
+ if (!/setProperty\(|["'`]\s*:/.test(text)) continue;
260
+ findings.push({ category: "custom-property", file, line, text, detail: m[2] });
261
+ }
262
+ return findings;
263
+ }
264
+
265
+ /** A selector that is one class, with pseudo-classes at most: `.tabs__tab:hover`. */
266
+ const DEFINITION = /^\.(-?[_a-zA-Z][\w-]*)(?::{1,2}[\w-]+(?:\([^)]*\))?)*$/;
267
+
268
+ /**
269
+ * The 0.1 class names the project owns: it defines each in a stylesheet of
270
+ * its own as a rule by itself (`.tabs__tab { … }`) and renders it in its own
271
+ * markup (`className="tabs__tab"`, outside tests). Such a project most likely
272
+ * has its own `.tabs__tab`, so findings on it are listed apart, as lower
273
+ * confidence. A definition alone is not enough: an override of ui-common's
274
+ * class (`.drawer__content { padding: 0 }`) looks the same.
275
+ *
276
+ * @param {Array<[string, string]>} files [relative path, content]
277
+ * @returns {{ownClasses: Set<string>}}
278
+ */
279
+ export function prepareScan(files) {
280
+ const defined = new Set();
281
+ /** @param {string} selectors */
282
+ const collect = (selectors) => {
283
+ for (const part of selectors.split(",")) {
284
+ const m = DEFINITION.exec(part.trim());
285
+ if (m && isLegacyClass(m[1])) defined.add(m[1]);
286
+ }
287
+ };
288
+ for (const [file, source] of files) {
289
+ if (file.endsWith(".css")) {
290
+ try {
291
+ postcss
292
+ .parse(source, { from: file })
293
+ .walkRules((rule) => collect(rule.selector));
294
+ } catch {
295
+ // unparseable: nothing defined
296
+ }
297
+ } else if (/\.(scss|sass|less)$/.test(file)) {
298
+ for (const line of source.split("\n")) {
299
+ const m = /^\s*([^{}/@]+?)\s*\{\s*$/.exec(line);
300
+ if (m) collect(m[1]);
301
+ }
302
+ }
303
+ }
304
+ const own = new Set();
305
+ if (defined.size === 0) return { ownClasses: own };
306
+ const escape = (/** @type {string} */ c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
307
+ const pattern = new RegExp(
308
+ `(?:^|[\\s"'\`])(${[...defined].map(escape).join("|")})(?=[\\s"'\`]|$)`,
309
+ "gm",
310
+ );
311
+ for (const [file, source] of files) {
312
+ if (/\.(css|scss|sass|less)$/.test(file) || isTestFile(file)) continue;
313
+ for (const m of source.matchAll(pattern)) own.add(m[1]);
314
+ }
315
+ return { ownClasses: own };
316
+ }
317
+
318
+ const ORIGIN = {
319
+ "css-selector": "CSS selector",
320
+ "dom-hook": "DOM hook",
321
+ "test-query": "test query",
322
+ };
323
+
324
+ /**
325
+ * @param {string} file relative path
326
+ * @param {string} source final content
327
+ * @param {{ownClasses?: Set<string>}} [context] from prepareScan
328
+ * @returns {Finding[]}
329
+ */
330
+ export function scanFile(file, source, context) {
331
+ /** @type {Finding[]} */
332
+ const findings = [];
333
+ if (/\.(css|scss|sass|less)$/.test(file))
334
+ findings.push(...scanStylesheet(file, source));
335
+ else findings.push(...scanScript(file, source));
336
+
337
+ findings.push(...scanTheme(file, source));
338
+ source.split("\n").forEach((line, i) => {
339
+ if (/@lablup\/ui-common\/styles\//.test(line)) {
340
+ findings.push({
341
+ category: "stylesheet-path",
342
+ file,
343
+ line: i + 1,
344
+ text: line.trim(),
345
+ detail: "0.1 stylesheet path: deprecated in 0.2, removed in 0.3",
346
+ });
347
+ }
348
+ });
349
+ const own = context?.ownClasses;
350
+ for (const f of findings) {
351
+ if (f.classes && own && f.classes.every((c) => own.has(c))) {
352
+ f.detail = `${/** @type {Record<string, string>} */ (ORIGIN)[f.category]}: ${f.detail}`;
353
+ f.category = "own-class";
354
+ }
355
+ delete f.classes;
356
+ }
357
+ return findings;
358
+ }
359
+
360
+ export const CATEGORIES = {
361
+ "css-selector": {
362
+ title: "CSS selectors on 0.1 class names",
363
+ help: "A removed component's classes are gone: Astryx renders its own. Restyle through the component's props, the theme, or your `components` layer. A kept component's classes were renamed to `uic-` names (shown as →), but its markup was rebuilt on Astryx, so check the selector still means what it did. Generic names (`.button`, `.select`) may be your own classes: skip those.",
364
+ },
365
+ "dom-hook": {
366
+ title: "DOM hooks on 0.1 class names",
367
+ help: "Scripts that find 0.1 markup by class stop matching. Use a ref, a data-testid, or the Astryx component's own API.",
368
+ },
369
+ "test-query": {
370
+ title: "Tests querying 0.1 class names",
371
+ help: "Query by role, label or data-testid instead.",
372
+ },
373
+ "module-mock": {
374
+ title: "Module mocks of @lablup/ui-common",
375
+ help: "A mock of the root barrel now stands in for all of Astryx too, and mocked names such as Button, Badge or Select no longer match what the code imports (Astryx's, from their own subpaths). Re-check every mock factory.",
376
+ },
377
+ "custom-property": {
378
+ title: "Custom properties that collide with Astryx tokens",
379
+ help: "Astryx declares the same name. Whichever rule wins the cascade now restyles both your CSS and Astryx's components. Rename yours, or set it through a theme (`defineTheme`) on purpose.",
380
+ },
381
+ theme: {
382
+ title: "0.1 theme switches and theme selectors",
383
+ help: '0.1 switched themes by stylesheet and `html[data-theme="orange-…"]`. In 0.2 `<Theme theme={lablupTheme} mode=…>` owns `html[data-theme]` and sets it to `light` or `dark`, so code that writes another value fights it, and selectors on another value never match.',
384
+ },
385
+ "own-class": {
386
+ title: "0.1 class names your own CSS also defines (lower confidence)",
387
+ help: "The same names as above, but your own stylesheets define each of them as a rule of its own (`.tabs__tab { … }`), so they most likely belong to markup you render, not to ui-common's. Skim them; most need nothing.",
388
+ },
389
+ "local-wrapper": {
390
+ title: "Local wrappers around 0.1 components",
391
+ help: "Your own component renders a 0.1 component and hands its props on, so it now renders the Astryx one. Its call sites pass the wrapper's props, which the upgrade does not rewrite: check the wrapper's props type and what it passes on against the Astryx component. A pure re-export (`export { Button } from …`) is not listed: its call sites were migrated.",
392
+ },
393
+ "stylesheet-path": {
394
+ title: "0.1 stylesheet paths left in place",
395
+ help: "Scripts, configs or tests that name `@lablup/ui-common/styles/*` directly. base.css and the orange themes are deprecated in 0.2 and removed in 0.3; the Lablup theme replaces them.",
396
+ },
397
+ };