@dependably/npm-check 1.7.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,325 @@
1
+ // src/deprecation.js
2
+ // Deprecated-package scan: checks each locked package version against the npm
3
+ // registry's per-version manifest `deprecated` field — the same signal npm
4
+ // surfaces as "npm warn deprecated <pkg>@<ver>: <message>" during `npm ci`.
5
+ //
6
+ // Like the integrity and vuln checks it is lockfile-first (no node_modules) and
7
+ // reuses the registry-base derivation + concurrency model. Deprecation is a soft
8
+ // signal (npm itself only warns; it never fails the install), so findings are
9
+ // warnings by default; pass `failOnDeprecated` to fail the run in CI. Identical
10
+ // name@version@registry entries are fetched once and the result is attributed to
11
+ // every lockfile path that shares them.
12
+ import { createProgressReporter } from './progress-reporter.js';
13
+ import { forEachPackageEntry } from './format-library.js';
14
+ import { DEFAULT_REGISTRY, fetchPackumentManifest } from './integrity.js';
15
+ import { buildEnvelope } from './schema.js';
16
+
17
+ /**
18
+ * Custom error class for deprecation-scan operations
19
+ */
20
+ export class DeprecationError extends Error {
21
+ constructor(message, code, context = {}) {
22
+ super(message);
23
+ this.name = 'DeprecationError';
24
+ this.code = code;
25
+ this.context = context;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Map items through an async fn with a concurrency cap, preserving input order.
31
+ * (Mirrors the private helper in checker.js/vuln.js — kept local to keep modules decoupled.)
32
+ */
33
+ async function mapWithConcurrency(items, limit, fn) {
34
+ const results = new Array(items.length);
35
+ let next = 0;
36
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length || 1)) }, async () => {
37
+ while (next < items.length) {
38
+ const index = next++;
39
+ results[index] = await fn(items[index], index);
40
+ }
41
+ });
42
+ await Promise.all(workers);
43
+ return results;
44
+ }
45
+
46
+ /**
47
+ * Normalize a manifest's `deprecated` field to a message string or null.
48
+ * npm marks a version deprecated with a non-empty string; an empty string means
49
+ * "un-deprecated", and a bare `true` (rare) carries no message of its own.
50
+ */
51
+ function deprecationMessage(raw) {
52
+ if (typeof raw === 'string' && raw.trim() !== '') return raw;
53
+ if (raw === true) return 'deprecated';
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Build a fresh, empty results accumulator with the public return shape.
59
+ */
60
+ function createResults() {
61
+ return {
62
+ valid: true,
63
+ scanned: 0,
64
+ deprecated: 0,
65
+ clean: 0,
66
+ unresolved: 0,
67
+ skipped: 0,
68
+ errors: [],
69
+ warnings: [],
70
+ unresolvedItems: [],
71
+ details: []
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Collect the verifiable candidates from the lockfile, counting everything that
77
+ * can't be checked this way as skipped (mirror checker.js / vuln.js skip logic).
78
+ */
79
+ function collectCandidates(lockfileData, defaultRegistry, results) {
80
+ const candidates = [];
81
+ forEachPackageEntry(lockfileData, (info) => {
82
+ const { key, entry, name, isRoot, isWorkspaceSource, isLink, isBundled, isGitDep, isFileDep } = info;
83
+ if (isRoot) return results.skipped++;
84
+ if (isWorkspaceSource) return results.skipped++;
85
+ if (isLink) return results.skipped++;
86
+ if (isBundled || isGitDep || isFileDep) return results.skipped++; // no registry manifest to check
87
+ if (!entry.version) return results.skipped++;
88
+ const registryBase = info.registryBase || defaultRegistry;
89
+ candidates.push({ key, name, version: entry.version, registryBase });
90
+ });
91
+ return candidates;
92
+ }
93
+
94
+ /**
95
+ * Dedupe identical name@version@registry candidates into fetch units; each unit
96
+ * is fetched once and its result is attributed to every entry that shares it.
97
+ */
98
+ function groupCandidates(candidates) {
99
+ const groups = new Map();
100
+ for (const c of candidates) {
101
+ const dedupeKey = `${c.registryBase}\n${c.name}\n${c.version}`;
102
+ if (!groups.has(dedupeKey)) {
103
+ groups.set(dedupeKey, { name: c.name, version: c.version, registryBase: c.registryBase, entries: [] });
104
+ }
105
+ groups.get(dedupeKey).entries.push(c);
106
+ }
107
+ return [...groups.values()];
108
+ }
109
+
110
+ /**
111
+ * Record an unresolved unit — registry unreachable or version not found, i.e. the
112
+ * scan could not complete for these entries — against every lockfile entry that
113
+ * shares it. Fails the run when failOnUnresolved (the default), so a registry
114
+ * outage can never be mistaken for "no deprecations". This is the operational
115
+ * scan-failure case, distinct from a clean unit (manifest fetched, not deprecated).
116
+ */
117
+ function recordUnresolved(unit, networkError, failOnUnresolved, results) {
118
+ const reason = networkError
119
+ ? `registry unreachable (${networkError.message})`
120
+ : `registry has no manifest for ${unit.name}@${unit.version}`;
121
+ for (const cand of unit.entries) {
122
+ const item = { package: unit.name, version: unit.version, packagePath: cand.key, reason };
123
+ results.unresolved++;
124
+ results.unresolvedItems.push(item);
125
+ results.details.push({ unresolved: true, ...item });
126
+ if (failOnUnresolved) {
127
+ results.valid = false;
128
+ results.errors.push(item);
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Record a deprecated unit against every lockfile entry that shares it; findings
135
+ * are warnings by default and errors when failOnDeprecated is set.
136
+ */
137
+ function recordDeprecated(unit, message, failOnDeprecated, results) {
138
+ for (const cand of unit.entries) {
139
+ results.deprecated++;
140
+ const finding = { package: unit.name, version: unit.version, packagePath: cand.key, message };
141
+ if (failOnDeprecated) {
142
+ results.errors.push(finding);
143
+ results.valid = false;
144
+ } else {
145
+ results.warnings.push(finding);
146
+ }
147
+ results.details.push({ deprecated: true, ...finding });
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Record a clean unit (manifest fetched, not deprecated) against every entry.
153
+ */
154
+ function recordClean(unit, results) {
155
+ for (const cand of unit.entries) {
156
+ results.clean++;
157
+ results.details.push({ deprecated: false, package: unit.name, version: unit.version, packagePath: cand.key });
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Resolve a single fetch unit's manifest and fold its outcome into results.
163
+ */
164
+ async function processUnit(unit, fetcher, failOnDeprecated, failOnUnresolved, results) {
165
+ let manifest = null;
166
+ let networkError = null;
167
+ try {
168
+ manifest = await fetcher(unit.name, unit.version, unit.registryBase);
169
+ } catch (e) {
170
+ networkError = e;
171
+ }
172
+
173
+ if (networkError || manifest === null) {
174
+ recordUnresolved(unit, networkError, failOnUnresolved, results);
175
+ } else {
176
+ const message = deprecationMessage(manifest.deprecated);
177
+ if (message) {
178
+ recordDeprecated(unit, message, failOnDeprecated, results);
179
+ } else {
180
+ recordClean(unit, results);
181
+ }
182
+ }
183
+
184
+ results.scanned += unit.entries.length;
185
+ }
186
+
187
+ /**
188
+ * Scan locked packages for deprecation notices via the registry version manifest.
189
+ *
190
+ * Outcomes per entry:
191
+ * - deprecated: registry manifest carries a `deprecated` message
192
+ * - clean: manifest fetched, not deprecated (obtained data, nothing found)
193
+ * - unresolved: registry unreachable or version not found — the scan could not
194
+ * complete (FAILS the run by default; fail-closed)
195
+ * - skipped: not checkable this way (root/workspace/link/git/file/bundled, missing version)
196
+ *
197
+ * Deprecated entries are warnings by default (npm itself only warns); pass
198
+ * `failOnDeprecated` to make a *found* deprecation an error. Note this is separate
199
+ * from `failOnUnresolved`, which governs whether a scan that *couldn't run* fails.
200
+ *
201
+ * @param {object} lockfileData - Parsed lockfile data (v2/v3)
202
+ * @param {object} options
203
+ * @param {number} options.concurrency - Parallel registry GETs (default: 8)
204
+ * @param {number} options.timeoutMs - Per-request timeout (default: 10000)
205
+ * @param {string} options.defaultRegistry - Registry for entries without a derivable base
206
+ * @param {boolean} options.offline - Skip all network; report everything as skipped
207
+ * @param {boolean} options.failOnDeprecated - Treat a *found* deprecation as a failure (default: false)
208
+ * @param {boolean} options.failOnUnresolved - Fail the run when the scan can't complete
209
+ * (registry unreachable / version not found). Default true (fail closed); set false to tolerate.
210
+ * @param {Function} options.fetchManifest - Injectable (name, version, registryBase) => Promise<object|null>
211
+ * @param {Function} options.onProgress - Progress callback
212
+ * @returns {Promise<object>} Results object with summary and details
213
+ */
214
+ export async function checkDeprecations(lockfileData, options = {}) {
215
+ const {
216
+ concurrency = 8,
217
+ timeoutMs = 10000,
218
+ defaultRegistry = DEFAULT_REGISTRY,
219
+ offline = false,
220
+ failOnDeprecated = false,
221
+ failOnUnresolved = true, // fail closed: a scan that couldn't complete must not pass as "clean"
222
+ fetchManifest = null,
223
+ onProgress = null
224
+ } = options;
225
+
226
+ if (lockfileData && lockfileData.lockfileVersion === 1) {
227
+ throw new DeprecationError(
228
+ 'v1 lockfiles are not supported; run `npm-check migrate 3` first',
229
+ 'UNSUPPORTED_VERSION'
230
+ );
231
+ }
232
+
233
+ const results = createResults();
234
+
235
+ // Collect verifiable candidates (mirror checker.js / vuln.js skip logic).
236
+ const candidates = collectCandidates(lockfileData, defaultRegistry, results);
237
+
238
+ // Offline: nothing left to do — count remaining candidates as skipped.
239
+ if (offline) {
240
+ results.skipped += candidates.length;
241
+ return results;
242
+ }
243
+
244
+ const fetcher = fetchManifest ||
245
+ ((name, version, registryBase) => fetchPackumentManifest(name, version, { registryBase, timeoutMs }));
246
+
247
+ // Dedupe identical name@version@registry so each unique version is fetched once;
248
+ // the single manifest result is attributed to every lockfile entry that shares it.
249
+ const units = groupCandidates(candidates);
250
+
251
+ const reporter = onProgress ? createProgressReporter(units.length, {
252
+ onProgress,
253
+ stage: 'Scanning for deprecated packages'
254
+ }) : null;
255
+ let completed = 0;
256
+
257
+ await mapWithConcurrency(units, concurrency, async (unit) => {
258
+ await processUnit(unit, fetcher, failOnDeprecated, failOnUnresolved, results);
259
+ completed++;
260
+ if (reporter) reporter.update(completed);
261
+ });
262
+
263
+ if (reporter) reporter.finish();
264
+
265
+ return results;
266
+ }
267
+
268
+ /**
269
+ * Map one deprecation notice (from results.errors/warnings) into the suite's shared
270
+ * Finding shape. Deprecation is not itself on the severity ladder, so the gate bucket
271
+ * (error when failOnDeprecated, else warn) becomes a top-level ladder string the same
272
+ * way report.js maps it — error→`high`, warn→`low` — and rides on under `extra.gate`.
273
+ * `category` is `deprecated`; `location` is null (a deprecation is package-level).
274
+ */
275
+ function toSchemaFinding(f, gate) {
276
+ return {
277
+ severity: gate === 'error' ? 'high' : 'low',
278
+ ruleId: 'deprecated',
279
+ category: 'deprecated',
280
+ message: `${f.package}@${f.version}: ${f.message}`,
281
+ location: null, // a deprecation notice is not file-scoped
282
+ remediation: 'replace deprecated package',
283
+ extra: {
284
+ package: f.package,
285
+ installedVersion: f.version,
286
+ gate
287
+ }
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Wrap a checkDeprecations() result in the shared finding-schema envelope.
293
+ * `findings` is the COMPLETE list of deprecation notices (errors that carry a
294
+ * message — i.e. the failOnDeprecated case — plus warnings); scan-completeness
295
+ * state (clean/unresolved/skipped, which drives the fail-closed gate) is preserved
296
+ * under `extra.scan` so nothing is lost. Mirrors vuln.js's vulnEnvelope.
297
+ *
298
+ * @param {object} result - a checkDeprecations() result
299
+ * @param {object} meta
300
+ * @param {string} meta.target - the lockfile path scanned, as given
301
+ * @param {number} meta.exitCode - the real process exit code (0/1/2)
302
+ * @returns {object} the shared envelope
303
+ */
304
+ export function deprecationEnvelope(result, { target, exitCode }) {
305
+ const findings = [
306
+ ...result.errors.filter((e) => e.message).map((e) => toSchemaFinding(e, 'error')),
307
+ ...result.warnings.map((w) => toSchemaFinding(w, 'warn'))
308
+ ];
309
+ return buildEnvelope({
310
+ target,
311
+ scanned: result.scanned,
312
+ findings,
313
+ exitCode,
314
+ extra: {
315
+ scan: {
316
+ deprecated: result.deprecated,
317
+ clean: result.clean,
318
+ unresolved: result.unresolved,
319
+ skipped: result.skipped,
320
+ valid: result.valid,
321
+ unresolvedItems: result.unresolvedItems
322
+ }
323
+ }
324
+ });
325
+ }
package/src/fixer.js ADDED
@@ -0,0 +1,207 @@
1
+ // src/fixer.js
2
+ import { migrateToVersion } from './migrator.js';
3
+ import { LOCKFILE_VERSIONS, forEachPackageEntry } from './format-library.js';
4
+ import { deduplicatePackages } from './updater.js';
5
+
6
+ /**
7
+ * Fixer error class for explicit error reporting
8
+ */
9
+ export class FixerError extends Error {
10
+ constructor(message, fixes = []) {
11
+ super(message);
12
+ this.name = 'FixerError';
13
+ this.fixes = fixes;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * True when the value is a non-null object with a usable packages map.
19
+ * @param {object} lockfile - The lockfile to inspect
20
+ * @returns {boolean} Whether `lockfile.packages` is a usable object
21
+ */
22
+ function hasPackagesMap(lockfile) {
23
+ return Boolean(lockfile.packages) && typeof lockfile.packages === 'object';
24
+ }
25
+
26
+ /**
27
+ * Sync the lockfile's root identity (name/version) with package.json when
28
+ * provided. A stale name/version here is exactly what the report's
29
+ * "Structure & format" errors flag after a package rename or version bump,
30
+ * and it's safe to correct without re-resolving the dependency tree.
31
+ * Mutates `fixed` (and `fixes`); returns the possibly-replaced lockfile.
32
+ * @param {object} fixed - The working lockfile
33
+ * @param {object} packageJson - The package.json to sync against
34
+ * @param {string[]} fixes - Accumulator for fix descriptions
35
+ * @returns {object} The working lockfile
36
+ */
37
+ function syncRootIdentity(fixed, packageJson, fixes) {
38
+ if (!packageJson || typeof packageJson !== 'object') return fixed;
39
+
40
+ for (const field of ['name', 'version']) {
41
+ const desired = packageJson[field];
42
+ if (typeof desired !== 'string' || desired === '') continue;
43
+
44
+ if (fixed[field] !== desired) {
45
+ fixed[field] = desired;
46
+ fixes.push(`Synced lockfile ${field} to package.json ("${desired}")`);
47
+ }
48
+
49
+ if (!fixed.packages || !fixed.packages['']) continue;
50
+ const root = fixed.packages[''];
51
+ if (root[field] !== desired) {
52
+ fixed.packages = { ...fixed.packages, '': { ...root, [field]: desired } };
53
+ fixes.push(`Synced root package ${field} to package.json ("${desired}")`);
54
+ }
55
+ }
56
+ return fixed;
57
+ }
58
+
59
+ /**
60
+ * Run a migration step, translating failures into either a thrown FixerError
61
+ * (when `throwOnError`) or a warning fix entry. Returns the migrated lockfile,
62
+ * or the untouched input when the migration failed without throwing.
63
+ * @param {object} fixed - The working lockfile
64
+ * @param {number} targetVersion - The lockfile version to migrate to
65
+ * @param {string} successMsg - Fix description on success
66
+ * @param {string} failPrefix - Prefix for the failure message
67
+ * @param {boolean} throwOnError - Whether to throw on failure
68
+ * @param {string[]} fixes - Accumulator for fix descriptions
69
+ * @returns {object} The migrated (or untouched) lockfile
70
+ * @throws {FixerError} When migration fails and `throwOnError` is set
71
+ */
72
+ function runMigration(fixed, targetVersion, successMsg, failPrefix, throwOnError, fixes) {
73
+ try {
74
+ const migrated = migrateToVersion(fixed, targetVersion);
75
+ fixes.push(successMsg);
76
+ return migrated;
77
+ } catch (e) {
78
+ if (throwOnError) {
79
+ throw new FixerError(`${failPrefix}: ${e.message}`, fixes);
80
+ }
81
+ fixes.push(`${failPrefix}: ${e.message}`);
82
+ return fixed;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Apply the requested explicit normalization plus the default v1→v2
88
+ * auto-migration. Returns the possibly-migrated lockfile.
89
+ * @param {object} fixed - The working lockfile
90
+ * @param {number|null} normalizeTo - Requested target version, if any
91
+ * @param {boolean} throwOnError - Whether to throw on failure
92
+ * @param {string[]} fixes - Accumulator for fix descriptions
93
+ * @returns {object} The working lockfile
94
+ */
95
+ function applyMigrations(fixed, normalizeTo, throwOnError, fixes) {
96
+ const supported = [LOCKFILE_VERSIONS.V1, LOCKFILE_VERSIONS.V2, LOCKFILE_VERSIONS.V3];
97
+
98
+ // Normalize format if requested
99
+ if (normalizeTo && supported.includes(normalizeTo)) {
100
+ fixed = runMigration(
101
+ fixed, normalizeTo,
102
+ `Migrated lockfile to v${normalizeTo}`,
103
+ `Failed to migrate lockfile to v${normalizeTo}`,
104
+ throwOnError, fixes,
105
+ );
106
+ }
107
+
108
+ // If lockfile is v1 and has dependencies but no packages map, migrate to v2 by
109
+ // default — but only when the caller did NOT ask for an explicit target. When
110
+ // `normalizeTo` is given (even v1), honor it verbatim; otherwise this default
111
+ // would override the caller's stated target and silently re-upgrade to v2.
112
+ if (!normalizeTo && fixed.lockfileVersion === LOCKFILE_VERSIONS.V1 && fixed.dependencies) {
113
+ fixed = runMigration(
114
+ fixed, LOCKFILE_VERSIONS.V2,
115
+ 'Auto-migrated v1 dependencies tree to v2 packages map',
116
+ 'Auto-migration v1→v2 failed',
117
+ throwOnError, fixes,
118
+ );
119
+ }
120
+ return fixed;
121
+ }
122
+
123
+ /**
124
+ * Fill placeholder integrity hashes for registry package entries that have none.
125
+ * Only registry deps legitimately carry an integrity hash, so entries the
126
+ * integrity/checksum tooling deliberately skips — the root, workspace source
127
+ * dirs, links, and git/file/bundled deps — are left alone (stamping a fake hash
128
+ * on them corrupts an otherwise-valid lockfile). Entries already holding a hash
129
+ * are untouched. Mutates entries in place on the (already-cloned) working copy.
130
+ * @param {object} fixed - The working lockfile (a private copy)
131
+ * @param {string[]} fixes - Accumulator for fix descriptions
132
+ */
133
+ function fillPlaceholderIntegrity(fixed, fixes) {
134
+ if (!hasPackagesMap(fixed)) return;
135
+
136
+ forEachPackageEntry(fixed, ({ key, entry, isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep, isBundled }) => {
137
+ // Only registry entries carry an integrity hash; everything else legitimately
138
+ // has none (matches the checksum-fixer / integrity-checker skip list).
139
+ if (isRoot || isWorkspaceSource || isLink || isGitDep || isFileDep || isBundled) return;
140
+ if (!entry || typeof entry !== 'object') return;
141
+ // Already has a placeholder or a real hash, no action needed
142
+ if (entry.integrity) return;
143
+
144
+ entry.integrity = 'sha512-PLACEHOLDER';
145
+ fixes.push(`Added placeholder integrity for package at ${key}`);
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Run the preserve-only deduplication step, reporting how many entries were
151
+ * removed. Failures become a thrown FixerError or a warning fix entry.
152
+ * @param {object} fixed - The working lockfile
153
+ * @param {boolean} throwOnError - Whether to throw on failure
154
+ * @param {string[]} fixes - Accumulator for fix descriptions
155
+ * @returns {object} The working lockfile
156
+ * @throws {FixerError} When deduplication fails and `throwOnError` is set
157
+ */
158
+ function applyDedupe(fixed, throwOnError, fixes) {
159
+ if (!hasPackagesMap(fixed)) return fixed;
160
+
161
+ try {
162
+ const beforeCount = Object.keys(fixed.packages).length;
163
+ fixed = deduplicatePackages(fixed, { keepLatest: true });
164
+ const afterCount = Object.keys(fixed.packages).length;
165
+ if (afterCount < beforeCount) {
166
+ fixes.push(`Deduplicated packages: removed ${beforeCount - afterCount} entries`);
167
+ }
168
+ } catch (e) {
169
+ if (throwOnError) {
170
+ throw new FixerError(`Deduplication failed: ${e.message}`, fixes);
171
+ }
172
+ fixes.push(`Deduplication failed: ${e.message}`);
173
+ }
174
+ return fixed;
175
+ }
176
+
177
+ /**
178
+ * Attempt to automatically fix common package-lock issues.
179
+ * Returns { fixedLockfile, fixes } where `fixes` is an array of descriptions.
180
+ * @param {object} lockfile - The lockfile to fix
181
+ * @param {object} options - Fix options
182
+ * @returns {{fixedLockfile: object, fixes: string[]}} Result with fixed lockfile and descriptions
183
+ * @throws {FixerError} If a critical error occurs during fixing
184
+ */
185
+ export function fixPackageLock(lockfile, options = {}) {
186
+ const fixes = [];
187
+ // Deep-copy up front so every fix step (incl. the in-place integrity fill) is
188
+ // non-destructive — the caller's lockfile object is never written through.
189
+ let fixed = structuredClone(lockfile);
190
+
191
+ const { fillMissingIntegrity = true, dedupe = true, normalizeTo = null, throwOnError = false, packageJson = null } = options;
192
+
193
+ fixed = syncRootIdentity(fixed, packageJson, fixes);
194
+ fixed = applyMigrations(fixed, normalizeTo, throwOnError, fixes);
195
+
196
+ if (fillMissingIntegrity) {
197
+ fillPlaceholderIntegrity(fixed, fixes);
198
+ }
199
+
200
+ if (dedupe) {
201
+ fixed = applyDedupe(fixed, throwOnError, fixes);
202
+ }
203
+
204
+ return { fixedLockfile: fixed, fixes };
205
+ }
206
+
207
+ export default { fixPackageLock, FixerError };
@@ -0,0 +1,132 @@
1
+ // src/format-library.js
2
+ import { deriveRegistryBase } from './integrity.js';
3
+ import { forEachPnpmPackageEntry } from './pnpm-format.js';
4
+
5
+ export const LOCKFILE_VERSIONS = {
6
+ V1: 1,
7
+ V2: 2,
8
+ V3: 3
9
+ };
10
+
11
+ export function detectLockfileVersion(lockfile) {
12
+ const version = lockfile.lockfileVersion;
13
+ if (version === 1) return LOCKFILE_VERSIONS.V1;
14
+ if (version === 2) return LOCKFILE_VERSIONS.V2;
15
+ if (version === 3) return LOCKFILE_VERSIONS.V3;
16
+ throw new Error(`Unsupported lockfile version: ${version}`);
17
+ }
18
+
19
+ /**
20
+ * Identify which package manager produced a parsed lockfile.
21
+ * pnpm-lock.yaml uses a STRING `lockfileVersion` ('9.0', '6.0', …) and carries
22
+ * `importers`/`snapshots`; npm uses a NUMERIC `lockfileVersion` and a `packages`
23
+ * map keyed by install path. The parser stamps `__npmCheckMeta.flavor`, which is
24
+ * trusted first when present.
25
+ * @param {object} lockfile - Parsed lockfile
26
+ * @returns {'npm'|'pnpm'} Flavor
27
+ */
28
+ export function detectLockfileFlavor(lockfile) {
29
+ if (!lockfile || typeof lockfile !== 'object') return 'npm';
30
+ if (lockfile.__npmCheckMeta && lockfile.__npmCheckMeta.flavor) return lockfile.__npmCheckMeta.flavor;
31
+ if (typeof lockfile.lockfileVersion === 'string') return 'pnpm';
32
+ if (lockfile.importers || lockfile.snapshots) return 'pnpm';
33
+ return 'npm';
34
+ }
35
+
36
+ // Collapse the boolean classification flags into the normalized node `kind`.
37
+ function npmEntryKind(flags) {
38
+ if (flags.isRoot) return 'root';
39
+ if (flags.isWorkspaceSource) return 'workspace';
40
+ if (flags.isLink) return 'link';
41
+ if (flags.isGitDep) return 'git';
42
+ if (flags.isFileDep) return 'file';
43
+ if (flags.isBundled) return 'bundled';
44
+ return 'registry';
45
+ }
46
+
47
+ export function hasPackagesMap(version) {
48
+ return version === LOCKFILE_VERSIONS.V2 || version === LOCKFILE_VERSIONS.V3;
49
+ }
50
+
51
+ export function hasDependenciesTree(version) {
52
+ return version === LOCKFILE_VERSIONS.V1;
53
+ }
54
+
55
+ /**
56
+ * Resolve the real package name for a packages-map entry.
57
+ * Uses entry.name when present (set for npm: aliases), otherwise the
58
+ * last node_modules/ segment of the key (handles scoped packages).
59
+ * @param {string} key - Key in the packages map
60
+ * @param {object} entry - Package entry data
61
+ * @returns {string|null} Package name or null for the root entry
62
+ */
63
+ export function resolvePackageName(key, entry) {
64
+ if (entry && entry.name) return entry.name;
65
+ if (!key) return null;
66
+ const marker = 'node_modules/';
67
+ const idx = key.lastIndexOf(marker);
68
+ if (idx === -1) return key;
69
+ return key.slice(idx + marker.length);
70
+ }
71
+
72
+ /**
73
+ * Iterate a lockfile's package entries, classifying each one. Dispatches by flavor:
74
+ * the npm v2/v3 `packages` map (keyed by install path) or pnpm-lock's `packages` +
75
+ * `importers`. Both flavors emit the SAME callback shape:
76
+ * { key, entry, name, isRoot, isWorkspaceSource, isLink, isBundled, isGitDep,
77
+ * isFileDep, registryBase, node }
78
+ * `registryBase` is the per-package registry (npm: derived from the entry's
79
+ * `resolved` URL, may be null; pnpm: resolved from config) so consumers read one
80
+ * uniform field instead of re-deriving it. `node` is the normalized package node.
81
+ * @param {object} lockfile - Parsed lockfile (npm v2/v3 or pnpm)
82
+ * @param {function} callback - Called for each entry
83
+ */
84
+ export function forEachPackageEntry(lockfile, callback) {
85
+ if (detectLockfileFlavor(lockfile) === 'pnpm') {
86
+ return forEachPnpmPackageEntry(lockfile, callback);
87
+ }
88
+ const packages = lockfile.packages || {};
89
+ for (const [key, entry] of Object.entries(packages)) {
90
+ const resolved = (entry && entry.resolved) || '';
91
+ const name = resolvePackageName(key, entry);
92
+ const flags = {
93
+ isRoot: key === '',
94
+ isWorkspaceSource: key !== '' && !key.includes('node_modules/'),
95
+ isLink: Boolean(entry && entry.link),
96
+ isBundled: Boolean(entry && entry.inBundle),
97
+ isGitDep: resolved.startsWith('git+') || resolved.startsWith('git://'),
98
+ isFileDep: resolved.startsWith('file:')
99
+ };
100
+ // Per-package registry from the resolved URL (null when not derivable — the
101
+ // historical `deriveRegistryBase(...) || defaultRegistry` fallback lives in the
102
+ // consumers, so behavior is byte-identical).
103
+ const registryBase = deriveRegistryBase(resolved, name);
104
+ callback({
105
+ key,
106
+ entry,
107
+ name,
108
+ ...flags,
109
+ registryBase,
110
+ node: {
111
+ name,
112
+ version: (entry && entry.version) || null,
113
+ integrity: (entry && entry.integrity) || null,
114
+ registryBase,
115
+ kind: npmEntryKind(flags),
116
+ path: key
117
+ }
118
+ });
119
+ }
120
+ }
121
+
122
+ export function parseLockfile(content) {
123
+ try {
124
+ return JSON.parse(content);
125
+ } catch (e) {
126
+ throw new Error('Invalid JSON in lockfile', { cause: e });
127
+ }
128
+ }
129
+
130
+ export function stringifyLockfile(lockfile) {
131
+ return JSON.stringify(lockfile, null, 2);
132
+ }