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.
- package/README.md +208 -40
- package/dist/aggregator.js +386 -45
- package/dist/cli.js +418 -78
- package/dist/cta.js +17 -0
- package/dist/generated/spdx.js +3 -0
- package/dist/report-assets.js +2 -2
- package/dist/report.js +241 -155
- package/dist/runners/lockfileGraph.js +1146 -0
- package/dist/runners/npmLs.js +135 -13
- package/dist/runners/npmOutdated.js +34 -18
- package/dist/utils.js +15 -1
- package/package.json +18 -23
|
@@ -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
|
+
}
|