@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.
- package/README.md +47 -0
- package/bin/cli.js +93 -1
- package/package.json +20 -3
- package/src/audit-config.js +98 -68
- package/src/audit.js +198 -58
- package/src/exceptions.js +82 -66
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +151 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +37 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +493 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/sourcescan.js +221 -0
- package/src/facts/specifier.js +89 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +579 -0
- package/src/facts/version.js +16 -0
- package/src/facts/workspace.js +278 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +12 -6
- package/src/schema.js +54 -0
|
@@ -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
|
+
}
|