dependency-radar 0.3.1 → 0.5.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.
@@ -0,0 +1,1146 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.tryBuildDependencyTreeFromLockfile = tryBuildDependencyTreeFromLockfile;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const yaml_1 = __importDefault(require("yaml"));
10
+ const treeCache = new Map();
11
+ const parseCache = new Map();
12
+ /**
13
+ * Builds a dependency tree from the project's package manager lockfile, using cached results when available.
14
+ *
15
+ * @param projectPath - Filesystem path of the project (used to locate package.json and determine the lockfile context)
16
+ * @param tool - Package manager to parse (`npm`, `pnpm`, or `yarn`)
17
+ * @param lockfileSearchRoot - Optional directory to start upward lockfile lookup; defaults to `projectPath`
18
+ * @returns A LockfileTreeResult containing the lockfile `sourceFile` and resolved dependency `data`, or `undefined` if no valid lockfile could be found or parsed
19
+ */
20
+ async function tryBuildDependencyTreeFromLockfile(projectPath, tool, lockfileSearchRoot) {
21
+ const searchRoot = path_1.default.resolve(lockfileSearchRoot || projectPath);
22
+ const cacheKey = `${tool}:${path_1.default.resolve(projectPath)}:${searchRoot}`;
23
+ if (treeCache.has(cacheKey)) {
24
+ const cached = treeCache.get(cacheKey);
25
+ return cached || undefined;
26
+ }
27
+ try {
28
+ let result;
29
+ if (tool === 'pnpm') {
30
+ result = parsePnpmTree(projectPath, searchRoot);
31
+ }
32
+ else if (tool === 'npm') {
33
+ result = parseNpmTree(projectPath, searchRoot);
34
+ }
35
+ else {
36
+ result = parseYarnTree(projectPath, searchRoot);
37
+ }
38
+ treeCache.set(cacheKey, result || null);
39
+ return result;
40
+ }
41
+ catch {
42
+ treeCache.set(cacheKey, null);
43
+ return undefined;
44
+ }
45
+ }
46
+ /**
47
+ * Builds a dependency tree from a pnpm lockfile for the given project path.
48
+ *
49
+ * Locates the nearest pnpm-lock.yaml, resolves the appropriate importer for the project,
50
+ * and constructs a normalized ResolvedTree of root dependencies by traversing pnpm package snapshots.
51
+ *
52
+ * @param projectPath - Filesystem path to the project directory used to locate the lockfile and determine the importer
53
+ * @param searchRoot - Optional directory at which to stop the upward search for pnpm-lock.yaml
54
+ * @returns A LockfileTreeResult containing `sourceFile` (the resolved lockfile path) and `data` (the resolved dependency tree),
55
+ * or `undefined` if the lockfile, importer, or required parsed data cannot be found or parsed
56
+ */
57
+ function parsePnpmTree(projectPath, searchRoot) {
58
+ const lockPath = findUpwards(projectPath, ['pnpm-lock.yaml'], searchRoot);
59
+ if (!lockPath)
60
+ return undefined;
61
+ const parsed = getCachedYaml(lockPath);
62
+ if (!parsed || typeof parsed !== 'object')
63
+ return undefined;
64
+ const importers = parsed.importers && typeof parsed.importers === 'object' ? parsed.importers : undefined;
65
+ if (!importers)
66
+ return undefined;
67
+ const lockDir = path_1.default.dirname(lockPath);
68
+ const importerKey = resolvePnpmImporterKey(projectPath, lockDir, importers);
69
+ if (!importerKey)
70
+ return undefined;
71
+ const importer = importers[importerKey];
72
+ if (!importer || typeof importer !== 'object')
73
+ return undefined;
74
+ const packageSnapshots = buildPnpmSnapshotMap(parsed.packages, parsed.snapshots);
75
+ const packageIndex = buildPnpmIndex(packageSnapshots);
76
+ const installState = createPnpmInstallState(projectPath);
77
+ const memo = new Map();
78
+ const rootDeps = collectPnpmImporterDependencies(importer);
79
+ const dependencies = {};
80
+ for (const [depName, depRef] of Object.entries(rootDeps)) {
81
+ const resolved = resolvePnpmDependency(depName, depRef, packageIndex);
82
+ if (!resolved)
83
+ continue;
84
+ const node = buildPnpmNode(resolved.packageKey, packageSnapshots, packageIndex, memo, installState, new Set());
85
+ if (node)
86
+ dependencies[node.name] = node;
87
+ }
88
+ return {
89
+ sourceFile: lockPath,
90
+ data: { dependencies }
91
+ };
92
+ }
93
+ /**
94
+ * Determine the importer key in a pnpm lockfile that corresponds to a project path.
95
+ *
96
+ * @param projectPath - Absolute path to the project whose importer key should be resolved
97
+ * @param lockDir - Directory containing the pnpm lockfile
98
+ * @param importers - The `importers` object from the parsed pnpm lockfile
99
+ * @returns The matching importer key from `importers`, or `undefined` if no match is found
100
+ */
101
+ function resolvePnpmImporterKey(projectPath, lockDir, importers) {
102
+ const rel = toPosixRelative(lockDir, projectPath);
103
+ const normalized = rel === '' ? '.' : rel;
104
+ const candidates = [normalized, normalized.replace(/^\.\//, '')];
105
+ if (normalized !== '.') {
106
+ candidates.push(`./${normalized}`);
107
+ }
108
+ for (const candidate of candidates) {
109
+ if (candidate in importers)
110
+ return candidate;
111
+ }
112
+ if ('.' in importers && normalized === '.') {
113
+ return '.';
114
+ }
115
+ return undefined;
116
+ }
117
+ /**
118
+ * Merge pnpm `packages` and `snapshots` maps into a single map of package keys to combined metadata.
119
+ *
120
+ * @param packages - Map of packageKey to package metadata (may be undefined)
121
+ * @param snapshots - Map of packageKey to snapshot metadata (may be undefined)
122
+ * @returns A map where each key is a packageKey and the value is the merged metadata object. Top-level snapshot fields override package fields when present; `dependencies`, `optionalDependencies`, and `peerDependencies` are merged with snapshot entries taking precedence over package entries.
123
+ */
124
+ function buildPnpmSnapshotMap(packages, snapshots) {
125
+ const out = {};
126
+ const keys = new Set([
127
+ ...Object.keys(packages || {}),
128
+ ...Object.keys(snapshots || {})
129
+ ]);
130
+ for (const key of keys) {
131
+ const pkg = packages === null || packages === void 0 ? void 0 : packages[key];
132
+ const snap = snapshots === null || snapshots === void 0 ? void 0 : snapshots[key];
133
+ out[key] = {
134
+ ...(pkg && typeof pkg === 'object' ? pkg : {}),
135
+ ...(snap && typeof snap === 'object' ? snap : {}),
136
+ dependencies: mergeStringRecord(pkg === null || pkg === void 0 ? void 0 : pkg.dependencies, snap === null || snap === void 0 ? void 0 : snap.dependencies),
137
+ optionalDependencies: mergeStringRecord(pkg === null || pkg === void 0 ? void 0 : pkg.optionalDependencies, snap === null || snap === void 0 ? void 0 : snap.optionalDependencies),
138
+ peerDependencies: mergeStringRecord(pkg === null || pkg === void 0 ? void 0 : pkg.peerDependencies, snap === null || snap === void 0 ? void 0 : snap.peerDependencies)
139
+ };
140
+ }
141
+ return out;
142
+ }
143
+ /**
144
+ * Build lookup maps that resolve pnpm package keys by exact and stripped package identifiers.
145
+ *
146
+ * Creates two maps: one mapping the full `name@ref` (exact) to the original package key, and
147
+ * another mapping `name@ref` with any pnpm peer suffix removed (stripped) to the original package key.
148
+ *
149
+ * @param snapshots - The pnpm packages/snapshots object keyed by packageKey from the lockfile
150
+ * @returns An object containing:
151
+ * - `byExact`: Map from `name@ref` to the original packageKey
152
+ * - `byStripped`: Map from `name@ref` (with peer suffix removed) to the original packageKey
153
+ */
154
+ function buildPnpmIndex(snapshots) {
155
+ const byExact = new Map();
156
+ const byStripped = new Map();
157
+ for (const packageKey of Object.keys(snapshots)) {
158
+ const parsed = parsePnpmPackageKey(packageKey);
159
+ if (!parsed)
160
+ continue;
161
+ const exact = `${parsed.name}@${parsed.ref}`;
162
+ const stripped = `${parsed.name}@${stripPnpmPeerSuffix(parsed.ref)}`;
163
+ if (!byExact.has(exact))
164
+ byExact.set(exact, packageKey);
165
+ if (!byStripped.has(stripped))
166
+ byStripped.set(stripped, packageKey);
167
+ }
168
+ return { byExact, byStripped };
169
+ }
170
+ /**
171
+ * Collects declared dependency references from a pnpm importer section.
172
+ *
173
+ * @param importer - The importer object from a pnpm lockfile (an entry under `importers`) containing dependency sections.
174
+ * @returns A map from dependency name to its lockfile reference string; excludes entries with missing/invalid refs or workspace-like specifiers.
175
+ */
176
+ function collectPnpmImporterDependencies(importer) {
177
+ const out = {};
178
+ for (const key of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
179
+ const section = importer === null || importer === void 0 ? void 0 : importer[key];
180
+ if (!section || typeof section !== 'object')
181
+ continue;
182
+ for (const [depName, rawValue] of Object.entries(section)) {
183
+ const ref = extractPnpmRef(rawValue);
184
+ if (!ref)
185
+ continue;
186
+ if (isWorkspaceLikeSpecifier(ref))
187
+ continue;
188
+ out[depName] = ref;
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+ /**
194
+ * Resolve a pnpm dependency reference to the corresponding lockfile package key.
195
+ *
196
+ * @param dependencyName - The dependency name used when resolving aliases.
197
+ * @param rawRef - The raw pnpm reference string from the lockfile.
198
+ * @param index - Lookup maps: `byExact` maps exact `name@ref` keys to package keys, `byStripped` maps peer-stripped `name@ref` keys to package keys.
199
+ * @returns `{ packageKey }` with the matched package key, or `undefined` if the reference cannot be resolved.
200
+ */
201
+ function resolvePnpmDependency(dependencyName, rawRef, index) {
202
+ const normalized = normalizePnpmRef(rawRef, dependencyName);
203
+ if (!normalized)
204
+ return undefined;
205
+ const exact = `${normalized.name}@${normalized.ref}`;
206
+ const stripped = `${normalized.name}@${stripPnpmPeerSuffix(normalized.ref)}`;
207
+ const packageKey = index.byExact.get(exact) ||
208
+ index.byStripped.get(stripped) ||
209
+ (normalized.ref.startsWith('/') ? index.byExact.get(`${normalized.name}@${normalized.ref.slice(1)}`) : undefined);
210
+ if (!packageKey)
211
+ return undefined;
212
+ return { packageKey };
213
+ }
214
+ /**
215
+ * Build a ResolvedNode for a pnpm package key by resolving its snapshot entry and recursively attaching installed child dependencies.
216
+ *
217
+ * @param packageKey - The pnpm package key (as found in lockfile snapshots) to resolve.
218
+ * @param snapshots - Map of packageKey to merged snapshot/package metadata used to look up dependency refs.
219
+ * @param index - Lookup maps (`byExact`, `byStripped`) used to resolve dependency refs to package keys.
220
+ * @param memo - Memoization cache mapping packageKey to previously built ResolvedNode or `undefined`; populated with the result before returning.
221
+ * @param installState - pnpm install state used to determine whether a package is considered installed locally.
222
+ * @param stack - Set used for cycle detection during recursive construction; the function adds and removes entries as it descends.
223
+ * @returns The constructed ResolvedNode for `packageKey`, or `undefined` if the package cannot be parsed, is not installed, or would create a cycle.
224
+ */
225
+ function buildPnpmNode(packageKey, snapshots, index, memo, installState, stack) {
226
+ if (memo.has(packageKey))
227
+ return memo.get(packageKey);
228
+ if (stack.has(packageKey))
229
+ return undefined;
230
+ const parsedKey = parsePnpmPackageKey(packageKey);
231
+ if (!parsedKey)
232
+ return undefined;
233
+ const version = extractVersionFromPnpmRef(parsedKey.ref);
234
+ if (!version)
235
+ return undefined;
236
+ if (!isPnpmPackageInstalled(parsedKey.name, version, installState)) {
237
+ memo.set(packageKey, undefined);
238
+ return undefined;
239
+ }
240
+ const snapshot = snapshots[packageKey] || {};
241
+ const out = {
242
+ name: parsedKey.name,
243
+ version,
244
+ dependencies: {}
245
+ };
246
+ stack.add(packageKey);
247
+ const childRefs = mergeStringRecord(snapshot === null || snapshot === void 0 ? void 0 : snapshot.dependencies, mergeStringRecord(snapshot === null || snapshot === void 0 ? void 0 : snapshot.optionalDependencies, snapshot === null || snapshot === void 0 ? void 0 : snapshot.peerDependencies));
248
+ for (const [childName, childRef] of Object.entries(childRefs)) {
249
+ if (!childRef || isWorkspaceLikeSpecifier(childRef))
250
+ continue;
251
+ const resolved = resolvePnpmDependency(childName, childRef, index);
252
+ if (!resolved)
253
+ continue;
254
+ const childNode = buildPnpmNode(resolved.packageKey, snapshots, index, memo, installState, stack);
255
+ if (!childNode)
256
+ continue;
257
+ out.dependencies[childNode.name] = childNode;
258
+ }
259
+ stack.delete(packageKey);
260
+ if (out.dependencies && Object.keys(out.dependencies).length === 0) {
261
+ delete out.dependencies;
262
+ }
263
+ memo.set(packageKey, out);
264
+ return out;
265
+ }
266
+ /**
267
+ * Parse a pnpm package key into its package name and reference.
268
+ *
269
+ * @param key - The raw package key from a pnpm lockfile (may be prefixed with '/')
270
+ * @returns An object with `name` and `ref` when `key` follows pnpm's package-key format, `undefined` otherwise.
271
+ */
272
+ function parsePnpmPackageKey(key) {
273
+ const normalized = key.startsWith('/') ? key.slice(1) : key;
274
+ if (!normalized)
275
+ return undefined;
276
+ if (normalized.startsWith('@')) {
277
+ const slashIndex = normalized.indexOf('/');
278
+ if (slashIndex < 0)
279
+ return undefined;
280
+ const atIndex = normalized.indexOf('@', slashIndex + 1);
281
+ if (atIndex < 0)
282
+ return undefined;
283
+ const name = normalized.slice(0, atIndex);
284
+ const ref = normalized.slice(atIndex + 1);
285
+ if (!name || !ref)
286
+ return undefined;
287
+ return { name, ref };
288
+ }
289
+ const atIndex = normalized.indexOf('@');
290
+ if (atIndex < 0)
291
+ return undefined;
292
+ const name = normalized.slice(0, atIndex);
293
+ const ref = normalized.slice(atIndex + 1);
294
+ if (!name || !ref)
295
+ return undefined;
296
+ return { name, ref };
297
+ }
298
+ /**
299
+ * Removes a trailing parenthesized peer-dependency suffix from a pnpm package reference.
300
+ *
301
+ * @param ref - A pnpm package reference that may end with a parenthesized peer suffix (e.g., "pkg@1.2.3 (peer)")
302
+ * @returns The reference with a trailing "(...)" suffix removed, if present
303
+ */
304
+ function stripPnpmPeerSuffix(ref) {
305
+ return ref.replace(/\(.+\)$/g, '');
306
+ }
307
+ /**
308
+ * Extracts a version string from a pnpm package reference.
309
+ *
310
+ * @param ref - Raw pnpm package reference (e.g., `npm:pkg@1.2.3`, `@scope/pkg@1.0.0`, or values with peer-suffixes like `pkg@1.0.0 (peer)`). Workspace-like specifiers (`link:`, `workspace:`, `file:`, `portal:`) may be provided.
311
+ * @returns The version portion of the reference, or an empty string if a version cannot be determined (including when the reference is a workspace-like specifier).
312
+ */
313
+ function extractVersionFromPnpmRef(ref) {
314
+ const stripped = stripPnpmPeerSuffix(ref).trim();
315
+ if (!stripped || isWorkspaceLikeSpecifier(stripped))
316
+ return '';
317
+ if (stripped.startsWith('npm:')) {
318
+ const target = stripped.slice(4);
319
+ const at = target.lastIndexOf('@');
320
+ if (at > 0)
321
+ return target.slice(at + 1);
322
+ return target;
323
+ }
324
+ return stripped;
325
+ }
326
+ /**
327
+ * Extracts a string reference from a pnpm package value.
328
+ *
329
+ * @param value - A pnpm package reference which may be a string or an object containing a `version` field.
330
+ * @returns The trimmed string reference if present, `undefined` otherwise.
331
+ */
332
+ function extractPnpmRef(value) {
333
+ if (typeof value === 'string') {
334
+ const trimmed = value.trim();
335
+ return trimmed || undefined;
336
+ }
337
+ if (!value || typeof value !== 'object')
338
+ return undefined;
339
+ const version = value.version;
340
+ if (typeof version === 'string') {
341
+ const trimmed = version.trim();
342
+ return trimmed || undefined;
343
+ }
344
+ return undefined;
345
+ }
346
+ /**
347
+ * Normalize a pnpm dependency reference into a { name, ref } shape or skip workspace-like/empty refs.
348
+ *
349
+ * @param rawRef - The raw reference string from the lockfile (may be an npm alias like `npm:pkg@1.0.0`, a path, or a workspace-like specifier).
350
+ * @param dependencyName - The dependency name to use when the ref does not provide an explicit package name.
351
+ * @returns The resolved `{ name, ref }` pair with a cleaned `ref`, or `undefined` if `rawRef` is empty or a workspace-like specifier.
352
+ */
353
+ function normalizePnpmRef(rawRef, dependencyName) {
354
+ const trimmed = rawRef.trim();
355
+ if (!trimmed || isWorkspaceLikeSpecifier(trimmed))
356
+ return undefined;
357
+ if (!trimmed.startsWith('npm:')) {
358
+ const cleaned = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
359
+ return { name: dependencyName, ref: cleaned };
360
+ }
361
+ const target = trimmed.slice(4);
362
+ const parsed = parsePackageAliasTarget(target);
363
+ if (!parsed) {
364
+ return { name: dependencyName, ref: target };
365
+ }
366
+ return parsed;
367
+ }
368
+ /**
369
+ * Parse a package alias target string (e.g. `pkg@1.2.3` or `@scope/pkg@1.2.3`) into its package name and reference.
370
+ *
371
+ * @param value - The alias target string to parse.
372
+ * @returns An object with `name` (package name or scoped name) and `ref` (the part after the `@`), or `undefined` if `value` is not a valid alias target.
373
+ */
374
+ function parsePackageAliasTarget(value) {
375
+ if (!value)
376
+ return undefined;
377
+ if (value.startsWith('@')) {
378
+ const slashIndex = value.indexOf('/');
379
+ if (slashIndex < 0)
380
+ return undefined;
381
+ const atIndex = value.indexOf('@', slashIndex + 1);
382
+ if (atIndex < 0)
383
+ return undefined;
384
+ return {
385
+ name: value.slice(0, atIndex),
386
+ ref: value.slice(atIndex + 1)
387
+ };
388
+ }
389
+ const atIndex = value.lastIndexOf('@');
390
+ if (atIndex <= 0)
391
+ return undefined;
392
+ return {
393
+ name: value.slice(0, atIndex),
394
+ ref: value.slice(atIndex + 1)
395
+ };
396
+ }
397
+ /**
398
+ * Builds a dependency tree from an npm lockfile found by searching upwards from the project path.
399
+ *
400
+ * Searches for `npm-shrinkwrap.json` or `package-lock.json`. If a lockfile is found and contains a resolvable
401
+ * dependency structure (either a `packages` map or legacy `dependencies`), returns a LockfileTreeResult for that lockfile.
402
+ *
403
+ * @param projectPath - Path inside the project where the search for a lockfile starts
404
+ * @param searchRoot - Directory at which to stop the upward search (optional)
405
+ * @returns A LockfileTreeResult when a suitable npm lockfile and dependency tree are found, `undefined` otherwise
406
+ */
407
+ function parseNpmTree(projectPath, searchRoot) {
408
+ const lockPath = findUpwards(projectPath, ['npm-shrinkwrap.json', 'package-lock.json'], searchRoot);
409
+ if (!lockPath)
410
+ return undefined;
411
+ const parsed = getCachedJson(lockPath);
412
+ if (!parsed || typeof parsed !== 'object')
413
+ return undefined;
414
+ if (parsed.packages && typeof parsed.packages === 'object') {
415
+ const data = parseNpmTreeFromPackages(parsed.packages, projectPath, path_1.default.dirname(lockPath));
416
+ if (!data)
417
+ return undefined;
418
+ return { sourceFile: lockPath, data };
419
+ }
420
+ if (parsed.dependencies && typeof parsed.dependencies === 'object') {
421
+ const dependencies = {};
422
+ for (const [depName, node] of Object.entries(parsed.dependencies)) {
423
+ const normalized = normalizeLegacyNpmNode(depName, node);
424
+ if (normalized)
425
+ dependencies[depName] = normalized;
426
+ }
427
+ return { sourceFile: lockPath, data: { dependencies } };
428
+ }
429
+ return undefined;
430
+ }
431
+ /**
432
+ * Builds a dependency tree from an npm lockfile "packages" map for the specified project.
433
+ *
434
+ * @param packages - The lockfile `packages` map (keys are normalized package paths from package-lock.json or npm-shrinkwrap.json)
435
+ * @param projectPath - The project directory to resolve as the root for dependency collection
436
+ * @param lockDir - The directory containing the lockfile; used to compute the root package key
437
+ * @returns A ResolvedTree mapping root dependency names to resolved nodes, or `undefined` if no valid root entry exists in `packages`
438
+ */
439
+ function parseNpmTreeFromPackages(packages, projectPath, lockDir) {
440
+ const projectRel = toPosixRelative(lockDir, projectPath);
441
+ const rootKey = projectRel === '' ? '' : projectRel;
442
+ if (!(rootKey in packages) && rootKey !== '') {
443
+ return undefined;
444
+ }
445
+ const packageKey = rootKey;
446
+ const rootEntry = packages[packageKey];
447
+ if (!rootEntry || typeof rootEntry !== 'object')
448
+ return undefined;
449
+ const rootDepNames = collectDependencyNames(rootEntry);
450
+ const dependencies = {};
451
+ const memo = new Map();
452
+ const stack = new Set();
453
+ for (const depName of rootDepNames) {
454
+ const childKey = resolveNpmPackagePath(packageKey, depName, packages);
455
+ if (!childKey)
456
+ continue;
457
+ const childNode = buildNpmNodeFromPackages(childKey, depName, packages, memo, stack);
458
+ if (childNode)
459
+ dependencies[childNode.name] = childNode;
460
+ }
461
+ return { dependencies };
462
+ }
463
+ /**
464
+ * Builds a ResolvedNode for an npm lockfile package entry.
465
+ *
466
+ * @param packageKey - Normalized key identifying the package entry within the lockfile `packages` map.
467
+ * @param fallbackName - Package name to use when the entry does not provide a valid `name` field.
468
+ * @param packages - The lockfile `packages` map containing package entry objects indexed by package keys.
469
+ * @param memo - Memoization map used to cache previously built nodes (or `undefined` for unresolved entries).
470
+ * @param stack - Recursion stack used to detect and avoid cycles while building the dependency graph.
471
+ * @returns The constructed `ResolvedNode` for `packageKey`, or `undefined` if the entry is missing, invalid, cyclic, or cannot be resolved.
472
+ */
473
+ function buildNpmNodeFromPackages(packageKey, fallbackName, packages, memo, stack) {
474
+ if (memo.has(packageKey))
475
+ return memo.get(packageKey);
476
+ if (stack.has(packageKey))
477
+ return undefined;
478
+ const entry = packages[packageKey];
479
+ if (!entry || typeof entry !== 'object')
480
+ return undefined;
481
+ if (entry.link === true && typeof entry.resolved === 'string') {
482
+ const linkedKey = normalizeLockPackageKey(entry.resolved);
483
+ if (linkedKey && linkedKey in packages) {
484
+ const linkedNode = buildNpmNodeFromPackages(linkedKey, fallbackName, packages, memo, stack);
485
+ memo.set(packageKey, linkedNode);
486
+ return linkedNode;
487
+ }
488
+ }
489
+ const version = typeof entry.version === 'string' ? entry.version.trim() : '';
490
+ if (!version || version === 'unknown' || version === 'missing' || version === 'invalid') {
491
+ memo.set(packageKey, undefined);
492
+ return undefined;
493
+ }
494
+ const name = typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : fallbackName;
495
+ const out = {
496
+ name,
497
+ version,
498
+ dependencies: {}
499
+ };
500
+ if ((entry === null || entry === void 0 ? void 0 : entry.dev) !== undefined) {
501
+ out.dev = Boolean(entry.dev);
502
+ }
503
+ stack.add(packageKey);
504
+ const depNames = collectDependencyNames(entry);
505
+ for (const depName of depNames) {
506
+ const childKey = resolveNpmPackagePath(packageKey, depName, packages);
507
+ if (!childKey)
508
+ continue;
509
+ const childNode = buildNpmNodeFromPackages(childKey, depName, packages, memo, stack);
510
+ if (!childNode)
511
+ continue;
512
+ out.dependencies[childNode.name] = childNode;
513
+ }
514
+ stack.delete(packageKey);
515
+ if (out.dependencies && Object.keys(out.dependencies).length === 0) {
516
+ delete out.dependencies;
517
+ }
518
+ memo.set(packageKey, out);
519
+ return out;
520
+ }
521
+ /**
522
+ * Normalize a lockfile package key into a POSIX-style relative path.
523
+ *
524
+ * @param value - The raw package key or path from a lockfile
525
+ * @returns The normalized package key with any leading "./" or "/" removed, path separators converted to `/`, or an empty string if `value` is empty or whitespace
526
+ */
527
+ function normalizeLockPackageKey(value) {
528
+ const trimmed = value.trim();
529
+ if (!trimmed)
530
+ return '';
531
+ return trimmed.replace(/^\.?\//, '').split(path_1.default.sep).join('/');
532
+ }
533
+ /**
534
+ * Resolve the lockfile packages map key for a dependency by searching node_modules upward from a starting package path.
535
+ *
536
+ * Searches for "<fromPackageKey>/node_modules/<depName>" and climbs parent directories until the repository root,
537
+ * then falls back to "node_modules/<depName>".
538
+ *
539
+ * @param fromPackageKey - The package key/path in the lockfile packages map to start from (posix-style, may be empty)
540
+ * @param depName - The dependency name to resolve
541
+ * @param packages - The lockfile `packages` map (keys are package paths)
542
+ * @returns The matching package key (e.g. "node_modules/dep" or "some/dir/node_modules/dep") if found, `undefined` otherwise
543
+ */
544
+ function resolveNpmPackagePath(fromPackageKey, depName, packages) {
545
+ const from = fromPackageKey || '';
546
+ let dir = from;
547
+ while (true) {
548
+ const candidate = dir ? `${dir}/node_modules/${depName}` : `node_modules/${depName}`;
549
+ if (candidate in packages)
550
+ return candidate;
551
+ if (!dir)
552
+ break;
553
+ const parent = path_1.default.posix.dirname(dir);
554
+ dir = parent === '.' ? '' : parent;
555
+ }
556
+ const rootCandidate = `node_modules/${depName}`;
557
+ return rootCandidate in packages ? rootCandidate : undefined;
558
+ }
559
+ /**
560
+ * Normalize a legacy npm lockfile node into a ResolvedNode.
561
+ *
562
+ * Recursively converts a legacy lockfile node structure into the module's ResolvedNode shape,
563
+ * preserving the package name, validated version, transitive dependencies, and the dev flag.
564
+ *
565
+ * @param name - The dependency name as declared in the lockfile
566
+ * @param node - The legacy lockfile node object to normalize (may contain version, dependencies, dev, missing, extraneous)
567
+ * @returns A ResolvedNode for the package, or `undefined` if the input node is missing, extraneous, or has an invalid/unknown/missing version
568
+ */
569
+ function normalizeLegacyNpmNode(name, node) {
570
+ if (!node || typeof node !== 'object')
571
+ return undefined;
572
+ if (node.missing || node.extraneous)
573
+ return undefined;
574
+ const version = typeof node.version === 'string' ? node.version.trim() : '';
575
+ if (!version || version === 'unknown' || version === 'missing' || version === 'invalid')
576
+ return undefined;
577
+ const out = {
578
+ name,
579
+ version,
580
+ dependencies: {}
581
+ };
582
+ if ((node === null || node === void 0 ? void 0 : node.dependencies) && typeof node.dependencies === 'object') {
583
+ for (const [childName, child] of Object.entries(node.dependencies)) {
584
+ const normalized = normalizeLegacyNpmNode(childName, child);
585
+ if (normalized)
586
+ out.dependencies[childName] = normalized;
587
+ }
588
+ }
589
+ if ((node === null || node === void 0 ? void 0 : node.dev) !== undefined) {
590
+ out.dev = Boolean(node.dev);
591
+ }
592
+ if (out.dependencies && Object.keys(out.dependencies).length === 0) {
593
+ delete out.dependencies;
594
+ }
595
+ return out;
596
+ }
597
+ /**
598
+ * Builds a resolved dependency tree from a Yarn lockfile for the given project.
599
+ *
600
+ * Searches upward for a yarn.lock from the provided project path (stopping at `searchRoot`), ensures the project's package.json is present and readable, parses the lockfile as Yarn v1 or v2, and constructs a LockfileTreeResult.
601
+ *
602
+ * @param projectPath - The project directory containing package.json used as the resolution root
603
+ * @param searchRoot - Directory at which to stop searching upward for a yarn.lock
604
+ * @returns A LockfileTreeResult with `sourceFile` set to the located yarn.lock and `data` containing the resolved dependency tree, or `undefined` if the lockfile or package.json cannot be found or parsed
605
+ */
606
+ function parseYarnTree(projectPath, searchRoot) {
607
+ const lockPath = findUpwards(projectPath, ['yarn.lock'], searchRoot);
608
+ if (!lockPath)
609
+ return undefined;
610
+ const raw = readCachedText(lockPath);
611
+ if (!raw)
612
+ return undefined;
613
+ const packageJsonPath = path_1.default.join(projectPath, 'package.json');
614
+ if (!safePathExists(packageJsonPath))
615
+ return undefined;
616
+ const packageJson = readJsonSafe(packageJsonPath);
617
+ if (!packageJson || typeof packageJson !== 'object')
618
+ return undefined;
619
+ if (/^#\s*yarn lockfile v1/m.test(raw)) {
620
+ const parsed = parseYarnV1(raw);
621
+ if (!parsed)
622
+ return undefined;
623
+ const data = buildYarnResolvedTree(parsed, packageJson);
624
+ return { sourceFile: lockPath, data };
625
+ }
626
+ const parsedYaml = parseYarnV2(raw);
627
+ if (!parsedYaml)
628
+ return undefined;
629
+ const data = buildYarnResolvedTree(parsedYaml, packageJson);
630
+ return { sourceFile: lockPath, data };
631
+ }
632
+ /**
633
+ * Parse a Yarn v1 lockfile string into a selector-to-entry map.
634
+ *
635
+ * @param raw - The raw contents of a yarn.lock file (Yarn v1 format)
636
+ * @returns A Map where each selector string maps to its YarnV1Entry, or `undefined` if parsing fails or the lockfile is not a Yarn v1 success parse
637
+ */
638
+ function parseYarnV1(raw) {
639
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
640
+ const lockfile = require('@yarnpkg/lockfile');
641
+ const parsed = lockfile.parse(raw);
642
+ if (!parsed || parsed.type !== 'success' || !parsed.object)
643
+ return undefined;
644
+ const map = new Map();
645
+ for (const [selectorKey, entry] of Object.entries(parsed.object)) {
646
+ for (const selector of splitSelectors(selectorKey)) {
647
+ if (!map.has(selector)) {
648
+ map.set(selector, entry || {});
649
+ }
650
+ }
651
+ }
652
+ return map;
653
+ }
654
+ /**
655
+ * Parse a Yarn v2 (Berry) lockfile YAML string into a selector-to-entry map.
656
+ *
657
+ * @param raw - Raw contents of a yarn.lock (Yarn v2+) file
658
+ * @returns A Map where each lockfile selector maps to its YarnV2Entry, or `undefined` if the input could not be parsed into an object
659
+ */
660
+ function parseYarnV2(raw) {
661
+ const parsed = yaml_1.default.parse(raw);
662
+ if (!parsed || typeof parsed !== 'object')
663
+ return undefined;
664
+ const map = new Map();
665
+ for (const [selectorKey, entry] of Object.entries(parsed)) {
666
+ if (selectorKey === '__metadata')
667
+ continue;
668
+ if (!entry || typeof entry !== 'object')
669
+ continue;
670
+ for (const selector of splitSelectors(selectorKey)) {
671
+ if (!map.has(selector)) {
672
+ map.set(selector, entry);
673
+ }
674
+ }
675
+ }
676
+ return map;
677
+ }
678
+ /**
679
+ * Build a resolved dependency tree from Yarn lockfile entries and a project package.json.
680
+ *
681
+ * Uses the package.json dependency sections to find root dependency ranges, resolves each range
682
+ * against the provided Yarn lock entries, and constructs a map of resolved package nodes.
683
+ *
684
+ * @param lockEntries - Map of selector keys to Yarn v1 or v2 lockfile entries used to resolve selectors.
685
+ * @param packageJson - The project's parsed package.json object whose dependency sections are consulted.
686
+ * @returns A ResolvedTree whose `dependencies` map contains resolved top-level ResolvedNode objects; workspace-like specifiers and unresolved selectors are omitted.
687
+ */
688
+ function buildYarnResolvedTree(lockEntries, packageJson) {
689
+ const memo = new Map();
690
+ const stack = new Set();
691
+ const dependencies = {};
692
+ const rootDeps = collectPackageJsonDependencySpecs(packageJson);
693
+ for (const [depName, depRange] of Object.entries(rootDeps)) {
694
+ if (isWorkspaceLikeSpecifier(depRange))
695
+ continue;
696
+ const selector = resolveYarnSelector(depName, depRange, lockEntries);
697
+ if (!selector)
698
+ continue;
699
+ const node = buildYarnNode(depName, selector, lockEntries, memo, stack);
700
+ if (node)
701
+ dependencies[node.name] = node;
702
+ }
703
+ return { dependencies };
704
+ }
705
+ /**
706
+ * Resolve a Yarn lockfile entry into a ResolvedNode, including its child dependencies.
707
+ *
708
+ * Builds a normalized ResolvedNode for the package identified by `name` and `selector` from `lockEntries`.
709
+ * Skips workspace-like specs and invalid/missing versions. Uses `memo` to cache results and `stack` to
710
+ * detect and avoid cycles during recursive resolution.
711
+ *
712
+ * @param name - The dependency name to resolve
713
+ * @param selector - The lockfile selector/key that identifies the specific locked entry
714
+ * @param lockEntries - Map of lockfile selectors to Yarn lock entries (v1 or v2)
715
+ * @param memo - Memoization map used to cache previously resolved nodes or unresolved results
716
+ * @param stack - Set tracking the current recursion path to prevent cycles
717
+ * @returns `ResolvedNode` for the resolved dependency, or `undefined` if the entry cannot be resolved or is invalid
718
+ */
719
+ function buildYarnNode(name, selector, lockEntries, memo, stack) {
720
+ const memoKey = `${name}|${selector}`;
721
+ if (memo.has(memoKey))
722
+ return memo.get(memoKey);
723
+ if (stack.has(memoKey))
724
+ return undefined;
725
+ const entry = lockEntries.get(selector);
726
+ if (!entry || typeof entry !== 'object')
727
+ return undefined;
728
+ const version = typeof entry.version === 'string' ? entry.version.trim() : '';
729
+ if (!version || version === 'unknown' || version === 'missing' || version === 'invalid') {
730
+ memo.set(memoKey, undefined);
731
+ return undefined;
732
+ }
733
+ const out = {
734
+ name,
735
+ version,
736
+ dependencies: {}
737
+ };
738
+ stack.add(memoKey);
739
+ const childDeps = mergeStringRecord(entry.dependencies, entry.optionalDependencies);
740
+ for (const [depName, depRange] of Object.entries(childDeps)) {
741
+ if (!depRange || isWorkspaceLikeSpecifier(depRange))
742
+ continue;
743
+ const childSelector = resolveYarnSelector(depName, depRange, lockEntries);
744
+ if (!childSelector)
745
+ continue;
746
+ const childNode = buildYarnNode(depName, childSelector, lockEntries, memo, stack);
747
+ if (!childNode)
748
+ continue;
749
+ out.dependencies[childNode.name] = childNode;
750
+ }
751
+ stack.delete(memoKey);
752
+ if (out.dependencies && Object.keys(out.dependencies).length === 0) {
753
+ delete out.dependencies;
754
+ }
755
+ memo.set(memoKey, out);
756
+ return out;
757
+ }
758
+ /**
759
+ * Resolve a Yarn lockfile selector key that corresponds to a dependency name and range.
760
+ *
761
+ * Attempts to match exact selector forms (including `npm:`-prefixed variants), then falls back
762
+ * to selectors that start with `name@` and contain the range; if multiple fallback matches
763
+ * exist the lexicographically first is chosen.
764
+ *
765
+ * @param dependencyName - The dependency package name
766
+ * @param dependencyRange - The version range or specifier (may be prefixed with `npm:`)
767
+ * @param lockEntries - Map of lockfile selector keys to their entries
768
+ * @returns A lockfile selector key that matches the dependency, or `undefined` if none found
769
+ */
770
+ function resolveYarnSelector(dependencyName, dependencyRange, lockEntries) {
771
+ const trimmedRange = dependencyRange.trim();
772
+ const candidates = new Set([
773
+ `${dependencyName}@${trimmedRange}`,
774
+ `${dependencyName}@npm:${trimmedRange}`
775
+ ]);
776
+ if (trimmedRange.startsWith('npm:')) {
777
+ candidates.add(`${dependencyName}@${trimmedRange.slice(4)}`);
778
+ }
779
+ for (const candidate of candidates) {
780
+ if (lockEntries.has(candidate))
781
+ return candidate;
782
+ }
783
+ const prefix = `${dependencyName}@`;
784
+ const fallback = [];
785
+ for (const selector of lockEntries.keys()) {
786
+ if (!selector.startsWith(prefix))
787
+ continue;
788
+ if (selector.includes(trimmedRange)) {
789
+ fallback.push(selector);
790
+ }
791
+ }
792
+ if (fallback.length === 1)
793
+ return fallback[0];
794
+ if (fallback.length > 1) {
795
+ return fallback.sort()[0];
796
+ }
797
+ return undefined;
798
+ }
799
+ /**
800
+ * Extracts all dependency specifiers declared in a package.json-like object.
801
+ *
802
+ * Aggregates entries from "dependencies", "devDependencies", "optionalDependencies", and "peerDependencies".
803
+ *
804
+ * @param pkg - The parsed package.json object to read dependency sections from
805
+ * @returns A map of dependency name to its version or range string for every declared dependency
806
+ */
807
+ function collectPackageJsonDependencySpecs(pkg) {
808
+ const out = {};
809
+ for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
810
+ const section = pkg === null || pkg === void 0 ? void 0 : pkg[sectionName];
811
+ if (!section || typeof section !== 'object')
812
+ continue;
813
+ for (const [name, spec] of Object.entries(section)) {
814
+ if (typeof spec !== 'string')
815
+ continue;
816
+ out[name] = spec;
817
+ }
818
+ }
819
+ return out;
820
+ }
821
+ /**
822
+ * Split a Yarn selector key into individual selector strings.
823
+ *
824
+ * @param selectorKey - A comma-separated selector string from a lockfile entry
825
+ * @returns An array of trimmed, non-empty selector strings
826
+ */
827
+ function splitSelectors(selectorKey) {
828
+ return selectorKey
829
+ .split(',')
830
+ .map((part) => part.trim())
831
+ .filter(Boolean);
832
+ }
833
+ /**
834
+ * Merge two arbitrary values into a string-to-string record, with entries from the second overriding the first.
835
+ *
836
+ * Non-object values are ignored; only own enumerable properties whose values are strings are included.
837
+ *
838
+ * @param first - Source whose string-valued properties are copied first
839
+ * @param second - Source whose string-valued properties override those from `first`
840
+ * @returns A record mapping property names to their string values collected from `first` and `second`
841
+ */
842
+ function mergeStringRecord(first, second) {
843
+ const out = {};
844
+ const assign = (value) => {
845
+ if (!value || typeof value !== 'object')
846
+ return;
847
+ for (const [key, val] of Object.entries(value)) {
848
+ if (typeof val === 'string') {
849
+ out[key] = val;
850
+ }
851
+ }
852
+ };
853
+ assign(first);
854
+ assign(second);
855
+ return out;
856
+ }
857
+ /**
858
+ * Collects all dependency names referenced by a lockfile or package entry.
859
+ *
860
+ * @param entry - An object that may contain dependency sections (`dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`)
861
+ * @returns An array of unique dependency names aggregated from the entry's dependency sections
862
+ */
863
+ function collectDependencyNames(entry) {
864
+ const names = new Set();
865
+ for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
866
+ const section = entry === null || entry === void 0 ? void 0 : entry[sectionName];
867
+ if (!section || typeof section !== 'object')
868
+ continue;
869
+ for (const name of Object.keys(section)) {
870
+ names.add(name);
871
+ }
872
+ }
873
+ return Array.from(names);
874
+ }
875
+ /**
876
+ * Check if a dependency specifier refers to a workspace or local reference.
877
+ *
878
+ * @param value - The dependency specifier to test (e.g., "file:../pkg", "workspace:^1.0.0")
879
+ * @returns `true` if the specifier starts with `link:`, `workspace:`, `file:`, or `portal:`, `false` otherwise.
880
+ */
881
+ function isWorkspaceLikeSpecifier(value) {
882
+ const trimmed = value.trim();
883
+ return (trimmed.startsWith('link:') ||
884
+ trimmed.startsWith('workspace:') ||
885
+ trimmed.startsWith('file:') ||
886
+ trimmed.startsWith('portal:'));
887
+ }
888
+ /**
889
+ * Search upward from a starting directory for any of the given filenames and return the first match.
890
+ *
891
+ * @param startPath - Directory path to begin the upward search from
892
+ * @param fileNames - List of filenames to look for in each directory
893
+ * @param stopPath - Directory path at which to stop searching (inclusive)
894
+ * @returns The resolved path to the first matching file found, or `undefined` if none is found before `stopPath`
895
+ */
896
+ function findUpwards(startPath, fileNames, stopPath) {
897
+ const stop = path_1.default.resolve(stopPath);
898
+ let current = path_1.default.resolve(startPath);
899
+ while (true) {
900
+ for (const fileName of fileNames) {
901
+ const candidate = path_1.default.join(current, fileName);
902
+ if (safePathExists(candidate)) {
903
+ return candidate;
904
+ }
905
+ }
906
+ if (current === stop) {
907
+ return undefined;
908
+ }
909
+ const parent = path_1.default.dirname(current);
910
+ if (parent === current)
911
+ return undefined;
912
+ current = parent;
913
+ }
914
+ }
915
+ /**
916
+ * Compute the POSIX-style relative path from one filesystem path to another.
917
+ *
918
+ * @param fromPath - The base path to resolve from
919
+ * @param toPath - The target path to resolve to
920
+ * @returns The relative path using forward slashes (`/`), or an empty string when the target is the same as the base
921
+ */
922
+ function toPosixRelative(fromPath, toPath) {
923
+ const relative = path_1.default.relative(fromPath, toPath);
924
+ if (!relative || relative === '.')
925
+ return '';
926
+ return relative.split(path_1.default.sep).join('/');
927
+ }
928
+ /**
929
+ * Checks whether a filesystem path exists and is accessible, returning false on any error.
930
+ *
931
+ * @param targetPath - The filesystem path to check
932
+ * @returns `true` if the path exists and is accessible, `false` otherwise.
933
+ */
934
+ function safePathExists(targetPath) {
935
+ try {
936
+ return fs_1.default.existsSync(targetPath);
937
+ }
938
+ catch {
939
+ return false;
940
+ }
941
+ }
942
+ /**
943
+ * Read a text file and cache its contents in memory.
944
+ *
945
+ * @param filePath - Path to the file to read
946
+ * @returns The file contents decoded as UTF-8, or `undefined` if the file cannot be read
947
+ */
948
+ function readCachedText(filePath) {
949
+ if (parseCache.has(filePath)) {
950
+ const cached = parseCache.get(filePath);
951
+ return typeof cached === 'string' ? cached : undefined;
952
+ }
953
+ try {
954
+ const raw = fs_1.default.readFileSync(filePath, 'utf8');
955
+ parseCache.set(filePath, raw);
956
+ return raw;
957
+ }
958
+ catch {
959
+ return undefined;
960
+ }
961
+ }
962
+ /**
963
+ * Read and cache the parsed JSON content of a file.
964
+ *
965
+ * @param filePath - Path to the JSON file
966
+ * @returns The parsed JSON value, or `undefined` if the file cannot be read
967
+ * @throws SyntaxError if the file is read but contains invalid JSON
968
+ */
969
+ function getCachedJson(filePath) {
970
+ const cacheKey = `${filePath}:json`;
971
+ if (parseCache.has(cacheKey))
972
+ return parseCache.get(cacheKey);
973
+ const raw = readCachedText(filePath);
974
+ if (!raw)
975
+ return undefined;
976
+ const parsed = JSON.parse(raw);
977
+ parseCache.set(cacheKey, parsed);
978
+ return parsed;
979
+ }
980
+ /**
981
+ * Parse YAML content from the given file path and cache the result for subsequent calls.
982
+ *
983
+ * @param filePath - Path to the YAML file to read and parse
984
+ * @returns The parsed YAML value, or `undefined` if the file could not be read or is empty
985
+ */
986
+ function getCachedYaml(filePath) {
987
+ const cacheKey = `${filePath}:yaml`;
988
+ if (parseCache.has(cacheKey))
989
+ return parseCache.get(cacheKey);
990
+ const raw = readCachedText(filePath);
991
+ if (!raw)
992
+ return undefined;
993
+ const parsed = yaml_1.default.parse(raw);
994
+ parseCache.set(cacheKey, parsed);
995
+ return parsed;
996
+ }
997
+ /**
998
+ * Read and parse a JSON file, returning its contents or undefined on failure.
999
+ *
1000
+ * @param filePath - Path to the JSON file to read
1001
+ * @returns The parsed JSON value, or `undefined` if the file cannot be read or parsed
1002
+ */
1003
+ function readJsonSafe(filePath) {
1004
+ try {
1005
+ const raw = fs_1.default.readFileSync(filePath, 'utf8');
1006
+ return JSON.parse(raw);
1007
+ }
1008
+ catch {
1009
+ return undefined;
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Create a pnpm installation state by discovering node_modules roots and .pnpm virtual store entries for a project path.
1014
+ *
1015
+ * @param projectPath - Filesystem path inside the project to start discovery from
1016
+ * @returns An object containing:
1017
+ * - `enabled`: `true` if any virtual store entries or node_modules roots were found, `false` otherwise;
1018
+ * - `virtualStoreEntries`: a set of directory names found under each discovered `.pnpm` folder;
1019
+ * - `nodeModulesRoots`: array of discovered `node_modules` root paths;
1020
+ * - `installedCache`: an initially empty cache map for package installation checks
1021
+ */
1022
+ function createPnpmInstallState(projectPath) {
1023
+ const nodeModulesRoots = findNodeModulesRoots(projectPath);
1024
+ const virtualStoreEntries = new Set();
1025
+ for (const root of nodeModulesRoots) {
1026
+ const virtualStoreDir = path_1.default.join(root, '.pnpm');
1027
+ if (!safePathExists(virtualStoreDir))
1028
+ continue;
1029
+ for (const entry of safeReadDirNames(virtualStoreDir)) {
1030
+ virtualStoreEntries.add(entry);
1031
+ }
1032
+ }
1033
+ return {
1034
+ enabled: virtualStoreEntries.size > 0 || nodeModulesRoots.length > 0,
1035
+ virtualStoreEntries,
1036
+ nodeModulesRoots,
1037
+ installedCache: new Map()
1038
+ };
1039
+ }
1040
+ /**
1041
+ * Discover node_modules directories by walking upward from a starting path.
1042
+ *
1043
+ * @param startPath - Path to begin the upward search from.
1044
+ * @returns An array of absolute paths to `node_modules` directories found while traversing upward, ordered from nearest to farthest.
1045
+ */
1046
+ function findNodeModulesRoots(startPath) {
1047
+ const roots = [];
1048
+ let current = path_1.default.resolve(startPath);
1049
+ while (true) {
1050
+ const candidate = path_1.default.join(current, 'node_modules');
1051
+ if (safePathExists(candidate)) {
1052
+ roots.push(candidate);
1053
+ }
1054
+ const parent = path_1.default.dirname(current);
1055
+ if (parent === current)
1056
+ break;
1057
+ current = parent;
1058
+ }
1059
+ return roots;
1060
+ }
1061
+ /**
1062
+ * Read the names of entries in a directory, returning an empty array if the directory cannot be read.
1063
+ *
1064
+ * @param dirPath - Path to the directory to read
1065
+ * @returns An array of directory entry names, or an empty array if the directory does not exist or cannot be read
1066
+ */
1067
+ function safeReadDirNames(dirPath) {
1068
+ try {
1069
+ return fs_1.default.readdirSync(dirPath);
1070
+ }
1071
+ catch {
1072
+ return [];
1073
+ }
1074
+ }
1075
+ /**
1076
+ * Determine whether a pnpm package with the given name and version is considered installed according to the provided pnpm install state.
1077
+ *
1078
+ * Looks up the package in the pnpm virtual store entries and in discovered node_modules roots. Results are cached on `installState.installedCache`.
1079
+ *
1080
+ * @param name - Package name (may be scoped)
1081
+ * @param version - Expected package version
1082
+ * @param installState - pnpm installation state used to inspect virtual store entries and node_modules roots
1083
+ * @returns `true` if the package is considered installed (found in the pnpm virtual store or node_modules and matching the version), `false` otherwise.
1084
+ */
1085
+ function isPnpmPackageInstalled(name, version, installState) {
1086
+ if (!installState.enabled)
1087
+ return true;
1088
+ const cacheKey = `${name}@${version}`;
1089
+ const cached = installState.installedCache.get(cacheKey);
1090
+ if (cached !== undefined)
1091
+ return cached;
1092
+ const normalizedName = normalizeScopedPackageNameForPnpmStore(name);
1093
+ const storePrefix = `${normalizedName}@${version}`;
1094
+ for (const entry of installState.virtualStoreEntries) {
1095
+ if (entry === storePrefix || entry.startsWith(`${storePrefix}_`) || entry.startsWith(`${storePrefix}(`)) {
1096
+ installState.installedCache.set(cacheKey, true);
1097
+ return true;
1098
+ }
1099
+ }
1100
+ for (const nodeModulesRoot of installState.nodeModulesRoots) {
1101
+ const packageDir = path_1.default.join(nodeModulesRoot, ...name.split('/'));
1102
+ if (safePathExists(packageDir) && packageDirectoryMatchesVersion(packageDir, version)) {
1103
+ installState.installedCache.set(cacheKey, true);
1104
+ return true;
1105
+ }
1106
+ }
1107
+ installState.installedCache.set(cacheKey, false);
1108
+ return false;
1109
+ }
1110
+ /**
1111
+ * Convert a scoped package name into the pnpm store form by replacing the scope/name slash with a plus.
1112
+ *
1113
+ * @param name - Package name, possibly scoped (for example, "@scope/pkg")
1114
+ * @returns The pnpm-store form (for example, "@scope+pkg"); returns the original `name` if it is not a valid scoped package
1115
+ */
1116
+ function normalizeScopedPackageNameForPnpmStore(name) {
1117
+ if (!name.startsWith('@'))
1118
+ return name;
1119
+ const slashIndex = name.indexOf('/');
1120
+ if (slashIndex <= 0)
1121
+ return name;
1122
+ return `${name.slice(0, slashIndex)}+${name.slice(slashIndex + 1)}`;
1123
+ }
1124
+ /**
1125
+ * Checks whether a package directory's declared version matches an expected version.
1126
+ *
1127
+ * Treats a missing or unreadable package.json, or a package.json without a `version` field, as matching.
1128
+ *
1129
+ * @param packageDir - Path to the package directory containing a package.json
1130
+ * @param expectedVersion - The version string to compare against the package's declared version
1131
+ * @returns `true` if the package.json is missing/unreadable, lacks a `version`, or its `version` equals `expectedVersion`; `false` otherwise
1132
+ */
1133
+ function packageDirectoryMatchesVersion(packageDir, expectedVersion) {
1134
+ const pkgJsonPath = path_1.default.join(packageDir, 'package.json');
1135
+ if (!safePathExists(pkgJsonPath))
1136
+ return true;
1137
+ try {
1138
+ const raw = fs_1.default.readFileSync(pkgJsonPath, 'utf8');
1139
+ const parsed = JSON.parse(raw);
1140
+ const version = typeof (parsed === null || parsed === void 0 ? void 0 : parsed.version) === 'string' ? parsed.version.trim() : '';
1141
+ return !version || version === expectedVersion;
1142
+ }
1143
+ catch {
1144
+ return true;
1145
+ }
1146
+ }