@dependably/npm-check 1.9.0 → 1.10.1

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