@dependably/npm-check 1.8.0 → 1.10.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/README.md +47 -0
- package/bin/cli.js +93 -1
- package/package.json +24 -6
- package/src/audit-config.js +127 -75
- package/src/audit.js +235 -35
- package/src/exceptions.js +87 -70
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +140 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +36 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +486 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/specifier.js +48 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +493 -0
- package/src/facts/version.js +11 -0
- package/src/facts/workspace.js +183 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +14 -6
- package/src/schema.js +54 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
// src/facts/resolve.js
|
|
2
|
+
// A Node-style module resolver — enough of the algorithm to follow real import
|
|
3
|
+
// statements INTO and THROUGH node_modules, so a consumer can see which
|
|
4
|
+
// packages are loaded by the packages first-party code loads. Parse-level and
|
|
5
|
+
// filesystem-level only: no bundler config, no loaders, no `NODE_PATH`.
|
|
6
|
+
//
|
|
7
|
+
// What it implements:
|
|
8
|
+
// - symlink-aware resolution: every resolved file is realpath'd, the way
|
|
9
|
+
// Node does it (`--preserve-symlinks` off). This is what makes pnpm's
|
|
10
|
+
// layout work — `node_modules/<name>` is a symlink into
|
|
11
|
+
// `.pnpm/<name>@<version>/node_modules/<name>`, and a package's own
|
|
12
|
+
// dependencies are SIBLINGS inside that `.pnpm` directory, so the
|
|
13
|
+
// node_modules walk-up for the next hop has to start from the real path;
|
|
14
|
+
// - relative / absolute specifiers with extension probing (and the
|
|
15
|
+
// TypeScript convention of writing `./x.js` for a `./x.ts` source);
|
|
16
|
+
// - bare specifiers via the node_modules walk-up from the importing file;
|
|
17
|
+
// - package.json `exports` (string / array / conditions / subpath maps /
|
|
18
|
+
// `*` patterns, with `import`-vs-`require` conditions), `main`, `module`,
|
|
19
|
+
// `index.*`;
|
|
20
|
+
// - package.json `imports` (`#internal` specifiers);
|
|
21
|
+
// - builtins (`fs`, `node:fs`).
|
|
22
|
+
//
|
|
23
|
+
// What it deliberately reports as unresolved rather than guessing: bundler
|
|
24
|
+
// aliases (the caller's tsconfig-paths prefixes), anything that only resolves
|
|
25
|
+
// to a `.d.ts`, `exports` maps that block the subpath, and specifiers that
|
|
26
|
+
// simply are not installed. Every unresolved edge is COUNTED by the graph
|
|
27
|
+
// walker and reported — an edge the resolver could not follow is a place a
|
|
28
|
+
// runtime path could hide, and the document must say so.
|
|
29
|
+
//
|
|
30
|
+
// Ported from sbom-reach's `packages/analyzer-npm/src/resolve.ts`.
|
|
31
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
32
|
+
import { builtinModules } from 'node:module';
|
|
33
|
+
import { basename, dirname, isAbsolute, join, resolve as pathResolve, sep } from 'node:path';
|
|
34
|
+
|
|
35
|
+
/** @typedef {import('./types.d.ts').PackageInfo} PackageInfo */
|
|
36
|
+
/** @typedef {import('./types.d.ts').Resolution} Resolution */
|
|
37
|
+
/** @typedef {import('./types.d.ts').ResolveMode} ResolveMode */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} PackageJson
|
|
41
|
+
* @property {unknown} [name]
|
|
42
|
+
* @property {unknown} [version]
|
|
43
|
+
* @property {unknown} [main]
|
|
44
|
+
* @property {unknown} [module]
|
|
45
|
+
* @property {unknown} [exports]
|
|
46
|
+
* @property {unknown} [imports]
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const BUILTINS = new Set(builtinModules);
|
|
50
|
+
const CODE_EXTS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.jsx', '.svelte'];
|
|
51
|
+
const CODE_EXT_SET = new Set(CODE_EXTS);
|
|
52
|
+
/** `import './x.js'` in a TypeScript source resolves to `./x.ts` under most setups.
|
|
53
|
+
* @type {Record<string, string[]>} */
|
|
54
|
+
const TS_FOR_JS = {
|
|
55
|
+
'.js': ['.ts', '.tsx'],
|
|
56
|
+
'.mjs': ['.mts'],
|
|
57
|
+
'.cjs': ['.cts'],
|
|
58
|
+
'.jsx': ['.tsx']
|
|
59
|
+
};
|
|
60
|
+
const NODE_MODULES = 'node_modules';
|
|
61
|
+
|
|
62
|
+
export class ModuleResolver {
|
|
63
|
+
/**
|
|
64
|
+
* @param {ReadonlySet<string>} [aliasPrefixes] tsconfig/jsconfig path-alias
|
|
65
|
+
* bases; a specifier under one resolves to `{ kind: 'alias' }`.
|
|
66
|
+
*/
|
|
67
|
+
constructor(aliasPrefixes = new Set()) {
|
|
68
|
+
/** @type {ReadonlySet<string>} */
|
|
69
|
+
this.aliasPrefixes = aliasPrefixes;
|
|
70
|
+
/** @type {Map<string, 'file' | 'dir' | null>} */
|
|
71
|
+
this.statCache = new Map();
|
|
72
|
+
/** @type {Map<string, PackageJson | null>} */
|
|
73
|
+
this.packageJsonCache = new Map();
|
|
74
|
+
/** @type {Map<string, PackageInfo | undefined>} */
|
|
75
|
+
this.packageInfoCache = new Map();
|
|
76
|
+
/** @type {Map<string, string>} */
|
|
77
|
+
this.realpathCache = new Map();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} fromFile absolute path of the importing file
|
|
82
|
+
* @param {string} specifier
|
|
83
|
+
* @param {ResolveMode} mode
|
|
84
|
+
* @returns {Resolution}
|
|
85
|
+
*/
|
|
86
|
+
resolve(fromFile, specifier, mode) {
|
|
87
|
+
if (specifier.length === 0) return { kind: 'unresolved', reason: 'empty specifier' };
|
|
88
|
+
if (specifier.startsWith('node:')) return { kind: 'builtin' };
|
|
89
|
+
if (/^(data:|file:|[a-z][a-z0-9+.-]*:\/\/)/i.test(specifier)) {
|
|
90
|
+
return { kind: 'unresolved', reason: 'URL specifier' };
|
|
91
|
+
}
|
|
92
|
+
const bareName = specifier.split('/')[0];
|
|
93
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#') && BUILTINS.has(bareName)) {
|
|
94
|
+
return { kind: 'builtin' };
|
|
95
|
+
}
|
|
96
|
+
// Strip a query/fragment (`./x.svg?raw`, bundler conventions); nothing
|
|
97
|
+
// real resolves through them. A leading `#` is a package `imports` key,
|
|
98
|
+
// not a fragment.
|
|
99
|
+
const cleaned = specifier.startsWith('#') ? specifier : specifier.replace(/[?#].*$/, '');
|
|
100
|
+
|
|
101
|
+
if (cleaned.startsWith('.') || isAbsolute(cleaned)) {
|
|
102
|
+
const target = pathResolve(dirname(fromFile), cleaned);
|
|
103
|
+
return this.loadAsFile(target) ?? this.loadAsDirectory(target) ?? unresolved(`not found: ${cleaned}`);
|
|
104
|
+
}
|
|
105
|
+
if (cleaned.startsWith('#')) return this.resolveImportsField(fromFile, cleaned, mode);
|
|
106
|
+
|
|
107
|
+
for (const alias of this.aliasPrefixes) {
|
|
108
|
+
if (cleaned === alias || cleaned.startsWith(`${alias}/`)) return { kind: 'alias' };
|
|
109
|
+
}
|
|
110
|
+
return this.resolveBare(fromFile, cleaned, mode);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The package a file belongs to, from its path alone: the directory right
|
|
115
|
+
* after the LAST `node_modules/` segment (two segments for a scope). A file
|
|
116
|
+
* with no node_modules segment is first-party. Subdirectory package.json
|
|
117
|
+
* markers (`{ "type": "module" }` shims, nested `esm/package.json`) never
|
|
118
|
+
* fool this, which a nearest-package.json walk would.
|
|
119
|
+
* @param {string} file
|
|
120
|
+
* @returns {PackageInfo | undefined}
|
|
121
|
+
*/
|
|
122
|
+
packageOf(file) {
|
|
123
|
+
const root = packageRootOf(file);
|
|
124
|
+
if (root === undefined) return undefined;
|
|
125
|
+
if (this.packageInfoCache.has(root)) return this.packageInfoCache.get(root);
|
|
126
|
+
const pj = this.readPackageJson(root);
|
|
127
|
+
const dirName = basename(root);
|
|
128
|
+
const scopeDir = basename(dirname(root));
|
|
129
|
+
const fallbackName = scopeDir.startsWith('@') ? `${scopeDir}/${dirName}` : dirName;
|
|
130
|
+
/** @type {PackageInfo | undefined} */
|
|
131
|
+
const info =
|
|
132
|
+
pj === null
|
|
133
|
+
? undefined
|
|
134
|
+
: {
|
|
135
|
+
name: typeof pj.name === 'string' && pj.name.length > 0 ? pj.name : fallbackName,
|
|
136
|
+
dirName: fallbackName,
|
|
137
|
+
version: typeof pj.version === 'string' ? pj.version : '',
|
|
138
|
+
root
|
|
139
|
+
};
|
|
140
|
+
this.packageInfoCache.set(root, info);
|
|
141
|
+
return info;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- bare specifiers -----------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} fromFile
|
|
148
|
+
* @param {string} specifier
|
|
149
|
+
* @param {ResolveMode} mode
|
|
150
|
+
* @returns {Resolution}
|
|
151
|
+
*/
|
|
152
|
+
resolveBare(fromFile, specifier, mode) {
|
|
153
|
+
const parts = specifier.split('/');
|
|
154
|
+
const scoped = specifier.startsWith('@');
|
|
155
|
+
if (scoped && parts.length < 2) return unresolved(`malformed scoped specifier: ${specifier}`);
|
|
156
|
+
const name = scoped ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
157
|
+
const subpath = parts.slice(scoped ? 2 : 1).join('/');
|
|
158
|
+
|
|
159
|
+
let dir = dirname(fromFile);
|
|
160
|
+
for (;;) {
|
|
161
|
+
// Never look inside a node_modules directory's own node_modules
|
|
162
|
+
// sibling twice: `a/node_modules/b/node_modules/c` is one level.
|
|
163
|
+
if (basename(dir) !== NODE_MODULES) {
|
|
164
|
+
const candidate = join(dir, NODE_MODULES, name);
|
|
165
|
+
if (this.kindOf(candidate) === 'dir') return this.resolvePackage(candidate, subpath, mode);
|
|
166
|
+
}
|
|
167
|
+
const parent = dirname(dir);
|
|
168
|
+
if (parent === dir) return unresolved(`package not installed: ${name}`);
|
|
169
|
+
dir = parent;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} root
|
|
175
|
+
* @param {string} subpath
|
|
176
|
+
* @param {ResolveMode} mode
|
|
177
|
+
* @returns {Resolution}
|
|
178
|
+
*/
|
|
179
|
+
resolvePackage(root, subpath, mode) {
|
|
180
|
+
const pj = this.readPackageJson(root);
|
|
181
|
+
if (pj !== null && pj.exports !== undefined && pj.exports !== null) {
|
|
182
|
+
const target = resolveExports(pj.exports, subpath === '' ? '.' : `./${subpath}`, conditionsFor(mode));
|
|
183
|
+
if (target === undefined) return unresolved(`exports map does not expose "${subpath || '.'}" of ${basename(root)}`);
|
|
184
|
+
if (target === null) return unresolved(`exports map blocks "${subpath || '.'}" of ${basename(root)}`);
|
|
185
|
+
if (target.includes('..') || !target.startsWith('./')) return unresolved(`invalid exports target ${target}`);
|
|
186
|
+
return this.loadAsFile(join(root, target)) ?? this.loadAsDirectory(join(root, target)) ?? unresolved(`exports target missing: ${target}`);
|
|
187
|
+
}
|
|
188
|
+
if (subpath !== '') {
|
|
189
|
+
const p = join(root, subpath);
|
|
190
|
+
return this.loadAsFile(p) ?? this.loadAsDirectory(p) ?? unresolved(`not found in package: ${subpath}`);
|
|
191
|
+
}
|
|
192
|
+
return this.loadAsDirectory(root) ?? unresolved(`no entry point in ${basename(root)}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} fromFile
|
|
197
|
+
* @param {string} specifier
|
|
198
|
+
* @param {ResolveMode} mode
|
|
199
|
+
* @returns {Resolution}
|
|
200
|
+
*/
|
|
201
|
+
resolveImportsField(fromFile, specifier, mode) {
|
|
202
|
+
// Nearest package.json that has an `imports` field, walking up from the file.
|
|
203
|
+
let dir = dirname(fromFile);
|
|
204
|
+
for (;;) {
|
|
205
|
+
const pj = this.readPackageJson(dir);
|
|
206
|
+
if (pj !== null && pj.imports !== undefined && pj.imports !== null) {
|
|
207
|
+
const target = resolveExports(pj.imports, specifier, conditionsFor(mode));
|
|
208
|
+
if (target === undefined || target === null) return unresolved(`imports map does not map ${specifier}`);
|
|
209
|
+
if (target.startsWith('./')) {
|
|
210
|
+
return this.loadAsFile(join(dir, target)) ?? this.loadAsDirectory(join(dir, target)) ?? unresolved(`imports target missing: ${target}`);
|
|
211
|
+
}
|
|
212
|
+
return this.resolve(fromFile, target, mode); // a bare specifier alias
|
|
213
|
+
}
|
|
214
|
+
const parent = dirname(dir);
|
|
215
|
+
if (parent === dir) return unresolved(`no package.json imports field for ${specifier}`);
|
|
216
|
+
dir = parent;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- files and directories ----------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} p
|
|
224
|
+
* @returns {Resolution | undefined}
|
|
225
|
+
*/
|
|
226
|
+
loadAsFile(p) {
|
|
227
|
+
const candidates = [p];
|
|
228
|
+
const ext = extOf(p);
|
|
229
|
+
if (ext !== undefined && TS_FOR_JS[ext]) {
|
|
230
|
+
for (const tsExt of TS_FOR_JS[ext]) candidates.push(p.slice(0, -ext.length) + tsExt);
|
|
231
|
+
}
|
|
232
|
+
for (const e of CODE_EXTS) candidates.push(p + e);
|
|
233
|
+
candidates.push(`${p}.json`);
|
|
234
|
+
for (const c of candidates) {
|
|
235
|
+
if (this.kindOf(c) !== 'file') continue;
|
|
236
|
+
return this.fileResolution(c);
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} p
|
|
243
|
+
* @returns {Resolution | undefined}
|
|
244
|
+
*/
|
|
245
|
+
loadAsDirectory(p) {
|
|
246
|
+
if (this.kindOf(p) !== 'dir') return undefined;
|
|
247
|
+
const pj = this.readPackageJson(p);
|
|
248
|
+
if (pj !== null) {
|
|
249
|
+
for (const field of /** @type {const} */ (['main', 'module'])) {
|
|
250
|
+
const entry = pj[field];
|
|
251
|
+
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
252
|
+
const target = join(p, entry);
|
|
253
|
+
const r = this.loadAsFile(target) ?? this.loadAsIndex(target);
|
|
254
|
+
if (r !== undefined) return r;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return this.loadAsIndex(p);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @param {string} dir
|
|
262
|
+
* @returns {Resolution | undefined}
|
|
263
|
+
*/
|
|
264
|
+
loadAsIndex(dir) {
|
|
265
|
+
if (this.kindOf(dir) !== 'dir') return undefined;
|
|
266
|
+
for (const e of CODE_EXTS) {
|
|
267
|
+
const c = join(dir, `index${e}`);
|
|
268
|
+
if (this.kindOf(c) === 'file') return this.fileResolution(c);
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {string} found
|
|
275
|
+
* @returns {Resolution}
|
|
276
|
+
*/
|
|
277
|
+
fileResolution(found) {
|
|
278
|
+
if (found.endsWith('.d.ts') || found.endsWith('.d.mts') || found.endsWith('.d.cts')) {
|
|
279
|
+
return unresolved(`types only: ${basename(found)}`);
|
|
280
|
+
}
|
|
281
|
+
const path = this.realpath(found);
|
|
282
|
+
const pkg = this.packageOf(path);
|
|
283
|
+
const ext = extOf(path);
|
|
284
|
+
return CODE_EXT_SET.has(ext ?? '') ? { kind: 'file', path, pkg } : { kind: 'asset', path, pkg };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @param {string} p
|
|
289
|
+
* @returns {string}
|
|
290
|
+
*/
|
|
291
|
+
realpath(p) {
|
|
292
|
+
const cached = this.realpathCache.get(p);
|
|
293
|
+
if (cached !== undefined) return cached;
|
|
294
|
+
/** @type {string} */
|
|
295
|
+
let real;
|
|
296
|
+
try {
|
|
297
|
+
real = realpathSync(p);
|
|
298
|
+
} catch {
|
|
299
|
+
real = p;
|
|
300
|
+
}
|
|
301
|
+
this.realpathCache.set(p, real);
|
|
302
|
+
return real;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- caches --------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {string} p
|
|
309
|
+
* @returns {'file' | 'dir' | null}
|
|
310
|
+
*/
|
|
311
|
+
kindOf(p) {
|
|
312
|
+
const cached = this.statCache.get(p);
|
|
313
|
+
if (cached !== undefined) return cached;
|
|
314
|
+
/** @type {'file' | 'dir' | null} */
|
|
315
|
+
let kind;
|
|
316
|
+
try {
|
|
317
|
+
const st = statSync(p);
|
|
318
|
+
kind = st.isFile() ? 'file' : st.isDirectory() ? 'dir' : null;
|
|
319
|
+
} catch {
|
|
320
|
+
kind = null;
|
|
321
|
+
}
|
|
322
|
+
this.statCache.set(p, kind);
|
|
323
|
+
return kind;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @param {string} dir
|
|
328
|
+
* @returns {PackageJson | null}
|
|
329
|
+
*/
|
|
330
|
+
readPackageJson(dir) {
|
|
331
|
+
const cached = this.packageJsonCache.get(dir);
|
|
332
|
+
if (cached !== undefined) return cached;
|
|
333
|
+
/** @type {PackageJson | null} */
|
|
334
|
+
let parsed = null;
|
|
335
|
+
const p = join(dir, 'package.json');
|
|
336
|
+
if (this.kindOf(p) === 'file') {
|
|
337
|
+
try {
|
|
338
|
+
const raw = /** @type {unknown} */ (JSON.parse(readFileSync(p, 'utf8')));
|
|
339
|
+
if (raw !== null && typeof raw === 'object') parsed = /** @type {PackageJson} */ (raw);
|
|
340
|
+
} catch {
|
|
341
|
+
parsed = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.packageJsonCache.set(dir, parsed);
|
|
345
|
+
return parsed;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* @param {string} reason
|
|
351
|
+
* @returns {Resolution}
|
|
352
|
+
*/
|
|
353
|
+
function unresolved(reason) {
|
|
354
|
+
return { kind: 'unresolved', reason };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {string} p
|
|
359
|
+
* @returns {string | undefined}
|
|
360
|
+
*/
|
|
361
|
+
function extOf(p) {
|
|
362
|
+
const base = basename(p);
|
|
363
|
+
const dot = base.lastIndexOf('.');
|
|
364
|
+
return dot <= 0 ? undefined : base.slice(dot);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* @param {ResolveMode} mode
|
|
369
|
+
* @returns {ReadonlySet<string>}
|
|
370
|
+
*/
|
|
371
|
+
function conditionsFor(mode) {
|
|
372
|
+
return mode === 'import'
|
|
373
|
+
? new Set(['import', 'module-sync', 'node', 'default'])
|
|
374
|
+
: new Set(['require', 'module-sync', 'node', 'default']);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The package root (directory directly under node_modules, scope included)
|
|
379
|
+
* for a path inside node_modules; undefined for a first-party path.
|
|
380
|
+
* @param {string} file
|
|
381
|
+
* @returns {string | undefined}
|
|
382
|
+
*/
|
|
383
|
+
export function packageRootOf(file) {
|
|
384
|
+
const parts = file.split(sep);
|
|
385
|
+
const idx = parts.lastIndexOf(NODE_MODULES);
|
|
386
|
+
if (idx === -1 || idx + 1 >= parts.length) return undefined;
|
|
387
|
+
const first = parts[idx + 1];
|
|
388
|
+
const take = first.startsWith('@') ? 2 : 1;
|
|
389
|
+
if (idx + take >= parts.length) return undefined;
|
|
390
|
+
return parts.slice(0, idx + 1 + take).join(sep);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Resolves a subpath (or `#import` key) against a package.json `exports` /
|
|
395
|
+
* `imports` value. Returns the target string (`./lib/x.js`), `null` when the
|
|
396
|
+
* map explicitly blocks it, or `undefined` when nothing matched.
|
|
397
|
+
* @param {unknown} map
|
|
398
|
+
* @param {string} subpath
|
|
399
|
+
* @param {ReadonlySet<string>} conditions
|
|
400
|
+
* @returns {string | null | undefined}
|
|
401
|
+
*/
|
|
402
|
+
export function resolveExports(map, subpath, conditions) {
|
|
403
|
+
if (typeof map === 'string') return subpath === '.' ? map : undefined;
|
|
404
|
+
if (Array.isArray(map)) {
|
|
405
|
+
for (const entry of map) {
|
|
406
|
+
const r = resolveExports(entry, subpath, conditions);
|
|
407
|
+
if (r !== undefined) return r;
|
|
408
|
+
}
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
if (map === null || typeof map !== 'object') return undefined;
|
|
412
|
+
const obj = /** @type {Record<string, unknown>} */ (map);
|
|
413
|
+
const keys = Object.keys(obj);
|
|
414
|
+
const isSubpathMap = keys.some((k) => k.startsWith('.') || k.startsWith('#'));
|
|
415
|
+
if (!isSubpathMap) {
|
|
416
|
+
// A conditions object for the requested subpath.
|
|
417
|
+
if (subpath !== '.') return undefined;
|
|
418
|
+
return resolveConditions(obj, conditions);
|
|
419
|
+
}
|
|
420
|
+
// Exact key first.
|
|
421
|
+
if (Object.prototype.hasOwnProperty.call(obj, subpath)) {
|
|
422
|
+
return resolveTarget(obj[subpath], conditions, undefined);
|
|
423
|
+
}
|
|
424
|
+
// Pattern keys, ranked like Node's PATTERN_KEY_COMPARE: the longest prefix
|
|
425
|
+
// before `*` wins, and on a tie the longer key overall (`./*.css` beats
|
|
426
|
+
// `./*` for `./400.css` — with prefix-only ranking the first key won and
|
|
427
|
+
// produced `./400.css.css`).
|
|
428
|
+
/** @type {{ key: string; prefixLength: number; captured: string } | undefined} */
|
|
429
|
+
let best;
|
|
430
|
+
for (const key of keys) {
|
|
431
|
+
const star = key.indexOf('*');
|
|
432
|
+
if (star === -1) continue;
|
|
433
|
+
const prefix = key.slice(0, star);
|
|
434
|
+
const suffix = key.slice(star + 1);
|
|
435
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix) || subpath.length < prefix.length + suffix.length) continue;
|
|
436
|
+
const better =
|
|
437
|
+
best === undefined ||
|
|
438
|
+
prefix.length > best.prefixLength ||
|
|
439
|
+
(prefix.length === best.prefixLength && key.length > best.key.length);
|
|
440
|
+
if (better) {
|
|
441
|
+
best = { key, prefixLength: prefix.length, captured: subpath.slice(prefix.length, subpath.length - suffix.length) };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (best === undefined) return undefined;
|
|
445
|
+
return resolveTarget(obj[best.key], conditions, best.captured);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @param {Record<string, unknown>} obj
|
|
450
|
+
* @param {ReadonlySet<string>} conditions
|
|
451
|
+
* @returns {string | null | undefined}
|
|
452
|
+
*/
|
|
453
|
+
function resolveConditions(obj, conditions) {
|
|
454
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
455
|
+
if (!conditions.has(key)) continue;
|
|
456
|
+
const r = resolveTarget(value, conditions, undefined);
|
|
457
|
+
if (r !== undefined) return r;
|
|
458
|
+
}
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param {unknown} value
|
|
464
|
+
* @param {ReadonlySet<string>} conditions
|
|
465
|
+
* @param {string | undefined} captured
|
|
466
|
+
* @returns {string | null | undefined}
|
|
467
|
+
*/
|
|
468
|
+
function resolveTarget(value, conditions, captured) {
|
|
469
|
+
if (value === null) return null;
|
|
470
|
+
if (typeof value === 'string') return captured === undefined ? value : value.replace(/\*/g, captured);
|
|
471
|
+
if (Array.isArray(value)) {
|
|
472
|
+
for (const entry of value) {
|
|
473
|
+
const r = resolveTarget(entry, conditions, captured);
|
|
474
|
+
if (r !== undefined) return r;
|
|
475
|
+
}
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
if (typeof value === 'object') {
|
|
479
|
+
for (const [key, inner] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
|
|
480
|
+
if (!conditions.has(key)) continue;
|
|
481
|
+
const r = resolveTarget(inner, conditions, captured);
|
|
482
|
+
if (r !== undefined) return r;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|