flatlock 1.2.0 → 1.3.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 (43) hide show
  1. package/README.md +42 -1
  2. package/bin/flatcover.js +398 -0
  3. package/bin/flatlock.js +158 -0
  4. package/package.json +16 -5
  5. package/src/parsers/index.js +14 -2
  6. package/src/parsers/npm.js +82 -0
  7. package/src/parsers/pnpm/index.js +70 -0
  8. package/src/parsers/yarn-berry.js +88 -0
  9. package/src/set.js +730 -41
  10. package/dist/compare.d.ts +0 -85
  11. package/dist/compare.d.ts.map +0 -1
  12. package/dist/detect.d.ts +0 -33
  13. package/dist/detect.d.ts.map +0 -1
  14. package/dist/index.d.ts +0 -72
  15. package/dist/index.d.ts.map +0 -1
  16. package/dist/parsers/index.d.ts +0 -5
  17. package/dist/parsers/index.d.ts.map +0 -1
  18. package/dist/parsers/npm.d.ts +0 -109
  19. package/dist/parsers/npm.d.ts.map +0 -1
  20. package/dist/parsers/pnpm/detect.d.ts +0 -136
  21. package/dist/parsers/pnpm/detect.d.ts.map +0 -1
  22. package/dist/parsers/pnpm/index.d.ts +0 -120
  23. package/dist/parsers/pnpm/index.d.ts.map +0 -1
  24. package/dist/parsers/pnpm/internal.d.ts +0 -5
  25. package/dist/parsers/pnpm/internal.d.ts.map +0 -1
  26. package/dist/parsers/pnpm/shrinkwrap.d.ts +0 -129
  27. package/dist/parsers/pnpm/shrinkwrap.d.ts.map +0 -1
  28. package/dist/parsers/pnpm/v5.d.ts +0 -139
  29. package/dist/parsers/pnpm/v5.d.ts.map +0 -1
  30. package/dist/parsers/pnpm/v6plus.d.ts +0 -212
  31. package/dist/parsers/pnpm/v6plus.d.ts.map +0 -1
  32. package/dist/parsers/pnpm.d.ts +0 -2
  33. package/dist/parsers/pnpm.d.ts.map +0 -1
  34. package/dist/parsers/types.d.ts +0 -23
  35. package/dist/parsers/types.d.ts.map +0 -1
  36. package/dist/parsers/yarn-berry.d.ts +0 -154
  37. package/dist/parsers/yarn-berry.d.ts.map +0 -1
  38. package/dist/parsers/yarn-classic.d.ts +0 -110
  39. package/dist/parsers/yarn-classic.d.ts.map +0 -1
  40. package/dist/result.d.ts +0 -12
  41. package/dist/result.d.ts.map +0 -1
  42. package/dist/set.d.ts +0 -189
  43. package/dist/set.d.ts.map +0 -1
package/src/set.js CHANGED
@@ -3,6 +3,12 @@ import { parseSyml } from '@yarnpkg/parsers';
3
3
  import yaml from 'js-yaml';
4
4
  import { detectType, Type } from './detect.js';
