@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.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/cli.js +1577 -0
- package/package.json +103 -0
- package/src/audit-config.js +331 -0
- package/src/audit.js +805 -0
- package/src/backup.js +235 -0
- package/src/checker.js +771 -0
- package/src/checksum-fixer.js +419 -0
- package/src/deprecation.js +325 -0
- package/src/fixer.js +207 -0
- package/src/format-library.js +132 -0
- package/src/index.js +138 -0
- package/src/integrity.js +519 -0
- package/src/migrator.js +263 -0
- package/src/npmrc-validator.js +205 -0
- package/src/overrides.js +71 -0
- package/src/package-json-validator.js +356 -0
- package/src/parallel-processor.js +374 -0
- package/src/parser.js +133 -0
- package/src/performance.js +283 -0
- package/src/pinner.js +248 -0
- package/src/pnpm-format.js +229 -0
- package/src/pnpm-workspace-validator.js +121 -0
- package/src/progress-reporter.js +245 -0
- package/src/pruner.js +177 -0
- package/src/remediate.js +389 -0
- package/src/report.js +663 -0
- package/src/schema.js +76 -0
- package/src/streaming-parser.js +251 -0
- package/src/updater.js +251 -0
- package/src/usage-scanner.js +215 -0
- package/src/validator.js +282 -0
- package/src/vuln.js +618 -0
- package/src/workers/dedupe-worker.js +20 -0
- package/src/workers/hash-upgrade-worker.js +20 -0
- package/src/workers/migration-worker.js +21 -0
- package/src/workers/validation-worker.js +20 -0
|
@@ -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
|
+
}
|