@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,467 @@
1
+ // src/facts/lockfile-graph.js
2
+ // The resolved dependency graph a lockfile records: every name@version in
3
+ // the closure, which of them the project's own package.json depends on
4
+ // directly, and the edges among them — with dev/runtime and `optional` read
5
+ // straight from what the lockfile asserts, never inferred.
6
+ //
7
+ // This is a separate reader from `parser.js`/`format-library.js` on purpose
8
+ // (and deliberately NOT merged with them in the change that introduced it):
9
+ // those parse a lockfile to validate, fix and migrate it, and couple to every
10
+ // field it has; this one answers one question — "what does the lockfile say
11
+ // is installed, and what depends on what" — and is what the import-facts
12
+ // consumer walks when node_modules is absent. `yaml` is imported statically
13
+ // here rather than through `parser.js`'s lazy `createRequire`: a consumer that
14
+ // bundles this subpath (sbom-reach does, with esbuild) would otherwise be left
15
+ // with an unbundled runtime `require('yaml')`. The lockfile commands never
16
+ // load this module, so the npm path still never loads `yaml` for a
17
+ // package-lock.json.
18
+ //
19
+ // Ported from sbom-reach's `packages/analyzer-npm/src/lockfile.ts` plus its
20
+ // `discoverInstalledPackages` (index.ts), here `discoverLockfileGraphs`.
21
+ import { readFileSync } from 'node:fs';
22
+ import { relative } from 'node:path';
23
+ import fg from 'fast-glob';
24
+ import { parse as parseYaml } from 'yaml';
25
+
26
+ /** @typedef {import('./types.d.ts').DiscoveredPackage} DiscoveredPackage */
27
+ /** @typedef {import('./types.d.ts').DependencyEdge} DependencyEdge */
28
+ /** @typedef {import('./types.d.ts').LockfileGraph} LockfileGraph */
29
+ /** @typedef {import('./types.d.ts').LockfileDiscovery} LockfileDiscovery */
30
+
31
+ /**
32
+ * Folds a second sighting of the same name@version into the first — two
33
+ * paths in one lockfile, or two lockfiles in one workspace. dev/runtime:
34
+ * `false` (ships somewhere) beats `true` (dev-only somewhere) beats absent —
35
+ * "runtime anywhere wins". `optional` is a claim of EXCLUSIVITY ("reachable
36
+ * only through optionalDependencies"), so one sighting that needs the package
37
+ * outright revokes it. First license seen is kept. Order-independent.
38
+ * @param {DiscoveredPackage} into
39
+ * @param {DiscoveredPackage} other
40
+ */
41
+ export function mergeDiscovered(into, other) {
42
+ if (other.devDeclared === false) into.devDeclared = false;
43
+ else if (other.devDeclared === true && into.devDeclared === undefined) into.devDeclared = true;
44
+ if (into.scope === 'optional' && other.scope !== 'optional') delete into.scope;
45
+ if (into.license === undefined && other.license !== undefined) into.license = other.license;
46
+ }
47
+
48
+ /**
49
+ * @typedef {object} NpmLockEntry
50
+ * @property {string} [version]
51
+ * @property {boolean} [link]
52
+ * @property {string} [license]
53
+ * @property {boolean} [dev]
54
+ * @property {boolean} [optional]
55
+ * @property {boolean} [devOptional]
56
+ * @property {boolean} [peer]
57
+ * @property {boolean} [extraneous]
58
+ * @property {Record<string, string>} [dependencies]
59
+ * @property {Record<string, string>} [devDependencies]
60
+ * @property {Record<string, string>} [optionalDependencies]
61
+ */
62
+
63
+ /**
64
+ * npm's per-entry dependency flags are a COMPUTED verdict over the whole
65
+ * tree, not a hint. `@npmcli/arborist`'s `calcDepFlags` (re-run on every
66
+ * lockfile save) starts every node flagged `dev`, `optional`, `devOptional`
67
+ * and `peer`, then walks from the root and CLEARS a flag the moment a path
68
+ * reaches the node without an edge of that type. Only `true` flags are
69
+ * written, so for an entry the walk reached at all:
70
+ *
71
+ * dev: true every path from the root crosses a devDependencies
72
+ * edge — dev-only, it does not ship.
73
+ * (no dev flag) some path reaches it with no dev edge — it ships.
74
+ * `devOptional: true` narrows that path to "goes through
75
+ * an optionalDependencies edge", which is still "may
76
+ * ship", so it stays `false` here.
77
+ * extraneous: true the walk never reached it (installed, depended on by
78
+ * nothing in the tree) — no claim either way.
79
+ *
80
+ * Reading an absent `dev` flag as `false` is therefore reading what npm
81
+ * asserted, not guessing — and it is the only per-package signal the
82
+ * transitive closure has (package.json names direct dependencies only).
83
+ * @param {NpmLockEntry} entry
84
+ * @returns {{ devDeclared?: boolean }}
85
+ */
86
+ function devDeclaredFromNpmFlags(entry) {
87
+ if (entry.extraneous === true) return {};
88
+ return { devDeclared: entry.dev === true };
89
+ }
90
+
91
+ /**
92
+ * package-lock.json (npm, lockfileVersion 2/3): `packages` is a flat object
93
+ * keyed by node_modules path ("node_modules/foo", "node_modules/@scope/bar",
94
+ * or a nested override like "node_modules/foo/node_modules/bar"). The name is
95
+ * whatever follows the LAST "node_modules/" segment — that also recovers
96
+ * scoped names correctly, since "@scope/bar" is kept whole.
97
+ *
98
+ * Building edges needs npm's own hoisting resolution: a package's own
99
+ * `dependencies` field names only the dependency's NAME (a version range,
100
+ * not a resolved version), and which physical install satisfies it depends
101
+ * on where in the node_modules tree Node's resolution algorithm finds it
102
+ * first — the requiring package's own node_modules, then each ancestor's,
103
+ * up to the root. `resolve()` below is exactly that walk over lockfile
104
+ * paths (each "node_modules/A/node_modules/B" segment is one directory
105
+ * level). A workspace-local (`link: true`) hit along that walk isn't
106
+ * resolvable to an external package, so the edge is silently dropped
107
+ * rather than guessed.
108
+ * @param {string} path
109
+ * @returns {LockfileGraph}
110
+ */
111
+ export function parsePackageLockJsonGraph(path) {
112
+ const raw = /** @type {{ packages?: Record<string, NpmLockEntry> }} */ (JSON.parse(readFileSync(path, 'utf8')));
113
+ const rawPackages = raw.packages;
114
+ if (!rawPackages) return { packages: [], rootDependencies: [], edges: [] };
115
+
116
+ /** @type {Map<string, DiscoveredPackage>} */
117
+ const nameVersionByPath = new Map();
118
+ const marker = 'node_modules/';
119
+ for (const [key, entry] of Object.entries(rawPackages)) {
120
+ if (key === '' || entry.link || !entry.version) continue;
121
+ const idx = key.lastIndexOf(marker);
122
+ if (idx === -1) continue;
123
+ const name = key.slice(idx + marker.length);
124
+ if (!name) continue;
125
+ nameVersionByPath.set(key, {
126
+ name,
127
+ version: entry.version,
128
+ ...(entry.license ? { license: entry.license } : {}),
129
+ ...devDeclaredFromNpmFlags(entry),
130
+ ...(entry.optional === true ? { scope: 'optional' } : {})
131
+ });
132
+ }
133
+
134
+ /** @param {string} fromPath @param {string} name @returns {string | undefined} */
135
+ const resolve = (fromPath, name) => {
136
+ let prefix = fromPath;
137
+ for (;;) {
138
+ const candidate = prefix === '' ? `${marker}${name}` : `${prefix}/${marker}${name}`;
139
+ if (nameVersionByPath.has(candidate)) return candidate;
140
+ if (prefix === '') return undefined;
141
+ const cut = prefix.lastIndexOf(`/${marker}`);
142
+ prefix = cut === -1 ? '' : prefix.slice(0, cut);
143
+ }
144
+ };
145
+ /** @param {DiscoveredPackage} nv */
146
+ const keyOf = (nv) => `${nv.name}@${nv.version}`;
147
+
148
+ // The same name@version can sit at several paths (hoisted at the root AND
149
+ // nested under a package that pinned it). One component, so the paths'
150
+ // flags are FOLDED, "runtime anywhere wins" — first-path-wins would let a
151
+ // nested `dev: true` copy label a shipping hoisted copy dev-only.
152
+ /** @type {Map<string, DiscoveredPackage>} */
153
+ const byKey = new Map();
154
+ /** @type {DiscoveredPackage[]} */
155
+ const packages = [];
156
+ for (const nv of nameVersionByPath.values()) {
157
+ const key = keyOf(nv);
158
+ const existing = byKey.get(key);
159
+ if (existing === undefined) {
160
+ const record = { ...nv };
161
+ byKey.set(key, record);
162
+ packages.push(record);
163
+ } else {
164
+ mergeDiscovered(existing, nv);
165
+ }
166
+ }
167
+
168
+ /** @param {NpmLockEntry} entry @returns {Record<string, string>} */
169
+ const depsOf = (entry) => ({
170
+ ...entry.dependencies,
171
+ ...entry.optionalDependencies
172
+ });
173
+
174
+ const rootEntry = rawPackages[''];
175
+ /** @type {string[]} */
176
+ const rootDependencies = [];
177
+ if (rootEntry) {
178
+ // Tracked per-section (not one merged spread) so a root-direct dependency
179
+ // is asserted from package.json's own sections below. A runtime section
180
+ // asserts `false` outright — "runtime anywhere wins" even at the root: a
181
+ // name in BOTH a runtime section and `devDependencies` is not dev-only,
182
+ // and a hand-edited lockfile whose flags drifted from package.json cannot
183
+ // make the root's own runtime dependency dev. `devDependencies` only
184
+ // FILLS a gap: npm's flags already know whether that package also ships
185
+ // through some other path, and a root section listing is one edge, not
186
+ // proof that every path is dev.
187
+ const rootRuntimeNames = new Set(Object.keys(depsOf(rootEntry)));
188
+ const rootDevOnlyNames = new Set(Object.keys(rootEntry.devDependencies ?? {}));
189
+ for (const name of rootRuntimeNames) rootDevOnlyNames.delete(name);
190
+
191
+ const rootDeps = { ...depsOf(rootEntry), ...rootEntry.devDependencies };
192
+ for (const name of Object.keys(rootDeps)) {
193
+ const resolved = resolve('', name);
194
+ if (!resolved) continue;
195
+ const key = keyOf(/** @type {DiscoveredPackage} */ (nameVersionByPath.get(resolved)));
196
+ rootDependencies.push(key);
197
+ const record = /** @type {DiscoveredPackage} */ (byKey.get(key));
198
+ if (rootRuntimeNames.has(name)) record.devDeclared = false;
199
+ else if (rootDevOnlyNames.has(name) && record.devDeclared === undefined) record.devDeclared = true;
200
+ }
201
+ }
202
+
203
+ /** @type {DependencyEdge[]} */
204
+ const edges = [];
205
+ const edgeSeen = new Set();
206
+ for (const [path, nv] of nameVersionByPath) {
207
+ const entry = rawPackages[path];
208
+ const fromKey = keyOf(nv);
209
+ for (const name of Object.keys(depsOf(entry))) {
210
+ const resolved = resolve(path, name);
211
+ if (!resolved) continue;
212
+ const toKey = keyOf(/** @type {DiscoveredPackage} */ (nameVersionByPath.get(resolved)));
213
+ const edgeKey = `${fromKey} ${toKey}`;
214
+ if (edgeSeen.has(edgeKey)) continue;
215
+ edgeSeen.add(edgeKey);
216
+ edges.push({ from: fromKey, to: toKey });
217
+ }
218
+ }
219
+
220
+ return { packages, rootDependencies: [...new Set(rootDependencies)], edges };
221
+ }
222
+
223
+ /**
224
+ * @param {string} path
225
+ * @returns {DiscoveredPackage[]}
226
+ */
227
+ export function parsePackageLockJson(path) {
228
+ return parsePackageLockJsonGraph(path).packages;
229
+ }
230
+
231
+ /**
232
+ * Strips a pnpm peer-dependency-hash suffix: "8.5.1(postcss@8.5.16)(yaml@2.9.0)" -> "8.5.1".
233
+ * @param {string} version
234
+ * @returns {string}
235
+ */
236
+ function stripPeerSuffix(version) {
237
+ const idx = version.indexOf('(');
238
+ return idx === -1 ? version : version.slice(0, idx);
239
+ }
240
+
241
+ /**
242
+ * Splits "name@version" / "@scope/name@version" into its two parts.
243
+ * @param {string} key
244
+ * @returns {DiscoveredPackage | undefined}
245
+ */
246
+ function splitNameVersion(key) {
247
+ let rest = key;
248
+ let scopePrefix = '';
249
+ if (rest.startsWith('@')) {
250
+ const slash = rest.indexOf('/');
251
+ if (slash === -1) return undefined;
252
+ scopePrefix = rest.slice(0, slash + 1);
253
+ rest = rest.slice(slash + 1);
254
+ }
255
+ const at = rest.indexOf('@');
256
+ if (at === -1) return undefined;
257
+ const name = scopePrefix + rest.slice(0, at);
258
+ const version = rest.slice(at + 1);
259
+ if (!name || !version) return undefined;
260
+ return { name, version };
261
+ }
262
+
263
+ /**
264
+ * @typedef {Partial<Record<'dependencies' | 'devDependencies' | 'optionalDependencies', Record<string, { version?: string }>>>} PnpmImporter
265
+ */
266
+
267
+ /**
268
+ * pnpm-lock.yaml (lockfileVersion 9): the `packages` top-level map is already
269
+ * keyed by bare "name@version" — no peer-dependency-hash suffix, that suffix
270
+ * only appears in `snapshots` (the resolved dependency graph) and in
271
+ * `importers`' per-dependency `version` field. Unlike npm, pnpm's snapshot
272
+ * dependency values are ALREADY fully resolved versions (no hoisting
273
+ * ambiguity to walk) — just peer-suffixed, so `stripPeerSuffix` is the only
274
+ * normalization edges need. EVERY importer is read, not just `.`: in a pnpm
275
+ * workspace the root importer is often empty (`.: {}`) and the real
276
+ * dependencies hang off `packages/*` importers, so reading the root alone
277
+ * yields no graph at all. Each importer's direct dependencies become root
278
+ * dependencies of the one workspace, and dev/runtime is "runtime anywhere
279
+ * wins" across importers — a package one importer ships is runtime even if
280
+ * another lists it under devDependencies. (Which importers themselves ship
281
+ * is not something the lockfile can say, so this is the loud direction.)
282
+ * @param {string} path
283
+ * @returns {LockfileGraph}
284
+ */
285
+ export function parsePnpmLockYamlGraph(path) {
286
+ const raw = /** @type {{
287
+ importers?: Record<string, PnpmImporter>;
288
+ packages?: Record<string, unknown>;
289
+ snapshots?: Record<string, { dependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }>;
290
+ }} */ (parseYaml(readFileSync(path, 'utf8')));
291
+
292
+ /** @type {DiscoveredPackage[]} */
293
+ const packages = [];
294
+ const validKeys = new Set();
295
+ for (const key of Object.keys(raw.packages ?? {})) {
296
+ const parsed = splitNameVersion(key);
297
+ if (!parsed) continue;
298
+ packages.push(parsed);
299
+ validKeys.add(`${parsed.name}@${parsed.version}`);
300
+ }
301
+
302
+ /** @type {DependencyEdge[]} */
303
+ const edges = [];
304
+ const edgeSeen = new Set();
305
+ /** @param {string} from @param {string} to */
306
+ const addEdge = (from, to) => {
307
+ // A dependency this closure never resolved to an installed package
308
+ // (peer-only, optional-and-skipped) — dropped, not guessed.
309
+ if (!validKeys.has(to)) return;
310
+ const edgeKey = `${from} ${to}`;
311
+ if (edgeSeen.has(edgeKey)) return;
312
+ edgeSeen.add(edgeKey);
313
+ edges.push({ from, to });
314
+ };
315
+
316
+ for (const [snapshotKey, snapshot] of Object.entries(raw.snapshots ?? {})) {
317
+ const nv = splitNameVersion(snapshotKey);
318
+ if (!nv) continue;
319
+ const fromKey = `${nv.name}@${stripPeerSuffix(nv.version)}`;
320
+ const deps = { ...snapshot.dependencies, ...snapshot.optionalDependencies };
321
+ for (const [depName, depVersion] of Object.entries(deps)) {
322
+ addEdge(fromKey, `${depName}@${stripPeerSuffix(depVersion)}`);
323
+ }
324
+ }
325
+
326
+ /** @type {string[]} */
327
+ const rootDependencies = [];
328
+ // Tracked per-section across ALL importers (not one merged spread) so a
329
+ // directly-named package can be marked `devDeclared: true`/`false` below —
330
+ // "runtime anywhere wins": a name in ANY importer's dependencies/
331
+ // optionalDependencies is not dev-only, whatever another importer says.
332
+ const runtimeKeys = new Set();
333
+ const devOnlyKeys = new Set();
334
+ for (const importer of Object.values(raw.importers ?? {})) {
335
+ /** @param {Record<string, { version?: string }> | undefined} section @param {boolean} isDev */
336
+ const collect = (section, isDev) => {
337
+ for (const [name, info] of Object.entries(section ?? {})) {
338
+ const version = info?.version;
339
+ // A `link:` is another importer of this same workspace — its own
340
+ // dependencies are read from its own importer entry, not through
341
+ // the link, so nothing is lost by skipping it here.
342
+ if (!version || version.startsWith('link:')) continue;
343
+ const key = `${name}@${stripPeerSuffix(version)}`;
344
+ if (!validKeys.has(key)) continue;
345
+ rootDependencies.push(key);
346
+ (isDev ? devOnlyKeys : runtimeKeys).add(key);
347
+ }
348
+ };
349
+ collect(importer.dependencies, false);
350
+ collect(importer.optionalDependencies, false);
351
+ collect(importer.devDependencies, true);
352
+ }
353
+ for (const key of runtimeKeys) devOnlyKeys.delete(key);
354
+
355
+ // Transitive-only pnpm packages stay unmarked — pnpm-lock.yaml carries no
356
+ // per-package dev flag the way package-lock.json does (see
357
+ // DiscoveredPackage.devDeclared); a consumer's graph walk fills them in.
358
+ for (const pkg of packages) {
359
+ const key = `${pkg.name}@${pkg.version}`;
360
+ if (devOnlyKeys.has(key)) pkg.devDeclared = true;
361
+ else if (runtimeKeys.has(key)) pkg.devDeclared = false;
362
+ }
363
+
364
+ return { packages, rootDependencies: [...new Set(rootDependencies)], edges };
365
+ }
366
+
367
+ /**
368
+ * @param {string} path
369
+ * @returns {DiscoveredPackage[]}
370
+ */
371
+ export function parsePnpmLockYaml(path) {
372
+ return parsePnpmLockYamlGraph(path).packages;
373
+ }
374
+
375
+ const LOCKFILE_IGNORE_DIRS = ['**/node_modules/**', '**/.git/**'];
376
+
377
+ /**
378
+ * Enumerates every npm package the lockfiles under `srcDir` resolve, plus the
379
+ * dependency graph among them. EVERY lockfile found (outside node_modules)
380
+ * feeds the graph, not just one at the root: a backend+frontend repo with
381
+ * `web/package-lock.json`, or a repo with a tooling lockfile beside the app's,
382
+ * is one workspace with several importers, and each lockfile's own root
383
+ * dependencies become root dependencies of that workspace. Edges are keyed
384
+ * "name@version", so the same package resolved by two lockfiles is one node
385
+ * with the union of both lockfiles' edges — an over-approximation that can
386
+ * only ADD paths, which is the loud direction.
387
+ *
388
+ * A package listed by more than one lockfile merges the same way: "runtime
389
+ * anywhere wins" for dev/runtime (one lockfile shipping it makes it runtime),
390
+ * `scope: optional` survives only if every lockfile says optional, and the
391
+ * first license seen is kept.
392
+ *
393
+ * A lockfile that does not parse is a diagnostic, not an exception — the
394
+ * rest of the tree is still described. No lockfile at all is `NO_LOCKFILE`.
395
+ * @param {string} srcDir absolute path
396
+ * @returns {LockfileDiscovery}
397
+ */
398
+ export function discoverLockfileGraphs(srcDir) {
399
+ /** @type {string[]} */
400
+ const diagnostics = [];
401
+ /** @type {Map<string, DiscoveredPackage>} */
402
+ const byKey = new Map();
403
+ /** @type {DiscoveredPackage[]} */
404
+ const packages = [];
405
+ const rootDependencies = new Set();
406
+ /** @type {DependencyEdge[]} */
407
+ const edges = [];
408
+ const edgeSeen = new Set();
409
+ /** @type {string[]} */
410
+ const files = [];
411
+
412
+ /** @param {LockfileGraph} graph */
413
+ const addGraph = (graph) => {
414
+ for (const pkg of graph.packages) {
415
+ const key = `${pkg.name}@${pkg.version}`;
416
+ const existing = byKey.get(key);
417
+ if (existing === undefined) {
418
+ const copy = { ...pkg };
419
+ byKey.set(key, copy);
420
+ packages.push(copy);
421
+ continue;
422
+ }
423
+ mergeDiscovered(existing, pkg);
424
+ }
425
+ for (const key of graph.rootDependencies) rootDependencies.add(key);
426
+ for (const edge of graph.edges) {
427
+ const edgeKey = `${edge.from} ${edge.to}`;
428
+ if (edgeSeen.has(edgeKey)) continue;
429
+ edgeSeen.add(edgeKey);
430
+ edges.push(edge);
431
+ }
432
+ };
433
+
434
+ const npmLockPaths = fg
435
+ .sync('**/package-lock.json', { cwd: srcDir, absolute: true, ignore: LOCKFILE_IGNORE_DIRS, dot: false })
436
+ .sort();
437
+ const pnpmLockPaths = fg
438
+ .sync('**/pnpm-lock.yaml', { cwd: srcDir, absolute: true, ignore: LOCKFILE_IGNORE_DIRS, dot: false })
439
+ .sort();
440
+
441
+ /** @type {[string[], (path: string) => LockfileGraph, string][]} */
442
+ const sources = [
443
+ [npmLockPaths, parsePackageLockJsonGraph, 'package-lock.json'],
444
+ [pnpmLockPaths, parsePnpmLockYamlGraph, 'pnpm-lock.yaml']
445
+ ];
446
+ for (const [paths, parse, what] of sources) {
447
+ for (const path of paths) {
448
+ try {
449
+ addGraph(parse(path));
450
+ files.push(path);
451
+ } catch (err) {
452
+ diagnostics.push(
453
+ `unparseable ${what} at ${relative(srcDir, path)}: ${err instanceof Error ? err.message : String(err)}`
454
+ );
455
+ }
456
+ }
457
+ }
458
+
459
+ if (npmLockPaths.length === 0 && pnpmLockPaths.length === 0) {
460
+ diagnostics.push(
461
+ 'NO_LOCKFILE: no package-lock.json or pnpm-lock.yaml found under srcDir; no npm packages could be enumerated'
462
+ );
463
+ }
464
+
465
+ packages.sort((a, b) => (a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name)));
466
+ return { packages, rootDependencies: [...rootDependencies], edges, files, diagnostics };
467
+ }