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/src/compare.js ADDED
@@ -0,0 +1,620 @@
1
+ import { constants } from 'node:buffer';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { parseSyml } from '@yarnpkg/parsers';
8
+ import yaml from 'js-yaml';
9
+ import { detectType, fromPath, Type } from './index.js';
10
+ import { parseYarnBerryKey, parseYarnClassic, parseYarnClassicKey } from './parsers/index.js';
11
+ import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ // Lazy-loaded optional dependencies
16
+ /** @type {typeof import('@npmcli/arborist') | false | null} */
17
+ let Arborist = null;
18
+ /** @type {typeof import('@pnpm/lockfile.fs').readWantedLockfile | false | null} */
19
+ let readWantedLockfile = null;
20
+ /** @type {string | false | null} */
21
+ let cyclonedxCliPath = null;
22
+ /** @type {typeof import('@yarnpkg/core') | false | null} */
23
+ let yarnCore = null;
24
+
25
+ /**
26
+ * Try to load @npmcli/arborist (optional dependency)
27
+ * @returns {Promise<typeof import('@npmcli/arborist') | null>}
28
+ */
29
+ async function loadArborist() {
30
+ if (Arborist === null) {
31
+ try {
32
+ const mod = await import('@npmcli/arborist');
33
+ Arborist = mod.default;
34
+ } catch {
35
+ Arborist = false; // Mark as unavailable
36
+ }
37
+ }
38
+ return Arborist || null;
39
+ }
40
+
41
+ /**
42
+ * Try to load @pnpm/lockfile.fs (optional dependency)
43
+ * @returns {Promise<typeof import('@pnpm/lockfile.fs').readWantedLockfile | null>}
44
+ */
45
+ async function loadPnpmLockfileFs() {
46
+ if (readWantedLockfile === null) {
47
+ try {
48
+ const mod = await import('@pnpm/lockfile.fs');
49
+ readWantedLockfile = mod.readWantedLockfile;
50
+ } catch {
51
+ readWantedLockfile = false; // Mark as unavailable
52
+ }
53
+ }
54
+ return readWantedLockfile || null;
55
+ }
56
+
57
+ /**
58
+ * Try to resolve @cyclonedx/cyclonedx-npm CLI path (optional dependency)
59
+ * @returns {string | null}
60
+ */
61
+ function loadCycloneDxCliPath() {
62
+ if (cyclonedxCliPath === null) {
63
+ try {
64
+ cyclonedxCliPath = require.resolve('@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js');
65
+ } catch {
66
+ cyclonedxCliPath = false; // Mark as unavailable
67
+ }
68
+ }
69
+ return cyclonedxCliPath || null;
70
+ }
71
+
72
+ /**
73
+ * Try to load @yarnpkg/core (optional dependency)
74
+ * @returns {Promise<typeof import('@yarnpkg/core') | null>}
75
+ */
76
+ async function loadYarnCore() {
77
+ if (yarnCore === null) {
78
+ try {
79
+ yarnCore = await import('@yarnpkg/core');
80
+ } catch {
81
+ yarnCore = false; // Mark as unavailable
82
+ }
83
+ }
84
+ return yarnCore || null;
85
+ }
86
+
87
+ /**
88
+ * @typedef {Object} CompareOptions
89
+ * @property {string} [tmpDir] - Temp directory for Arborist/CycloneDX (npm only)
90
+ * @property {string[]} [workspace] - Workspace paths for CycloneDX (-w flag)
91
+ */
92
+
93
+ /**
94
+ * @typedef {Object} ComparisonResult
95
+ * @property {string} type - Lockfile type
96
+ * @property {string} [source] - Comparison source used (e.g., '@npmcli/arborist', '@cyclonedx/cyclonedx-npm')
97
+ * @property {boolean | null} equinumerous - Whether flatlock and comparison have same cardinality
98
+ * @property {number} flatlockCount - Number of packages found by flatlock
99
+ * @property {number} [comparisonCount] - Number of packages found by comparison parser
100
+ * @property {number} [workspaceCount] - Number of workspace packages skipped
101
+ * @property {string[]} [onlyInFlatlock] - Packages only found by flatlock
102
+ * @property {string[]} [onlyInComparison] - Packages only found by comparison parser
103
+ */
104
+
105
+ /**
106
+ * @typedef {Object} PackagesResult
107
+ * @property {Set<string>} packages - Set of package@version strings
108
+ * @property {number} workspaceCount - Number of workspace packages skipped
109
+ * @property {string} source - Comparison source used
110
+ */
111
+
112
+ /**
113
+ * Get packages from npm lockfile using Arborist (ground truth)
114
+ * @param {string} content - Lockfile content
115
+ * @param {string} _filepath - Path to lockfile (unused)
116
+ * @param {CompareOptions} [options] - Options
117
+ * @returns {Promise<PackagesResult | null>}
118
+ */
119
+ async function getPackagesFromArborist(content, _filepath, options = {}) {
120
+ const Arb = await loadArborist();
121
+ if (!Arb) return null;
122
+
123
+ // Arborist needs a directory with package-lock.json
124
+ const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-cmp-')));
125
+ const lockPath = join(tmpDir, 'package-lock.json');
126
+ const pkgPath = join(tmpDir, 'package.json');
127
+
128
+ try {
129
+ await writeFile(lockPath, content);
130
+
131
+ // Create minimal package.json from lockfile root entry
132
+ const lockfile = JSON.parse(content);
133
+ const root = lockfile.packages?.[''] || {};
134
+ const pkg = {
135
+ name: root.name || 'arborist-temp',
136
+ version: root.version || '1.0.0'
137
+ };
138
+ await writeFile(pkgPath, JSON.stringify(pkg));
139
+
140
+ const arb = new Arb({ path: tmpDir });
141
+ const tree = await arb.loadVirtual();
142
+
143
+ const packages = new Set();
144
+ let workspaceCount = 0;
145
+
146
+ for (const node of tree.inventory.values()) {
147
+ if (node.isRoot) continue;
148
+ // Skip workspace symlinks (link:true, no version in raw lockfile)
149
+ if (node.isLink) {
150
+ workspaceCount++;
151
+ continue;
152
+ }
153
+ // Skip workspace package definitions (not in node_modules)
154
+ // Flatlock only yields packages from node_modules/ paths
155
+ if (node.location && !node.location.includes('node_modules')) {
156
+ workspaceCount++;
157
+ continue;
158
+ }
159
+ if (node.name && node.version) {
160
+ packages.add(`${node.name}@${node.version}`);
161
+ }
162
+ }
163
+
164
+ return { packages, workspaceCount, source: '@npmcli/arborist' };
165
+ } finally {
166
+ if (!options.tmpDir) {
167
+ await rm(tmpDir, { recursive: true, force: true });
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Get packages from npm lockfile using CycloneDX SBOM generation
174
+ * @param {string} content - Lockfile content
175
+ * @param {string} _filepath - Path to lockfile (unused)
176
+ * @param {CompareOptions} [options] - Options
177
+ * @returns {Promise<PackagesResult | null>}
178
+ */
179
+ async function getPackagesFromCycloneDX(content, _filepath, options = {}) {
180
+ const cliPath = loadCycloneDxCliPath();
181
+ if (!cliPath) return null;
182
+
183
+ // CycloneDX needs a directory with package-lock.json and package.json
184
+ const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-cdx-')));
185
+ const lockPath = join(tmpDir, 'package-lock.json');
186
+ const pkgPath = join(tmpDir, 'package.json');
187
+
188
+ try {
189
+ await writeFile(lockPath, content);
190
+
191
+ // Create minimal package.json from lockfile root entry
192
+ const lockfile = JSON.parse(content);
193
+ const root = lockfile.packages?.[''] || {};
194
+ const pkg = {
195
+ name: root.name || 'cyclonedx-temp',
196
+ version: root.version || '1.0.0'
197
+ };
198
+ await writeFile(pkgPath, JSON.stringify(pkg));
199
+
200
+ const args = [
201
+ cliPath,
202
+ '--output-format',
203
+ 'JSON',
204
+ '--output-file',
205
+ '-',
206
+ '--package-lock-only' // Don't require node_modules
207
+ ];
208
+
209
+ // Add workspace flags if specified
210
+ if (options.workspace) {
211
+ for (const ws of options.workspace) {
212
+ args.push('-w', ws);
213
+ }
214
+ }
215
+
216
+ const sbomBuffer = execFileSync(process.execPath, args, {
217
+ cwd: tmpDir,
218
+ stdio: ['ignore', 'pipe', 'ignore'],
219
+ encoding: 'buffer',
220
+ maxBuffer: constants.MAX_LENGTH
221
+ });
222
+
223
+ const sbom = JSON.parse(sbomBuffer.toString('utf8'));
224
+ const packages = new Set();
225
+
226
+ for (const component of sbom.components || []) {
227
+ if (component.name && component.version) {
228
+ packages.add(`${component.name}@${component.version}`);
229
+ }
230
+ }
231
+
232
+ return { packages, workspaceCount: 0, source: '@cyclonedx/cyclonedx-npm' };
233
+ } finally {
234
+ if (!options.tmpDir) {
235
+ await rm(tmpDir, { recursive: true, force: true });
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Get packages from npm lockfile - tries Arborist first, falls back to CycloneDX
242
+ * @param {string} content - Lockfile content
243
+ * @param {string} filepath - Path to lockfile
244
+ * @param {CompareOptions} [options] - Options
245
+ * @returns {Promise<PackagesResult>}
246
+ */
247
+ async function getPackagesFromNpm(content, filepath, options = {}) {
248
+ // Try Arborist first (faster, more accurate)
249
+ const arboristResult = await getPackagesFromArborist(content, filepath, options);
250
+ if (arboristResult) return arboristResult;
251
+
252
+ // Fall back to CycloneDX
253
+ const cyclonedxResult = await getPackagesFromCycloneDX(content, filepath, options);
254
+ if (cyclonedxResult) return cyclonedxResult;
255
+
256
+ throw new Error(
257
+ 'No npm comparison parser available. Install @npmcli/arborist or @cyclonedx/cyclonedx-npm'
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Get packages from yarn classic lockfile
263
+ * @param {string} content - Lockfile content
264
+ * @returns {Promise<PackagesResult>}
265
+ */
266
+ async function getPackagesFromYarnClassic(content) {
267
+ const parsed = parseYarnClassic(content);
268
+
269
+ if (parsed.type !== 'success' && parsed.type !== 'merge') {
270
+ throw new Error('Failed to parse yarn.lock');
271
+ }
272
+
273
+ const packages = new Set();
274
+ let workspaceCount = 0;
275
+
276
+ for (const [key, value] of Object.entries(parsed.object)) {
277
+ if (key === '__metadata') continue;
278
+
279
+ // Skip workspace/link entries - flatlock only cares about external dependencies
280
+ const resolved = value.resolved || '';
281
+ if (resolved.startsWith('file:') || resolved.startsWith('link:')) {
282
+ workspaceCount++;
283
+ continue;
284
+ }
285
+
286
+ // Extract package name from lockfile key
287
+ const name = parseYarnClassicKey(key);
288
+ if (name && value.version) {
289
+ packages.add(`${name}@${value.version}`);
290
+ }
291
+ }
292
+
293
+ return { packages, workspaceCount, source: '@yarnpkg/lockfile' };
294
+ }
295
+
296
+ /**
297
+ * Get packages from yarn berry lockfile using @yarnpkg/core Project.originalPackages
298
+ *
299
+ * This is the equivalent of Arborist.loadVirtual() - loads yarn's internal representation.
300
+ *
301
+ * Key insight: By calling setupResolutions() directly (a private method), we can populate
302
+ * originalPackages from the lockfile WITHOUT requiring a valid project setup. This gives
303
+ * us yarn's ground truth package data.
304
+ *
305
+ * @param {string} content - Lockfile content
306
+ * @param {CompareOptions} [options] - Options
307
+ * @returns {Promise<PackagesResult | null>}
308
+ */
309
+ async function getPackagesFromYarnBerryCore(content, options = {}) {
310
+ const core = await loadYarnCore();
311
+ if (!core) return null;
312
+
313
+ const { Configuration, Project, structUtils } = core;
314
+
315
+ // Create temp directory with yarn.lock and minimal package.json
316
+ const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-yarn-')));
317
+ const lockPath = join(tmpDir, 'yarn.lock');
318
+ const pkgPath = join(tmpDir, 'package.json');
319
+
320
+ try {
321
+ await writeFile(lockPath, content);
322
+ // Minimal package.json - only needed for Configuration.find
323
+ await writeFile(
324
+ pkgPath,
325
+ JSON.stringify({ name: 'flatlock-temp', version: '0.0.0', private: true })
326
+ );
327
+
328
+ // Load configuration
329
+ const configuration = await Configuration.find(/** @type {any} */ (tmpDir), null);
330
+
331
+ // Create project manually (don't use Project.find which calls setupWorkspaces)
332
+ const project = new Project(/** @type {any} */ (tmpDir), { configuration });
333
+
334
+ // Call setupResolutions directly - this parses the lockfile and populates originalPackages
335
+ // This is a private method but it's the only way to get ground truth without a full project
336
+ await /** @type {any} */ (project).setupResolutions();
337
+
338
+ const packages = new Set();
339
+ let workspaceCount = 0;
340
+
341
+ // Iterate over originalPackages - this is yarn's ground truth from the lockfile
342
+ for (const pkg of project.originalPackages.values()) {
343
+ const ref = pkg.reference;
344
+
345
+ // Check for workspace/link/portal protocols
346
+ if (ref.startsWith('workspace:') || ref.startsWith('link:') || ref.startsWith('portal:')) {
347
+ workspaceCount++;
348
+ continue;
349
+ }
350
+
351
+ // Skip virtual packages (peer dependency variants)
352
+ if (structUtils.isVirtualLocator(pkg)) {
353
+ continue;
354
+ }
355
+
356
+ const name = structUtils.stringifyIdent(pkg);
357
+ if (name && pkg.version) {
358
+ packages.add(`${name}@${pkg.version}`);
359
+ }
360
+ }
361
+
362
+ return { packages, workspaceCount, source: '@yarnpkg/core' };
363
+ } catch (_err) {
364
+ // If @yarnpkg/core fails (e.g., incompatible lockfile), return null to fall back
365
+ return null;
366
+ } finally {
367
+ if (!options.tmpDir) {
368
+ await rm(tmpDir, { recursive: true, force: true });
369
+ }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Get packages from yarn berry lockfile using @yarnpkg/parsers (fallback)
375
+ * @param {string} content - Lockfile content
376
+ * @returns {Promise<PackagesResult>}
377
+ */
378
+ async function getPackagesFromYarnBerryParsers(content) {
379
+ const parsed = parseSyml(content);
380
+
381
+ const packages = new Set();
382
+ let workspaceCount = 0;
383
+
384
+ for (const [key, value] of Object.entries(parsed)) {
385
+ if (key === '__metadata') continue;
386
+
387
+ // Skip workspace/link entries - flatlock only cares about external dependencies
388
+ // Keys look like: "@pkg@workspace:path" or "pkg@workspace:path"
389
+ // Resolutions look like: "@pkg@workspace:path" or "pkg@npm:1.0.0"
390
+ const resolution = value.resolution || '';
391
+ if (
392
+ key.includes('@workspace:') ||
393
+ key.includes('@portal:') ||
394
+ key.includes('@link:') ||
395
+ resolution.includes('@workspace:') ||
396
+ resolution.includes('@portal:') ||
397
+ resolution.includes('@link:')
398
+ ) {
399
+ workspaceCount++;
400
+ continue;
401
+ }
402
+
403
+ // Extract package name from lockfile key
404
+ const name = parseYarnBerryKey(key);
405
+ if (name && value.version) {
406
+ packages.add(`${name}@${value.version}`);
407
+ }
408
+ }
409
+
410
+ return { packages, workspaceCount, source: '@yarnpkg/parsers' };
411
+ }
412
+
413
+ /**
414
+ * Get packages from yarn berry lockfile - tries @yarnpkg/core first, falls back to @yarnpkg/parsers
415
+ * @param {string} content - Lockfile content
416
+ * @param {CompareOptions} [options] - Options
417
+ * @returns {Promise<PackagesResult>}
418
+ */
419
+ async function getPackagesFromYarnBerry(content, options = {}) {
420
+ // Try @yarnpkg/core first (official yarn implementation with Project.restoreInstallState)
421
+ const coreResult = await getPackagesFromYarnBerryCore(content, options);
422
+ if (coreResult) return coreResult;
423
+
424
+ // Fall back to @yarnpkg/parsers with manual filtering
425
+ return getPackagesFromYarnBerryParsers(content);
426
+ }
427
+
428
+ /**
429
+ * Get packages from pnpm lockfile using @pnpm/lockfile.fs (official parser)
430
+ * @param {string} _content - Lockfile content (unused, reads from disk)
431
+ * @param {string} filepath - Path to lockfile
432
+ * @param {CompareOptions} [_options] - Options (unused)
433
+ * @returns {Promise<PackagesResult | null>}
434
+ */
435
+ async function getPackagesFromPnpmOfficial(_content, filepath, _options = {}) {
436
+ const readLockfile = await loadPnpmLockfileFs();
437
+ if (!readLockfile) return null;
438
+
439
+ const projectDir = dirname(filepath);
440
+
441
+ try {
442
+ const lockfile = await readLockfile(projectDir, {
443
+ ignoreIncompatible: true
444
+ });
445
+
446
+ if (!lockfile) return null;
447
+
448
+ const packages = new Set();
449
+ let workspaceCount = 0;
450
+ const pkgs = lockfile.packages || {};
451
+
452
+ // If no packages found, likely version incompatibility - fall back to js-yaml
453
+ if (Object.keys(pkgs).length === 0) {
454
+ return null;
455
+ }
456
+
457
+ for (const [key, _value] of Object.entries(pkgs)) {
458
+ // Skip link/file entries
459
+ if (
460
+ key.startsWith('link:') ||
461
+ key.startsWith('file:') ||
462
+ key.includes('@link:') ||
463
+ key.includes('@file:')
464
+ ) {
465
+ workspaceCount++;
466
+ continue;
467
+ }
468
+
469
+ const { name, version } = parsePnpmSpec(key);
470
+ if (name && version) {
471
+ packages.add(`${name}@${version}`);
472
+ }
473
+ }
474
+
475
+ return { packages, workspaceCount, source: '@pnpm/lockfile.fs' };
476
+ } catch {
477
+ // Fall back to js-yaml if official parser fails (version incompatibility)
478
+ return null;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Get packages from pnpm lockfile using js-yaml (fallback)
484
+ * @param {string} content - Lockfile content
485
+ * @returns {Promise<PackagesResult>}
486
+ */
487
+ async function getPackagesFromPnpmYaml(content) {
488
+ const parsed = /** @type {{ packages?: Record<string, any> }} */ (yaml.load(content));
489
+
490
+ const packages = new Set();
491
+ let workspaceCount = 0;
492
+ const pkgs = parsed.packages || {};
493
+
494
+ for (const [key, value] of Object.entries(pkgs)) {
495
+ // Skip link/file entries - flatlock only cares about external dependencies
496
+ // Keys can be: link:path, file:path, or @pkg@file:path
497
+ if (
498
+ key.startsWith('link:') ||
499
+ key.startsWith('file:') ||
500
+ key.includes('@link:') ||
501
+ key.includes('@file:')
502
+ ) {
503
+ workspaceCount++;
504
+ continue;
505
+ }
506
+ // Also skip if resolution.type is 'directory' (workspace)
507
+ if (value.resolution?.type === 'directory') {
508
+ workspaceCount++;
509
+ continue;
510
+ }
511
+
512
+ // Extract name and version from pnpm lockfile key
513
+ const { name, version } = parsePnpmSpec(key);
514
+ if (name && version) {
515
+ packages.add(`${name}@${version}`);
516
+ }
517
+ }
518
+
519
+ return { packages, workspaceCount, source: 'js-yaml' };
520
+ }
521
+
522
+ /**
523
+ * Get packages from pnpm lockfile - tries official parser first, falls back to js-yaml
524
+ * @param {string} content - Lockfile content
525
+ * @param {string} filepath - Path to lockfile
526
+ * @param {CompareOptions} [options] - Options
527
+ * @returns {Promise<PackagesResult>}
528
+ */
529
+ async function getPackagesFromPnpm(content, filepath, options = {}) {
530
+ // Try official pnpm parser first
531
+ const officialResult = await getPackagesFromPnpmOfficial(content, filepath, options);
532
+ if (officialResult) return officialResult;
533
+
534
+ // Fall back to js-yaml
535
+ return getPackagesFromPnpmYaml(content);
536
+ }
537
+
538
+ /**
539
+ * Compare flatlock output against established parser for a lockfile
540
+ * @param {string} filepath - Path to lockfile
541
+ * @param {CompareOptions} [options] - Options
542
+ * @returns {Promise<ComparisonResult>}
543
+ */
544
+ export async function compare(filepath, options = {}) {
545
+ const content = await readFile(filepath, 'utf8');
546
+ const type = detectType({ path: filepath, content });
547
+
548
+ const flatlockSet = new Set();
549
+ for await (const dep of fromPath(filepath)) {
550
+ if (dep.name && dep.version) {
551
+ flatlockSet.add(`${dep.name}@${dep.version}`);
552
+ }
553
+ }
554
+
555
+ let comparisonResult;
556
+ switch (type) {
557
+ case Type.NPM:
558
+ comparisonResult = await getPackagesFromNpm(content, filepath, options);
559
+ break;
560
+ case Type.YARN_CLASSIC:
561
+ comparisonResult = await getPackagesFromYarnClassic(content);
562
+ break;
563
+ case Type.YARN_BERRY:
564
+ comparisonResult = await getPackagesFromYarnBerry(content, options);
565
+ break;
566
+ case Type.PNPM:
567
+ comparisonResult = await getPackagesFromPnpm(content, filepath, options);
568
+ break;
569
+ default:
570
+ return { type, equinumerous: null, flatlockCount: flatlockSet.size };
571
+ }
572
+
573
+ const { packages: comparisonSet, workspaceCount, source } = comparisonResult;
574
+ const onlyInFlatlock = new Set([...flatlockSet].filter(x => !comparisonSet.has(x)));
575
+ const onlyInComparison = new Set([...comparisonSet].filter(x => !flatlockSet.has(x)));
576
+ const equinumerous = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
577
+
578
+ return {
579
+ type,
580
+ source,
581
+ equinumerous,
582
+ flatlockCount: flatlockSet.size,
583
+ comparisonCount: comparisonSet.size,
584
+ workspaceCount,
585
+ onlyInFlatlock: [...onlyInFlatlock],
586
+ onlyInComparison: [...onlyInComparison]
587
+ };
588
+ }
589
+
590
+ /**
591
+ * Compare multiple lockfiles
592
+ * @param {string[]} filepaths - Paths to lockfiles
593
+ * @param {CompareOptions} [options] - Options
594
+ * @returns {AsyncGenerator<ComparisonResult & { filepath: string }>}
595
+ */
596
+ export async function* compareAll(filepaths, options = {}) {
597
+ for (const filepath of filepaths) {
598
+ yield { filepath, ...(await compare(filepath, options)) };
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Check which optional comparison parsers are available
604
+ * @returns {Promise<{ arborist: boolean, cyclonedx: boolean, pnpmLockfileFs: boolean, yarnCore: boolean }>}
605
+ */
606
+ export async function getAvailableParsers() {
607
+ const [arborist, pnpmLockfileFs, yarnCoreModule] = await Promise.all([
608
+ loadArborist(),
609
+ loadPnpmLockfileFs(),
610
+ loadYarnCore()
611
+ ]);
612
+ const cyclonedx = loadCycloneDxCliPath();
613
+
614
+ return {
615
+ arborist: !!arborist,
616
+ cyclonedx: !!cyclonedx,
617
+ pnpmLockfileFs: !!pnpmLockfileFs,
618
+ yarnCore: !!yarnCoreModule
619
+ };
620
+ }
package/src/detect.js CHANGED
@@ -1,6 +1,6 @@
1
- import yaml from 'js-yaml';
2
1
  import { parseSyml } from '@yarnpkg/parsers';
3
- import yarnLockfile from '@yarnpkg/lockfile';
2
+ import yaml from 'js-yaml';
3
+ import { parseYarnClassic } from './parsers/yarn-classic.js';
4
4
 
5
5
  /**
6
6
  * @typedef {'npm' | 'pnpm' | 'yarn-classic' | 'yarn-berry'} LockfileType
@@ -40,6 +40,7 @@ function tryParseYarnBerry(content) {
40
40
  try {
41
41
  const parsed = parseSyml(content);
42
42
  // Must have __metadata object at root with version property
43
+ // biome-ignore format: preserve multiline logical expression
43
44
  return parsed
44
45
  && typeof parsed.__metadata === 'object'
45
46
  && parsed.__metadata !== null
@@ -56,10 +57,9 @@ function tryParseYarnBerry(content) {
56
57
  */
57
58
  function tryParseYarnClassic(content) {
58
59
  try {
59
- const parse = yarnLockfile.default?.parse || yarnLockfile.parse;
60
- if (!parse) return false;
60
+ if (!parseYarnClassic) return false;
61
61
 
62
- const result = parse(content);
62
+ const result = parseYarnClassic(content);
63
63
  // Must parse successfully and NOT have __metadata (that's berry)
64
64
  // Must have at least one package entry (not empty object)
65
65
  const isValidResult = result.type === 'success' || result.type === 'merge';
@@ -81,10 +81,11 @@ function tryParsePnpm(content) {
81
81
  try {
82
82
  const parsed = yaml.load(content);
83
83
  // Must have lockfileVersion at root and NOT have __metadata
84
- return parsed
84
+ // biome-ignore format: preserve multiline logical expression
85
+ return !!(parsed
85
86
  && typeof parsed === 'object'
86
87
  && 'lockfileVersion' in parsed
87
- && !('__metadata' in parsed);
88
+ && !('__metadata' in parsed));
88
89
  } catch {
89
90
  return false;
90
91
  }