flatlock 1.1.0 → 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 (49) hide show
  1. package/README.md +54 -1
  2. package/bin/flatlock-cmp.js +71 -45
  3. package/dist/compare.d.ts +25 -3
  4. package/dist/compare.d.ts.map +1 -1
  5. package/dist/detect.d.ts.map +1 -1
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/parsers/index.d.ts +2 -2
  9. package/dist/parsers/npm.d.ts +64 -37
  10. package/dist/parsers/npm.d.ts.map +1 -1
  11. package/dist/parsers/pnpm/detect.d.ts +136 -0
  12. package/dist/parsers/pnpm/detect.d.ts.map +1 -0
  13. package/dist/parsers/pnpm/index.d.ts +120 -0
  14. package/dist/parsers/pnpm/index.d.ts.map +1 -0
  15. package/dist/parsers/pnpm/internal.d.ts +5 -0
  16. package/dist/parsers/pnpm/internal.d.ts.map +1 -0
  17. package/dist/parsers/pnpm/shrinkwrap.d.ts +129 -0
  18. package/dist/parsers/pnpm/shrinkwrap.d.ts.map +1 -0
  19. package/dist/parsers/pnpm/v5.d.ts +139 -0
  20. package/dist/parsers/pnpm/v5.d.ts.map +1 -0
  21. package/dist/parsers/pnpm/v6plus.d.ts +212 -0
  22. package/dist/parsers/pnpm/v6plus.d.ts.map +1 -0
  23. package/dist/parsers/pnpm.d.ts +1 -59
  24. package/dist/parsers/pnpm.d.ts.map +1 -1
  25. package/dist/parsers/types.d.ts +23 -0
  26. package/dist/parsers/types.d.ts.map +1 -0
  27. package/dist/parsers/yarn-berry.d.ts +141 -52
  28. package/dist/parsers/yarn-berry.d.ts.map +1 -1
  29. package/dist/parsers/yarn-classic.d.ts +79 -33
  30. package/dist/parsers/yarn-classic.d.ts.map +1 -1
  31. package/dist/set.d.ts +189 -0
  32. package/dist/set.d.ts.map +1 -0
  33. package/package.json +7 -5
  34. package/src/compare.js +385 -28
  35. package/src/detect.js +3 -4
  36. package/src/index.js +9 -2
  37. package/src/parsers/index.js +10 -2
  38. package/src/parsers/npm.js +64 -16
  39. package/src/parsers/pnpm/detect.js +198 -0
  40. package/src/parsers/pnpm/index.js +289 -0
  41. package/src/parsers/pnpm/internal.js +41 -0
  42. package/src/parsers/pnpm/shrinkwrap.js +241 -0
  43. package/src/parsers/pnpm/v5.js +225 -0
  44. package/src/parsers/pnpm/v6plus.js +290 -0
  45. package/src/parsers/pnpm.js +11 -89
  46. package/src/parsers/types.js +10 -0
  47. package/src/parsers/yarn-berry.js +183 -36
  48. package/src/parsers/yarn-classic.js +81 -21
  49. package/src/set.js +618 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flatlock",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "The Matlock of lockfile parsers - extracts packages without building dependency graphs",
5
5
  "keywords": [
6
6
  "lockfile",
@@ -43,7 +43,7 @@
43
43
  "bin"
44
44
  ],
45
45
  "scripts": {
46
- "test": "node --test ./test/*.test.js",
46
+ "test": "node --test ./test/*.test.js ./test/**/*.test.js",
47
47
  "test:coverage": "c8 node --test ./test/*.test.js",
48
48
  "build:types": "tsc",
49
49
  "lint": "biome lint src test",
@@ -62,7 +62,6 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@biomejs/biome": "^2.3.8",
65
- "@pnpm/lockfile-file": "^9.0.0",
66
65
  "@types/js-yaml": "^4.0.9",
67
66
  "@types/node": "^22.10.2",
68
67
  "c8": "^10.1.3",
@@ -71,11 +70,14 @@
71
70
  "typescript": "^5.7.2"
72
71
  },