5
5
  import {
6
+ buildNpmWorkspacePackages,
7
+ buildPnpmWorkspacePackages,
8
+ buildYarnBerryWorkspacePackages,
9
+ extractNpmWorkspacePaths,
10
+ extractPnpmWorkspacePaths,
11
+ extractYarnBerryWorkspacePaths,
6
12
  fromPackageLock,
7
13
  fromPnpmLock,
8
14
  fromYarnBerryLock,
@@ -17,12 +23,24 @@ import {
17
23
  * @typedef {import('./detect.js').LockfileType} LockfileType
18
24
  */
19
25
 
26
+ /**
27
+ * @typedef {Object} WorkspacePackage
28
+ * @property {string} name - Package name (e.g., '@vue/shared')
29
+ * @property {string} version - Package version (e.g., '3.5.26')
30
+ * @property {Record<string, string>} [dependencies] - Production dependencies (for yarn berry workspace traversal)
31
+ * @property {Record<string, string>} [devDependencies] - Dev dependencies
32
+ * @property {Record<string, string>} [optionalDependencies] - Optional dependencies
33
+ * @property {Record<string, string>} [peerDependencies] - Peer dependencies
34
+ */
35
+
20
36
  /**
21
37
  * @typedef {Object} DependenciesOfOptions
22
38
  * @property {string} [workspacePath] - Path to workspace (e.g., 'packages/foo')
39
+ * @property {string} [repoDir] - Path to repository root for reading workspace package.json files
23
40
  * @property {boolean} [dev=false] - Include devDependencies
24
41
  * @property {boolean} [optional=true] - Include optionalDependencies
25
42
  * @property {boolean} [peer=false] - Include peerDependencies (default false: peers are provided by consumer)
43
+ * @property {Record<string, WorkspacePackage>} [workspacePackages] - Map of workspace path to package info for resolving workspace links (auto-built if repoDir provided)
26
44
  */
27
45
 
28
46
  /**
@@ -68,7 +86,7 @@ const INTERNAL = Symbol('FlatlockSet.internal');
68
86
  *
69
87
  * // Get dependencies for a specific workspace
70
88
  * const pkg = JSON.parse(await readFile('./packages/foo/package.json'));
71
- * const subset = set.dependenciesOf(pkg, { workspacePath: 'packages/foo' });
89
+ * const subset = await set.dependenciesOf(pkg, { workspacePath: 'packages/foo', repoDir: '.' });
72
90
  *
73
91
  * // Set operations
74
92
  * const other = await FlatlockSet.fromPath('./other-lock.json');
@@ -84,6 +102,9 @@ export class FlatlockSet {
84
102
  /** @type {LockfileImporters | null} Workspace importers (pnpm) */
85
103
  #importers = null;
86
104
 
105
+ /** @type {Record<string, any> | null} Snapshots (pnpm v9) */
106
+ #snapshots = null;
107
+
87
108
  /** @type {LockfileType | null} */
88
109
  #type = null;
89
110
 
@@ -95,9 +116,10 @@ export class FlatlockSet {
95
116
  * @param {Map<string, Dependency>} deps
96
117
  * @param {LockfilePackages | null} packages
97
118
  * @param {LockfileImporters | null} importers
119
+ * @param {Record<string, any> | null} snapshots
98
120
  * @param {LockfileType | null} type
99
121
  */
100
- constructor(internal, deps, packages, importers, type) {
122
+ constructor(internal, deps, packages, importers, snapshots, type) {
101
123
  if (internal !== INTERNAL) {
102
124
  throw new Error(
103
125
  'FlatlockSet cannot be constructed directly. Use FlatlockSet.fromPath() or FlatlockSet.fromString()'
@@ -106,6 +128,7 @@ export class FlatlockSet {
106
128
  this.#deps = deps;
107
129
  this.#packages = packages;
108
130
  this.#importers = importers;
131
+ this.#snapshots = snapshots;
109
132
  this.#type = type;
110
133
  this.#canTraverse = packages !== null;
111
134
  }
@@ -138,9 +161,9 @@ export class FlatlockSet {
138
161
  }
139
162
 
140
163
  // Parse once, extract both deps and raw data
141
- const { deps, packages, importers } = FlatlockSet.#parseAll(content, type, options);
164
+ const { deps, packages, importers, snapshots } = FlatlockSet.#parseAll(content, type, options);
142
165
 
143
- return new FlatlockSet(INTERNAL, deps, packages, importers, type);
166
+ return new FlatlockSet(INTERNAL, deps, packages, importers, snapshots, type);
144
167
  }
145
168
 
146
169
  /**
@@ -148,7 +171,7 @@ export class FlatlockSet {
148
171
  * @param {string} content
149
172
  * @param {LockfileType} type
150
173
  * @param {FromStringOptions} options
151
- * @returns {{ deps: Map<string, Dependency>, packages: LockfilePackages, importers: LockfileImporters | null }}
174
+ * @returns {{ deps: Map<string, Dependency>, packages: LockfilePackages, importers: LockfileImporters | null, snapshots: Record<string, any> | null }}
152
175
  */
153
176
  static #parseAll(content, type, options) {
154
177
  /** @type {Map<string, Dependency>} */
@@ -157,6 +180,8 @@ export class FlatlockSet {
157
180
  let packages = {};
158
181
  /** @type {LockfileImporters | null} */
159
182
  let importers = null;
183
+ /** @type {Record<string, any> | null} */
184
+ let snapshots = null;
160
185
 
161
186
  switch (type) {
162
187
  case Type.NPM: {
@@ -173,6 +198,7 @@ export class FlatlockSet {
173
198
  const lockfile = yaml.load(content);
174
199
  packages = lockfile.packages || {};
175
200
  importers = lockfile.importers || null;
201
+ snapshots = lockfile.snapshots || null;
176
202
  // Pass pre-parsed lockfile object to avoid re-parsing
177
203
  for (const dep of fromPnpmLock(lockfile, options)) {
178
204
  deps.set(`${dep.name}@${dep.version}`, dep);
@@ -198,7 +224,7 @@ export class FlatlockSet {
198
224
  }
199
225
  }
200
226
 
201
- return { deps, packages, importers };
227
+ return { deps, packages, importers, snapshots };
202
228
  }
203
229
 
204
230
  /** @returns {number} */
@@ -216,6 +242,42 @@ export class FlatlockSet {
216
242
  return this.#canTraverse;
217
243
  }
218
244
 
245
+ /**
246
+ * Get workspace paths from lockfile.
247
+ * Supports npm, pnpm, and yarn berry lockfiles.
248
+ * @returns {string[]} Array of workspace paths (e.g., ['packages/foo', 'packages/bar'])
249
+ */
250
+ getWorkspacePaths() {
251
+ switch (this.#type) {
252
+ case Type.NPM:
253
+ return extractNpmWorkspacePaths(this.#packages ? { packages: this.#packages } : {});
254
+ case Type.PNPM:
255
+ return extractPnpmWorkspacePaths(this.#importers ? { importers: this.#importers } : {});
256
+ case Type.YARN_BERRY:
257
+ return extractYarnBerryWorkspacePaths(this.#packages || {});
258
+ default:
259
+ return [];
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Build workspace packages map by reading package.json files.
265
+ * @param {string} repoDir - Path to repository root
266
+ * @returns {Promise<Record<string, WorkspacePackage>>}
267
+ */
268
+ async #buildWorkspacePackages(repoDir) {
269
+ switch (this.#type) {
270
+ case Type.NPM:
271
+ return buildNpmWorkspacePackages({ packages: this.#packages || {} }, repoDir);
272
+ case Type.PNPM:
273
+ return buildPnpmWorkspacePackages({ importers: this.#importers || {} }, repoDir);
274
+ case Type.YARN_BERRY:
275
+ return buildYarnBerryWorkspacePackages(this.#packages || {}, repoDir);
276
+ default:
277
+ return {};
278
+ }
279
+ }
280
+
219
281
  /**
220
282
  * Check if a dependency exists
221
283
  * @param {string} nameAtVersion - e.g., "lodash@4.17.21"
@@ -277,7 +339,7 @@ export class FlatlockSet {
277
339
  deps.set(key, dep);
278
340
  }
279
341
  }
280
- return new FlatlockSet(INTERNAL, deps, null, null, null);
342
+ return new FlatlockSet(INTERNAL, deps, null, null, null, null);
281
343
  }
282
344
 
283
345
  /**
@@ -292,7 +354,7 @@ export class FlatlockSet {
292
354
  deps.set(key, dep);
293
355
  }
294
356
  }
295
- return new FlatlockSet(INTERNAL, deps, null, null, null);
357
+ return new FlatlockSet(INTERNAL, deps, null, null, null, null);
296
358
  }
297
359
 
298
360
  /**
@@ -307,7 +369,7 @@ export class FlatlockSet {
307
369
  deps.set(key, dep);
308
370
  }
309
371
  }
310
- return new FlatlockSet(INTERNAL, deps, null, null, null);
372
+ return new FlatlockSet(INTERNAL, deps, null, null, null, null);
311
373
  }
312
374
 
313
375
  /**
@@ -346,7 +408,7 @@ export class FlatlockSet {
346
408
  /**
347
409
  * Get transitive dependencies of a package.json
348
410
  *
349
- * For monorepos, provide workspacePath to get correct resolution.
411
+ * For monorepos, provide workspacePath and repoDir to get correct resolution.
350
412
  * Without workspacePath, assumes root package (hoisted deps only).
351
413
  *
352
414
  * NOTE: This method is only available on sets created directly from
@@ -355,10 +417,17 @@ export class FlatlockSet {
355
417
  *
356
418
  * @param {PackageJson} packageJson - Parsed package.json
357
419
  * @param {DependenciesOfOptions} [options]
358
- * @returns {FlatlockSet}
420
+ * @returns {Promise<FlatlockSet>}
359
421
  * @throws {Error} If called on a set that cannot traverse
422
+ *
423
+ * @example
424
+ * // Simple usage with repoDir (recommended)
425
+ * const deps = await lockfile.dependenciesOf(pkg, {
426
+ * workspacePath: 'packages/foo',
427
+ * repoDir: '/path/to/repo'
428
+ * });
360
429
  */
361
- dependenciesOf(packageJson, options = {}) {
430
+ async dependenciesOf(packageJson, options = {}) {
362
431
  if (!packageJson || typeof packageJson !== 'object') {
363
432
  throw new TypeError('packageJson must be a non-null object');
364
433
  }
@@ -371,17 +440,60 @@ export class FlatlockSet {
371
440
  );
372
441
  }
373
442
 
374
- const { workspacePath, dev = false, optional = true, peer = false } = options;
443
+ const { workspacePath, repoDir, dev = false, optional = true, peer = false } = options;
444
+
445
+ // Build workspacePackages if repoDir provided and not already supplied
446
+ let { workspacePackages } = options;
447
+ if (!workspacePackages && repoDir && workspacePath) {
448
+ workspacePackages = await this.#buildWorkspacePackages(repoDir);
449
+ }
375
450
 
376
451
  // Collect seed dependencies from package.json
377
452
  const seeds = this.#collectSeeds(packageJson, { dev, optional, peer });
378
453
 
379
454
  // If pnpm with workspacePath, use importers to get resolved versions
380
455
  if (this.#type === Type.PNPM && workspacePath && this.#importers) {
381
- return this.#dependenciesOfPnpm(seeds, workspacePath, { dev, optional, peer });
456
+ return this.#dependenciesOfPnpm(seeds, workspacePath, {
457
+ dev,
458
+ optional,
459
+ peer,
460
+ ...(workspacePackages && { workspacePackages })
461
+ });
462
+ }
463
+
464
+ // If yarn berry with workspace context, use workspace-aware resolution
465
+ // Auto-extract workspacePackages from lockfile if not provided
466
+ if (this.#type === Type.YARN_BERRY && workspacePath) {
467
+ const wsPackages = workspacePackages || this.#extractYarnBerryWorkspaces();
468
+ return this.#dependenciesOfYarnBerry(seeds, packageJson, {
469
+ dev,
470
+ optional,
471
+ peer,
472
+ workspacePackages: wsPackages
473
+ });
382
474
  }
383
475
 
384
- // BFS traversal for npm/yarn (hoisted resolution)
476
+ // If yarn classic with workspace packages, use workspace-aware resolution
477
+ if (this.#type === Type.YARN_CLASSIC && workspacePackages) {
478
+ return this.#dependenciesOfYarnClassic(seeds, packageJson, {
479
+ dev,
480
+ optional,
481
+ peer,
482
+ workspacePackages
483
+ });
484
+ }
485
+
486
+ // If npm with workspace packages and workspacePath, use workspace-aware resolution
487
+ if (this.#type === Type.NPM && workspacePackages && workspacePath) {
488
+ return this.#dependenciesOfNpm(seeds, workspacePath, {
489
+ dev,
490
+ optional,
491
+ peer,
492
+ workspacePackages
493
+ });
494
+ }
495
+
496
+ // BFS traversal for npm/yarn-classic (hoisted resolution)
385
497
  return this.#dependenciesOfHoisted(seeds, workspacePath);
386
498
  }
387
499
 
@@ -418,12 +530,455 @@ export class FlatlockSet {
418
530
 
419
531
  /**
420
532
  * pnpm-specific resolution using importers
421
- * @param {Set<string>} seeds
533
+ *
534
+ * pnpm monorepos have workspace packages linked via link: protocol.
535
+ * To get the full dependency tree, we need to:
536
+ * 1. Follow workspace links recursively, emitting workspace packages
537
+ * 2. Collect external deps from each visited workspace
538
+ * 3. Traverse the packages/snapshots section for transitive deps
539
+ *
540
+ * @param {Set<string>} _seeds - Unused, we derive from importers
422
541
  * @param {string} workspacePath
423
- * @param {{ dev: boolean, optional: boolean, peer: boolean }} options
542
+ * @param {{ dev: boolean, optional: boolean, peer: boolean, workspacePackages?: Record<string, {name: string, version: string}> }} options
543
+ * @returns {FlatlockSet}
544
+ */
545
+ #dependenciesOfPnpm(_seeds, workspacePath, { dev, optional, peer, workspacePackages }) {
546
+ /** @type {Map<string, Dependency>} */
547
+ const result = new Map();
548
+
549
+ // Phase 1: Follow workspace links, emit workspace packages, collect external deps
550
+ const externalDeps = new Set();
551
+ const visitedWorkspaces = new Set();
552
+ const workspaceQueue = [workspacePath];
553
+
554
+ while (workspaceQueue.length > 0) {
555
+ const ws = /** @type {string} */ (workspaceQueue.shift());
556
+ if (visitedWorkspaces.has(ws)) continue;
557
+ visitedWorkspaces.add(ws);
558
+
559
+ // If we have workspace package info, emit this workspace as a dependency
560
+ // (except for the starting workspace - dependenciesOf returns deps, not self)
561
+ if (workspacePackages && ws !== workspacePath) {
562
+ const wsPkg = workspacePackages[ws];
563
+ if (wsPkg) {
564
+ result.set(`${wsPkg.name}@${wsPkg.version}`, {
565
+ name: wsPkg.name,
566
+ version: wsPkg.version
567
+ });
568
+ }
569
+ }
570
+
571
+ const importer = this.#importers?.[ws];
572
+ if (!importer) continue;
573
+
574
+ // Collect dependencies from importer
575
+ // v9 format: { specifier: '...', version: '...' }
576
+ // older format: version string directly
577
+ const depSections = [importer.dependencies];
578
+ if (dev) depSections.push(importer.devDependencies);
579
+ if (optional) depSections.push(importer.optionalDependencies);
580
+ if (peer) depSections.push(importer.peerDependencies);
581
+
582
+ for (const deps of depSections) {
583
+ if (!deps) continue;
584
+ for (const [name, spec] of Object.entries(deps)) {
585
+ // Handle v9 object format or older string format
586
+ const version =
587
+ typeof spec === 'object' && spec !== null
588
+ ? /** @type {{version?: string}} */ (spec).version
589
+ : /** @type {string} */ (spec);
590
+
591
+ if (!version) continue;
592
+
593
+ if (version.startsWith('link:')) {
594
+ // Workspace link - resolve and follow
595
+ const linkedPath = version.slice(5); // Remove 'link:'
596
+ const resolvedPath = this.#resolveRelativePath(ws, linkedPath);
597
+ if (!visitedWorkspaces.has(resolvedPath)) {
598
+ workspaceQueue.push(resolvedPath);
599
+ }
600
+ } else {
601
+ // External dependency
602
+ externalDeps.add(name);
603
+ }
604
+ }
605
+ }
606
+ }
607
+
608
+ // Phase 2: Traverse external deps and their transitive dependencies
609
+ // In v9, dependencies are in snapshots; in older versions, they're in packages
610
+ const depsSource = this.#snapshots || this.#packages || {};
611
+ const visitedDeps = new Set();
612
+ const depQueue = [...externalDeps];
613
+
614
+ while (depQueue.length > 0) {
615
+ const name = /** @type {string} */ (depQueue.shift());
616
+ if (visitedDeps.has(name)) continue;
617
+ visitedDeps.add(name);
618
+
619
+ // Find this package in our deps map
620
+ const dep = this.#findByName(name);
621
+ if (dep) {
622
+ result.set(`${dep.name}@${dep.version}`, dep);
623
+
624
+ // Find transitive deps from snapshots/packages
625
+ // Keys are like "name@version" or "@scope/name@version" or with peer deps suffix
626
+ // In pnpm v9, same package can have multiple entries with different peer configurations
627
+ // e.g., "ts-api-utils@1.2.1(typescript@4.9.5)" and "ts-api-utils@1.2.1(typescript@5.3.3)"
628
+ // We must process ALL matching entries to capture deps from all peer variants
629
+ for (const [key, pkg] of Object.entries(depsSource)) {
630
+ const keyPackageName = this.#extractPnpmPackageName(key);
631
+ if (keyPackageName === name) {
632
+ // Found a package entry, get its dependencies
633
+ for (const transName of Object.keys(pkg.dependencies || {})) {
634
+ if (!visitedDeps.has(transName)) {
635
+ depQueue.push(transName);
636
+ }
637
+ }
638
+ if (optional) {
639
+ for (const transName of Object.keys(pkg.optionalDependencies || {})) {
640
+ if (!visitedDeps.has(transName)) {
641
+ depQueue.push(transName);
642
+ }
643
+ }
644
+ }
645
+ // NOTE: No break - continue processing all peer variants of this package
646
+ }
647
+ }
648
+ }
649
+ }
650
+
651
+ return new FlatlockSet(INTERNAL, result, null, null, null, this.#type);
652
+ }
653
+
654
+ /**
655
+ * npm-specific resolution with workspace support
656
+ *
657
+ * npm monorepos have workspace packages that are symlinked from node_modules.
658
+ * Packages can have nested node_modules with different versions.
659
+ *
660
+ * @param {Set<string>} _seeds - Seed dependency names (unused, derived from lockfile)
661
+ * @param {string} workspacePath - Path to workspace (e.g., 'workspaces/arborist')
662
+ * @param {{ dev: boolean, optional: boolean, peer: boolean, workspacePackages: Record<string, {name: string, version: string}> }} options
663
+ * @returns {FlatlockSet}
664
+ */
665
+ #dependenciesOfNpm(_seeds, workspacePath, { dev, optional, peer, workspacePackages }) {
666
+ /** @type {Map<string, Dependency>} */
667
+ const result = new Map();
668
+
669
+ // Build name -> workspace path mapping
670
+ const nameToWorkspace = new Map();
671
+ for (const [wsPath, pkg] of Object.entries(workspacePackages)) {
672
+ nameToWorkspace.set(pkg.name, wsPath);
673
+ }
674
+
675
+ // Queue entries: { name, contextPath } where contextPath is where to look for nested node_modules
676
+ // contextPath is either a workspace path or a node_modules package path
677
+ const queue = [];
678
+ const visited = new Set(); // Track "name@contextPath" to handle same package at different contexts
679
+
680
+ // Phase 1: Process workspace packages
681
+ const visitedWorkspaces = new Set();
682
+ const workspaceQueue = [workspacePath];
683
+
684
+ while (workspaceQueue.length > 0) {
685
+ const wsPath = /** @type {string} */ (workspaceQueue.shift());
686
+ if (visitedWorkspaces.has(wsPath)) continue;
687
+ visitedWorkspaces.add(wsPath);
688
+
689
+ const wsEntry = this.#packages?.[wsPath];
690
+ if (!wsEntry) continue;
691
+
692
+ // Emit this workspace package (except starting workspace)
693
+ // Name comes from workspacePackages map since lockfile may not have it
694
+ if (wsPath !== workspacePath) {
695
+ const wsPkg = workspacePackages[wsPath];
696
+ if (wsPkg?.name && wsPkg?.version) {
697
+ result.set(`${wsPkg.name}@${wsPkg.version}`, {
698
+ name: wsPkg.name,
699
+ version: wsPkg.version
700
+ });
701
+ }
702
+ }
703
+
704
+ // Collect dependencies
705
+ const depSections = [wsEntry.dependencies];
706
+ if (dev) depSections.push(wsEntry.devDependencies);
707
+ if (optional) depSections.push(wsEntry.optionalDependencies);
708
+ if (peer) depSections.push(wsEntry.peerDependencies);
709
+
710
+ for (const deps of depSections) {
711
+ if (!deps) continue;
712
+ for (const name of Object.keys(deps)) {
713
+ if (nameToWorkspace.has(name)) {
714
+ const linkedWsPath = nameToWorkspace.get(name);
715
+ if (!visitedWorkspaces.has(linkedWsPath)) {
716
+ workspaceQueue.push(linkedWsPath);
717
+ }
718
+ } else {
719
+ // Add to queue with workspace context
720
+ queue.push({ name, contextPath: wsPath });
721
+ }
722
+ }
723
+ }
724
+ }
725
+
726
+ // Phase 2: Traverse external dependencies with context-aware resolution
727
+ while (queue.length > 0) {
728
+ const { name, contextPath } = /** @type {{name: string, contextPath: string}} */ (
729
+ queue.shift()
730
+ );
731
+ const visitKey = `${name}@${contextPath}`;
732
+ if (visited.has(visitKey)) continue;
733
+ visited.add(visitKey);
734
+
735
+ // Check if this is a workspace package
736
+ if (nameToWorkspace.has(name)) {
737
+ const wsPath = nameToWorkspace.get(name);
738
+ const wsEntry = this.#packages?.[wsPath];
739
+ if (wsEntry?.name && wsEntry?.version) {
740
+ result.set(`${wsEntry.name}@${wsEntry.version}`, {
741
+ name: wsEntry.name,
742
+ version: wsEntry.version
743
+ });
744
+ }
745
+ continue;
746
+ }
747
+
748
+ // Resolve package using npm's resolution algorithm:
749
+ // 1. Check nested node_modules at contextPath
750
+ // 2. Walk up the path checking each parent's node_modules
751
+ // 3. Fall back to root node_modules
752
+ let entry = null;
753
+ let pkgPath = null;
754
+
755
+ // Try nested node_modules at context path
756
+ const nestedKey = `${contextPath}/node_modules/${name}`;
757
+ if (this.#packages?.[nestedKey]) {
758
+ entry = this.#packages[nestedKey];
759
+ pkgPath = nestedKey;
760
+ }
761
+
762
+ // Walk up context path looking for the package
763
+ if (!entry) {
764
+ const parts = contextPath.split('/');
765
+ while (parts.length > 0) {
766
+ const parentKey = `${parts.join('/')}/node_modules/${name}`;
767
+ if (this.#packages?.[parentKey]) {
768
+ entry = this.#packages[parentKey];
769
+ pkgPath = parentKey;
770
+ break;
771
+ }
772
+ parts.pop();
773
+ }
774
+ }
775
+
776
+ // Fall back to root node_modules
777
+ if (!entry) {
778
+ const rootKey = `node_modules/${name}`;
779
+ if (this.#packages?.[rootKey]) {
780
+ entry = this.#packages[rootKey];
781
+ pkgPath = rootKey;
782
+ }
783
+ }
784
+
785
+ if (!entry?.version) continue;
786
+
787
+ // Follow symlinks for workspace packages
788
+ if (entry.link && entry.resolved) {
789
+ const resolvedEntry = this.#packages?.[entry.resolved];
790
+ if (resolvedEntry?.version) {
791
+ entry = resolvedEntry;
792
+ pkgPath = entry.resolved;
793
+ }
794
+ }
795
+
796
+ result.set(`${name}@${entry.version}`, { name, version: entry.version });
797
+
798
+ // Queue transitive dependencies with this package's path as context
799
+ // The context should be the directory containing this package's node_modules
800
+ const depContext = pkgPath;
801
+
802
+ for (const transName of Object.keys(entry.dependencies || {})) {
803
+ queue.push({ name: transName, contextPath: depContext });
804
+ }
805
+ if (optional) {
806
+ for (const transName of Object.keys(entry.optionalDependencies || {})) {
807
+ queue.push({ name: transName, contextPath: depContext });
808
+ }
809
+ }
810
+ }
811
+
812
+ return new FlatlockSet(INTERNAL, result, null, null, null, this.#type);
813
+ }
814
+
815
+ /**
816
+ * Extract package name from a pnpm snapshot/packages key.
817
+ * Handles formats like:
818
+ * - name@version
819
+ * - @scope/name@version
820
+ * - name@version(peer@peerVersion)
821
+ * - @scope/name@version(peer@peerVersion)
822
+ * @param {string} key - The snapshot key
823
+ * @returns {string} The package name
824
+ */
825
+ #extractPnpmPackageName(key) {
826
+ // For scoped packages (@scope/name), find the second @
827
+ if (key.startsWith('@')) {
828
+ const secondAt = key.indexOf('@', 1);
829
+ return secondAt === -1 ? key : key.slice(0, secondAt);
830
+ }
831
+ // For unscoped packages, find the first @
832
+ const firstAt = key.indexOf('@');
833
+ return firstAt === -1 ? key : key.slice(0, firstAt);
834
+ }
835
+
836
+ /**
837
+ * Resolve a relative path from a workspace path
838
+ * @param {string} from - Current workspace path (e.g., 'packages/vue')
839
+ * @param {string} relative - Relative path (e.g., '../compiler-dom')
840
+ * @returns {string} Resolved path (e.g., 'packages/compiler-dom')
841
+ */
842
+ #resolveRelativePath(from, relative) {
843
+ const parts = from.split('/');
844
+ const relParts = relative.split('/');
845
+
846
+ for (const p of relParts) {
847
+ if (p === '..') {
848
+ parts.pop();
849
+ } else if (p !== '.') {
850
+ parts.push(p);
851
+ }
852
+ }
853
+
854
+ return parts.join('/');
855
+ }
856
+
857
+ /**
858
+ * Yarn berry-specific resolution with workspace support
859
+ *
860
+ * Yarn berry workspace packages use `workspace:*` or `workspace:^` specifiers.
861
+ * These need to be resolved to actual package versions from workspacePackages.
862
+ *
863
+ * @param {Set<string>} _seeds - Seed dependency names (unused, derived from packageJson)
864
+ * @param {PackageJson} packageJson - The workspace's package.json
865
+ * @param {{ dev: boolean, optional: boolean, peer: boolean, workspacePackages: Record<string, {name: string, version: string}> }} options
424
866
  * @returns {FlatlockSet}
425
867
  */
426
- #dependenciesOfPnpm(seeds, workspacePath, { dev, optional, peer }) {
868
+ #dependenciesOfYarnBerry(_seeds, packageJson, { dev, optional, peer, workspacePackages }) {
869
+ /** @type {Map<string, Dependency>} */
870
+ const result = new Map();
871
+ /** @type {Set<string>} */
872
+ const visited = new Set(); // Track by name@version
873
+
874
+ // Build a map of package name -> workspace path for quick lookup
875
+ const nameToWorkspace = new Map();
876
+ for (const [wsPath, pkg] of Object.entries(workspacePackages)) {
877
+ nameToWorkspace.set(pkg.name, { path: wsPath, ...pkg });
878
+ }
879
+
880
+ // Get dependency specifiers from package.json
881
+ const rootSpecs = {
882
+ ...packageJson.dependencies,
883
+ ...(dev ? packageJson.devDependencies : {}),
884
+ ...(optional ? packageJson.optionalDependencies : {}),
885
+ ...(peer ? packageJson.peerDependencies : {})
886
+ };
887
+
888
+ // Queue items are {name, spec} pairs
889
+ /** @type {Array<{name: string, spec: string}>} */
890
+ const queue = Object.entries(rootSpecs).map(([name, spec]) => ({ name, spec }));
891
+
892
+ while (queue.length > 0) {
893
+ const { name, spec } = /** @type {{name: string, spec: string}} */ (queue.shift());
894
+
895
+ const isWorkspaceDep = typeof spec === 'string' && spec.startsWith('workspace:');
896
+
897
+ let dep;
898
+ let entry;
899
+
900
+ if (isWorkspaceDep && nameToWorkspace.has(name)) {
901
+ // Use workspace package info
902
+ const wsPkg = nameToWorkspace.get(name);
903
+ dep = { name: wsPkg.name, version: wsPkg.version };
904
+ entry = this.#getYarnWorkspaceEntry(name);
905
+ } else if (nameToWorkspace.has(name)) {
906
+ // It's a workspace package referenced transitively
907
+ const wsPkg = nameToWorkspace.get(name);
908
+ dep = { name: wsPkg.name, version: wsPkg.version };
909
+ entry = this.#getYarnWorkspaceEntry(name);
910
+ } else {
911
+ // Regular npm dependency - use spec to find correct version
912
+ entry = this.#getYarnBerryEntryBySpec(name, spec);
913
+ if (entry) {
914
+ dep = { name, version: entry.version };
915
+ } else {
916
+ // Fallback to first match
917
+ dep = this.#findByName(name);
918
+ if (dep) {
919
+ entry = this.#getYarnBerryEntry(name, dep.version);
920
+ }
921
+ }
922
+ }
923
+
924
+ if (!dep) continue;
925
+
926
+ const key = `${dep.name}@${dep.version}`;
927
+ if (visited.has(key)) continue;
928
+ visited.add(key);
929
+
930
+ result.set(key, dep);
931
+
932
+ // Get transitive deps
933
+ // For workspace packages, ALWAYS use workspacePackages dependencies (from package.json)
934
+ // instead of lockfile entry (which merges prod + dev deps).
935
+ // Even if the workspace has no deps (null/empty), we should NOT fall back to lockfile.
936
+ const wsInfo = nameToWorkspace.get(name);
937
+
938
+ if (wsInfo) {
939
+ // This is a workspace package - use package.json deps (respects prod/dev separation)
940
+ const depsToTraverse = {
941
+ ...(wsInfo.dependencies || {}),
942
+ ...(dev ? wsInfo.devDependencies || {} : {}),
943
+ ...(optional ? wsInfo.optionalDependencies || {} : {}),
944
+ ...(peer ? wsInfo.peerDependencies || {} : {})
945
+ };
946
+ for (const [transName, transSpec] of Object.entries(depsToTraverse)) {
947
+ queue.push({ name: transName, spec: transSpec });
948
+ }
949
+ } else if (entry) {
950
+ // Non-workspace package - use lockfile entry
951
+ for (const [transName, transSpec] of Object.entries(entry.dependencies || {})) {
952
+ queue.push({ name: transName, spec: transSpec });
953
+ }
954
+ if (optional) {
955
+ for (const [transName, transSpec] of Object.entries(entry.optionalDependencies || {})) {
956
+ queue.push({ name: transName, spec: transSpec });
957
+ }
958
+ }
959
+ }
960
+ }
961
+
962
+ return new FlatlockSet(INTERNAL, result, null, null, null, this.#type);
963
+ }
964
+
965
+ /**
966
+ * Yarn classic-specific resolution with workspace support
967
+ *
968
+ * Yarn classic workspace packages are NOT in the lockfile - they're resolved
969
+ * from the filesystem. So when a dependency isn't found in the lockfile,
970
+ * we check if it's a workspace package.
971
+ *
972
+ * @param {Set<string>} seeds - Seed dependency names from package.json
973
+ * @param {PackageJson} _packageJson - The workspace's package.json (unused)
974
+ * @param {{ dev: boolean, optional: boolean, peer: boolean, workspacePackages: Record<string, {name: string, version: string}> }} options
975
+ * @returns {FlatlockSet}
976
+ */
977
+ #dependenciesOfYarnClassic(
978
+ seeds,
979
+ _packageJson,
980
+ { dev: _dev, optional, peer, workspacePackages }
981
+ ) {
427
982
  /** @type {Map<string, Dependency>} */
428
983
  const result = new Map();
429
984
  /** @type {Set<string>} */
@@ -431,29 +986,33 @@ export class FlatlockSet {
431
986
  /** @type {string[]} */
432
987
  const queue = [...seeds];
433
988
 
434
- // Get resolved versions from importers
435
- const importer = this.#importers?.[workspacePath] || this.#importers?.['.'] || {};
436
- const resolvedDeps = {
437
- ...importer.dependencies,
438
- ...(dev ? importer.devDependencies : {}),
439
- ...(optional ? importer.optionalDependencies : {}),
440
- ...(peer ? importer.peerDependencies : {})
441
- };
989
+ // Build a map of package name -> workspace info for quick lookup
990
+ const nameToWorkspace = new Map();
991
+ for (const [wsPath, pkg] of Object.entries(workspacePackages)) {
992
+ nameToWorkspace.set(pkg.name, { path: wsPath, ...pkg });
993
+ }
442
994
 
443
995
  while (queue.length > 0) {
444
996
  const name = /** @type {string} */ (queue.shift());
445
997
  if (visited.has(name)) continue;
446
998
  visited.add(name);
447
999
 
448
- // Get resolved version from importer or find in deps
449
- const version = resolvedDeps[name];
450
1000
  let dep;
451
-
452
- if (version) {
453
- // pnpm stores version directly or as specifier
454
- dep = this.get(`${name}@${version}`) || this.#findByName(name);
1001
+ let entry;
1002
+
1003
+ // Check if this is a workspace package
1004
+ if (nameToWorkspace.has(name)) {
1005
+ // Use workspace package info
1006
+ const wsPkg = nameToWorkspace.get(name);
1007
+ dep = { name: wsPkg.name, version: wsPkg.version };
1008
+ // Workspace packages don't have lockfile entries in yarn classic
1009
+ entry = null;
455
1010
  } else {
1011
+ // Regular npm dependency - find in lockfile
456
1012
  dep = this.#findByName(name);
1013
+ if (dep) {
1014
+ entry = this.#getYarnClassicEntry(name, dep.version);
1015
+ }
457
1016
  }
458
1017
 
459
1018
  if (!dep) continue;
@@ -461,22 +1020,135 @@ export class FlatlockSet {
461
1020
  const key = `${dep.name}@${dep.version}`;
462
1021
  result.set(key, dep);
463
1022
 
464
- // Get transitive deps
465
- const pkgKey = `/${dep.name}@${dep.version}`;
466
- const pkgEntry = this.#packages?.[pkgKey];
467
- if (pkgEntry) {
468
- for (const transName of Object.keys(pkgEntry.dependencies || {})) {
1023
+ // Get transitive deps from lockfile entry
1024
+ if (entry) {
1025
+ for (const transName of Object.keys(entry.dependencies || {})) {
469
1026
  if (!visited.has(transName)) queue.push(transName);
470
1027
  }
471
1028
  if (optional) {
472
- for (const transName of Object.keys(pkgEntry.optionalDependencies || {})) {
1029
+ for (const transName of Object.keys(entry.optionalDependencies || {})) {
1030
+ if (!visited.has(transName)) queue.push(transName);
1031
+ }
1032
+ }
1033
+ if (peer) {
1034
+ for (const transName of Object.keys(entry.peerDependencies || {})) {
473
1035
  if (!visited.has(transName)) queue.push(transName);
474
1036
  }
475
1037
  }
476
1038
  }
477
1039
  }
478
1040
 
479
- return new FlatlockSet(INTERNAL, result, null, null, this.#type);
1041
+ return new FlatlockSet(INTERNAL, result, null, null, null, this.#type);
1042
+ }
1043
+
1044
+ /**
1045
+ * Find a yarn classic entry by name and version
1046
+ * @param {string} name
1047
+ * @param {string} version
1048
+ * @returns {any}
1049
+ */
1050
+ #getYarnClassicEntry(name, version) {
1051
+ if (!this.#packages) return null;
1052
+ for (const [key, entry] of Object.entries(this.#packages)) {
1053
+ if (entry.version === version) {
1054
+ const keyName = parseYarnClassicKey(key);
1055
+ if (keyName === name) return entry;
1056
+ }
1057
+ }
1058
+ return null;
1059
+ }
1060
+
1061
+ /**
1062
+ * Find a yarn berry workspace entry by package name
1063
+ * @param {string} name
1064
+ * @returns {any}
1065
+ */
1066
+ #getYarnWorkspaceEntry(name) {
1067
+ if (!this.#packages) return null;
1068
+ for (const [key, entry] of Object.entries(this.#packages)) {
1069
+ if (
1070
+ key.includes('@workspace:') &&
1071
+ (key.startsWith(`${name}@`) || key.includes(`/${name}@`))
1072
+ ) {
1073
+ return entry;
1074
+ }
1075
+ }
1076
+ return null;
1077
+ }
1078
+
1079
+ /**
1080
+ * Find a yarn berry npm entry by name and version
1081
+ * @param {string} name
1082
+ * @param {string} version
1083
+ * @returns {any}
1084
+ */
1085
+ #getYarnBerryEntry(name, version) {
1086
+ if (!this.#packages) return null;
1087
+ // Yarn berry keys are like "@babel/types@npm:^7.24.0" and resolution is "@babel/types@npm:7.24.0"
1088
+ for (const [key, entry] of Object.entries(this.#packages)) {
1089
+ if (entry.version === version && key.includes(`${name}@`)) {
1090
+ return entry;
1091
+ }
1092
+ }
1093
+ return null;
1094
+ }
1095
+
1096
+ /**
1097
+ * Find a yarn berry entry by spec (e.g., "npm:^3.1.0")
1098
+ * Yarn berry keys contain the spec like "p-limit@npm:^3.1.0"
1099
+ * @param {string} name
1100
+ * @param {string} spec - The spec like "npm:^3.1.0" or "^3.1.0"
1101
+ * @returns {any}
1102
+ */
1103
+ #getYarnBerryEntryBySpec(name, spec) {
1104
+ if (!this.#packages || !spec) return null;
1105
+
1106
+ // Normalize spec - yarn specs may or may not have npm: prefix
1107
+ // Key format: "p-limit@npm:^3.0.2, p-limit@npm:^3.1.0"
1108
+ const normalizedSpec = spec.startsWith('npm:') ? spec : `npm:${spec}`;
1109
+ const searchKey = `${name}@${normalizedSpec}`;
1110
+
1111
+ for (const [key, entry] of Object.entries(this.#packages)) {
1112
+ // Key can have multiple specs comma-separated
1113
+ if (key.includes(searchKey)) {
1114
+ return entry;
1115
+ }
1116
+ }
1117
+ return null;
1118
+ }
1119
+
1120
+ /**
1121
+ * Extract workspace packages from yarn berry lockfile.
1122
+ * Yarn berry lockfiles contain workspace entries with `@workspace:` protocol.
1123
+ * @returns {Record<string, {name: string, version: string}>}
1124
+ */
1125
+ #extractYarnBerryWorkspaces() {
1126
+ /** @type {Record<string, {name: string, version: string}>} */
1127
+ const workspacePackages = {};
1128
+
1129
+ for (const [key, entry] of Object.entries(this.#packages || {})) {
1130
+ if (!key.includes('@workspace:')) continue;
1131
+
1132
+ // Handle potentially multiple descriptors (comma-separated)
1133
+ const descriptors = key.split(', ');
1134
+ for (const descriptor of descriptors) {
1135
+ if (!descriptor.includes('@workspace:')) continue;
1136
+
1137
+ // Find @workspace: and extract path after it
1138
+ const wsIndex = descriptor.indexOf('@workspace:');
1139
+ const path = descriptor.slice(wsIndex + '@workspace:'.length);
1140
+
1141
+ // Extract name - everything before @workspace:
1142
+ const name = descriptor.slice(0, wsIndex);
1143
+
1144
+ workspacePackages[path] = {
1145
+ name,
1146
+ version: entry.version || '0.0.0'
1147
+ };
1148
+ }
1149
+ }
1150
+
1151
+ return workspacePackages;
480
1152
  }
481
1153
 
482
1154
  /**
@@ -517,7 +1189,7 @@ export class FlatlockSet {
517
1189
  }
518
1190
  }
519
1191
 
520
- return new FlatlockSet(INTERNAL, result, null, null, this.#type);
1192
+ return new FlatlockSet(INTERNAL, result, null, null, null, this.#type);
521
1193
  }
522
1194
 
523
1195
  /**
@@ -553,6 +1225,14 @@ export class FlatlockSet {
553
1225
  if (entry?.version) {
554
1226
  return this.get(`${name}@${entry.version}`);
555
1227
  }
1228
+ // Follow workspace symlinks: link:true with resolved points to workspace
1229
+ if (entry?.link && entry?.resolved) {
1230
+ const workspaceEntry = this.#packages?.[entry.resolved];
1231
+ if (workspaceEntry?.version) {
1232
+ // Return a synthetic dependency for the workspace package
1233
+ return { name, version: workspaceEntry.version, link: true };
1234
+ }
1235
+ }
556
1236
  }
557
1237
 
558
1238
  // Fallback: iterate deps (may return arbitrary version if multiple)
@@ -580,7 +1260,16 @@ export class FlatlockSet {
580
1260
  if (this.#packages[localKey]) return this.#packages[localKey];
581
1261
  }
582
1262
  // Fall back to hoisted
583
- return this.#packages[`node_modules/${name}`] || null;
1263
+ const hoistedKey = `node_modules/${name}`;
1264
+ const hoistedEntry = this.#packages[hoistedKey];
1265
+ if (hoistedEntry) {
1266
+ // Follow workspace symlinks to get the actual package entry
1267
+ if (hoistedEntry.link && hoistedEntry.resolved) {
1268
+ return this.#packages[hoistedEntry.resolved] || hoistedEntry;
1269
+ }
1270
+ return hoistedEntry;
1271
+ }
1272
+ return null;
584
1273
  }
585
1274
  case Type.PNPM: {
586
1275
  return this.#packages[`/${name}@${version}`] || null;