@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,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
+ }