flatlock 1.2.0 → 1.4.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/README.md CHANGED
@@ -38,6 +38,29 @@ for await (const pkg of flatlock.fromPath('./package-lock.json')) {
38
38
  - **yarn classic**: yarn.lock v1
39
39
  - **yarn berry**: yarn.lock v2+
40
40
 
41
+ ## CLI Tools
42
+
43
+ Three command-line tools are included for common workflows:
44
+
45
+ ```bash
46
+ # Extract dependencies from any lockfile
47
+ npx flatlock package-lock.json --specs --json
48
+
49
+ # Verify parser accuracy against official tools
50
+ npx flatlock-cmp --dir ./fixtures --glob "**/*lock*"
51
+
52
+ # Check registry availability of all dependencies
53
+ npx flatcover package-lock.json --summary
54
+ ```
55
+
56
+ | Command | Purpose |
57
+ |---------|---------|
58
+ | `flatlock` | Extract dependencies as plain text, JSON, or NDJSON |
59
+ | `flatlock-cmp` | Compare output against @npmcli/arborist, @yarnpkg/lockfile, @pnpm/lockfile.fs |
60
+ | `flatcover` | Verify packages exist on registry (useful for private registry migrations) |
61
+
62
+ Run any command with `--help` for full options.
63
+
41
64
  ## API
42
65
 
43
66
  ```javascript
@@ -130,8 +153,9 @@ const lockfile = await FlatlockSet.fromPath('./package-lock.json');
130
153
  const pkg = JSON.parse(await readFile('./packages/api/package.json', 'utf8'));
131
154
 
132
155
  // Get only dependencies reachable from this workspace
133
- const subset = lockfile.dependenciesOf(pkg, {
156
+ const subset = await lockfile.dependenciesOf(pkg, {
134
157
  workspacePath: 'packages/api', // for correct resolution in monorepos
158
+ repoDir: '.', // reads workspace package.json files for accurate traversal
135
159
  dev: false, // exclude devDependencies
136
160
  optional: true, // include optionalDependencies
137
161
  peer: false // exclude peerDependencies
@@ -142,6 +166,23 @@ console.log(`${pkg.name} has ${subset.size} production dependencies`);
142
166
 
143
167
  **Note:** Sets created via `union()`, `intersection()`, or `difference()` cannot use `dependenciesOf()` because they lack the raw lockfile data needed for traversal. Check `set.canTraverse` before calling.
144
168
 
169
+ ## Compare API
170
+
171
+ Verify flatlock output against official package manager parsers:
172
+
173
+ ```javascript
174
+ // Both import styles work - use whichever you prefer
175
+ import { compare } from 'flatlock';
176
+ import { compare } from 'flatlock/compare';
177
+
178
+ const result = await compare('./package-lock.json');
179
+ console.log(result.equinumerous); // true if counts match
180
+ console.log(result.flatlockCount); // packages found by flatlock
181
+ console.log(result.comparisonCount); // packages found by official parser
182
+ ```
183
+
184
+ The dedicated `flatlock/compare` entry point exists for tools that want explicit imports, but the main export works identically.
185
+
145
186
  ## License
146
187
 
147
188
  Apache-2.0
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * flatcover - Check lockfile package coverage against a registry
4
+ *
5
+ * Checks if packages from a lockfile exist in a npm registry.
6
+ * Outputs CSV by default: package,version,present
7
+ *
8
+ * Usage:
9
+ * flatcover <lockfile> --cover # check against npmjs.org
10
+ * flatcover <lockfile> --cover --registry <url> # custom registry
11
+ * flatcover <lockfile> --cover --registry <url> --auth u:p # with basic auth
12
+ * flatcover <lockfile> --cover --ndjson # streaming output
13
+ */
14
+
15
+ import { parseArgs } from 'node:util';
16
+ import { readFileSync } from 'node:fs';
17
+ import { createReadStream } from 'node:fs';
18
+ import { createInterface } from 'node:readline';
19
+ import { dirname, join } from 'node:path';
20
+ import { Pool, RetryAgent } from 'undici';
21
+ import { FlatlockSet } from '../src/set.js';
22
+
23
+ const { values, positionals } = parseArgs({
24
+ options: {
25
+ workspace: { type: 'string', short: 'w' },
26
+ list: { type: 'string', short: 'l' },
27
+ dev: { type: 'boolean', default: false },
28
+ peer: { type: 'boolean', default: true },
29
+ specs: { type: 'boolean', short: 's', default: false },
30
+ json: { type: 'boolean', default: false },
31
+ ndjson: { type: 'boolean', default: false },
32
+ full: { type: 'boolean', default: false },
33
+ cover: { type: 'boolean', default: false },
34
+ registry: { type: 'string', default: 'https://registry.npmjs.org' },
35
+ auth: { type: 'string' },
36
+ token: { type: 'string' },
37
+ concurrency: { type: 'string', default: '20' },
38
+ progress: { type: 'boolean', default: false },
39
+ summary: { type: 'boolean', default: false },
40
+ help: { type: 'boolean', short: 'h' }
41
+ },
42
+ allowPositionals: true
43
+ });
44
+
45
+ // Check if stdin input is requested via '-' positional argument (Unix convention)
46
+ const useStdin = positionals[0] === '-';
47
+
48
+ // Determine if we have a valid input source
49
+ const hasInputSource = positionals.length > 0 || values.list;
50
+
51
+ if (values.help || !hasInputSource) {
52
+ console.log(`flatcover - Check lockfile package coverage against a registry
53
+
54
+ Usage:
55
+ flatcover <lockfile> --cover
56
+ flatcover --list packages.json --cover
57
+ cat packages.ndjson | flatcover - --cover
58
+ flatcover <lockfile> --cover --registry <url> --auth user:pass
59
+
60
+ Input sources (mutually exclusive):
61
+ <lockfile> Parse lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock)
62
+ -l, --list <file> Read JSON array of {name, version} objects from file
63
+ - Read NDJSON {name, version} objects from stdin (one per line)
64
+
65
+ Options:
66
+ -w, --workspace <path> Workspace path within monorepo (lockfile mode only)
67
+ -s, --specs Include version (name@version or {name,version})
68
+ --json Output as JSON array
69
+ --ndjson Output as newline-delimited JSON (streaming)
70
+ --full Include all metadata (integrity, resolved)
71
+ --dev Include dev dependencies (default: false)
72
+ --peer Include peer dependencies (default: true)
73
+ -h, --help Show this help
74
+
75
+ Coverage options:
76
+ --cover Enable registry coverage checking
77
+ --registry <url> Registry URL (default: https://registry.npmjs.org)
78
+ --auth <user:pass> Basic authentication credentials
79
+ --token <token> Bearer token for authentication
80
+ --concurrency <n> Concurrent requests (default: 20)
81
+ --progress Show progress on stderr
82
+ --summary Show coverage summary on stderr
83
+
84
+ Output formats (with --cover):
85
+ (default) CSV: package,version,present
86
+ --full CSV: package,version,present,integrity,resolved
87
+ --json [{"name":"...","version":"...","present":true}, ...]
88
+ --full --json Adds "integrity" and "resolved" fields to JSON
89
+ --ndjson {"name":"...","version":"...","present":true} per line
90
+
91
+ Examples:
92
+ # From lockfile
93
+ flatcover package-lock.json --cover
94
+ flatcover package-lock.json --cover --full --json
95
+
96
+ # From JSON list file
97
+ flatcover --list packages.json --cover --summary
98
+ echo '[{"name":"lodash","version":"4.17.21"}]' > pkgs.json && flatcover -l pkgs.json --cover
99
+
100
+ # From stdin (NDJSON) - use '-' to read from stdin
101
+ echo '{"name":"lodash","version":"4.17.21"}' | flatcover - --cover
102
+ cat packages.ndjson | flatcover - --cover --json
103
+
104
+ # With custom registry
105
+ flatcover package-lock.json --cover --registry https://npm.pkg.github.com --token ghp_xxx
106
+ flatcover pnpm-lock.yaml --cover --auth admin:secret --ndjson`);
107
+ process.exit(values.help ? 0 : 1);
108
+ }
109
+
110
+ if (values.json && values.ndjson) {
111
+ console.error('Error: --json and --ndjson are mutually exclusive');
112
+ process.exit(1);
113
+ }
114
+
115
+ if (values.auth && values.token) {
116
+ console.error('Error: --auth and --token are mutually exclusive');
117
+ process.exit(1);
118
+ }
119
+
120
+ // Validate mutually exclusive input sources
121
+ // Note: useStdin means positionals[0] === '-', so it's already counted in positionals.length
122
+ if (positionals.length > 0 && values.list) {
123
+ console.error('Error: Cannot use both lockfile/stdin and --list');
124
+ process.exit(1);
125
+ }
126
+
127
+ // --workspace only works with lockfile input (not stdin or --list)
128
+ if (values.workspace && (useStdin || values.list || !positionals.length)) {
129
+ console.error('Error: --workspace can only be used with lockfile input');
130
+ process.exit(1);
131
+ }
132
+
133
+ // --full implies --specs
134
+ if (values.full) {
135
+ values.specs = true;
136
+ }
137
+
138
+ // --cover implies --specs (need versions to check)
139
+ if (values.cover) {
140
+ values.specs = true;
141
+ }
142
+
143
+ const lockfilePath = positionals[0];
144
+ const concurrency = Math.max(1, Math.min(50, Number.parseInt(values.concurrency, 10) || 20));
145
+
146
+ /**
147
+ * Read packages from a JSON list file
148
+ * @param {string} filePath - Path to JSON file containing [{name, version}, ...]
149
+ * @returns {Array<{ name: string, version: string }>}
150
+ */
151
+ function readJsonList(filePath) {
152
+ const content = readFileSync(filePath, 'utf8');
153
+ const data = JSON.parse(content);
154
+
155
+ if (!Array.isArray(data)) {
156
+ throw new Error('--list file must contain a JSON array');
157
+ }
158
+
159
+ const packages = [];
160
+ for (const item of data) {
161
+ if (!item.name || !item.version) {
162
+ throw new Error('Each item in --list must have "name" and "version" fields');
163
+ }
164
+ packages.push({
165
+ name: item.name,
166
+ version: item.version,
167
+ integrity: item.integrity,
168
+ resolved: item.resolved
169
+ });
170
+ }
171
+
172
+ return packages;
173
+ }
174
+
175
+ /**
176
+ * Read packages from stdin as NDJSON
177
+ * @returns {Promise<Array<{ name: string, version: string }>>}
178
+ */
179
+ async function readStdinNdjson() {
180
+ const packages = [];
181
+
182
+ const rl = createInterface({
183
+ input: process.stdin,
184
+ crlfDelay: Infinity
185
+ });
186
+
187
+ for await (const line of rl) {
188
+ const trimmed = line.trim();
189
+ if (!trimmed) continue;
190
+
191
+ try {
192
+ const item = JSON.parse(trimmed);
193
+ if (!item.name || !item.version) {
194
+ throw new Error('Each line must have "name" and "version" fields');
195
+ }
196
+ packages.push({
197
+ name: item.name,
198
+ version: item.version,
199
+ integrity: item.integrity,
200
+ resolved: item.resolved
201
+ });
202
+ } catch (err) {
203
+ throw new Error(`Invalid JSON on stdin: ${err.message}`);
204
+ }
205
+ }
206
+
207
+ return packages;
208
+ }
209
+
210
+ /**
211
+ * Encode package name for URL (handle scoped packages)
212
+ * @param {string} name - Package name like @babel/core
213
+ * @returns {string} URL-safe name like @babel%2fcore
214
+ */
215
+ function encodePackageName(name) {
216
+ // Scoped packages: @scope/name -> @scope%2fname
217
+ return name.replace('/', '%2f');
218
+ }
219
+
220
+ /**
221
+ * Create undici client with retry support
222
+ * @param {string} registryUrl
223
+ * @param {{ auth?: string, token?: string }} options
224
+ * @returns {{ client: RetryAgent, headers: Record<string, string>, baseUrl: URL }}
225
+ */
226
+ function createClient(registryUrl, { auth, token }) {
227
+ const baseUrl = new URL(registryUrl);
228
+
229
+ const pool = new Pool(baseUrl.origin, {
230
+ connections: Math.min(concurrency, 50),
231
+ pipelining: 1, // Conservative - most proxies don't support HTTP pipelining
232
+ keepAliveTimeout: 30000,
233
+ keepAliveMaxTimeout: 60000
234
+ });
235
+
236
+ const client = new RetryAgent(pool, {
237
+ maxRetries: 3,
238
+ minTimeout: 1000,
239
+ maxTimeout: 10000,
240
+ timeoutFactor: 2,
241
+ retryAfter: true, // Respect Retry-After header
242
+ statusCodes: [429, 500, 502, 503, 504],
243
+ errorCodes: [
244
+ 'ECONNRESET',
245
+ 'ECONNREFUSED',
246
+ 'ENOTFOUND',
247
+ 'ENETUNREACH',
248
+ 'ETIMEDOUT',
249
+ 'UND_ERR_SOCKET'
250
+ ]
251
+ });
252
+
253
+ const headers = {
254
+ Accept: 'application/json',
255
+ 'User-Agent': 'flatcover/1.0.0'
256
+ };
257
+
258
+ if (auth) {
259
+ headers.Authorization = `Basic ${Buffer.from(auth).toString('base64')}`;
260
+ } else if (token) {
261
+ headers.Authorization = `Bearer ${token}`;
262
+ }
263
+
264
+ return { client, headers, baseUrl };
265
+ }
266
+
267
+ /**
268
+ * Check coverage for all dependencies
269
+ * @param {Array<{ name: string, version: string, integrity?: string, resolved?: string }>} deps
270
+ * @param {{ registry: string, auth?: string, token?: string, progress: boolean }} options
271
+ * @returns {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }>}
272
+ */
273
+ async function* checkCoverage(deps, { registry, auth, token, progress }) {
274
+ const { client, headers, baseUrl } = createClient(registry, { auth, token });
275
+
276
+ // Group by package name to avoid duplicate requests
277
+ // Store full dep info (including integrity/resolved) keyed by version
278
+ /** @type {Map<string, Map<string, { name: string, version: string, integrity?: string, resolved?: string }>>} */
279
+ const byPackage = new Map();
280
+ for (const dep of deps) {
281
+ if (!byPackage.has(dep.name)) {
282
+ byPackage.set(dep.name, new Map());
283
+ }
284
+ byPackage.get(dep.name).set(dep.version, dep);
285
+ }
286
+
287
+ const packages = [...byPackage.entries()];
288
+ let completed = 0;
289
+ const total = packages.length;
290
+
291
+ // Process in batches for bounded concurrency
292
+ for (let i = 0; i < packages.length; i += concurrency) {
293
+ const batch = packages.slice(i, i + concurrency);
294
+
295
+ const results = await Promise.all(
296
+ batch.map(async ([name, versionMap]) => {
297
+ const encodedName = encodePackageName(name);
298
+ const basePath = baseUrl.pathname.replace(/\/$/, '');
299
+ const path = `${basePath}/${encodedName}`;
300
+
301
+ try {
302
+ const response = await client.request({
303
+ method: 'GET',
304
+ path,
305
+ headers
306
+ });
307
+
308
+ const chunks = [];
309
+ for await (const chunk of response.body) {
310
+ chunks.push(chunk);
311
+ }
312
+
313
+ if (response.statusCode === 401 || response.statusCode === 403) {
314
+ console.error(`Error: Authentication failed for ${name} (${response.statusCode})`);
315
+ process.exit(1);
316
+ }
317
+
318
+ let packumentVersions = null;
319
+ if (response.statusCode === 200) {
320
+ const body = Buffer.concat(chunks).toString('utf8');
321
+ const packument = JSON.parse(body);
322
+ packumentVersions = packument.versions || {};
323
+ }
324
+
325
+ // Check each version, preserving integrity/resolved from original dep
326
+ const versionResults = [];
327
+ for (const [version, dep] of versionMap) {
328
+ const present = packumentVersions ? !!packumentVersions[version] : false;
329
+ const result = { name, version, present };
330
+ if (dep.integrity) result.integrity = dep.integrity;
331
+ if (dep.resolved) result.resolved = dep.resolved;
332
+ versionResults.push(result);
333
+ }
334
+ return versionResults;
335
+ } catch (err) {
336
+ // Return error for all versions of this package
337
+ return [...versionMap.values()].map(dep => {
338
+ const result = {
339
+ name: dep.name,
340
+ version: dep.version,
341
+ present: false,
342
+ error: err.message
343
+ };
344
+ if (dep.integrity) result.integrity = dep.integrity;
345
+ if (dep.resolved) result.resolved = dep.resolved;
346
+ return result;
347
+ });
348
+ }
349
+ })
350
+ );
351
+
352
+ // Flatten and yield results
353
+ for (const packageResults of results) {
354
+ for (const result of packageResults) {
355
+ yield result;
356
+ }
357
+ completed++;
358
+ if (progress) {
359
+ process.stderr.write(`\r Checking: ${completed}/${total} packages`);
360
+ }
361
+ }
362
+ }
363
+
364
+ if (progress) {
365
+ process.stderr.write('\n');
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Format a single dependency based on output options
371
+ * @param {{ name: string, version: string, integrity?: string, resolved?: string }} dep
372
+ * @param {{ specs: boolean, full: boolean }} options
373
+ * @returns {string | object}
374
+ */
375
+ function formatDep(dep, { specs, full }) {
376
+ if (full) {
377
+ const obj = { name: dep.name, version: dep.version };
378
+ if (dep.integrity) obj.integrity = dep.integrity;
379
+ if (dep.resolved) obj.resolved = dep.resolved;
380
+ return obj;
381
+ }
382
+ if (specs) {
383
+ return { name: dep.name, version: dep.version };
384
+ }
385
+ return dep.name;
386
+ }
387
+
388
+ /**
389
+ * Output dependencies in the requested format (non-cover mode)
390
+ * @param {Iterable<{ name: string, version: string, integrity?: string, resolved?: string }>} deps
391
+ * @param {{ specs: boolean, json: boolean, ndjson: boolean, full: boolean }} options
392
+ */
393
+ function outputDeps(deps, { specs, json, ndjson, full }) {
394
+ const sorted = [...deps].sort((a, b) => a.name.localeCompare(b.name));
395
+
396
+ if (json) {
397
+ const data = sorted.map(d => formatDep(d, { specs, full }));
398
+ console.log(JSON.stringify(data, null, 2));
399
+ return;
400
+ }
401
+
402
+ if (ndjson) {
403
+ for (const d of sorted) {
404
+ console.log(JSON.stringify(formatDep(d, { specs, full })));
405
+ }
406
+ return;
407
+ }
408
+
409
+ // Plain text
410
+ for (const d of sorted) {
411
+ console.log(specs ? `${d.name}@${d.version}` : d.name);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Output coverage results
417
+ * @param {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }>} results
418
+ * @param {{ json: boolean, ndjson: boolean, summary: boolean, full: boolean }} options
419
+ */
420
+ async function outputCoverage(results, { json, ndjson, summary, full }) {
421
+ const all = [];
422
+ let presentCount = 0;
423
+ let missingCount = 0;
424
+
425
+ for await (const result of results) {
426
+ if (result.present) {
427
+ presentCount++;
428
+ } else {
429
+ missingCount++;
430
+ }
431
+
432
+ if (ndjson) {
433
+ // Stream immediately
434
+ const obj = { name: result.name, version: result.version, present: result.present };
435
+ if (full && result.integrity) obj.integrity = result.integrity;
436
+ if (full && result.resolved) obj.resolved = result.resolved;
437
+ console.log(JSON.stringify(obj));
438
+ } else {
439
+ all.push(result);
440
+ }
441
+ }
442
+
443
+ if (!ndjson) {
444
+ // Sort by name, then version
445
+ all.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
446
+
447
+ if (json) {
448
+ const data = all.map(r => {
449
+ const obj = { name: r.name, version: r.version, present: r.present };
450
+ if (full && r.integrity) obj.integrity = r.integrity;
451
+ if (full && r.resolved) obj.resolved = r.resolved;
452
+ return obj;
453
+ });
454
+ console.log(JSON.stringify(data, null, 2));
455
+ } else {
456
+ // CSV output
457
+ if (full) {
458
+ console.log('package,version,present,integrity,resolved');
459
+ for (const r of all) {
460
+ console.log(`${r.name},${r.version},${r.present},${r.integrity || ''},${r.resolved || ''}`);
461
+ }
462
+ } else {
463
+ console.log('package,version,present');
464
+ for (const r of all) {
465
+ console.log(`${r.name},${r.version},${r.present}`);
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ if (summary) {
472
+ const total = presentCount + missingCount;
473
+ const percentage = total > 0 ? ((presentCount / total) * 100).toFixed(1) : 0;
474
+ process.stderr.write(`\nCoverage: ${presentCount}/${total} (${percentage}%) packages present\n`);
475
+ if (missingCount > 0) {
476
+ process.stderr.write(`Missing: ${missingCount} packages\n`);
477
+ }
478
+ }
479
+ }
480
+
481
+ try {
482
+ let deps;
483
+
484
+ // Determine input source and load dependencies
485
+ if (useStdin) {
486
+ // Read from stdin (NDJSON)
487
+ deps = await readStdinNdjson();
488
+ if (deps.length === 0) {
489
+ console.error('Error: No packages read from stdin');
490
+ process.exit(1);
491
+ }
492
+ } else if (values.list) {
493
+ // Read from JSON list file
494
+ deps = readJsonList(values.list);
495
+ if (deps.length === 0) {
496
+ console.error('Error: No packages found in --list file');
497
+ process.exit(1);
498
+ }
499
+ } else {
500
+ // Read from lockfile (existing behavior)
501
+ const lockfile = await FlatlockSet.fromPath(lockfilePath);
502
+
503
+ if (values.workspace) {
504
+ const repoDir = dirname(lockfilePath);
505
+ const workspacePkgPath = join(repoDir, values.workspace, 'package.json');
506
+ const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, 'utf8'));
507
+
508
+ deps = await lockfile.dependenciesOf(workspacePkg, {
509
+ workspacePath: values.workspace,
510
+ repoDir,
511
+ dev: values.dev,
512
+ peer: values.peer
513
+ });
514
+ } else {
515
+ deps = lockfile;
516
+ }
517
+ }
518
+
519
+ if (values.cover) {
520
+ // Coverage mode
521
+ const sorted = [...deps].sort((a, b) => a.name.localeCompare(b.name));
522
+ const results = checkCoverage(sorted, {
523
+ registry: values.registry,
524
+ auth: values.auth,
525
+ token: values.token,
526
+ progress: values.progress
527
+ });
528
+
529
+ await outputCoverage(results, {
530
+ json: values.json,
531
+ ndjson: values.ndjson,
532
+ summary: values.summary,
533
+ full: values.full
534
+ });
535
+ } else {
536
+ // Standard flatlock mode
537
+ outputDeps(deps, {
538
+ specs: values.specs,
539
+ json: values.json,
540
+ ndjson: values.ndjson,
541
+ full: values.full
542
+ });
543
+ }
544
+ } catch (err) {
545
+ console.error(`Error: ${err.message}`);
546
+ process.exit(1);
547
+ }
@@ -187,7 +187,7 @@ Examples:
187
187
  const wsNote = result.workspaceCount > 0 ? ` (${result.workspaceCount} workspaces excluded)` : '';
188
188
  console.log(`✓ ${result.path}${wsNote}`);
189
189
  console.log(` count: flatlock=${result.flatlockCount} ${result.source}=${result.comparisonCount}`);
190
- console.log(` sets: equinumerous`);
190
+ console.log(` sets: equinumerous\n`);
191
191
  }
192
192
  } else {
193
193
  // Determine if this is a "superset" (flatlock found more, expected for pnpm)
@@ -203,7 +203,7 @@ Examples:
203
203
  console.log(`⊃ ${result.path}${wsNote}`);
204
204
  console.log(` count: flatlock=${result.flatlockCount} ${result.source}=${result.comparisonCount}`);
205
205
  console.log(` sets: SUPERSET (+${result.onlyInFlatlock.length} reachable deps)`);
206
- console.log(` note: flatlock's reachability analysis found transitive deps pnpm omits`);
206
+ console.log(` note: flatlock's reachability analysis found transitive deps pnpm omits\n`);
207
207
  }
208
208
  } else {
209
209
  mismatchCount++;