flatlock 1.0.1 → 1.2.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.
Files changed (55) hide show
  1. package/README.md +55 -2
  2. package/bin/flatlock-cmp.js +109 -356
  3. package/dist/compare.d.ts +85 -0
  4. package/dist/compare.d.ts.map +1 -0
  5. package/dist/detect.d.ts +33 -0
  6. package/dist/detect.d.ts.map +1 -0
  7. package/dist/index.d.ts +60 -20
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/parsers/index.d.ts +5 -0
  10. package/dist/parsers/index.d.ts.map +1 -0
  11. package/dist/parsers/npm.d.ts +109 -0
  12. package/dist/parsers/npm.d.ts.map +1 -0
  13. package/dist/parsers/pnpm/detect.d.ts +136 -0
  14. package/dist/parsers/pnpm/detect.d.ts.map +1 -0
  15. package/dist/parsers/pnpm/index.d.ts +120 -0
  16. package/dist/parsers/pnpm/index.d.ts.map +1 -0
  17. package/dist/parsers/pnpm/internal.d.ts +5 -0
  18. package/dist/parsers/pnpm/internal.d.ts.map +1 -0
  19. package/dist/parsers/pnpm/shrinkwrap.d.ts +129 -0
  20. package/dist/parsers/pnpm/shrinkwrap.d.ts.map +1 -0
  21. package/dist/parsers/pnpm/v5.d.ts +139 -0
  22. package/dist/parsers/pnpm/v5.d.ts.map +1 -0
  23. package/dist/parsers/pnpm/v6plus.d.ts +212 -0
  24. package/dist/parsers/pnpm/v6plus.d.ts.map +1 -0
  25. package/dist/parsers/pnpm.d.ts +2 -0
  26. package/dist/parsers/pnpm.d.ts.map +1 -0
  27. package/dist/parsers/types.d.ts +23 -0
  28. package/dist/parsers/types.d.ts.map +1 -0
  29. package/dist/parsers/yarn-berry.d.ts +154 -0
  30. package/dist/parsers/yarn-berry.d.ts.map +1 -0
  31. package/dist/parsers/yarn-classic.d.ts +110 -0
  32. package/dist/parsers/yarn-classic.d.ts.map +1 -0
  33. package/dist/result.d.ts +12 -0
  34. package/dist/result.d.ts.map +1 -0
  35. package/dist/set.d.ts +189 -0
  36. package/dist/set.d.ts.map +1 -0
  37. package/package.json +18 -7
  38. package/src/compare.js +620 -0
  39. package/src/detect.js +8 -7
  40. package/src/index.js +33 -15
  41. package/src/parsers/index.js +12 -4
  42. package/src/parsers/npm.js +70 -23
  43. package/src/parsers/pnpm/detect.js +198 -0
  44. package/src/parsers/pnpm/index.js +289 -0
  45. package/src/parsers/pnpm/internal.js +41 -0
  46. package/src/parsers/pnpm/shrinkwrap.js +241 -0
  47. package/src/parsers/pnpm/v5.js +225 -0
  48. package/src/parsers/pnpm/v6plus.js +290 -0
  49. package/src/parsers/pnpm.js +12 -77
  50. package/src/parsers/types.js +10 -0
  51. package/src/parsers/yarn-berry.js +187 -38
  52. package/src/parsers/yarn-classic.js +85 -24
  53. package/src/result.js +2 -2
  54. package/src/set.js +618 -0
  55. package/src/types.d.ts +54 -0
package/README.md CHANGED
@@ -4,14 +4,14 @@ The Matlock of lockfile parsers - cuts through the complexity to get just the fa
4
4
 
5
5
  ## What makes `flatlock` different?
6
6
 