73
72
  "optionalDependencies": {
74
- "@npmcli/arborist": "^9.1.9"
73
+ "@cyclonedx/cyclonedx-npm": "^4.1.2",
74
+ "@npmcli/arborist": "^9.1.9",
75
+ "@pnpm/lockfile.fs": "^1001.0.0",
76
+ "@yarnpkg/core": "^4.5.0"
75
77
  },
76
78
  "packageManager": "pnpm@10.25.0",
77
79
  "engines": {
78
- "node": ">=20"
80
+ "node": ">=22"
79
81
  },
80
82
  "c8": {
81
83
  "all": true,
package/src/compare.js CHANGED
@@ -1,23 +1,100 @@
1
+ import { constants } from 'node:buffer';
2
+ import { execFileSync } from 'node:child_process';
1
3
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
2
5
  import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
- import Arborist from '@npmcli/arborist';
5
- import yarnLockfile from '@yarnpkg/lockfile';
6
+ import { dirname, join } from 'node:path';
6
7
  import { parseSyml } from '@yarnpkg/parsers';
7
8
  import yaml from 'js-yaml';
8
9
  import { detectType, fromPath, Type } from './index.js';
9
- import { parseYarnBerryKey, parseYarnClassicKey } from './parsers/index.js';
10
+ import { parseYarnBerryKey, parseYarnClassic, parseYarnClassicKey } from './parsers/index.js';
10
11
  import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
11
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
+
12
87
  /**
13
88
  * @typedef {Object} CompareOptions
14
- * @property {string} [tmpDir] - Temp directory for Arborist (npm only)
89
+ * @property {string} [tmpDir] - Temp directory for Arborist/CycloneDX (npm only)
90
+ * @property {string[]} [workspace] - Workspace paths for CycloneDX (-w flag)
15
91
  */
16
92
 
17
93
  /**
18
94
  * @typedef {Object} ComparisonResult
19
95
  * @property {string} type - Lockfile type
20
- * @property {boolean | null} identical - Whether flatlock matches comparison parser
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
21
98
  * @property {number} flatlockCount - Number of packages found by flatlock
22
99
  * @property {number} [comparisonCount] - Number of packages found by comparison parser
23
100
  * @property {number} [workspaceCount] - Number of workspace packages skipped
@@ -29,6 +106,7 @@ import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
29
106
  * @typedef {Object} PackagesResult
30
107
  * @property {Set<string>} packages - Set of package@version strings
31
108
  * @property {number} workspaceCount - Number of workspace packages skipped
109
+ * @property {string} source - Comparison source used
32
110
  */
33
111
 
34
112
  /**
@@ -36,9 +114,12 @@ import { parseSpec as parsePnpmSpec } from './parsers/pnpm.js';
36
114
  * @param {string} content - Lockfile content
37
115
  * @param {string} _filepath - Path to lockfile (unused)
38
116
  * @param {CompareOptions} [options] - Options
39
- * @returns {Promise<PackagesResult>}
117
+ * @returns {Promise<PackagesResult | null>}
40
118
  */
41
- async function getPackagesFromNpm(content, _filepath, options = {}) {
119
+ async function getPackagesFromArborist(content, _filepath, options = {}) {
120
+ const Arb = await loadArborist();
121
+ if (!Arb) return null;
122
+
42
123
  // Arborist needs a directory with package-lock.json
43
124
  const tmpDir = options.tmpDir || (await mkdtemp(join(tmpdir(), 'flatlock-cmp-')));
44
125
  const lockPath = join(tmpDir, 'package-lock.json');
@@ -56,7 +137,7 @@ async function getPackagesFromNpm(content, _filepath, options = {}) {
56
137
  };
57
138
  await writeFile(pkgPath, JSON.stringify(pkg));
58
139
 
59
- const arb = new Arborist({ path: tmpDir });
140
+ const arb = new Arb({ path: tmpDir });
60
141
  const tree = await arb.loadVirtual();
61
142
 
62
143
  const packages = new Set();
@@ -80,7 +161,75 @@ async function getPackagesFromNpm(content, _filepath, options = {}) {
80
161
  }
81
162
  }
82
163
 
83
- return { packages, workspaceCount };
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' };
84
233
  } finally {
85
234
  if (!options.tmpDir) {
86
235
  await rm(tmpDir, { recursive: true, force: true });
@@ -88,14 +237,34 @@ async function getPackagesFromNpm(content, _filepath, options = {}) {
88
237
  }
89
238
  }
90
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
+
91
261
  /**
92
262
  * Get packages from yarn classic lockfile
93
263
  * @param {string} content - Lockfile content
94
264
  * @returns {Promise<PackagesResult>}
95
265
  */
96
266
  async function getPackagesFromYarnClassic(content) {
97
- const parse = yarnLockfile.parse || yarnLockfile.default?.parse;
98
- const parsed = parse(content);
267
+ const parsed = parseYarnClassic(content);
99
268
 
100
269
  if (parsed.type !== 'success' && parsed.type !== 'merge') {
101
270
  throw new Error('Failed to parse yarn.lock');
@@ -121,15 +290,92 @@ async function getPackagesFromYarnClassic(content) {
121
290
  }
122
291
  }
123
292
 
124
- return { packages, workspaceCount };
293
+ return { packages, workspaceCount, source: '@yarnpkg/lockfile' };
125
294
  }
126
295
 
127
296
  /**
128
- * Get packages from yarn berry lockfile
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)
129
375
  * @param {string} content - Lockfile content
130
376
  * @returns {Promise<PackagesResult>}
131
377
  */
132
- async function getPackagesFromYarnBerry(content) {
378
+ async function getPackagesFromYarnBerryParsers(content) {
133
379
  const parsed = parseSyml(content);
134
380
 
135
381
  const packages = new Set();
@@ -139,11 +385,16 @@ async function getPackagesFromYarnBerry(content) {
139
385
  if (key === '__metadata') continue;
140
386
 
141
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"
142
390
  const resolution = value.resolution || '';
143
391
  if (
144
- resolution.startsWith('workspace:') ||
145
- resolution.startsWith('portal:') ||
146
- resolution.startsWith('link:')
392
+ key.includes('@workspace:') ||
393
+ key.includes('@portal:') ||
394
+ key.includes('@link:') ||
395
+ resolution.includes('@workspace:') ||
396
+ resolution.includes('@portal:') ||
397
+ resolution.includes('@link:')
147
398
  ) {
148
399
  workspaceCount++;
149
400
  continue;
@@ -156,15 +407,84 @@ async function getPackagesFromYarnBerry(content) {
156
407
  }
157
408
  }
158
409
 
159
- return { packages, workspaceCount };
410
+ return { packages, workspaceCount, source: '@yarnpkg/parsers' };
160
411
  }
161
412
 
162
413
  /**
163
- * Get packages from pnpm lockfile
414
+ * Get packages from yarn berry lockfile - tries @yarnpkg/core first, falls back to @yarnpkg/parsers
164
415
  * @param {string} content - Lockfile content
416
+ * @param {CompareOptions} [options] - Options
165
417
  * @returns {Promise<PackagesResult>}
166
418
  */
167
- async function getPackagesFromPnpm(content) {
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) {
168
488
  const parsed = /** @type {{ packages?: Record<string, any> }} */ (yaml.load(content));
169
489
 
170
490
  const packages = new Set();
@@ -196,7 +516,23 @@ async function getPackagesFromPnpm(content) {
196
516
  }
197
517
  }
198
518
 
199
- return { packages, workspaceCount };
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);
200
536
  }
201
537
 
202
538
  /**
@@ -225,23 +561,24 @@ export async function compare(filepath, options = {}) {
225
561
  comparisonResult = await getPackagesFromYarnClassic(content);
226
562
  break;
227
563
  case Type.YARN_BERRY:
228
- comparisonResult = await getPackagesFromYarnBerry(content);
564
+ comparisonResult = await getPackagesFromYarnBerry(content, options);
229
565
  break;
230
566
  case Type.PNPM:
231
- comparisonResult = await getPackagesFromPnpm(content);
567
+ comparisonResult = await getPackagesFromPnpm(content, filepath, options);
232
568
  break;
233
569
  default:
234
- return { type, identical: null, flatlockCount: flatlockSet.size };
570
+ return { type, equinumerous: null, flatlockCount: flatlockSet.size };
235
571
  }
236
572
 
237
- const { packages: comparisonSet, workspaceCount } = comparisonResult;
573
+ const { packages: comparisonSet, workspaceCount, source } = comparisonResult;
238
574
  const onlyInFlatlock = new Set([...flatlockSet].filter(x => !comparisonSet.has(x)));
239
575
  const onlyInComparison = new Set([...comparisonSet].filter(x => !flatlockSet.has(x)));
240
- const identical = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
576
+ const equinumerous = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
241
577
 
242
578
  return {
243
579
  type,
244
- identical,
580
+ source,
581
+ equinumerous,
245
582
  flatlockCount: flatlockSet.size,
246
583
  comparisonCount: comparisonSet.size,
247
584
  workspaceCount,
@@ -261,3 +598,23 @@ export async function* compareAll(filepaths, options = {}) {
261
598
  yield { filepath, ...(await compare(filepath, options)) };
262
599
  }
263
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 yarnLockfile from '@yarnpkg/lockfile';
2
1
  import { parseSyml } from '@yarnpkg/parsers';
3
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
@@ -57,10 +57,9 @@ function tryParseYarnBerry(content) {
57
57
  */
58
58
  function tryParseYarnClassic(content) {
59
59
  try {
60
- const parse = yarnLockfile.default?.parse || yarnLockfile.parse;
61
- if (!parse) return false;
60
+ if (!parseYarnClassic) return false;
62
61
 
63
- const result = parse(content);
62
+ const result = parseYarnClassic(content);
64
63
  // Must parse successfully and NOT have __metadata (that's berry)
65
64
  // Must have at least one package entry (not empty object)
66
65
  const isValidResult = result.type === 'success' || result.type === 'merge';
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  fromYarnClassicLock
8
8
  } from './parsers/index.js';
9
9
  import { Err, Ok } from './result.js';
10
+ import { FlatlockSet } from './set.js';
10
11
 
11
12
  /** @typedef {import('./detect.js').LockfileType} LockfileType */
12
13
  /** @typedef {import('./parsers/npm.js').Dependency} Dependency */
@@ -26,6 +27,9 @@ export { Ok, Err };
26
27
  // Re-export individual parsers
27
28
  export { fromPackageLock, fromPnpmLock, fromYarnClassicLock, fromYarnBerryLock };
28
29
 
30
+ // Re-export FlatlockSet class
31
+ export { FlatlockSet };
32
+
29
33
  /**
30
34
  * Parse lockfile from path (auto-detect type)
31
35
  * @param {string} path - Path to lockfile
@@ -130,8 +134,8 @@ export function* fromYarnLock(content, options = {}) {
130
134
  export async function collect(pathOrContent, options = {}) {
131
135
  const deps = [];
132
136
 
133
- // Check if it's a path or content
134
- const isPath = !pathOrContent.includes('\n') && !pathOrContent.startsWith('{');
137
+ // Better heuristic: paths don't contain newlines and are reasonably short
138
+ const isPath = !pathOrContent.includes('\n') && pathOrContent.length < 1000;
135
139
 
136
140
  if (isPath) {
137
141
  for await (const dep of fromPath(pathOrContent, options)) {
@@ -146,6 +150,9 @@ export async function collect(pathOrContent, options = {}) {
146
150
  return deps;
147
151
  }
148
152
 
153
+ // Re-export compare API
154
+ export { compare, compareAll, getAvailableParsers } from './compare.js';
155
+
149
156
  // Re-export lockfile key parsing utilities
150
157
  export {
151
158
  parseNpmKey,
@@ -4,5 +4,13 @@
4
4
 
5
5
  export { fromPackageLock, parseLockfileKey as parseNpmKey } from './npm.js';
6
6
  export { fromPnpmLock, parseLockfileKey as parsePnpmKey } from './pnpm.js';
7
- export { fromYarnBerryLock, parseLockfileKey as parseYarnBerryKey } from './yarn-berry.js';
8
- export { fromYarnClassicLock, parseLockfileKey as parseYarnClassicKey } from './yarn-classic.js';
7
+ export {
8
+ fromYarnBerryLock,
9
+ parseLockfileKey as parseYarnBerryKey,
10
+ parseResolution as parseYarnBerryResolution
11
+ } from './yarn-berry.js';
12
+ export {
13
+ fromYarnClassicLock,
14
+ parseLockfileKey as parseYarnClassicKey,
15
+ parseYarnClassic
16
+ } from './yarn-classic.js';