@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/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
+ }
@@ -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
+ }