@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,374 @@
1
+ /**
2
+ * Parallel processing utilities using worker threads.
3
+ * Distributes CPU-bound operations across multiple cores.
4
+ */
5
+
6
+ import { Worker } from 'worker_threads';
7
+ import { cpus } from 'os';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { chunkLockfile, mergeLockfileChunks, isLargeLockfile } from './performance.js';
11
+ import { migrateToVersion } from './migrator.js';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ /**
17
+ * Worker pool for managing worker threads
18
+ */
19
+ export class WorkerPool {
20
+ /**
21
+ * Create a worker pool
22
+ * @param {number} size - Number of workers (default: CPU count - 1)
23
+ * @param {string} workerScript - Path to worker script
24
+ */
25
+ constructor(size = null, workerScript = null) {
26
+ this.size = size || Math.max(1, cpus().length - 1);
27
+ this.workerScript = workerScript;
28
+ this.workers = []; // [{ worker, busy, index, current }]
29
+ this.queue = []; // [{ task, resolve, reject }]
30
+ this.active = 0;
31
+ this.terminated = false;
32
+ }
33
+
34
+ /**
35
+ * Initialize workers
36
+ */
37
+ init() {
38
+ if (this.workers.length > 0) {
39
+ return; // Already initialized
40
+ }
41
+
42
+ this.terminated = false;
43
+ for (let i = 0; i < this.size; i++) {
44
+ this.workers.push(this._spawnWorker(i));
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Spawn a single worker and register its persistent lifecycle handlers.
50
+ * The 'error'/'exit' listeners are registered ONCE per worker (not per task),
51
+ * so they never accumulate. Per-task handlers are added in _assign and always
52
+ * removed in the settle path, preventing the listener leak.
53
+ * @param {number} index - Worker id
54
+ * @returns {Object} Worker info record
55
+ */
56
+ _spawnWorker(index) {
57
+ const worker = new Worker(this.workerScript, {
58
+ workerData: { workerId: index }
59
+ });
60
+ const info = { worker, busy: false, index, current: null, dead: false };
61
+
62
+ // A worker 'error' means an uncaught exception in the thread — the thread has
63
+ // exited and must not be reused. Reject its in-flight task and replace it.
64
+ worker.on('error', (error) => this._handleWorkerFailure(info, error));
65
+ worker.on('exit', (code) => {
66
+ if (this.terminated) return;
67
+ // Any exit that orphans an in-flight task must reject it and respawn —
68
+ // even a clean (code 0) exit mid-task, which would otherwise leave the
69
+ // task's promise forever unsettled and (at pool size 1) wedge the pool.
70
+ if (info.current || code !== 0) {
71
+ this._handleWorkerFailure(info, new Error(`Worker ${index} exited with code ${code}`));
72
+ }
73
+ });
74
+
75
+ return info;
76
+ }
77
+
78
+ /**
79
+ * Execute a task on the pool. Every task is enqueued and a single dispatcher
80
+ * assigns queued tasks to idle workers, so queued tasks always drain (no
81
+ * deadlock when tasks > workers).
82
+ * @param {Object} task - Task data
83
+ * @returns {Promise} Promise that resolves with the worker result
84
+ */
85
+ execute(task) {
86
+ return new Promise((resolve, reject) => {
87
+ if (this.terminated) {
88
+ reject(new Error('WorkerPool has been terminated'));
89
+ return;
90
+ }
91
+ this.queue.push({ task, resolve, reject });
92
+ this._dispatch();
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Assign as many queued tasks as there are idle workers. Called on every
98
+ * enqueue and on every task completion/failure so the queue never stalls.
99
+ */
100
+ _dispatch() {
101
+ if (this.terminated) return;
102
+
103
+ while (this.queue.length > 0) {
104
+ const workerInfo = this.workers.find(w => !w.busy);
105
+ if (!workerInfo) return; // all workers busy; retry on next completion
106
+ const job = this.queue.shift();
107
+ this._assign(workerInfo, job);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Run one job on one worker, with a per-task 'message' handler that is always
113
+ * removed once it fires (via once + explicit settle), so no handlers leak.
114
+ * @param {Object} workerInfo - Worker record
115
+ * @param {Object} job - { task, resolve, reject }
116
+ */
117
+ _assign(workerInfo, job) {
118
+ workerInfo.busy = true;
119
+ workerInfo.current = job;
120
+ this.active++;
121
+
122
+ const onMessage = (result) => {
123
+ // Settle this worker before dispatching queued work.
124
+ workerInfo.busy = false;
125
+ workerInfo.current = null;
126
+ this.active--;
127
+ if (result && result.success === false) {
128
+ job.reject(new Error(result.error || 'Worker task failed'));
129
+ } else {
130
+ job.resolve(result);
131
+ }
132
+ this._dispatch();
133
+ };
134
+
135
+ // `once` auto-removes the message listener when it fires. Task failures are
136
+ // handled by the persistent 'error'/'exit' listeners (_handleWorkerFailure),
137
+ // so no per-task error listener is registered — the source of the old leak.
138
+ workerInfo.worker.once('message', onMessage);
139
+ workerInfo.worker.postMessage(job.task);
140
+ }
141
+
142
+ /**
143
+ * Handle a worker that has crashed/exited: reject its in-flight task, remove
144
+ * the dead worker from the pool (so nothing posts to a dead thread), respawn a
145
+ * replacement to keep capacity, and resume draining the queue.
146
+ * @param {Object} info - Worker record
147
+ * @param {Error} error - Failure cause
148
+ */
149
+ _handleWorkerFailure(info, error) {
150
+ if (info.dead) return; // guard against error+exit firing for the same failure
151
+ info.dead = true;
152
+
153
+ // The thread is gone; drop any pending per-task message listener.
154
+ info.worker.removeAllListeners('message');
155
+
156
+ if (info.current) {
157
+ const job = info.current;
158
+ info.current = null;
159
+ this.active--;
160
+ job.reject(error);
161
+ }
162
+ info.busy = false;
163
+
164
+ const idx = this.workers.indexOf(info);
165
+ if (idx !== -1) {
166
+ this.workers.splice(idx, 1);
167
+ // Replace the dead worker so the pool keeps its configured capacity.
168
+ if (!this.terminated) {
169
+ this.workers.push(this._spawnWorker(info.index));
170
+ }
171
+ }
172
+
173
+ this._dispatch();
174
+ }
175
+
176
+ /**
177
+ * Terminate all workers
178
+ */
179
+ async terminate() {
180
+ this.terminated = true;
181
+ const workers = this.workers.slice();
182
+ this.workers = [];
183
+ // Reject any tasks still queued so callers don't hang.
184
+ const pending = this.queue.splice(0, this.queue.length);
185
+ for (const job of pending) {
186
+ job.reject(new Error('WorkerPool has been terminated'));
187
+ }
188
+ this.active = 0;
189
+ await Promise.all(workers.map(({ worker }) => worker.terminate()));
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Process lockfile chunks in parallel
195
+ * @param {Object} lockfile - Lockfile to process
196
+ * @param {string} operation - Operation name (hash-upgrade, dedupe, migration, validation)
197
+ * @param {Object} options - Options
198
+ * @param {number} options.workerCount - Number of workers (default: CPU count - 1)
199
+ * @param {number} options.chunkSize - Packages per chunk (default: 5000)
200
+ * @param {Function} options.onProgress - Progress callback
201
+ * @param {Object} options.operationOptions - Options specific to operation
202
+ * @returns {Promise<Object>} Processed lockfile
203
+ */
204
+ export async function processInParallel(lockfile, operation, options = {}) {
205
+ const {
206
+ workerCount = Math.max(1, cpus().length - 1),
207
+ chunkSize = 5000,
208
+ onProgress = null,
209
+ operationOptions = {}
210
+ } = options;
211
+
212
+ // Only use parallel processing for large files
213
+ if (!isLargeLockfile(lockfile, 10)) {
214
+ // For smaller files, parallel overhead isn't worth it
215
+ throw new Error('Parallel processing is only recommended for large lockfiles (>10MB)');
216
+ }
217
+
218
+ // Migration between lockfile formats reconstructs the dependency tree from the
219
+ // ENTIRE package graph, so it cannot be split across chunks: each chunk would see
220
+ // only a slice of `packages`, and merging the results keeps only one chunk's
221
+ // reconstructed `dependencies` tree (silent data loss). Run migration on the whole
222
+ // lockfile via parallelMigrate() / migrateToVersion() instead.
223
+ if (operation === 'migration') {
224
+ throw new Error(
225
+ 'Migration is not chunk-parallelizable (tree reconstruction needs the whole graph); ' +
226
+ 'use migrateToVersion() on the whole lockfile'
227
+ );
228
+ }
229
+
230
+ // Chunk the lockfile
231
+ const chunks = chunkLockfile(lockfile, chunkSize);
232
+
233
+ if (chunks.length === 1 && operation !== 'validation') {
234
+ // Single chunk, no need for parallel processing. Validation is excluded: it
235
+ // must still run through the worker so it returns the merged
236
+ // `{ valid, errors, warnings, info }` shape rather than a raw lockfile chunk.
237
+ return chunks[0];
238
+ }
239
+
240
+ // Determine worker script based on operation
241
+ const workerScripts = {
242
+ 'hash-upgrade': path.join(__dirname, 'workers', 'hash-upgrade-worker.js'),
243
+ 'dedupe': path.join(__dirname, 'workers', 'dedupe-worker.js'),
244
+ 'migration': path.join(__dirname, 'workers', 'migration-worker.js'),
245
+ 'validation': path.join(__dirname, 'workers', 'validation-worker.js')
246
+ };
247
+
248
+ const workerScript = workerScripts[operation];
249
+ if (!workerScript) {
250
+ throw new Error(`Unknown operation: ${operation}`);
251
+ }
252
+
253
+ // Create worker pool
254
+ const pool = new WorkerPool(workerCount, workerScript);
255
+ pool.init();
256
+
257
+ try {
258
+ // Process chunks in parallel
259
+ const tasks = chunks.map((chunk, index) => ({
260
+ chunk,
261
+ operation,
262
+ options: operationOptions,
263
+ chunkIndex: index
264
+ }));
265
+
266
+ let completed = 0;
267
+ const results = await Promise.all(
268
+ tasks.map(async (task) => {
269
+ const response = await pool.execute(task);
270
+ completed++;
271
+
272
+ if (onProgress) {
273
+ onProgress({
274
+ current: completed,
275
+ total: tasks.length,
276
+ percentage: Math.round((completed / tasks.length) * 100),
277
+ stage: `Processing chunk ${completed}/${tasks.length}`
278
+ });
279
+ }
280
+
281
+ // Extract result from worker response
282
+ return response.result || response;
283
+ })
284
+ );
285
+
286
+ // Merge results with an operation-appropriate aggregator. Validation produces
287
+ // `{ valid, errors, warnings, info }` result objects, NOT lockfile chunks, so
288
+ // merging them as lockfiles (via mergeLockfileChunks) is nonsensical.
289
+ if (operation === 'validation') {
290
+ return mergeValidationResults(results);
291
+ }
292
+
293
+ // hash-upgrade / dedupe operate per-entry on the packages map, so merging the
294
+ // packages maps back together is correct.
295
+ const merged = mergeLockfileChunks(results);
296
+ return merged;
297
+ } finally {
298
+ await pool.terminate();
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Aggregate per-chunk validation results into a single validation report.
304
+ * @param {Array<Object>} results - Array of `{ valid, errors, warnings, info }`
305
+ * @returns {Object} Combined validation result
306
+ */
307
+ export function mergeValidationResults(results) {
308
+ const merged = { valid: true, errors: [], warnings: [], info: [] };
309
+
310
+ for (const result of results) {
311
+ if (!result || typeof result !== 'object') continue;
312
+ if (result.valid === false) merged.valid = false;
313
+ if (Array.isArray(result.errors)) merged.errors.push(...result.errors);
314
+ if (Array.isArray(result.warnings)) merged.warnings.push(...result.warnings);
315
+ if (Array.isArray(result.info)) merged.info.push(...result.info);
316
+ }
317
+
318
+ return merged;
319
+ }
320
+
321
+ /**
322
+ * Parallel hash upgrade
323
+ * @param {Object} lockfile - Lockfile to process
324
+ * @param {Object} options - Options
325
+ * @returns {Promise<Object>} Processed lockfile
326
+ */
327
+ export async function parallelUpgradeIntegrityHashes(lockfile, options = {}) {
328
+ return processInParallel(lockfile, 'hash-upgrade', {
329
+ ...options,
330
+ operationOptions: {
331
+ all: options.all || false
332
+ }
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Parallel deduplication
338
+ * @param {Object} lockfile - Lockfile to process
339
+ * @param {Object} options - Options
340
+ * @returns {Promise<Object>} Processed lockfile
341
+ */
342
+ export async function parallelDeduplicatePackages(lockfile, options = {}) {
343
+ return processInParallel(lockfile, 'dedupe', {
344
+ ...options,
345
+ operationOptions: {
346
+ keepLatest: options.keepLatest || false
347
+ }
348
+ });
349
+ }
350
+
351
+ /**
352
+ * Parallel migration
353
+ * @param {Object} lockfile - Lockfile to process
354
+ * @param {number} targetVersion - Target version
355
+ * @param {Object} options - Options
356
+ * @returns {Promise<Object>} Processed lockfile
357
+ */
358
+ // eslint-disable-next-line no-unused-vars
359
+ export async function parallelMigrate(lockfile, targetVersion, options = {}) {
360
+ // Migration is NOT chunk-parallelizable: reconstructing the dependency tree
361
+ // (e.g. v3 -> v2/v1) requires the whole package graph, and the old chunked
362
+ // implementation silently dropped every chunk's tree but the first, corrupting
363
+ // the output. Run the migration on the whole lockfile instead. It stays async
364
+ // for API compatibility (callers `await` it).
365
+ return migrateToVersion(lockfile, targetVersion);
366
+ }
367
+
368
+ export default {
369
+ WorkerPool,
370
+ processInParallel,
371
+ parallelUpgradeIntegrityHashes,
372
+ parallelDeduplicatePackages,
373
+ parallelMigrate
374
+ };
package/src/parser.js ADDED
@@ -0,0 +1,133 @@
1
+ // src/parser.js
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { createRequire } from 'module';
5
+ import { parseLockfile as parseLockfileFromFormat, stringifyLockfile } from './format-library.js';
6
+ import { parseNpmrc } from './npmrc-validator.js';
7
+ import { DEFAULT_REGISTRY } from './integrity.js';
8
+ import { BackupError } from './backup.js';
9
+ import { parseLockfileStreamSync } from './streaming-parser.js';
10
+
11
+ // `yaml` is the one runtime dependency, and it is pulled in LAZILY (and
12
+ // synchronously, via createRequire) only when a pnpm-lock.yaml is actually
13
+ // parsed — the npm JSON path never loads it, preserving the zero-dependency
14
+ // core for npm-only users.
15
+ const require = createRequire(import.meta.url);
16
+ let _yaml = null;
17
+ function loadYaml() {
18
+ if (!_yaml) _yaml = require('yaml');
19
+ return _yaml;
20
+ }
21
+
22
+ // A lockfile is pnpm's when it's a YAML file (pnpm-lock.yaml / *.yaml / *.yml).
23
+ function isPnpmLockfilePath(filePath) {
24
+ const base = path.basename(filePath).toLowerCase();
25
+ return base === 'pnpm-lock.yaml' || base.endsWith('.yaml') || base.endsWith('.yml');
26
+ }
27
+
28
+ /**
29
+ * Build the registry config for a pnpm lockfile from its sibling .npmrc — pnpm
30
+ * reads .npmrc ONLY for registry + auth, so this is the authoritative source for
31
+ * the per-package registry base (there is no `resolved` URL to parse).
32
+ * @param {string} filePath - Path to the pnpm lockfile
33
+ * @returns {{ registry: string, scopedRegistries: object }}
34
+ */
35
+ function buildPnpmRegistryConfig(filePath) {
36
+ const npmrcPath = path.join(path.dirname(filePath), '.npmrc');
37
+ let registry = DEFAULT_REGISTRY;
38
+ const scopedRegistries = {};
39
+ if (fs.existsSync(npmrcPath)) {
40
+ try {
41
+ for (const { key, value } of parseNpmrc(fs.readFileSync(npmrcPath, 'utf8'))) {
42
+ if (key === 'registry') registry = value;
43
+ else if (key && key.startsWith('@') && key.endsWith(':registry')) {
44
+ scopedRegistries[key.slice(0, key.indexOf(':'))] = value;
45
+ }
46
+ }
47
+ } catch {
48
+ // A malformed .npmrc shouldn't break lockfile parsing — fall back to defaults.
49
+ }
50
+ }
51
+ return { registry, scopedRegistries };
52
+ }
53
+
54
+ /**
55
+ * Parse a pnpm-lock.yaml into an object stamped with a non-enumerable
56
+ * `__npmCheckMeta` ({ flavor, lockfileVersion, registry, scopedRegistries }).
57
+ * The meta is non-enumerable so it never leaks into JSON output or stringify.
58
+ * @param {string} filePath - Path to the pnpm lockfile
59
+ * @returns {object} Parsed pnpm lockfile
60
+ */
61
+ function parsePnpmLockfile(filePath) {
62
+ const content = fs.readFileSync(filePath, 'utf8');
63
+ const data = loadYaml().parse(content);
64
+ if (!data || typeof data !== 'object') {
65
+ throw new Error(`Invalid pnpm-lock.yaml: ${filePath}`);
66
+ }
67
+ Object.defineProperty(data, '__npmCheckMeta', {
68
+ value: {
69
+ flavor: 'pnpm',
70
+ lockfileVersion: data.lockfileVersion,
71
+ ...buildPnpmRegistryConfig(filePath)
72
+ },
73
+ enumerable: false,
74
+ writable: true,
75
+ configurable: true
76
+ });
77
+ return data;
78
+ }
79
+
80
+ /**
81
+ * Parse a lockfile from file path. Dispatches by flavor: pnpm-lock.yaml is parsed
82
+ * as YAML (with the lazy `yaml` dependency); npm package-lock.json is parsed as
83
+ * JSON, using the streaming parser for large files.
84
+ * @param {string} filePath - Path to lockfile
85
+ * @param {Object} options - Options
86
+ * @param {boolean} options.useStreaming - Force streaming parser (default: auto-detect, npm only)
87
+ * @param {number} options.streamingThreshold - File size threshold in bytes for streaming (default: 10MB)
88
+ * @param {Function} options.onProgress - Progress callback for streaming
89
+ * @returns {Object} Parsed lockfile object
90
+ */
91
+ export function parseLockfile(filePath, options = {}) {
92
+ // pnpm lockfiles are YAML and rarely huge — parse directly, no streaming.
93
+ if (isPnpmLockfilePath(filePath)) {
94
+ return parsePnpmLockfile(filePath);
95
+ }
96
+
97
+ const {
98
+ useStreaming = null, // null = auto-detect
99
+ streamingThreshold = 10 * 1024 * 1024, // 10MB
100
+ onProgress = null
101
+ } = options;
102
+
103
+ // Check file size
104
+ let shouldUseStreaming = useStreaming;
105
+ if (shouldUseStreaming === null) {
106
+ try {
107
+ const stats = fs.statSync(filePath);
108
+ shouldUseStreaming = stats.size >= streamingThreshold;
109
+ } catch {
110
+ // If we can't get stats, fall back to standard parsing
111
+ shouldUseStreaming = false;
112
+ }
113
+ }
114
+
115
+ if (shouldUseStreaming) {
116
+ return parseLockfileStreamSync(filePath, {
117
+ streamingThreshold,
118
+ onProgress
119
+ });
120
+ }
121
+
122
+ // Standard parsing for smaller files
123
+ const content = fs.readFileSync(filePath, 'utf8');
124
+ return parseLockfileFromFormat(content);
125
+ }
126
+
127
+ export function serializeLockfile(filePath, data, overwrite = false) {
128
+ if (!overwrite && fs.existsSync(filePath)) {
129
+ throw new BackupError(`File ${filePath} already exists`);
130
+ }
131
+ const content = stringifyLockfile(data);
132
+ fs.writeFileSync(filePath, content, 'utf8');
133
+ }