flatlock 1.1.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.
package/src/set.js ADDED
@@ -0,0 +1,1307 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parseSyml } from '@yarnpkg/parsers';
3
+ import yaml from 'js-yaml';
4
+ import { detectType, Type } from './detect.js';
5
+ import {
6
+ buildNpmWorkspacePackages,
7
+ buildPnpmWorkspacePackages,
8
+ buildYarnBerryWorkspacePackages,
9
+ extractNpmWorkspacePaths,
10
+ extractPnpmWorkspacePaths,
11
+ extractYarnBerryWorkspacePaths,
12
+ fromPackageLock,
13
+ fromPnpmLock,
14
+ fromYarnBerryLock,
15
+ fromYarnClassicLock,
16
+ parseYarnBerryKey,
17
+ parseYarnClassic,
18
+ parseYarnClassicKey
19
+ } from './parsers/index.js';
20
+
21
+ /**
22
+ * @typedef {import('./parsers/npm.js').Dependency} Dependency
23
+ * @typedef {import('./detect.js').LockfileType} LockfileType
24
+ */
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
+
36
+ /**
37
+ * @typedef {Object} DependenciesOfOptions
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
40
+ * @property {boolean} [dev=false] - Include devDependencies
41
+ * @property {boolean} [optional=true] - Include optionalDependencies
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)
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} FromStringOptions
48
+ * @property {string} [path] - Path hint for type detection
49
+ * @property {LockfileType} [type] - Explicit lockfile type
50
+ */
51
+
52
+ /**
53
+ * @typedef {Object} PackageJson
54
+ * @property {Record<string, string>} [dependencies]
55
+ * @property {Record<string, string>} [devDependencies]
56
+ * @property {Record<string, string>} [optionalDependencies]
57
+ * @property {Record<string, string>} [peerDependencies]
58
+ */
59
+
60
+ /**
61
+ * @typedef {Record<string, any>} LockfilePackages
62
+ */
63
+
64
+ /**
65
+ * @typedef {Record<string, any>} LockfileImporters
66
+ */
67
+
68
+ /** Symbol to prevent direct construction */
69
+ const INTERNAL = Symbol('FlatlockSet.internal');
70
+
71
+ /**
72
+ * A Set-like container for lockfile dependencies.
73
+ *
74
+ * Identity is determined by name@version. Two dependencies with the same
75
+ * name and version are considered equal, regardless of integrity or resolved URL.
76
+ *
77
+ * All set operations return new FlatlockSet instances (immutable pattern).
78
+ *
79
+ * NOTE: Set operations (union, intersection, difference) return sets that
80
+ * cannot use dependenciesOf() because they lack lockfile traversal data.
81
+ *
82
+ * @example
83
+ * const set = await FlatlockSet.fromPath('./package-lock.json');
84
+ * console.log(set.size); // 1234
85
+ * console.log(set.has('lodash@4.17.21')); // true
86
+ *
87
+ * // Get dependencies for a specific workspace
88
+ * const pkg = JSON.parse(await readFile('./packages/foo/package.json'));
89
+ * const subset = await set.dependenciesOf(pkg, { workspacePath: 'packages/foo', repoDir: '.' });
90
+ *
91
+ * // Set operations
92
+ * const other = await FlatlockSet.fromPath('./other-lock.json');
93
+ * const common = set.intersection(other);
94
+ */
95
+ export class FlatlockSet {
96
+ /** @type {Map<string, Dependency>} */
97
+ #deps = new Map();
98
+
99
+ /** @type {LockfilePackages | null} Raw lockfile packages for traversal */
100
+ #packages = null;
101
+
102
+ /** @type {LockfileImporters | null} Workspace importers (pnpm) */
103
+ #importers = null;
104
+
105
+ /** @type {Record<string, any> | null} Snapshots (pnpm v9) */
106
+ #snapshots = null;
107
+
108
+ /** @type {LockfileType | null} */
109
+ #type = null;
110
+
111
+ /** @type {boolean} Whether this set supports dependenciesOf */
112
+ #canTraverse = false;
113
+
114
+ /**
115
+ * @param {symbol} internal - Must be INTERNAL symbol
116
+ * @param {Map<string, Dependency>} deps
117
+ * @param {LockfilePackages | null} packages
118
+ * @param {LockfileImporters | null} importers
119
+ * @param {Record<string, any> | null} snapshots
120
+ * @param {LockfileType | null} type
121
+ */
122
+ constructor(internal, deps, packages, importers, snapshots, type) {
123
+ if (internal !== INTERNAL) {
124
+ throw new Error(
125
+ 'FlatlockSet cannot be constructed directly. Use FlatlockSet.fromPath() or FlatlockSet.fromString()'
126
+ );
127
+ }
128
+ this.#deps = deps;
129
+ this.#packages = packages;
130
+ this.#importers = importers;
131
+ this.#snapshots = snapshots;
132
+ this.#type = type;
133
+ this.#canTraverse = packages !== null;
134
+ }
135
+
136
+ /**
137
+ * Create FlatlockSet from lockfile path (auto-detect type)
138
+ * @param {string} path - Path to lockfile
139
+ * @param {FromStringOptions} [options] - Parser options
140
+ * @returns {Promise<FlatlockSet>}
141
+ */
142
+ static async fromPath(path, options = {}) {
143
+ const content = await readFile(path, 'utf8');
144
+ return FlatlockSet.fromString(content, { ...options, path });
145
+ }
146
+
147
+ /**
148
+ * Create FlatlockSet from lockfile string
149
+ * @param {string} content - Lockfile content
150
+ * @param {FromStringOptions} [options] - Parser options
151
+ * @returns {FlatlockSet}
152
+ */
153
+ static fromString(content, options = {}) {
154
+ const type = options.type || detectType({ path: options.path, content });
155
+
156
+ if (!type) {
157
+ throw new Error(
158
+ 'Unable to detect lockfile type. ' +
159
+ 'Provide options.type explicitly or ensure content is a valid lockfile format.'
160
+ );
161
+ }
162
+
163
+ // Parse once, extract both deps and raw data
164
+ const { deps, packages, importers, snapshots } = FlatlockSet.#parseAll(content, type, options);
165
+
166
+ return new FlatlockSet(INTERNAL, deps, packages, importers, snapshots, type);
167
+ }
168
+
169
+ /**
170
+ * Parse lockfile once, returning both processed deps and raw data
171
+ * @param {string} content
172
+ * @param {LockfileType} type
173
+ * @param {FromStringOptions} options
174
+ * @returns {{ deps: Map<string, Dependency>, packages: LockfilePackages, importers: LockfileImporters | null, snapshots: Record<string, any> | null }}
175
+ */
176
+ static #parseAll(content, type, options) {
177
+ /** @type {Map<string, Dependency>} */
178
+ const deps = new Map();
179
+ /** @type {LockfilePackages} */
180
+ let packages = {};
181
+ /** @type {LockfileImporters | null} */
182
+ let importers = null;
183
+ /** @type {Record<string, any> | null} */
184
+ let snapshots = null;
185
+
186
+ switch (type) {
187
+ case Type.NPM: {
188
+ const lockfile = JSON.parse(content);
189
+ packages = lockfile.packages || {};
190
+ // Pass pre-parsed lockfile object to avoid re-parsing
191
+ for (const dep of fromPackageLock(lockfile, options)) {
192
+ deps.set(`${dep.name}@${dep.version}`, dep);
193
+ }
194
+ break;
195
+ }
196
+ case Type.PNPM: {
197
+ /** @type {any} */
198
+ const lockfile = yaml.load(content);
199
+ packages = lockfile.packages || {};
200
+ importers = lockfile.importers || null;
201
+ snapshots = lockfile.snapshots || null;
202
+ // Pass pre-parsed lockfile object to avoid re-parsing
203
+ for (const dep of fromPnpmLock(lockfile, options)) {
204
+ deps.set(`${dep.name}@${dep.version}`, dep);
205
+ }
206
+ break;
207
+ }
208
+ case Type.YARN_CLASSIC: {
209
+ const result = parseYarnClassic(content);
210
+ packages = result.object || {};
211
+ // Pass pre-parsed lockfile object to avoid re-parsing
212
+ for (const dep of fromYarnClassicLock(packages, options)) {
213
+ deps.set(`${dep.name}@${dep.version}`, dep);
214
+ }
215
+ break;
216
+ }
217
+ case Type.YARN_BERRY: {
218
+ packages = parseSyml(content);
219
+ // Pass pre-parsed lockfile object to avoid re-parsing
220
+ for (const dep of fromYarnBerryLock(packages, options)) {
221
+ deps.set(`${dep.name}@${dep.version}`, dep);
222
+ }
223
+ break;
224
+ }
225
+ }
226
+
227
+ return { deps, packages, importers, snapshots };
228
+ }
229
+
230
+ /** @returns {number} */
231
+ get size() {
232
+ return this.#deps.size;
233
+ }
234
+
235
+ /** @returns {LockfileType | null} */
236
+ get type() {
237
+ return this.#type;
238
+ }
239
+
240
+ /** @returns {boolean} */
241
+ get canTraverse() {
242
+ return this.#canTraverse;
243
+ }
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
+
281
+ /**
282
+ * Check if a dependency exists
283
+ * @param {string} nameAtVersion - e.g., "lodash@4.17.21"
284
+ * @returns {boolean}
285
+ */
286
+ has(nameAtVersion) {
287
+ return this.#deps.has(nameAtVersion);
288
+ }
289
+
290
+ /**
291
+ * Get a dependency by name@version
292
+ * @param {string} nameAtVersion
293
+ * @returns {Dependency | undefined}
294
+ */
295
+ get(nameAtVersion) {
296
+ return this.#deps.get(nameAtVersion);
297
+ }
298
+
299
+ /** @returns {IterableIterator<Dependency>} */
300
+ [Symbol.iterator]() {
301
+ return this.#deps.values();
302
+ }
303
+
304
+ /** @returns {IterableIterator<Dependency>} */
305
+ values() {
306
+ return this.#deps.values();
307
+ }
308
+
309
+ /** @returns {IterableIterator<string>} */
310
+ keys() {
311
+ return this.#deps.keys();
312
+ }
313
+
314
+ /** @returns {IterableIterator<[string, Dependency]>} */
315
+ entries() {
316
+ return this.#deps.entries();
317
+ }
318
+
319
+ /**
320
+ * Execute a callback for each dependency
321
+ * @param {(dep: Dependency, key: string, set: FlatlockSet) => void} callback
322
+ * @param {any} [thisArg]
323
+ */
324
+ forEach(callback, thisArg) {
325
+ for (const [key, dep] of this.#deps) {
326
+ callback.call(thisArg, dep, key, this);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Union of this set with another
332
+ * @param {FlatlockSet} other
333
+ * @returns {FlatlockSet}
334
+ */
335
+ union(other) {
336
+ const deps = new Map(this.#deps);
337
+ for (const [key, dep] of other.#deps) {
338
+ if (!deps.has(key)) {
339
+ deps.set(key, dep);
340
+ }
341
+ }
342
+ return new FlatlockSet(INTERNAL, deps, null, null, null, null);
343
+ }
344
+
345
+ /**
346
+ * Intersection of this set with another
347
+ * @param {FlatlockSet} other
348
+ * @returns {FlatlockSet}
349
+ */
350
+ intersection(other) {
351
+ const deps = new Map();
352
+ for (const [key, dep] of this.#deps) {
353
+ if (other.has(key)) {
354
+ deps.set(key, dep);
355
+ }
356
+ }
357
+ return new FlatlockSet(INTERNAL, deps, null, null, null, null);
358
+ }
359
+
360
+ /**
361
+ * Difference: elements in this set but not in other
362
+ * @param {FlatlockSet} other
363
+ * @returns {FlatlockSet}
364
+ */
365
+ difference(other) {
366
+ const deps = new Map();
367
+ for (const [key, dep] of this.#deps) {
368
+ if (!other.has(key)) {
369
+ deps.set(key, dep);
370
+ }
371
+ }
372
+ return new FlatlockSet(INTERNAL, deps, null, null, null, null);
373
+ }
374
+
375
+ /**
376
+ * Check if this set is a subset of another
377
+ * @param {FlatlockSet} other
378
+ * @returns {boolean}
379
+ */
380
+ isSubsetOf(other) {
381
+ for (const key of this.#deps.keys()) {
382
+ if (!other.has(key)) return false;
383
+ }
384
+ return true;
385
+ }
386
+
387
+ /**
388
+ * Check if this set is a superset of another
389
+ * @param {FlatlockSet} other
390
+ * @returns {boolean}
391
+ */
392
+ isSupersetOf(other) {
393
+ return other.isSubsetOf(this);
394
+ }
395
+
396
+ /**
397
+ * Check if this set has no elements in common with another
398
+ * @param {FlatlockSet} other
399
+ * @returns {boolean}
400
+ */
401
+ isDisjointFrom(other) {
402
+ for (const key of this.#deps.keys()) {
403
+ if (other.has(key)) return false;
404
+ }
405
+ return true;
406
+ }
407
+
408
+ /**
409
+ * Get transitive dependencies of a package.json
410
+ *
411
+ * For monorepos, provide workspacePath and repoDir to get correct resolution.
412
+ * Without workspacePath, assumes root package (hoisted deps only).
413
+ *
414
+ * NOTE: This method is only available on sets created directly from
415
+ * fromPath/fromString. Sets created via union/intersection/difference
416
+ * cannot use this method (canTraverse will be false).
417
+ *
418
+ * @param {PackageJson} packageJson - Parsed package.json
419
+ * @param {DependenciesOfOptions} [options]
420
+ * @returns {Promise<FlatlockSet>}
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
+ * });
429
+ */
430
+ async dependenciesOf(packageJson, options = {}) {
431
+ if (!packageJson || typeof packageJson !== 'object') {
432
+ throw new TypeError('packageJson must be a non-null object');
433
+ }
434
+
435
+ if (!this.#canTraverse) {
436
+ throw new Error(
437
+ 'dependenciesOf() requires lockfile data. ' +
438
+ 'This set was created via set operations and cannot traverse dependencies. ' +
439
+ 'Use dependenciesOf() on the original set before set operations.'
440
+ );
441
+ }
442
+
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
+ }
450
+
451
+ // Collect seed dependencies from package.json
452
+ const seeds = this.#collectSeeds(packageJson, { dev, optional, peer });
453
+
454
+ // If pnpm with workspacePath, use importers to get resolved versions
455
+ if (this.#type === Type.PNPM && workspacePath && this.#importers) {
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
+ });
474
+ }
475
+
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)
497
+ return this.#dependenciesOfHoisted(seeds, workspacePath);
498
+ }
499
+
500
+ /**
501
+ * Collect seed dependency names from package.json
502
+ * @param {PackageJson} packageJson
503
+ * @param {{ dev: boolean, optional: boolean, peer: boolean }} options
504
+ * @returns {Set<string>}
505
+ */
506
+ #collectSeeds(packageJson, { dev, optional, peer }) {
507
+ const seeds = new Set();
508
+
509
+ for (const name of Object.keys(packageJson.dependencies || {})) {
510
+ seeds.add(name);
511
+ }
512
+ if (dev) {
513
+ for (const name of Object.keys(packageJson.devDependencies || {})) {
514
+ seeds.add(name);
515
+ }
516
+ }
517
+ if (optional) {
518
+ for (const name of Object.keys(packageJson.optionalDependencies || {})) {
519
+ seeds.add(name);
520
+ }
521
+ }
522
+ if (peer) {
523
+ for (const name of Object.keys(packageJson.peerDependencies || {})) {
524
+ seeds.add(name);
525
+ }
526
+ }
527
+
528
+ return seeds;
529
+ }
530
+
531
+ /**
532
+ * pnpm-specific resolution using importers
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
541
+ * @param {string} workspacePath
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
866
+ * @returns {FlatlockSet}
867
+ */
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
+ ) {
982
+ /** @type {Map<string, Dependency>} */
983
+ const result = new Map();
984
+ /** @type {Set<string>} */
985
+ const visited = new Set();
986
+ /** @type {string[]} */
987
+ const queue = [...seeds];
988
+
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
+ }
994
+
995
+ while (queue.length > 0) {
996
+ const name = /** @type {string} */ (queue.shift());
997
+ if (visited.has(name)) continue;
998
+ visited.add(name);
999
+
1000
+ let dep;
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;
1010
+ } else {
1011
+ // Regular npm dependency - find in lockfile
1012
+ dep = this.#findByName(name);
1013
+ if (dep) {
1014
+ entry = this.#getYarnClassicEntry(name, dep.version);
1015
+ }
1016
+ }
1017
+
1018
+ if (!dep) continue;
1019
+
1020
+ const key = `${dep.name}@${dep.version}`;
1021
+ result.set(key, dep);
1022
+
1023
+ // Get transitive deps from lockfile entry
1024
+ if (entry) {
1025
+ for (const transName of Object.keys(entry.dependencies || {})) {
1026
+ if (!visited.has(transName)) queue.push(transName);
1027
+ }
1028
+ if (optional) {
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 || {})) {
1035
+ if (!visited.has(transName)) queue.push(transName);
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+
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;
1152
+ }
1153
+
1154
+ /**
1155
+ * npm/yarn resolution with hoisting
1156
+ * @param {Set<string>} seeds
1157
+ * @param {string} [workspacePath]
1158
+ * @returns {FlatlockSet}
1159
+ */
1160
+ #dependenciesOfHoisted(seeds, workspacePath) {
1161
+ /** @type {Map<string, Dependency>} */
1162
+ const result = new Map();
1163
+ /** @type {Set<string>} */
1164
+ const visited = new Set();
1165
+ /** @type {string[]} */
1166
+ const queue = [...seeds];
1167
+
1168
+ while (queue.length > 0) {
1169
+ const name = /** @type {string} */ (queue.shift());
1170
+ if (visited.has(name)) continue;
1171
+ visited.add(name);
1172
+
1173
+ // Find package: check workspace-local first, then hoisted
1174
+ const dep = this.#findPackage(name, workspacePath);
1175
+ if (!dep) continue;
1176
+
1177
+ const key = `${dep.name}@${dep.version}`;
1178
+ result.set(key, dep);
1179
+
1180
+ // Get transitive deps from raw lockfile
1181
+ const entry = this.#getPackageEntry(dep.name, dep.version, workspacePath);
1182
+ if (entry) {
1183
+ for (const transName of Object.keys(entry.dependencies || {})) {
1184
+ if (!visited.has(transName)) queue.push(transName);
1185
+ }
1186
+ for (const transName of Object.keys(entry.optionalDependencies || {})) {
1187
+ if (!visited.has(transName)) queue.push(transName);
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ return new FlatlockSet(INTERNAL, result, null, null, null, this.#type);
1193
+ }
1194
+
1195
+ /**
1196
+ * Find a package by name, checking workspace-local then hoisted
1197
+ * @param {string} name
1198
+ * @param {string} [workspacePath]
1199
+ * @returns {Dependency | undefined}
1200
+ */
1201
+ #findPackage(name, workspacePath) {
1202
+ if (this.#type === Type.NPM && workspacePath) {
1203
+ // Check workspace-local node_modules first
1204
+ const localKey = `${workspacePath}/node_modules/${name}`;
1205
+ const localEntry = this.#packages?.[localKey];
1206
+ if (localEntry?.version) {
1207
+ return this.get(`${name}@${localEntry.version}`);
1208
+ }
1209
+ }
1210
+
1211
+ // Fall back to hoisted (root node_modules)
1212
+ return this.#findByName(name);
1213
+ }
1214
+
1215
+ /**
1216
+ * Find a dependency by name (returns hoisted/first match)
1217
+ * @param {string} name
1218
+ * @returns {Dependency | undefined}
1219
+ */
1220
+ #findByName(name) {
1221
+ // For npm, check root node_modules path first
1222
+ if (this.#type === Type.NPM) {
1223
+ const rootKey = `node_modules/${name}`;
1224
+ const entry = this.#packages?.[rootKey];
1225
+ if (entry?.version) {
1226
+ return this.get(`${name}@${entry.version}`);
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
+ }
1236
+ }
1237
+
1238
+ // Fallback: iterate deps (may return arbitrary version if multiple)
1239
+ for (const dep of this.#deps.values()) {
1240
+ if (dep.name === name) return dep;
1241
+ }
1242
+ return undefined;
1243
+ }
1244
+
1245
+ /**
1246
+ * Get raw package entry for transitive dep lookup
1247
+ * @param {string} name
1248
+ * @param {string} version
1249
+ * @param {string} [workspacePath]
1250
+ * @returns {any}
1251
+ */
1252
+ #getPackageEntry(name, version, workspacePath) {
1253
+ if (!this.#packages) return null;
1254
+
1255
+ switch (this.#type) {
1256
+ case Type.NPM: {
1257
+ // Check workspace-local first
1258
+ if (workspacePath) {
1259
+ const localKey = `${workspacePath}/node_modules/${name}`;
1260
+ if (this.#packages[localKey]) return this.#packages[localKey];
1261
+ }
1262
+ // Fall back to hoisted
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;
1273
+ }
1274
+ case Type.PNPM: {
1275
+ return this.#packages[`/${name}@${version}`] || null;
1276
+ }
1277
+ case Type.YARN_CLASSIC: {
1278
+ for (const [key, entry] of Object.entries(this.#packages)) {
1279
+ if (entry.version === version) {
1280
+ const keyName = parseYarnClassicKey(key);
1281
+ if (keyName === name) return entry;
1282
+ }
1283
+ }
1284
+ return null;
1285
+ }
1286
+ case Type.YARN_BERRY: {
1287
+ for (const [key, entry] of Object.entries(this.#packages)) {
1288
+ if (entry.version === version) {
1289
+ const keyName = parseYarnBerryKey(key);
1290
+ if (keyName === name) return entry;
1291
+ }
1292
+ }
1293
+ return null;
1294
+ }
1295
+ default:
1296
+ return null;
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * Convert to array
1302
+ * @returns {Dependency[]}
1303
+ */
1304
+ toArray() {
1305
+ return [...this.#deps.values()];
1306
+ }
1307
+ }