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