@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +28 -0
- package/bin/ui-common.mjs +12 -0
- package/cli/agents.mjs +335 -0
- package/cli/diff.mjs +125 -0
- package/cli/lab-peer.mjs +226 -0
- package/cli/main.mjs +97 -0
- package/cli/passthrough.mjs +153 -0
- package/cli/paths.mjs +290 -0
- package/cli/report.mjs +157 -0
- package/cli/resolve.mjs +257 -0
- package/cli/rewrite.mjs +133 -0
- package/cli/semver.mjs +278 -0
- package/cli/shadow.mjs +161 -0
- package/cli/sync-astryx.mjs +417 -0
- package/cli/upgrade.mjs +776 -0
- package/codemods/0.2/components.mjs +903 -0
- package/codemods/0.2/elements.mjs +745 -0
- package/codemods/0.2/index.mjs +68 -0
- package/codemods/0.2/legacy-classes.json +292 -0
- package/codemods/0.2/local-modules.mjs +510 -0
- package/codemods/0.2/map.mjs +178 -0
- package/codemods/0.2/package-json.mjs +333 -0
- package/codemods/0.2/scan.mjs +397 -0
- package/codemods/0.2/stylesheets.mjs +479 -0
- package/codemods/0.2/theme.mjs +325 -0
- package/codemods/lib/jsx.mjs +352 -0
- package/codemods/lib/todo.mjs +151 -0
- package/codemods/registry.mjs +95 -0
- package/codemods/upstream.mjs +155 -0
- package/migration/0.1-to-0.2.json +629 -0
- package/package.json +50 -0
package/cli/paths.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where things live. Two packages are involved, and they are found apart:
|
|
3
|
+
*
|
|
4
|
+
* - The CLI's own package, @lablup/ui-common-cli: the Astryx CLI it pins,
|
|
5
|
+
* jscodeshift, the codemods and the migration map.
|
|
6
|
+
* - The @lablup/ui-common package the CLI works for: its version, its export
|
|
7
|
+
* lists (exports.exclude.json, exports.customs.json) and the Astryx packages
|
|
8
|
+
* it pins (core, theme-neutral, lab). A consumer depends on ui-common, so
|
|
9
|
+
* under pnpm's isolated layout those are reachable from ui-common's install
|
|
10
|
+
* location and not from the consumer's own directory.
|
|
11
|
+
*
|
|
12
|
+
* Nothing is resolved from the working directory except the project's own
|
|
13
|
+
* @lablup/ui-common.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
export const UI_COMMON = "@lablup/ui-common";
|
|
21
|
+
|
|
22
|
+
/** Root of the installed @lablup/ui-common-cli package. */
|
|
23
|
+
export const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
|
|
25
|
+
/** @param {string} file */
|
|
26
|
+
export function readJson(file) {
|
|
27
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @returns {{name: string, version: string, dependencies?: Record<string,string>, peerDependencies?: Record<string,string>}} */
|
|
31
|
+
export function cliPackageJson() {
|
|
32
|
+
return readJson(join(CLI_ROOT, "package.json"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The @lablup/ui-common package root `fromDir` resolves, or null. Its
|
|
37
|
+
* `exports` map lists `./package.json`, so this works from a consumer, from
|
|
38
|
+
* the CLI package (its peer) and, by self-reference, from this repository.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} fromDir
|
|
41
|
+
*/
|
|
42
|
+
function resolveUiCommonFrom(fromDir) {
|
|
43
|
+
try {
|
|
44
|
+
const req = createRequire(join(fromDir, "package.json"));
|
|
45
|
+
return dirname(req.resolve(`${UI_COMMON}/package.json`));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Root of the @lablup/ui-common a command works on: the project's own install
|
|
53
|
+
* first, so the CLI reads the ui-common the project actually has, then the one
|
|
54
|
+
* beside the CLI (its peer, which a `pnpm dlx` run installs).
|
|
55
|
+
*
|
|
56
|
+
* @param {string} [cwd]
|
|
57
|
+
*/
|
|
58
|
+
export function uiCommonRoot(cwd = process.cwd()) {
|
|
59
|
+
const project = findProjectDir(cwd);
|
|
60
|
+
const dir =
|
|
61
|
+
(project && resolveUiCommonFrom(project)) ?? resolveUiCommonFrom(CLI_ROOT);
|
|
62
|
+
if (!dir) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`${UI_COMMON} is not installed. Add it to the project (the CLI is @lablup/ui-common-cli, a devDependency next to it).`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Root of the @lablup/ui-common this CLI release ships with: its peer, at the
|
|
72
|
+
* same version. `upgrade` reads its target's pins from here, since the project
|
|
73
|
+
* may still be on the version it is upgrading from. Falls back to the
|
|
74
|
+
* project's when the peer is not reachable from the CLI.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} [cwd]
|
|
77
|
+
*/
|
|
78
|
+
export function targetUiCommonRoot(cwd = process.cwd()) {
|
|
79
|
+
return resolveUiCommonFrom(CLI_ROOT) ?? uiCommonRoot(cwd);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* package.json of the @lablup/ui-common a command works on (see uiCommonRoot).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} [root]
|
|
86
|
+
* @returns {{name: string, version: string, dependencies?: Record<string,string>, peerDependencies?: Record<string,string>, devDependencies?: Record<string,string>}}
|
|
87
|
+
*/
|
|
88
|
+
export function uiCommonPackageJson(root = uiCommonRoot()) {
|
|
89
|
+
return readJson(join(root, "package.json"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Walk up from a resolved file to the directory holding its package.json. */
|
|
93
|
+
function packageDirOf(file, name) {
|
|
94
|
+
let dir = dirname(file);
|
|
95
|
+
for (;;) {
|
|
96
|
+
const pkg = join(dir, "package.json");
|
|
97
|
+
if (existsSync(pkg)) {
|
|
98
|
+
try {
|
|
99
|
+
if (readJson(pkg).name === name) return dir;
|
|
100
|
+
} catch {
|
|
101
|
+
// keep walking
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const parent = dirname(dir);
|
|
105
|
+
if (parent === dir) return null;
|
|
106
|
+
dir = parent;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The directory of a package as `fromDir` resolves it, or null. Resolution
|
|
112
|
+
* goes through the package's main entry, because not every Astryx package
|
|
113
|
+
* exports its package.json.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} name
|
|
116
|
+
* @param {string} fromDir
|
|
117
|
+
*/
|
|
118
|
+
function packageDirFrom(name, fromDir) {
|
|
119
|
+
try {
|
|
120
|
+
const req = createRequire(join(fromDir, "package.json"));
|
|
121
|
+
return packageDirOf(req.resolve(name), name);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The directory of a dependency of @lablup/ui-common (an Astryx package it
|
|
129
|
+
* pins), or null.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @param {string} [root] the ui-common root to resolve from
|
|
133
|
+
*/
|
|
134
|
+
export function dependencyDir(name, root) {
|
|
135
|
+
let from = root;
|
|
136
|
+
if (!from) {
|
|
137
|
+
try {
|
|
138
|
+
from = uiCommonRoot();
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return packageDirFrom(name, from);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The directory of a dependency of the CLI package itself, or null.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} name
|
|
150
|
+
*/
|
|
151
|
+
export function cliDependencyDir(name) {
|
|
152
|
+
return packageDirFrom(name, CLI_ROOT);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Root of the exact-pinned @astryxdesign/cli. */
|
|
156
|
+
export function astryxCliRoot() {
|
|
157
|
+
const dir = cliDependencyDir("@astryxdesign/cli");
|
|
158
|
+
if (!dir) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
"@astryxdesign/cli is not installed next to @lablup/ui-common-cli. Reinstall @lablup/ui-common-cli.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return dir;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The `astryx` bin script of the pinned CLI. */
|
|
167
|
+
export function astryxBin() {
|
|
168
|
+
const root = astryxCliRoot();
|
|
169
|
+
const bin = readJson(join(root, "package.json")).bin?.astryx;
|
|
170
|
+
return join(root, bin ?? "clients/cli/bin/astryx.mjs");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Import a module file from inside the pinned Astryx CLI. These are internals,
|
|
175
|
+
* not the CLI's public API; the pin is exact and `sync-astryx` re-runs the
|
|
176
|
+
* tests that use them on every bump.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} relative path under the CLI root
|
|
179
|
+
*/
|
|
180
|
+
export async function importAstryxInternal(relative) {
|
|
181
|
+
const file = join(astryxCliRoot(), relative);
|
|
182
|
+
return import(pathToFileURL(file).href);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Version of an installed dependency of ui-common, or null.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} name
|
|
189
|
+
* @param {string} [root] the ui-common root to resolve from
|
|
190
|
+
*/
|
|
191
|
+
export function dependencyVersion(name, root) {
|
|
192
|
+
const dir = dependencyDir(name, root);
|
|
193
|
+
return dir ? readJson(join(dir, "package.json")).version : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Version of an installed dependency of the CLI package, or null.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} name
|
|
200
|
+
*/
|
|
201
|
+
export function cliDependencyVersion(name) {
|
|
202
|
+
const dir = cliDependencyDir(name);
|
|
203
|
+
return dir ? readJson(join(dir, "package.json")).version : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `exports.exclude.json`: Astryx subpaths ui-common hides, with replacements. */
|
|
207
|
+
export function excludedExports(root = uiCommonRoot()) {
|
|
208
|
+
const file = join(root, "exports.exclude.json");
|
|
209
|
+
if (!existsSync(file)) return [];
|
|
210
|
+
return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ (
|
|
211
|
+
readJson(file)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `exports.customs.json`: ui-common's own exports. */
|
|
216
|
+
export function customExports(root = uiCommonRoot()) {
|
|
217
|
+
const file = join(root, "exports.customs.json");
|
|
218
|
+
if (!existsSync(file)) return [];
|
|
219
|
+
return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ (
|
|
220
|
+
readJson(file)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* ui-common's own copies of Astryx components (src/forks/): same names and
|
|
226
|
+
* import paths as Astryx's, which exports.exclude.json hides. `names` are the
|
|
227
|
+
* names a reader may meet it by; `from` is where a consumer imports it.
|
|
228
|
+
*
|
|
229
|
+
* @returns {Array<{name: string, from: string, names: string[], reason: string}>}
|
|
230
|
+
*/
|
|
231
|
+
export function forkedExports() {
|
|
232
|
+
const exclusions = excludedExports();
|
|
233
|
+
return customExports()
|
|
234
|
+
.filter((c) => c.fork !== undefined)
|
|
235
|
+
.map((c) => {
|
|
236
|
+
const exclusion = exclusions.find((e) => e.replacedBy === c.name);
|
|
237
|
+
return {
|
|
238
|
+
name: c.name,
|
|
239
|
+
from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`,
|
|
240
|
+
names: exclusion?.exports ?? [c.name],
|
|
241
|
+
reason: exclusion?.reason ?? "",
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The exclusions that hide an Astryx name behind a different one. A fork's
|
|
248
|
+
* exclusion is not one: the name stays, now ui-common's.
|
|
249
|
+
*/
|
|
250
|
+
export function hiddenExports() {
|
|
251
|
+
const forks = new Set(forkedExports().map((f) => f.name));
|
|
252
|
+
return excludedExports().filter(
|
|
253
|
+
(e) => e.replacedBy === null || !forks.has(e.replacedBy),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The nearest directory at or above `start` holding a package.json.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} start
|
|
261
|
+
*/
|
|
262
|
+
export function findProjectDir(start) {
|
|
263
|
+
let dir = resolve(start);
|
|
264
|
+
for (;;) {
|
|
265
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
266
|
+
const parent = dirname(dir);
|
|
267
|
+
if (parent === dir) return null;
|
|
268
|
+
dir = parent;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* How this project runs a locally installed bin, from its lockfile.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} projectDir
|
|
276
|
+
*/
|
|
277
|
+
export function binInvocation(projectDir, bin = "ui-common") {
|
|
278
|
+
let dir = resolve(projectDir);
|
|
279
|
+
for (;;) {
|
|
280
|
+
if (existsSync(join(dir, "pnpm-lock.yaml"))) return `pnpm exec ${bin}`;
|
|
281
|
+
if (existsSync(join(dir, "yarn.lock"))) return `yarn ${bin}`;
|
|
282
|
+
if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) {
|
|
283
|
+
return `bunx ${bin}`;
|
|
284
|
+
}
|
|
285
|
+
if (existsSync(join(dir, "package-lock.json"))) return `npx ${bin}`;
|
|
286
|
+
const parent = dirname(dir);
|
|
287
|
+
if (parent === dir) return `npx ${bin}`;
|
|
288
|
+
dir = parent;
|
|
289
|
+
}
|
|
290
|
+
}
|
package/cli/report.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ui-common-upgrade-report.md`: what `ui-common upgrade` changed, and what it
|
|
3
|
+
* left for a person. Deterministic (no timestamps), so a report can be
|
|
4
|
+
* committed and diffed between runs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The report's first line; `ui-common upgrade` overwrites only a file that starts with it. */
|
|
8
|
+
export const REPORT_HEADING = "# ui-common upgrade report";
|
|
9
|
+
|
|
10
|
+
/** @param {string} text */
|
|
11
|
+
function code(text) {
|
|
12
|
+
const ticks = text.includes("`") ? "``" : "`";
|
|
13
|
+
const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
|
|
14
|
+
return `${ticks}${pad}${text}${pad}${ticks}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {string} text */
|
|
18
|
+
function escapeCell(text) {
|
|
19
|
+
return text.replace(/\|/g, "\\|");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} data
|
|
24
|
+
* @param {string} data.from
|
|
25
|
+
* @param {string} data.to
|
|
26
|
+
* @param {string} data.version installed ui-common
|
|
27
|
+
* @param {boolean} data.dryRun
|
|
28
|
+
* @param {string[]} data.roots
|
|
29
|
+
* @param {number} data.fileCount
|
|
30
|
+
* @param {string[]} [data.scanRoots] where the manual-review scan looked
|
|
31
|
+
* @param {number} [data.scanCount]
|
|
32
|
+
* @param {Array<{version: string, title: string, notes: string[]}>} data.steps
|
|
33
|
+
* @param {Array<{file: string, created: boolean, transforms: string[], added: number, removed: number}>} data.changed
|
|
34
|
+
* @param {{changed: boolean, notes: string[]}} data.packageJson
|
|
35
|
+
* @param {Array<{file: string, line: number, text: string}>} data.todos
|
|
36
|
+
* @param {Array<{category: string, file: string, line: number, text: string, detail?: string}>} data.findings
|
|
37
|
+
* @param {Record<string, {title: string, help: string}>} data.categories
|
|
38
|
+
* @param {Array<{file: string, transform: string, error: string}>} data.errors
|
|
39
|
+
* @param {string[]} [data.notices] where the run did something other than the usual
|
|
40
|
+
* @param {string[]} [data.alerts] what has to be done before the app works; first in the report
|
|
41
|
+
* @param {number} data.tokenReads
|
|
42
|
+
*/
|
|
43
|
+
export function renderReport(data) {
|
|
44
|
+
const out = [];
|
|
45
|
+
out.push(REPORT_HEADING, "");
|
|
46
|
+
out.push(
|
|
47
|
+
`\`ui-common upgrade\` ${data.from} → ${data.to} (@lablup/ui-common-cli ${data.version})` +
|
|
48
|
+
`${data.dryRun ? ", **dry run: nothing was written**" : ""}.`,
|
|
49
|
+
"",
|
|
50
|
+
`Ran the codemods over ${data.fileCount} file${data.fileCount === 1 ? "" : "s"} under ${data.roots.map(code).join(", ")}` +
|
|
51
|
+
(data.scanRoots
|
|
52
|
+
? `; searched ${data.scanCount ?? 0} file${data.scanCount === 1 ? "" : "s"} under ${data.scanRoots.map((r) => (r === "." ? "the project root" : code(r))).join(", ")} for manual-review findings.`
|
|
53
|
+
: "."),
|
|
54
|
+
"",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (data.alerts && data.alerts.length > 0) {
|
|
58
|
+
out.push("## Action required", "");
|
|
59
|
+
for (const alert of data.alerts) out.push(`- ${alert}`);
|
|
60
|
+
out.push("");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const manual = data.findings.length;
|
|
64
|
+
out.push("## Summary", "");
|
|
65
|
+
out.push("| | Count |", "|---|---:|");
|
|
66
|
+
out.push(
|
|
67
|
+
`| Files ${data.dryRun ? "that would change" : "changed"} | ${data.changed.length} |`,
|
|
68
|
+
);
|
|
69
|
+
out.push(
|
|
70
|
+
`| package.json ${data.dryRun ? "would change" : "changed"} | ${data.packageJson.changed ? "yes" : "no"} |`,
|
|
71
|
+
);
|
|
72
|
+
out.push(`| TODO markers left in code | ${data.todos.length} |`);
|
|
73
|
+
out.push(`| Manual-review findings | ${manual} |`);
|
|
74
|
+
if (data.errors.length > 0)
|
|
75
|
+
out.push(`| Files the codemods could not transform | ${data.errors.length} |`);
|
|
76
|
+
out.push("");
|
|
77
|
+
|
|
78
|
+
out.push("## Steps", "");
|
|
79
|
+
for (const step of data.steps) out.push(`- ${step.version}: ${step.title}`);
|
|
80
|
+
out.push("");
|
|
81
|
+
|
|
82
|
+
if (data.errors.length > 0) {
|
|
83
|
+
out.push(
|
|
84
|
+
"## Errors",
|
|
85
|
+
"",
|
|
86
|
+
"These files were left as they were. Migrate them by hand.",
|
|
87
|
+
"",
|
|
88
|
+
);
|
|
89
|
+
for (const e of data.errors)
|
|
90
|
+
out.push(`- ${code(e.file)} (${e.transform}): ${e.error}`);
|
|
91
|
+
out.push("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
out.push(`## ${data.dryRun ? "Files that would change" : "Changed files"}`, "");
|
|
95
|
+
if (data.changed.length === 0) out.push("None.");
|
|
96
|
+
for (const c of data.changed) {
|
|
97
|
+
out.push(
|
|
98
|
+
`- ${code(c.file)}${c.created ? " (new)" : ""}: +${c.added} −${c.removed}, ${c.transforms.join(", ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
out.push("");
|
|
102
|
+
|
|
103
|
+
out.push("## package.json", "");
|
|
104
|
+
if (data.packageJson.notes.length === 0) out.push("No change.");
|
|
105
|
+
for (const note of data.packageJson.notes) out.push(`- ${note}`);
|
|
106
|
+
out.push("");
|
|
107
|
+
|
|
108
|
+
out.push("## Manual review", "");
|
|
109
|
+
out.push(`### TODO markers (${data.todos.length})`, "");
|
|
110
|
+
if (data.todos.length === 0) {
|
|
111
|
+
out.push("None.", "");
|
|
112
|
+
} else {
|
|
113
|
+
out.push(
|
|
114
|
+
"Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment.",
|
|
115
|
+
"",
|
|
116
|
+
);
|
|
117
|
+
for (const t of data.todos) out.push(`- ${code(`${t.file}:${t.line}`)} ${t.text}`);
|
|
118
|
+
out.push("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const byCategory = new Map();
|
|
122
|
+
for (const f of data.findings) {
|
|
123
|
+
const list = byCategory.get(f.category) ?? [];
|
|
124
|
+
list.push(f);
|
|
125
|
+
byCategory.set(f.category, list);
|
|
126
|
+
}
|
|
127
|
+
for (const [category, meta] of Object.entries(data.categories)) {
|
|
128
|
+
const list = byCategory.get(category) ?? [];
|
|
129
|
+
out.push(`### ${meta.title} (${list.length})`, "");
|
|
130
|
+
if (list.length === 0) {
|
|
131
|
+
out.push("None.", "");
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
out.push(meta.help, "");
|
|
135
|
+
out.push("| Where | What | Detail |", "|---|---|---|");
|
|
136
|
+
for (const f of list) {
|
|
137
|
+
out.push(
|
|
138
|
+
`| ${code(`${f.file}:${f.line}`)} | ${escapeCell(code(f.text.length > 120 ? `${f.text.slice(0, 117)}...` : f.text))} | ${escapeCell(f.detail ?? "")} |`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
out.push("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const notes = [...(data.notices ?? []), ...data.steps.flatMap((s) => s.notes)];
|
|
145
|
+
if (notes.length > 0 || data.tokenReads > 0) {
|
|
146
|
+
out.push("## Notes", "");
|
|
147
|
+
for (const note of notes) out.push(`- ${note}`);
|
|
148
|
+
if (data.tokenReads > 0) {
|
|
149
|
+
out.push(
|
|
150
|
+
`- ${data.tokenReads} \`var(--token-*)\` read${data.tokenReads === 1 ? "" : "s"} left as they are. \`@lablup/ui-common/legacy-tokens.css\` declares the 0.1 names (deprecated, removed in 0.3).`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
out.push("");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return `${out.join("\n").replace(/\n+$/, "")}\n`;
|
|
157
|
+
}
|
package/cli/resolve.mjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a project's own import specifiers to files, the way its bundler
|
|
3
|
+
* would for the common cases: relative paths, and the `paths` / `baseUrl`
|
|
4
|
+
* aliases its tsconfig declares (`@/components/common`). A specifier that
|
|
5
|
+
* resolves neither way and is not an installed package is recorded as an
|
|
6
|
+
* unresolved alias (a Vite `resolve.alias`, say), for the report.
|
|
7
|
+
*
|
|
8
|
+
* Only what `ui-common upgrade` needs: file-to-file resolution inside the
|
|
9
|
+
* project. Packages are never resolved.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { builtinModules } from "node:module";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
const SCRIPT_EXTENSIONS = [
|
|
16
|
+
".tsx",
|
|
17
|
+
".ts",
|
|
18
|
+
".jsx",
|
|
19
|
+
".js",
|
|
20
|
+
".mts",
|
|
21
|
+
".mjs",
|
|
22
|
+
".cts",
|
|
23
|
+
".cjs",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* JSON with comments and trailing commas, as tsconfig allows.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} text
|
|
30
|
+
*/
|
|
31
|
+
export function parseJsonc(text) {
|
|
32
|
+
let out = "";
|
|
33
|
+
for (let i = 0; i < text.length; i++) {
|
|
34
|
+
const ch = text[i];
|
|
35
|
+
if (ch === '"') {
|
|
36
|
+
const start = i;
|
|
37
|
+
for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++;
|
|
38
|
+
out += text.slice(start, i + 1);
|
|
39
|
+
} else if (ch === "/" && text[i + 1] === "/") {
|
|
40
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
41
|
+
out += "\n";
|
|
42
|
+
} else if (ch === "/" && text[i + 1] === "*") {
|
|
43
|
+
const end = text.indexOf("*/", i + 2);
|
|
44
|
+
i = end === -1 ? text.length : end + 1;
|
|
45
|
+
} else out += ch;
|
|
46
|
+
}
|
|
47
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} path */
|
|
51
|
+
function isFile(path) {
|
|
52
|
+
try {
|
|
53
|
+
return statSync(path).isFile();
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The file an import of `base` (no extension, or a `.js` one standing for a
|
|
61
|
+
* `.ts` source) loads: `base` itself, `base.<ext>`, or `base/index.<ext>`.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} base absolute
|
|
64
|
+
*/
|
|
65
|
+
export function resolveFile(base) {
|
|
66
|
+
if (isFile(base) && SCRIPT_EXTENSIONS.some((e) => base.endsWith(e))) return base;
|
|
67
|
+
for (const ext of SCRIPT_EXTENSIONS)
|
|
68
|
+
if (isFile(`${base}${ext}`)) return `${base}${ext}`;
|
|
69
|
+
const js = /\.(m|c)?jsx?$/.exec(base);
|
|
70
|
+
if (js) {
|
|
71
|
+
const stem = base.slice(0, js.index);
|
|
72
|
+
for (const ext of SCRIPT_EXTENSIONS)
|
|
73
|
+
if (isFile(`${stem}${ext}`)) return `${stem}${ext}`;
|
|
74
|
+
}
|
|
75
|
+
for (const ext of SCRIPT_EXTENSIONS) {
|
|
76
|
+
const index = join(base, `index${ext}`);
|
|
77
|
+
if (isFile(index)) return index;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `compilerOptions` of a tsconfig, merged over what it extends (relative
|
|
84
|
+
* `extends` only), with `paths` and `baseUrl` made absolute.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} file
|
|
87
|
+
* @param {Set<string>} [seen]
|
|
88
|
+
* @returns {{baseUrl?: string, paths?: Record<string, string[]>, pathsBase?: string}}
|
|
89
|
+
*/
|
|
90
|
+
function readTsconfig(file, seen = new Set()) {
|
|
91
|
+
if (seen.has(file) || !isFile(file)) return {};
|
|
92
|
+
seen.add(file);
|
|
93
|
+
let json;
|
|
94
|
+
try {
|
|
95
|
+
json = parseJsonc(readFileSync(file, "utf8"));
|
|
96
|
+
} catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
const dir = dirname(file);
|
|
100
|
+
/** @type {{baseUrl?: string, paths?: Record<string, string[]>, pathsBase?: string}} */
|
|
101
|
+
let merged = {};
|
|
102
|
+
const parents = Array.isArray(json.extends) ? json.extends : [json.extends];
|
|
103
|
+
for (const parent of parents) {
|
|
104
|
+
if (typeof parent !== "string" || !parent.startsWith(".")) continue;
|
|
105
|
+
const target = resolve(dir, parent);
|
|
106
|
+
merged = {
|
|
107
|
+
...merged,
|
|
108
|
+
...readTsconfig(target.endsWith(".json") ? target : `${target}.json`, seen),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const options = json.compilerOptions ?? {};
|
|
112
|
+
if (typeof options.baseUrl === "string")
|
|
113
|
+
merged.baseUrl = resolve(dir, options.baseUrl);
|
|
114
|
+
if (options.paths && typeof options.paths === "object") {
|
|
115
|
+
merged.paths = options.paths;
|
|
116
|
+
merged.pathsBase = merged.baseUrl ?? dir;
|
|
117
|
+
} else if (merged.paths && typeof options.baseUrl === "string") {
|
|
118
|
+
merged.pathsBase = merged.baseUrl;
|
|
119
|
+
}
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Every tsconfig of the project that may declare aliases: `tsconfig.json`,
|
|
125
|
+
* the configs it references, and the usual Vite split (`tsconfig.app.json`).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} projectDir
|
|
128
|
+
*/
|
|
129
|
+
function projectTsconfigs(projectDir) {
|
|
130
|
+
const root = join(projectDir, "tsconfig.json");
|
|
131
|
+
const files = [root, join(projectDir, "tsconfig.app.json")];
|
|
132
|
+
try {
|
|
133
|
+
const json = parseJsonc(readFileSync(root, "utf8"));
|
|
134
|
+
for (const ref of json.references ?? []) {
|
|
135
|
+
if (typeof ref?.path !== "string") continue;
|
|
136
|
+
const target = resolve(projectDir, ref.path);
|
|
137
|
+
files.push(target.endsWith(".json") ? target : join(target, "tsconfig.json"));
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// No tsconfig, or one this cannot read: relative imports only.
|
|
141
|
+
}
|
|
142
|
+
return [...new Set(files)].filter(isFile);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {string} specifier */
|
|
146
|
+
function packageName(specifier) {
|
|
147
|
+
const parts = specifier.split("/");
|
|
148
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const BUILTINS = new Set(builtinModules);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} projectDir
|
|
155
|
+
*/
|
|
156
|
+
export function createResolver(projectDir) {
|
|
157
|
+
const configs = projectTsconfigs(projectDir).map((f) => readTsconfig(f));
|
|
158
|
+
/** @type {Array<{prefix: string, suffix: string, wildcard: boolean, targets: string[]}>} */
|
|
159
|
+
const aliases = [];
|
|
160
|
+
/** @type {string[]} */
|
|
161
|
+
const baseUrls = [];
|
|
162
|
+
for (const config of configs) {
|
|
163
|
+
if (config.baseUrl) baseUrls.push(config.baseUrl);
|
|
164
|
+
if (!config.paths || !config.pathsBase) continue;
|
|
165
|
+
for (const [pattern, targets] of Object.entries(config.paths)) {
|
|
166
|
+
if (!Array.isArray(targets)) continue;
|
|
167
|
+
const star = pattern.indexOf("*");
|
|
168
|
+
aliases.push({
|
|
169
|
+
prefix: star === -1 ? pattern : pattern.slice(0, star),
|
|
170
|
+
suffix: star === -1 ? "" : pattern.slice(star + 1),
|
|
171
|
+
wildcard: star !== -1,
|
|
172
|
+
targets: targets.map((t) =>
|
|
173
|
+
resolve(/** @type {string} */ (config.pathsBase), t),
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Longest prefix first, as TypeScript matches.
|
|
179
|
+
aliases.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
180
|
+
|
|
181
|
+
/** @type {Map<string, boolean>} */
|
|
182
|
+
const installed = new Map();
|
|
183
|
+
/** @param {string} name */
|
|
184
|
+
const isInstalled = (name) => {
|
|
185
|
+
let hit = installed.get(name);
|
|
186
|
+
if (hit !== undefined) return hit;
|
|
187
|
+
hit = false;
|
|
188
|
+
for (let dir = projectDir; ;) {
|
|
189
|
+
if (existsSync(join(dir, "node_modules", name))) {
|
|
190
|
+
hit = true;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
const parent = dirname(dir);
|
|
194
|
+
if (parent === dir) break;
|
|
195
|
+
dir = parent;
|
|
196
|
+
}
|
|
197
|
+
installed.set(name, hit);
|
|
198
|
+
return hit;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Alias-like specifiers nothing resolved, by their first segment. @type {Map<string, number>} */
|
|
202
|
+
const unresolved = new Map();
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {string} fromFile absolute
|
|
206
|
+
* @param {string} specifier
|
|
207
|
+
* @param {{probe?: boolean}} [options] probe: a lookup that records no miss
|
|
208
|
+
* @returns {string | null}
|
|
209
|
+
*/
|
|
210
|
+
function resolveImport(fromFile, specifier, { probe = false } = {}) {
|
|
211
|
+
if (specifier.startsWith(".") || specifier.startsWith("/")) {
|
|
212
|
+
return resolveFile(resolve(dirname(fromFile), specifier));
|
|
213
|
+
}
|
|
214
|
+
if (specifier.includes(":") || BUILTINS.has(packageName(specifier))) return null;
|
|
215
|
+
let aliased = false;
|
|
216
|
+
for (const alias of aliases) {
|
|
217
|
+
let rest;
|
|
218
|
+
if (alias.wildcard) {
|
|
219
|
+
if (!specifier.startsWith(alias.prefix) || !specifier.endsWith(alias.suffix))
|
|
220
|
+
continue;
|
|
221
|
+
rest = specifier.slice(
|
|
222
|
+
alias.prefix.length,
|
|
223
|
+
specifier.length - alias.suffix.length,
|
|
224
|
+
);
|
|
225
|
+
} else if (specifier !== alias.prefix) continue;
|
|
226
|
+
aliased = true;
|
|
227
|
+
for (const target of alias.targets) {
|
|
228
|
+
const hit = resolveFile(
|
|
229
|
+
rest === undefined ? target : target.replace("*", rest),
|
|
230
|
+
);
|
|
231
|
+
if (hit) return hit;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// A tsconfig alias to a stylesheet or JSON file: resolved, just not a script.
|
|
235
|
+
if (aliased) return null;
|
|
236
|
+
const name = packageName(specifier);
|
|
237
|
+
if (isInstalled(name)) return null;
|
|
238
|
+
for (const base of baseUrls) {
|
|
239
|
+
const hit = resolveFile(join(base, specifier));
|
|
240
|
+
if (hit) return hit;
|
|
241
|
+
}
|
|
242
|
+
if (probe) return null;
|
|
243
|
+
// Only what looks like an alias is worth reporting: a bare name that is
|
|
244
|
+
// not installed is as likely a package this checkout has not installed.
|
|
245
|
+
const first = specifier.split("/")[0];
|
|
246
|
+
if (
|
|
247
|
+
(/^[@~#$]/.test(specifier) && !/^@[\w-]/.test(specifier)) ||
|
|
248
|
+
existsSync(join(projectDir, first))
|
|
249
|
+
) {
|
|
250
|
+
const key = `${first}/`;
|
|
251
|
+
unresolved.set(key, (unresolved.get(key) ?? 0) + 1);
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { resolveImport, unresolved };
|
|
257
|
+
}
|