7
- ![matlockish](https://github.com/indexzero/flatlock/raw/main/doc/matlockish.png)
7
+ ![matlockish](https://github.com/indexzero/flatlock/raw/main/doc/img/matlockish.png)
8
8
 
9
9
  Most lockfile parsers (like `@npmcli/arborist` or `snyk-nodejs-lockfile-parser`) build the full dependency graph with edges representing relationships between packages. This is necessary for dependency resolution but overkill for many use cases.
10
10
 
11
11
  **flatlock** takes a different approach: it extracts a flat stream of packages from any lockfile format. No trees, no graphs, no edges - just packages.
12
12
 
13
13
  ```javascript
14
- import * as `flatlock`from 'flatlock';
14
+ import * as flatlock from 'flatlock';
15
15
 
16
16
  // Stream packages from any lockfile
17
17
  for await (const pkg of flatlock.fromPath('./package-lock.json')) {
@@ -89,6 +89,59 @@ Each yielded package has:
89
89
  }
90
90
  ```
91
91
 
92
+ ## FlatlockSet
93
+
94
+ For more advanced use cases, `FlatlockSet` provides Set-like operations on lockfile dependencies:
95
+
96
+ ```javascript
97
+ import { FlatlockSet } from 'flatlock';
98
+
99
+ // Create from lockfile
100
+ const set = await FlatlockSet.fromPath('./package-lock.json');
101
+ console.log(set.size); // 1234
102
+ console.log(set.has('lodash@4.17.21')); // true
103
+
104
+ // Set operations (immutable - return new sets)
105
+ const other = await FlatlockSet.fromPath('./other-lock.json');
106
+ const common = set.intersection(other); // packages in both
107
+ const added = other.difference(set); // packages only in other
108
+ const all = set.union(other); // packages in either
109
+
110
+ // Predicates
111
+ set.isSubsetOf(other); // true if all packages in set are in other
112
+ set.isSupersetOf(other); // true if set contains all packages in other
113
+ set.isDisjointFrom(other); // true if no packages in common
114
+
115
+ // Iterate like a Set
116
+ for (const dep of set) {
117
+ console.log(dep.name, dep.version);
118
+ }
119
+ ```
120
+
121
+ ### Workspace-Specific SBOMs
122
+
123
+ For monorepos, use `dependenciesOf()` to get only the dependencies of a specific workspace:
124
+
125
+ ```javascript
126
+ import { readFile } from 'node:fs/promises';
127
+ import { FlatlockSet } from 'flatlock';
128
+
129
+ const lockfile = await FlatlockSet.fromPath('./package-lock.json');
130
+ const pkg = JSON.parse(await readFile('./packages/api/package.json', 'utf8'));
131
+
132
+ // Get only dependencies reachable from this workspace
133
+ const subset = lockfile.dependenciesOf(pkg, {
134
+ workspacePath: 'packages/api', // for correct resolution in monorepos
135
+ dev: false, // exclude devDependencies
136
+ optional: true, // include optionalDependencies
137
+ peer: false // exclude peerDependencies
138
+ });
139
+
140
+ console.log(`${pkg.name} has ${subset.size} production dependencies`);
141
+ ```
142
+
143
+ **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
+
92
145
  ## License
93
146
 
94
147
  Apache-2.0
@@ -1,277 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFile, readdir, stat, mkdir, writeFile, rm } from 'node:fs/promises';
4
- import { join, dirname } from 'node:path';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
5
  import { parseArgs } from 'node:util';
6
- import { fileURLToPath } from 'node:url';
7
- import crypto from 'node:crypto';
8
6
  import * as flatlock from '../src/index.js';
9
7
 
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
-
12
- // Unique run ID for this script execution (7 char hash of timestamp)
13
- const RUN_ID = Date.now().toString(36).slice(-7);
14
- const TMP_BASE = join(__dirname, 'tmp', RUN_ID);
15
- let tmpDirCreated = false;
16
-
17
- // Comparison parsers (lazy loaded)
18
- let Arborist, yarnLockfile, parseSyml, yaml;
19
-
20
- async function loadArborist() {
21
- if (!Arborist) {
22
- const mod = await import('@npmcli/arborist');
23
- Arborist = mod.default;
24
- }
25
- return Arborist;
26
- }
27
-
28
- async function loadYarnClassic() {
29
- if (!yarnLockfile) {
30
- const mod = await import('@yarnpkg/lockfile');
31
- yarnLockfile = mod.default || mod;
32
- }
33
- return yarnLockfile;
34
- }
35
-
36
- async function loadYarnBerry() {
37
- if (!parseSyml) {
38
- const mod = await import('@yarnpkg/parsers');
39
- parseSyml = mod.parseSyml;
40
- }
41
- return parseSyml;
42
- }
43
-
44
- async function loadYaml() {
45
- if (!yaml) {
46
- const mod = await import('js-yaml');
47
- yaml = mod.default;
48
- }
49
- return yaml;
50
- }
51
-
52
- /**
53
- * Ensure the temp directory for this run exists
54
- */
55
- async function ensureTmpDir() {
56
- if (!tmpDirCreated) {
57
- await mkdir(TMP_BASE, { recursive: true });
58
- tmpDirCreated = true;
59
- }
60
- }
61
-
62
- /**
63
- * Cleanup the temp directory for this run
64
- */
65
- async function cleanup() {
66
- if (tmpDirCreated) {
67
- try {
68
- await rm(TMP_BASE, { recursive: true, force: true });
69
- } catch {
70
- // Ignore cleanup errors
71
- }
72
- }
73
- }
74
-
75
- /**
76
- * Get packages set using @npmcli/arborist
77
- *
78
- * Arborist requires a directory with package-lock.json (and optionally package.json).
79
- * We create a temp directory structure for each lockfile:
80
- * bin/tmp/<run-id>/<file-hash>/package-lock.json
81
- */
82
- async function getPackagesFromNpm(content, filepath) {
83
- const ArboristClass = await loadArborist();
84
- await ensureTmpDir();
85
-
86
- // Create unique subdir for this lockfile
87
- const fileId = crypto.createHash('md5').update(filepath).digest('hex').slice(0, 7);
88
- const tmpDir = join(TMP_BASE, fileId);
89
-
90
- await mkdir(tmpDir, { recursive: true });
91
- await writeFile(join(tmpDir, 'package-lock.json'), content);
92
-
93
- // Create minimal package.json from lockfile root entry
94
- const lockfile = JSON.parse(content);
95
- const root = lockfile.packages?.[''] || {};
96
- const pkg = {
97
- name: root.name || 'arborist-temp',
98
- version: root.version || '1.0.0'
99
- };
100
- await writeFile(join(tmpDir, 'package.json'), JSON.stringify(pkg));
101
-
102
- try {
103
- const arb = new ArboristClass({ path: tmpDir });
104
- const tree = await arb.loadVirtual();
105
-
106
- const result = new Set();
107
- let workspaceCount = 0;
108
- for (const node of tree.inventory.values()) {
109
- if (node.isRoot) continue;
110
- // Skip workspace symlinks (link:true, no version in raw lockfile)
111
- if (node.isLink) {
112
- workspaceCount++;
113
- continue;
114
- }
115
- // Skip workspace package definitions (not in node_modules)
116
- // Flatlock only yields packages from node_modules/ paths
117
- if (node.location && !node.location.includes('node_modules')) {
118
- workspaceCount++;
119
- continue;
120
- }
121
- if (node.name && node.version) {
122
- result.add(`${node.name}@${node.version}`);
123
- }
124
- }
125
- return { packages: result, workspaceCount };
126
- } finally {
127
- // Cleanup this specific lockfile's temp dir
128
- await rm(tmpDir, { recursive: true, force: true });
129
- }
130
- }
131
-
132
- /**
133
- * Get packages set using @yarnpkg/lockfile (classic)
134
- */
135
- async function getPackagesFromYarnClassic(content) {
136
- const yarnLock = await loadYarnClassic();
137
- const parse = yarnLock.parse || yarnLock.default?.parse;
138
- const { object: lockfile } = parse(content);
139
-
140
- const result = new Set();
141
- let workspaceCount = 0;
142
- for (const [key, pkg] of Object.entries(lockfile)) {
143
- if (key === '__metadata') continue;
144
-
145
- // Skip workspace/link entries - flatlock only cares about external dependencies
146
- const resolved = pkg.resolved || '';
147
- if (resolved.startsWith('file:') || resolved.startsWith('link:')) {
148
- workspaceCount++;
149
- continue;
150
- }
151
-
152
- let name;
153
- if (key.startsWith('@')) {
154
- const idx = key.indexOf('@', 1);
155
- name = key.slice(0, idx);
156
- } else {
157
- name = key.split('@')[0];
158
- }
159
- if (name && pkg.version) result.add(`${name}@${pkg.version}`);
160
- }
161
-
162
- return { packages: result, workspaceCount };
163
- }
164
-
165
- /**
166
- * Get packages set using @yarnpkg/parsers (berry)
167
- */
168
- async function getPackagesFromYarnBerry(content) {
169
- const parse = await loadYarnBerry();
170
- const lockfile = parse(content);
171
-
172
- const result = new Set();
173
- let workspaceCount = 0;
174
- for (const [key, pkg] of Object.entries(lockfile)) {
175
- if (key === '__metadata') continue;
176
-
177
- // Skip workspace/link entries - flatlock only cares about external dependencies
178
- const resolution = pkg.resolution || '';
179
- if (resolution.startsWith('workspace:') ||
180
- resolution.startsWith('portal:') ||
181
- resolution.startsWith('link:')) {
182
- workspaceCount++;
183
- continue;
184
- }
185
-
186
- let name;
187
- if (key.startsWith('@')) {
188
- const idx = key.indexOf('@', 1);
189
- name = key.slice(0, idx);
190
- } else {
191
- name = key.split('@')[0];
192
- }
193
- if (name && pkg.version) result.add(`${name}@${pkg.version}`);
194
- }
195
-
196
- return { packages: result, workspaceCount };
197
- }
198
-
199
- /**
200
- * Get packages set using js-yaml (pnpm)
201
- */
202
- async function getPackagesFromPnpm(content) {
203
- const y = await loadYaml();
204
- const lockfile = y.load(content);
205
- const packages = lockfile.packages || {};
206
-
207
- const result = new Set();
208
- let workspaceCount = 0;
209
- for (const [key, pkg] of Object.entries(packages)) {
210
- // Skip link/file entries - flatlock only cares about external dependencies
211
- // Keys can be: link:path, file:path, or @pkg@file:path
212
- if (key.startsWith('link:') || key.startsWith('file:') ||
213
- key.includes('@link:') || key.includes('@file:')) {
214
- workspaceCount++;
215
- continue;
216
- }
217
- // Also skip if resolution.type is 'directory' (workspace)
218
- if (pkg.resolution?.type === 'directory') {
219
- workspaceCount++;
220
- continue;
221
- }
222
- // pnpm keys are like /lodash@4.17.21 or /@babel/core@7.0.0
223
- const match = key.match(/^\/?(@?[^@]+)@(.+)$/);
224
- if (match) result.add(`${match[1]}@${match[2]}`);
225
- }
226
-
227
- return { packages: result, workspaceCount };
228
- }
229
-
230
- /**
231
- * Get packages set with flatlock
232
- */
233
- async function getPackagesFromFlatlock(filepath) {
234
- const result = new Set();
235
- for await (const dep of flatlock.fromPath(filepath)) {
236
- if (dep.name && dep.version) result.add(`${dep.name}@${dep.version}`);
237
- }
238
- return result;
239
- }
240
-
241
- /**
242
- * Get comparison parser name for type
243
- */
244
- function getComparisonName(type) {
245
- switch (type) {
246
- case 'npm': return '@npmcli/arborist';
247
- case 'yarn-classic': return '@yarnpkg/lockfile';
248
- case 'yarn-berry': return '@yarnpkg/parsers';
249
- case 'pnpm': return 'js-yaml';
250
- default: return 'unknown';
251
- }
252
- }
253
-
254
- /**
255
- * Get packages with comparison parser based on type
256
- */
257
- async function getPackagesFromComparison(type, content, filepath) {
258
- switch (type) {
259
- case 'npm': return getPackagesFromNpm(content, filepath);
260
- case 'yarn-classic': return getPackagesFromYarnClassic(content);
261
- case 'yarn-berry': return getPackagesFromYarnBerry(content);
262
- case 'pnpm': return getPackagesFromPnpm(content);
263
- default: return null;
264
- }
265
- }
266
-
267
- /**
268
- * Compare two sets and return differences
269
- */
270
- function compareSets(setA, setB) {
271
- const onlyInA = new Set([...setA].filter(x => !setB.has(x)));
272
- const onlyInB = new Set([...setB].filter(x => !setA.has(x)));
273
- return { onlyInA, onlyInB };
274
- }
275
8
 
276
9
  /**
277
10
  * Convert glob pattern to regex
@@ -308,52 +41,38 @@ async function findFiles(dir, pattern) {
308
41
  }
309
42
 
310
43
  /**
311
- * Process a single lockfile - compare sets, not just counts
44
+ * Process a single lockfile using flatlock.compare()
312
45
  */
313
46
  async function processFile(filepath, baseDir) {
314
47
  try {
315
- const content = await readFile(filepath, 'utf8');
316
- const type = flatlock.detectType({ path: filepath, content });
48
+ const result = await flatlock.compare(filepath);
317
49
  const rel = baseDir ? filepath.replace(baseDir + '/', '') : filepath;
318
- const comparisonName = getComparisonName(type);
319
50
 
320
- const flatlockSet = await getPackagesFromFlatlock(filepath);
321
- let comparisonResult;
322
-
323
- try {
324
- comparisonResult = await getPackagesFromComparison(type, content, filepath);
325
- } catch (err) {
326
- comparisonResult = null;
327
- }
328
-
329
- if (!comparisonResult) {
51
+ if (result.equinumerous === null) {
52
+ // Unsupported type or no comparison available
330
53
  return {
331
- type,
54
+ type: result.type,
332
55
  path: rel,
333
- comparisonName,
334
- flatlockCount: flatlockSet.size,
56
+ source: result.source || 'unknown',
57
+ flatlockCount: result.flatlockCount,
335
58
  comparisonCount: null,
336
59
  workspaceCount: 0,
337
- identical: null,
60
+ equinumerous: null,
338
61
  onlyInFlatlock: null,
339
62
  onlyInComparison: null
340
63
  };
341
64
  }
342
65
 
343
- const { packages: comparisonSet, workspaceCount } = comparisonResult;
344
- const { onlyInA: onlyInFlatlock, onlyInB: onlyInComparison } = compareSets(flatlockSet, comparisonSet);
345
- const identical = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
346
-
347
66
  return {
348
- type,
67
+ type: result.type,
349
68
  path: rel,
350
- comparisonName,
351
- flatlockCount: flatlockSet.size,
352
- comparisonCount: comparisonSet.size,
353
- workspaceCount,
354
- identical,
355
- onlyInFlatlock,
356
- onlyInComparison
69
+ source: result.source,
70
+ flatlockCount: result.flatlockCount,
71
+ comparisonCount: result.comparisonCount,
72
+ workspaceCount: result.workspaceCount,
73
+ equinumerous: result.equinumerous,
74
+ onlyInFlatlock: result.onlyInFlatlock,
75
+ onlyInComparison: result.onlyInComparison
357
76
  };
358
77
  } catch (err) {
359
78
  const rel = baseDir ? filepath.replace(baseDir + '/', '') : filepath;
@@ -386,10 +105,21 @@ Options:
386
105
  -h, --help Show this help
387
106
 
388
107
  Comparison parsers (workspace/link entries excluded from all):
389
- npm: @npmcli/arborist (loadVirtual)
108
+ npm: @npmcli/arborist (preferred) or @cyclonedx/cyclonedx-npm
390
109
  yarn-classic: @yarnpkg/lockfile
391
110
  yarn-berry: @yarnpkg/parsers
392
- pnpm: js-yaml
111
+ pnpm: @pnpm/lockfile.fs (preferred) or js-yaml
112
+
113
+ Result types:
114
+ ✓ equinumerous Same packages in both (exact match)
115
+ ⊃ SUPERSET flatlock found MORE packages (expected for pnpm)
116
+ ❌ MISMATCH Unexpected difference (comparison found packages flatlock missed)
117
+
118
+ Note on pnpm supersets:
119
+ flatlock performs full reachability analysis on lockfiles, finding all
120
+ transitive dependencies. pnpm's official tools don't enumerate all reachable
121
+ packages - they omit some transitive deps from their API output. When flatlock
122
+ finds MORE packages than pnpm, this is expected and correct behavior.
393
123
 
394
124
  Examples:
395
125
  flatlock-cmp package-lock.json
@@ -421,94 +151,117 @@ Examples:
421
151
  let fileCount = 0;
422
152
  let errorCount = 0;
423
153
  let matchCount = 0;
154
+ let supersetCount = 0; // flatlock found more (expected for pnpm reachability)
424
155
  let mismatchCount = 0;
425
156
 
426
- try {
427
- for (const file of files) {
428
- const result = await processFile(file, baseDir);
157
+ for (const file of files) {
158
+ const result = await processFile(file, baseDir);
429
159
 
430
- if (result.error) {
431
- errorCount++;
432
- if (!values.quiet) {
433
- console.log(`\n❌ ERROR: ${result.path}`);
434
- console.log(` ${result.error}`);
435
- }
436
- continue;
160
+ if (result.error) {
161
+ errorCount++;
162
+ if (!values.quiet) {
163
+ console.log(`\n❌ ERROR: ${result.path}`);
164
+ console.log(` ${result.error}`);
437
165
  }
166
+ continue;
167
+ }
438
168
 
439
- fileCount++;
440
- totalFlatlock += result.flatlockCount;
441
- totalWorkspaces += result.workspaceCount || 0;
169
+ fileCount++;
170
+ totalFlatlock += result.flatlockCount;
171
+ totalWorkspaces += result.workspaceCount || 0;
442
172
 
443
- if (result.comparisonCount === null) {
444
- if (!values.quiet) {
445
- console.log(`\n⚠️ ${result.path}`);
446
- console.log(` flatlock: ${result.flatlockCount} packages`);
447
- console.log(` ${result.comparisonName}: unavailable`);
448
- }
449
- continue;
173
+ if (result.comparisonCount === null) {
174
+ if (!values.quiet) {
175
+ console.log(`\n⚠️ ${result.path}`);
176
+ console.log(` flatlock: ${result.flatlockCount} packages`);
177
+ console.log(` ${result.source}: unavailable`);
450
178
  }
179
+ continue;
180
+ }
451
181
 
452
- totalComparison += result.comparisonCount;
182
+ totalComparison += result.comparisonCount;
453
183
 
454
- if (result.identical) {
455
- matchCount++;
184
+ if (result.equinumerous) {
185
+ matchCount++;
186
+ if (!values.quiet) {
187
+ const wsNote = result.workspaceCount > 0 ? ` (${result.workspaceCount} workspaces excluded)` : '';
188
+ console.log(`✓ ${result.path}${wsNote}`);
189
+ console.log(` count: flatlock=${result.flatlockCount} ${result.source}=${result.comparisonCount}`);
190
+ console.log(` sets: equinumerous`);
191
+ }
192
+ } else {
193
+ // Determine if this is a "superset" (flatlock found more, expected for pnpm)
194
+ // or a true "mismatch" (comparison found packages flatlock missed)
195
+ const isPnpm = result.type === 'pnpm' || result.path.includes('pnpm-lock');
196
+ const isSuperset = result.onlyInFlatlock.length > 0 && result.onlyInComparison.length === 0;
197
+
198
+ if (isPnpm && isSuperset) {
199
+ // Expected behavior: flatlock's reachability analysis found more packages
200
+ supersetCount++;
456
201
  if (!values.quiet) {
457
202
  const wsNote = result.workspaceCount > 0 ? ` (${result.workspaceCount} workspaces excluded)` : '';
458
- console.log(`✓ ${result.path}${wsNote}`);
459
- console.log(` count: flatlock=${result.flatlockCount} ${result.comparisonName}=${result.comparisonCount}`);
460
- console.log(` sets: identical`);
203
+ console.log(`⊃ ${result.path}${wsNote}`);
204
+ console.log(` count: flatlock=${result.flatlockCount} ${result.source}=${result.comparisonCount}`);
205
+ console.log(` sets: SUPERSET (+${result.onlyInFlatlock.length} reachable deps)`);
206
+ console.log(` note: flatlock's reachability analysis found transitive deps pnpm omits`);
461
207
  }
462
208
  } else {
463
209
  mismatchCount++;
464
210
  console.log(`\n❌ ${result.path}`);
465
- console.log(` count: flatlock=${result.flatlockCount} ${result.comparisonName}=${result.comparisonCount}`);
211
+ console.log(` count: flatlock=${result.flatlockCount} ${result.source}=${result.comparisonCount}`);
466
212
  console.log(` sets: MISMATCH`);
467
213
 
468
- if (result.onlyInFlatlock.size > 0) {
469
- console.log(` only in flatlock (${result.onlyInFlatlock.size}):`);
470
- for (const pkg of [...result.onlyInFlatlock].slice(0, 10)) {
214
+ if (result.onlyInFlatlock.length > 0) {
215
+ console.log(` only in flatlock (${result.onlyInFlatlock.length}):`);
216
+ for (const pkg of result.onlyInFlatlock.slice(0, 10)) {
471
217
  console.log(` + ${pkg}`);
472
218
  }
473
- if (result.onlyInFlatlock.size > 10) {
474
- console.log(` ... and ${result.onlyInFlatlock.size - 10} more`);
219
+ if (result.onlyInFlatlock.length > 10) {
220
+ console.log(` ... and ${result.onlyInFlatlock.length - 10} more`);
475
221
  }
476
222
  }
477
223
 
478
- if (result.onlyInComparison.size > 0) {
479
- console.log(` only in ${result.comparisonName} (${result.onlyInComparison.size}):`);
480
- for (const pkg of [...result.onlyInComparison].slice(0, 10)) {
224
+ if (result.onlyInComparison.length > 0) {
225
+ console.log(` only in ${result.source} (${result.onlyInComparison.length}):`);
226
+ for (const pkg of result.onlyInComparison.slice(0, 10)) {
481
227
  console.log(` - ${pkg}`);
482
228
  }
483
- if (result.onlyInComparison.size > 10) {
484
- console.log(` ... and ${result.onlyInComparison.size - 10} more`);
229
+ if (result.onlyInComparison.length > 10) {
230
+ console.log(` ... and ${result.onlyInComparison.length - 10} more`);
485
231
  }
486
232
  }
487
233
  }
488
234
  }
235
+ }
489
236
 
490
- // Summary
491
- console.log('\n' + '='.repeat(70));
492
- console.log(`SUMMARY: ${fileCount} files, ${matchCount} identical, ${mismatchCount} mismatches, ${errorCount} errors`);
493
- console.log(` flatlock total: ${totalFlatlock.toString().padStart(8)} packages`);
494
- if (totalComparison > 0) {
495
- console.log(` comparison total: ${totalComparison.toString().padStart(8)} packages`);
496
- }
497
- if (totalWorkspaces > 0) {
498
- console.log(` workspaces: ${totalWorkspaces.toString().padStart(8)} excluded (local/workspace refs)`);
499
- }
237
+ // Summary
238
+ console.log('\n' + '='.repeat(70));
239
+ const summaryParts = [`${fileCount} files`, `${matchCount} equinumerous`];
240
+ if (supersetCount > 0) {
241
+ summaryParts.push(`${supersetCount} supersets`);
242
+ }
243
+ summaryParts.push(`${mismatchCount} mismatches`, `${errorCount} errors`);
244
+ console.log(`SUMMARY: ${summaryParts.join(', ')}`);
500
245
 
501
- // Exit with error if any mismatches
502
- if (mismatchCount > 0) {
503
- process.exit(1);
504
- }
505
- } finally {
506
- await cleanup();
246
+ console.log(` flatlock total: ${totalFlatlock.toString().padStart(8)} packages`);
247
+ if (totalComparison > 0) {
248
+ console.log(` comparison total: ${totalComparison.toString().padStart(8)} packages`);
249
+ }
250
+ if (totalWorkspaces > 0) {
251
+ console.log(` workspaces: ${totalWorkspaces.toString().padStart(8)} excluded (local/workspace refs)`);
252
+ }
253
+ if (supersetCount > 0) {
254
+ console.log(` supersets: ${supersetCount.toString().padStart(8)} (flatlock found more via reachability)`);
255
+ }
256
+
257
+ // Exit with error only for true mismatches (not supersets)
258
+ // Supersets are expected: flatlock's reachability analysis is more thorough
259
+ if (mismatchCount > 0) {
260
+ process.exit(1);
507
261
  }
508
262
  }
509
263
 
510
- main().catch(async err => {
511
- await cleanup();
264
+ main().catch(err => {
512
265
  console.error('Fatal error:', err.message);
513
266
  process.exit(1);
514
267
  });