@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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance utilities for handling large lockfiles efficiently.
|
|
3
|
+
* Includes streaming, memory optimization, and batch processing strategies.
|
|
4
|
+
*/
|
|
5
|
+
import { resolvePackageName } from './format-library.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a shallow copy of a lockfile to avoid deep cloning
|
|
9
|
+
* Useful when only modifying specific nested properties
|
|
10
|
+
* @param {object} lockfile - The lockfile to copy
|
|
11
|
+
* @returns {object} Shallow copy of lockfile
|
|
12
|
+
*/
|
|
13
|
+
export function shallowCopyLockfile(lockfile) {
|
|
14
|
+
if (!lockfile || typeof lockfile !== 'object') return lockfile;
|
|
15
|
+
|
|
16
|
+
const copy = { ...lockfile };
|
|
17
|
+
|
|
18
|
+
// Shallow copy packages if present
|
|
19
|
+
if (lockfile.packages && typeof lockfile.packages === 'object') {
|
|
20
|
+
copy.packages = { ...lockfile.packages };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Shallow copy dependencies if present
|
|
24
|
+
if (lockfile.dependencies && typeof lockfile.dependencies === 'object') {
|
|
25
|
+
copy.dependencies = { ...lockfile.dependencies };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return copy;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Invoke a progress callback for a completed batch, computing the percentage
|
|
33
|
+
* @param {Function|null} onProgress - Progress callback function(progressInfo)
|
|
34
|
+
* @param {number} processed - Number of packages processed so far
|
|
35
|
+
* @param {number} total - Total number of packages
|
|
36
|
+
* @param {string} stage - Stage name for progress reporting
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
function reportBatchProgress(onProgress, processed, total, stage) {
|
|
40
|
+
if (!onProgress) return;
|
|
41
|
+
|
|
42
|
+
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
|
43
|
+
onProgress({
|
|
44
|
+
current: processed,
|
|
45
|
+
total,
|
|
46
|
+
percentage,
|
|
47
|
+
stage
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Process packages in batches to reduce memory pressure
|
|
53
|
+
* @param {object} packagesMap - The packages object to process
|
|
54
|
+
* @param {Function} processor - Function to apply to each package (called as processor(path, pkg))
|
|
55
|
+
* @param {number|Object} batchSizeOrOptions - Batch size (number) or options object
|
|
56
|
+
* @param {number} batchSizeOrOptions.batchSize - Number of packages per batch (default: 1000)
|
|
57
|
+
* @param {Function} batchSizeOrOptions.onProgress - Progress callback function(progressInfo)
|
|
58
|
+
* @param {string} batchSizeOrOptions.stage - Stage name for progress reporting
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
export async function processBatchedPackages(packagesMap, processor, batchSizeOrOptions = 1000) {
|
|
62
|
+
if (!packagesMap || typeof packagesMap !== 'object') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle both old signature (batchSize as number) and new signature (options object)
|
|
67
|
+
const options = typeof batchSizeOrOptions === 'object' ? batchSizeOrOptions : { batchSize: batchSizeOrOptions };
|
|
68
|
+
const batchSize = options.batchSize || 1000;
|
|
69
|
+
const onProgress = options.onProgress || null;
|
|
70
|
+
const stage = options.stage || 'Processing packages';
|
|
71
|
+
|
|
72
|
+
const entries = Object.entries(packagesMap);
|
|
73
|
+
const total = entries.length;
|
|
74
|
+
let processed = 0;
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < entries.length; i += batchSize) {
|
|
77
|
+
const batch = entries.slice(i, i + batchSize);
|
|
78
|
+
|
|
79
|
+
for (const [path, pkg] of batch) {
|
|
80
|
+
processor(path, pkg);
|
|
81
|
+
processed++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Report progress if callback provided
|
|
85
|
+
reportBatchProgress(onProgress, processed, total, stage);
|
|
86
|
+
|
|
87
|
+
// Yield control to allow garbage collection
|
|
88
|
+
if (i + batchSize < entries.length) {
|
|
89
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get memory usage estimate for current process
|
|
96
|
+
* @returns {object} Object with memory stats (heapUsed, heapTotal, external, rss in MB)
|
|
97
|
+
*/
|
|
98
|
+
export function getMemoryStats() {
|
|
99
|
+
if (typeof process !== 'undefined' && process.memoryUsage) {
|
|
100
|
+
const mem = process.memoryUsage();
|
|
101
|
+
return {
|
|
102
|
+
heapUsed: Math.round(mem.heapUsed / 1024 / 1024 * 100) / 100,
|
|
103
|
+
heapTotal: Math.round(mem.heapTotal / 1024 / 1024 * 100) / 100,
|
|
104
|
+
external: Math.round(mem.external / 1024 / 1024 * 100) / 100,
|
|
105
|
+
rss: Math.round(mem.rss / 1024 / 1024 * 100) / 100
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create a filtered view of packages without full copy
|
|
113
|
+
* Useful for operations that only need to inspect certain packages
|
|
114
|
+
* @param {object} packagesMap - The packages map to filter
|
|
115
|
+
* @param {Function} predicate - Function that returns true for packages to include
|
|
116
|
+
* @returns {object} New object with only matching packages
|
|
117
|
+
*/
|
|
118
|
+
export function filterPackagesLazy(packagesMap, predicate) {
|
|
119
|
+
const filtered = {};
|
|
120
|
+
|
|
121
|
+
for (const [path, pkg] of Object.entries(packagesMap || {})) {
|
|
122
|
+
if (predicate(path, pkg)) {
|
|
123
|
+
filtered[path] = pkg;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return filtered;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Optimize deduplication by using a Map instead of nested objects
|
|
132
|
+
* Faster lookups for large collections
|
|
133
|
+
* @param {object} packagesMap - The packages object to deduplicate
|
|
134
|
+
* @returns {Map} Map with deduped entries (key: packageName#version, value: {path, pkg})
|
|
135
|
+
*/
|
|
136
|
+
export function createDedupeMap(packagesMap) {
|
|
137
|
+
const dedupeMap = new Map();
|
|
138
|
+
|
|
139
|
+
for (const [path, pkg] of Object.entries(packagesMap || {})) {
|
|
140
|
+
if (!pkg || typeof pkg !== 'object') continue;
|
|
141
|
+
// Derive the name from the path — v2/v3 entries usually have no `.name` field,
|
|
142
|
+
// so keying off `pkg.name` alone silently drops nearly every real entry.
|
|
143
|
+
const name = resolvePackageName(path, pkg);
|
|
144
|
+
if (!name) continue;
|
|
145
|
+
|
|
146
|
+
const key = `${name}#${pkg.version || 'unknown'}`;
|
|
147
|
+
|
|
148
|
+
if (!dedupeMap.has(key)) {
|
|
149
|
+
dedupeMap.set(key, { path, pkg });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return dedupeMap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reconstruct packages object from a dedupe map
|
|
158
|
+
* @param {Map} dedupeMap - Map created by createDedupeMap
|
|
159
|
+
* @returns {object} Reconstructed packages object
|
|
160
|
+
*/
|
|
161
|
+
export function reconstructFromDedupeMap(dedupeMap) {
|
|
162
|
+
const packages = {};
|
|
163
|
+
|
|
164
|
+
for (const { path, pkg } of dedupeMap.values()) {
|
|
165
|
+
packages[path] = pkg;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return packages;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Split a large lockfile into manageable chunks for processing
|
|
173
|
+
* Useful for parallel processing or streaming
|
|
174
|
+
* @param {object} lockfile - The lockfile to chunk
|
|
175
|
+
* @param {number} chunkSize - Number of packages per chunk (default: 5000)
|
|
176
|
+
* @returns {Array<object>} Array of partial lockfile objects
|
|
177
|
+
*/
|
|
178
|
+
export function chunkLockfile(lockfile, chunkSize = 5000) {
|
|
179
|
+
if (!lockfile.packages) {
|
|
180
|
+
return [lockfile];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const chunks = [];
|
|
184
|
+
const entries = Object.entries(lockfile.packages);
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < entries.length; i += chunkSize) {
|
|
187
|
+
const packageChunk = Object.fromEntries(
|
|
188
|
+
entries.slice(i, i + chunkSize)
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const chunk = {
|
|
192
|
+
...lockfile,
|
|
193
|
+
packages: packageChunk
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
chunks.push(chunk);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return chunks.length > 0 ? chunks : [lockfile];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Merge multiple processed lockfile chunks back into a single lockfile
|
|
204
|
+
* Assumes chunks have the same metadata (lockfileVersion, etc)
|
|
205
|
+
* @param {Array<object>} chunks - Array of lockfile chunks
|
|
206
|
+
* @returns {object} Merged lockfile
|
|
207
|
+
*/
|
|
208
|
+
export function mergeLockfileChunks(chunks) {
|
|
209
|
+
if (chunks.length === 0) {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (chunks.length === 1) {
|
|
214
|
+
return chunks[0];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Use first chunk as base
|
|
218
|
+
const merged = { ...chunks[0] };
|
|
219
|
+
merged.packages = {};
|
|
220
|
+
|
|
221
|
+
// Merge all packages from all chunks
|
|
222
|
+
for (const chunk of chunks) {
|
|
223
|
+
if (chunk.packages && typeof chunk.packages === 'object') {
|
|
224
|
+
Object.assign(merged.packages, chunk.packages);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return merged;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Estimate the size of a lockfile in memory (approximate)
|
|
233
|
+
* @param {object} lockfile - The lockfile to measure
|
|
234
|
+
* @returns {number} Approximate size in bytes
|
|
235
|
+
*/
|
|
236
|
+
export function estimateLockfileSize(lockfile) {
|
|
237
|
+
// This is a rough estimate using JSON.stringify
|
|
238
|
+
try {
|
|
239
|
+
const json = JSON.stringify(lockfile);
|
|
240
|
+
return json.length;
|
|
241
|
+
} catch {
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Check if a lockfile is considered "large" (over threshold)
|
|
248
|
+
* @param {object} lockfile - The lockfile to check
|
|
249
|
+
* @param {number} thresholdMB - Size threshold in MB (default: 10)
|
|
250
|
+
* @returns {boolean} True if lockfile size exceeds threshold
|
|
251
|
+
*/
|
|
252
|
+
export function isLargeLockfile(lockfile, thresholdMB = 10) {
|
|
253
|
+
// Heuristics: use JSON size estimate, but also fallback to package count for very large maps
|
|
254
|
+
const estimatedBytes = estimateLockfileSize(lockfile);
|
|
255
|
+
const estimatedMB = estimatedBytes / 1024 / 1024;
|
|
256
|
+
|
|
257
|
+
if (estimatedMB > thresholdMB) return true;
|
|
258
|
+
|
|
259
|
+
// If packages map exists and is extremely large, consider it large regardless of JSON size estimate
|
|
260
|
+
try {
|
|
261
|
+
if (lockfile && lockfile.packages && typeof lockfile.packages === 'object') {
|
|
262
|
+
const pkgCount = Object.keys(lockfile.packages).length;
|
|
263
|
+
if (pkgCount > 10000) return true;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore and fall through
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export default {
|
|
273
|
+
shallowCopyLockfile,
|
|
274
|
+
processBatchedPackages,
|
|
275
|
+
getMemoryStats,
|
|
276
|
+
filterPackagesLazy,
|
|
277
|
+
createDedupeMap,
|
|
278
|
+
reconstructFromDedupeMap,
|
|
279
|
+
chunkLockfile,
|
|
280
|
+
mergeLockfileChunks,
|
|
281
|
+
estimateLockfileSize,
|
|
282
|
+
isLargeLockfile
|
|
283
|
+
};
|
package/src/pinner.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// src/pinner.js
|
|
2
|
+
import { detectLockfileVersion, LOCKFILE_VERSIONS } from './format-library.js';
|
|
3
|
+
import { walkOverrides } from './overrides.js';
|
|
4
|
+
|
|
5
|
+
export class PinnerError extends Error {
|
|
6
|
+
constructor(message, code, context = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'PinnerError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.context = context;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.+]+)?$/;
|
|
15
|
+
const CARET_RANGE = /^\^\d+(?:\.\d+){0,2}(?:[-+][0-9A-Za-z-.+]+)?$/;
|
|
16
|
+
const TILDE_RANGE = /^~\d+(?:\.\d+){0,2}(?:[-+][0-9A-Za-z-.+]+)?$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Classify a package.json version range.
|
|
20
|
+
* @param {string} range - The range string
|
|
21
|
+
* @returns {string} 'exact' | 'caret' | 'tilde' | 'complex' | 'git' | 'file' | 'link' | 'workspace' | 'alias' | 'url'
|
|
22
|
+
*/
|
|
23
|
+
export function classifyRange(range) {
|
|
24
|
+
if (typeof range !== 'string' || range.trim() === '') return 'complex';
|
|
25
|
+
const r = range.trim();
|
|
26
|
+
|
|
27
|
+
if (r.startsWith('npm:')) return 'alias';
|
|
28
|
+
if (r.startsWith('workspace:')) return 'workspace';
|
|
29
|
+
if (r.startsWith('file:')) return 'file';
|
|
30
|
+
if (r.startsWith('link:')) return 'link';
|
|
31
|
+
if (r.startsWith('git+') || r.startsWith('git://') || /^(github|gitlab|bitbucket):/.test(r)) return 'git';
|
|
32
|
+
if (/^https?:\/\//.test(r)) return 'url';
|
|
33
|
+
|
|
34
|
+
if (EXACT_VERSION.test(r)) return 'exact';
|
|
35
|
+
if (CARET_RANGE.test(r)) return 'caret';
|
|
36
|
+
if (TILDE_RANGE.test(r)) return 'tilde';
|
|
37
|
+
|
|
38
|
+
// Everything else: >=, <=, ||, x-ranges, *, dist-tags, hyphen ranges,
|
|
39
|
+
// and GitHub shorthand like user/repo (indistinguishable from tags safely → complex)
|
|
40
|
+
return 'complex';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Look up the version a package resolved to in the lockfile.
|
|
47
|
+
* v2/v3 use the packages map keyed by install path; v1 uses the dependencies tree.
|
|
48
|
+
* @param {object} lockfile - Parsed lockfile
|
|
49
|
+
* @param {string} name - Package name
|
|
50
|
+
* @param {boolean} hasPackages - Whether the lockfile has a packages map (v2/v3)
|
|
51
|
+
* @returns {string|undefined} The resolved version, or undefined if absent
|
|
52
|
+
*/
|
|
53
|
+
function resolvedVersionFor(lockfile, name, hasPackages) {
|
|
54
|
+
if (hasPackages) {
|
|
55
|
+
const entry = lockfile.packages && lockfile.packages[`node_modules/${name}`];
|
|
56
|
+
return entry && entry.version;
|
|
57
|
+
}
|
|
58
|
+
const entry = lockfile.dependencies && lockfile.dependencies[name];
|
|
59
|
+
return entry && entry.version;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keep the lockfile root entry (packages['']) in sync after pinning (v2/v3 only).
|
|
64
|
+
* Only rewrites a range that's already present in the matching root section.
|
|
65
|
+
* @param {object} lockfile - The (cloned) lockfile being mutated
|
|
66
|
+
* @param {string} section - The dependency section
|
|
67
|
+
* @param {string} name - Package name
|
|
68
|
+
* @param {string} resolvedVersion - The exact version to pin to
|
|
69
|
+
*/
|
|
70
|
+
function syncLockfileRoot(lockfile, section, name, resolvedVersion) {
|
|
71
|
+
if (!lockfile.packages || !lockfile.packages['']) return;
|
|
72
|
+
const rootSection = lockfile.packages[''][section];
|
|
73
|
+
if (rootSection && Object.prototype.hasOwnProperty.call(rootSection, name)) {
|
|
74
|
+
rootSection[name] = resolvedVersion;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pin a single dependency's range to the lockfile-resolved version, recording
|
|
80
|
+
* the result into changes/skipped and syncing the lockfile root on success.
|
|
81
|
+
* @param {object} ctx - { sourceLockfile, newLockfile, hasPackages, changes, skipped }
|
|
82
|
+
* @param {object} deps - The (cloned) package.json section being mutated
|
|
83
|
+
* @param {string} section - The dependency section
|
|
84
|
+
* @param {string} name - Package name
|
|
85
|
+
* @param {string} range - The original range
|
|
86
|
+
*/
|
|
87
|
+
function pinDependency(ctx, deps, section, name, range) {
|
|
88
|
+
const kind = classifyRange(range);
|
|
89
|
+
|
|
90
|
+
if (kind === 'exact') return;
|
|
91
|
+
if (kind !== 'caret' && kind !== 'tilde') {
|
|
92
|
+
ctx.skipped.push({ section, name, range, reason: `${kind}-range` });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const resolvedVersion = resolvedVersionFor(ctx.sourceLockfile, name, ctx.hasPackages);
|
|
97
|
+
if (!resolvedVersion) {
|
|
98
|
+
ctx.skipped.push({ section, name, range, reason: 'not-in-lockfile' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
deps[name] = resolvedVersion;
|
|
103
|
+
ctx.changes.push({ section, name, from: range, to: resolvedVersion });
|
|
104
|
+
|
|
105
|
+
// Keep the lockfile root entry in sync (v2/v3)
|
|
106
|
+
if (ctx.hasPackages) {
|
|
107
|
+
syncLockfileRoot(ctx.newLockfile, section, name, resolvedVersion);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Collect every DISTINCT version a package name resolves to anywhere in the
|
|
113
|
+
* lockfile. An override forces all instances and a nested selector targets a
|
|
114
|
+
* shadowed install path (e.g. node_modules/a/node_modules/b), so the top-level
|
|
115
|
+
* `node_modules/<name>` entry alone can be the wrong instance to pin to.
|
|
116
|
+
* @param {object} lockfile - The source lockfile
|
|
117
|
+
* @param {string} name - Package name (selector already stripped)
|
|
118
|
+
* @param {boolean} hasPackages - v2/v3 (packages map) vs v1 (dependencies tree)
|
|
119
|
+
* @returns {Set<string>} Distinct resolved versions
|
|
120
|
+
*/
|
|
121
|
+
function versionsFromPackagesMap(packages, name) {
|
|
122
|
+
const versions = new Set();
|
|
123
|
+
const suffix = `node_modules/${name}`;
|
|
124
|
+
for (const [key, entry] of Object.entries(packages || {})) {
|
|
125
|
+
if ((key === suffix || key.endsWith(`/${suffix}`)) && entry && entry.version) {
|
|
126
|
+
versions.add(entry.version);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return versions;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function versionsFromV1Tree(tree, name, versions = new Set()) {
|
|
133
|
+
if (!tree || typeof tree !== 'object') return versions;
|
|
134
|
+
for (const [depName, node] of Object.entries(tree)) {
|
|
135
|
+
if (!node || typeof node !== 'object') continue;
|
|
136
|
+
if (depName === name && node.version) versions.add(node.version);
|
|
137
|
+
if (node.dependencies) versionsFromV1Tree(node.dependencies, name, versions);
|
|
138
|
+
}
|
|
139
|
+
return versions;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectResolvedVersions(lockfile, name, hasPackages) {
|
|
143
|
+
return hasPackages
|
|
144
|
+
? versionsFromPackagesMap(lockfile.packages, name)
|
|
145
|
+
: versionsFromV1Tree(lockfile.dependencies, name);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pin caret/tilde ranges inside the npm `overrides` field to their lockfile-
|
|
150
|
+
* resolved versions. `overrides` is nested and never mirrored into packages[''],
|
|
151
|
+
* so there is no lockfile-root sync — the caller runs `npm install` to reconcile.
|
|
152
|
+
* `$`-references and non-caret/tilde forms are left alone (reported in skipped).
|
|
153
|
+
* Pins only when the name resolves to a SINGLE version tree-wide; a name present
|
|
154
|
+
* at multiple versions is skipped `ambiguous-resolution` rather than pinned to a
|
|
155
|
+
* possibly-wrong instance.
|
|
156
|
+
* @param {object} ctx - Shared context (see pinDependency)
|
|
157
|
+
* @param {object} overrides - The (cloned) package.json `overrides` object
|
|
158
|
+
*/
|
|
159
|
+
function pinOverrides(ctx, overrides) {
|
|
160
|
+
if (!overrides || typeof overrides !== 'object') return;
|
|
161
|
+
for (const { path, name, range, container, key } of walkOverrides(overrides)) {
|
|
162
|
+
const kind = classifyRange(range);
|
|
163
|
+
if (kind === 'exact') continue;
|
|
164
|
+
if (kind !== 'caret' && kind !== 'tilde') {
|
|
165
|
+
ctx.skipped.push({ section: 'overrides', name: path, range, reason: `${kind}-range` });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const versions = collectResolvedVersions(ctx.sourceLockfile, name, ctx.hasPackages);
|
|
169
|
+
if (versions.size === 0) {
|
|
170
|
+
ctx.skipped.push({ section: 'overrides', name: path, range, reason: 'not-in-lockfile' });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (versions.size > 1) {
|
|
174
|
+
ctx.skipped.push({ section: 'overrides', name: path, range, reason: 'ambiguous-resolution' });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const resolvedVersion = [...versions][0];
|
|
178
|
+
container[key] = resolvedVersion;
|
|
179
|
+
ctx.changes.push({ section: 'overrides', name: path, from: range, to: resolvedVersion });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Pin caret/tilde ranges in package.json to the exact versions resolved in
|
|
185
|
+
* the lockfile, and keep the lockfile's root entry (packages['']) in sync.
|
|
186
|
+
* All other range forms are left alone and reported in `skipped`.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} packageJson - Parsed package.json
|
|
189
|
+
* @param {object} lockfile - Parsed lockfile
|
|
190
|
+
* @param {object} options - { sections, includePeer }
|
|
191
|
+
* @returns {{packageJson, lockfile, changes, skipped, warnings}}
|
|
192
|
+
*/
|
|
193
|
+
export function pinVersions(packageJson, lockfile, options = {}) {
|
|
194
|
+
const { sections = DEFAULT_SECTIONS, includePeer = false } = options;
|
|
195
|
+
|
|
196
|
+
if (!packageJson || typeof packageJson !== 'object') {
|
|
197
|
+
throw new PinnerError('package.json data is required', 'MISSING_PACKAGE_JSON');
|
|
198
|
+
}
|
|
199
|
+
if (!lockfile || typeof lockfile !== 'object') {
|
|
200
|
+
throw new PinnerError('lockfile data is required', 'MISSING_LOCKFILE');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const hasPackages = detectLockfileVersion(lockfile) !== LOCKFILE_VERSIONS.V1;
|
|
204
|
+
const activeSections = includePeer ? [...sections, 'peerDependencies'] : sections;
|
|
205
|
+
|
|
206
|
+
const newPackageJson = JSON.parse(JSON.stringify(packageJson));
|
|
207
|
+
const newLockfile = JSON.parse(JSON.stringify(lockfile));
|
|
208
|
+
|
|
209
|
+
const changes = [];
|
|
210
|
+
const skipped = [];
|
|
211
|
+
const warnings = [];
|
|
212
|
+
|
|
213
|
+
if (!hasPackages) {
|
|
214
|
+
warnings.push('v1 lockfile has no packages map; pinned package.json only — consider `npm-check migrate 3`');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ctx = { sourceLockfile: lockfile, newLockfile, hasPackages, changes, skipped };
|
|
218
|
+
|
|
219
|
+
for (const section of activeSections) {
|
|
220
|
+
const deps = newPackageJson[section];
|
|
221
|
+
if (!deps || typeof deps !== 'object') continue;
|
|
222
|
+
|
|
223
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
224
|
+
pinDependency(ctx, deps, section, name, range);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// npm `overrides` (nested) — pnpm.overrides is deliberately left alone: `pin`
|
|
229
|
+
// refuses pnpm lockfiles, and its selector keys don't map to a resolvable
|
|
230
|
+
// package name here. The audit `pinned-versions` rule still flags both.
|
|
231
|
+
pinOverrides(ctx, newPackageJson.overrides);
|
|
232
|
+
|
|
233
|
+
if (skipped.some((s) => s.reason === 'not-in-lockfile')) {
|
|
234
|
+
warnings.push('some dependencies are missing from the lockfile; run `npm install` to sync it');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { packageJson: newPackageJson, lockfile: newLockfile, changes, skipped, warnings };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Detect the indentation used in a JSON file's raw text (default two spaces).
|
|
242
|
+
* @param {string} rawText - Original file content
|
|
243
|
+
* @returns {string} Indentation string
|
|
244
|
+
*/
|
|
245
|
+
export function detectIndent(rawText) {
|
|
246
|
+
const match = typeof rawText === 'string' ? rawText.match(/^([ \t]+)["{[]/m) : null;
|
|
247
|
+
return match ? match[1] : ' ';
|
|
248
|
+
}
|