flatlock 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * @fileoverview pnpm lockfile parser supporting all documented versions
3
+ *
4
+ * Supported formats:
5
+ * - shrinkwrap.yaml v3/v4 (2016-2019): shrinkwrapVersion field
6
+ * - pnpm-lock.yaml v5.x (2019-2022): lockfileVersion number
7
+ * - pnpm-lock.yaml v5.4-inlineSpecifiers (experimental): lockfileVersion string
8
+ * - pnpm-lock.yaml v6.0 (2023): lockfileVersion '6.0'
9
+ * - pnpm-lock.yaml v9.0 (2024+): lockfileVersion '9.0'
10
+ *
11
+ * @module flatlock/parsers/pnpm
12
+ */
13
+
14
+ import { readFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import yaml from 'js-yaml';
17
+
18
+ import { detectVersion } from './detect.js';
19
+ import { parseSpecShrinkwrap } from './shrinkwrap.js';
20
+ import { parseSpecV5 } from './v5.js';
21
+ import { parseSpecV6Plus } from './v6plus.js';
22
+
23
+ /** @typedef {import('../types.js').Dependency} Dependency */
24
+
25
+ /**
26
+ * @typedef {Object} WorkspacePackage
27
+ * @property {string} name
28
+ * @property {string} version
29
+ * @property {Record<string, string>} [dependencies]
30
+ * @property {Record<string, string>} [devDependencies]
31
+ * @property {Record<string, string>} [optionalDependencies]
32
+ * @property {Record<string, string>} [peerDependencies]
33
+ */
34
+
35
+ // Public API: detectVersion for users who need to inspect lockfile version
36
+ export { detectVersion } from './detect.js';
37
+
38
+ // Version-specific internals available via 'flatlock/parsers/pnpm/internal'
39
+
40
+ /**
41
+ * Parse pnpm package spec to extract name and version.
42
+ *
43
+ * This is the unified parser that auto-detects the format based on the spec pattern.
44
+ * It supports all pnpm lockfile versions without requiring version context.
45
+ *
46
+ * Detection heuristics:
47
+ * 1. If spec contains '(' -> v6+ format (peer deps in parentheses)
48
+ * 2. If spec contains '@' after position 0 and no '/' after the '@' -> v6+ format
49
+ * 3. Otherwise -> v5 or earlier format (slash separator)
50
+ *
51
+ * @param {string} spec - Package spec from pnpm lockfile
52
+ * @returns {{ name: string | null, version: string | null }}
53
+ *
54
+ * @example
55
+ * // v5 format - unscoped package
56
+ * parseSpec('/lodash/4.17.21')
57
+ * // => { name: 'lodash', version: '4.17.21' }
58
+ *
59
+ * @example
60
+ * // v5 format - scoped package
61
+ * parseSpec('/@babel/core/7.23.0')
62
+ * // => { name: '@babel/core', version: '7.23.0' }
63
+ *
64
+ * @example
65
+ * // v5 format - with peer dependency suffix (underscore)
66
+ * parseSpec('/styled-jsx/3.0.9_react@17.0.2')
67
+ * // => { name: 'styled-jsx', version: '3.0.9' }
68
+ *
69
+ * @example
70
+ * // v6 format - unscoped package (with leading slash)
71
+ * parseSpec('/lodash@4.17.21')
72
+ * // => { name: 'lodash', version: '4.17.21' }
73
+ *
74
+ * @example
75
+ * // v6 format - scoped package
76
+ * parseSpec('/@babel/core@7.23.0')
77
+ * // => { name: '@babel/core', version: '7.23.0' }
78
+ *
79
+ * @example
80
+ * // v9 format - unscoped package (no leading slash)
81
+ * parseSpec('lodash@4.17.21')
82
+ * // => { name: 'lodash', version: '4.17.21' }
83
+ *
84
+ * @example
85
+ * // v9 format - scoped package (no leading slash)
86
+ * parseSpec('@babel/core@7.23.0')
87
+ * // => { name: '@babel/core', version: '7.23.0' }
88
+ *
89
+ * @example
90
+ * // v9 format - with peer dependency suffix (parentheses)
91
+ * parseSpec('@babel/core@7.23.0(@types/node@20.0.0)')
92
+ * // => { name: '@babel/core', version: '7.23.0' }
93
+ *
94
+ * @example
95
+ * // v9 format - multiple peer dependencies
96
+ * parseSpec('@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0)')
97
+ * // => { name: '@testing-library/react', version: '14.0.0' }
98
+ *
99
+ * @example
100
+ * // Shrinkwrap v3/v4 format - with peer suffix (slash)
101
+ * parseSpec('/foo/1.0.0/bar@2.0.0')
102
+ * // => { name: 'foo', version: '1.0.0' }
103
+ *
104
+ * @example
105
+ * // link: protocol - skipped (returns null)
106
+ * parseSpec('link:packages/my-pkg')
107
+ * // => { name: null, version: null }
108
+ *
109
+ * @example
110
+ * // file: protocol - skipped (returns null)
111
+ * parseSpec('file:../local-package')
112
+ * // => { name: null, version: null }
113
+ *
114
+ * @example
115
+ * // Null input
116
+ * parseSpec(null)
117
+ * // => { name: null, version: null }
118
+ *
119
+ * @example
120
+ * // Prerelease version
121
+ * parseSpec('@verdaccio/ui-theme@6.0.0-6-next.50')
122
+ * // => { name: '@verdaccio/ui-theme', version: '6.0.0-6-next.50' }
123
+ */
124
+ export function parseSpec(spec) {
125
+ // Handle null/undefined input
126
+ if (spec == null || typeof spec !== 'string') {
127
+ return { name: null, version: null };
128
+ }
129
+
130
+ // Skip special protocols
131
+ if (spec.startsWith('link:') || spec.startsWith('file:')) {
132
+ return { name: null, version: null };
133
+ }
134
+
135
+ // Detect format based on spec pattern
136
+ // v6+ uses parentheses for peer deps and @ separator
137
+ // v5 and earlier use _ for peer deps and / separator
138
+
139
+ // Check for v6+ parentheses peer suffix
140
+ if (spec.includes('(')) {
141
+ return parseSpecV6Plus(spec);
142
+ }
143
+
144
+ // Remove leading slash for analysis
145
+ const cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
146
+
147
+ // Check for v6+ @ separator format
148
+ // In v6+, the format is name@version where @ separates name from version
149
+ // We need to find @ that isn't at position 0 (which would be a scope)
150
+ // And check that it's not part of a v5 peer suffix (which comes after _)
151
+
152
+ // First strip any v5 peer suffix for cleaner analysis
153
+ const withoutV5Peer = cleaned.split('_')[0];
154
+
155
+ // Find the last @ in the cleaned string
156
+ const lastAtIndex = withoutV5Peer.lastIndexOf('@');
157
+
158
+ if (lastAtIndex > 0) {
159
+ // Check if this is v6+ format by seeing if there's a / after the @
160
+ // In v6+: @babel/core@7.23.0 - the last @ separates name@version
161
+ // In v5: @babel/core/7.23.0 - no @ after the scope
162
+
163
+ const afterAt = withoutV5Peer.slice(lastAtIndex + 1);
164
+
165
+ // If there's no / in the part after the last @, it's likely v6+ format
166
+ // (the part after @ is just the version like "7.23.0")
167
+ if (!afterAt.includes('/')) {
168
+ return parseSpecV6Plus(spec);
169
+ }
170
+ }
171
+
172
+ // Fall back to v5 format (also handles shrinkwrap v3/v4 for basic cases)
173
+ // Note: shrinkwrap v3/v4 peer suffix with / is handled differently,
174
+ // but parseSpecV5 will still extract the correct name/version
175
+ // because it stops at _ (v5) and the shrinkwrap / peer suffix
176
+ // comes after the version anyway
177
+
178
+ return parseSpecV5(spec);
179
+ }
180
+
181
+ /**
182
+ * Extract package name from pnpm lockfile key.
183
+ * Wraps parseSpec to return just the name (consistent with other parsers).
184
+ *
185
+ * @param {string} key - pnpm lockfile key
186
+ * @returns {string | null} Package name
187
+ *
188
+ * @example
189
+ * parseLockfileKey('/@babel/core@7.23.0') // => '@babel/core'
190
+ * parseLockfileKey('/lodash/4.17.21') // => 'lodash'
191
+ */
192
+ export function parseLockfileKey(key) {
193
+ return parseSpec(key).name;
194
+ }
195
+
196
+ /**
197
+ * Parse pnpm lockfile (shrinkwrap.yaml, pnpm-lock.yaml v5.x, v6, v9)
198
+ *
199
+ * @param {string | object} input - Lockfile content string or pre-parsed object
200
+ * @param {Object} [_options] - Parser options (unused, reserved for future use)
201
+ * @returns {Generator<Dependency>}
202
+ *
203
+ * @example
204
+ * // Parse from string
205
+ * const deps = [...fromPnpmLock(yamlContent)];
206
+ *
207
+ * @example
208
+ * // Parse from pre-parsed object
209
+ * const lockfile = yaml.load(content);
210
+ * const deps = [...fromPnpmLock(lockfile)];
211
+ */
212
+ export function* fromPnpmLock(input, _options = {}) {
213
+ const lockfile = /** @type {Record<string, any>} */ (
214
+ typeof input === 'string' ? yaml.load(input) : input
215
+ );
216
+
217
+ // Detect version to determine where to look for packages
218
+ const detected = detectVersion(lockfile);
219
+
220
+ // Select era-specific parser
221
+ // Each era has different peer suffix formats that parseSpec can't auto-detect
222
+ const parseSpecForEra =
223
+ detected.era === 'shrinkwrap'
224
+ ? parseSpecShrinkwrap
225
+ : detected.era === 'v5'
226
+ ? parseSpecV5
227
+ : parseSpecV6Plus;
228
+
229
+ // Get packages object - location varies by version
230
+ // v5, v6: packages section directly
231
+ // v9: packages section has metadata, snapshots has relationships
232
+ // For dependency extraction, we primarily need packages (for resolution info)
233
+ const packages = lockfile.packages || {};
234
+
235
+ // For v9, we should also look at snapshots for additional entries
236
+ // that might only be in snapshots (peer variants)
237
+ const snapshots = lockfile.snapshots || {};
238
+
239
+ // Track seen packages to avoid duplicates (v9 has same package in both sections)
240
+ const seen = new Set();
241
+
242
+ // Process packages section
243
+ for (const [spec, pkg] of Object.entries(packages)) {
244
+ // Skip if we couldn't parse name/version
245
+ const { name, version } = parseSpecForEra(spec);
246
+ if (!name || !version) continue;
247
+
248
+ // Create dedup key
249
+ const key = `${name}@${version}`;
250
+ if (seen.has(key)) continue;
251
+ seen.add(key);
252
+
253
+ const resolution = pkg.resolution || {};
254
+ const integrity = resolution.integrity;
255
+ const resolved = resolution.tarball;
256
+ const link = spec.startsWith('link:') || resolution.type === 'directory';
257
+
258
+ // Skip workspace/link entries - flatlock only cares about external dependencies
259
+ if (link) continue;
260
+
261
+ /** @type {Dependency} */
262
+ const dep = { name, version };
263
+ if (integrity) dep.integrity = integrity;
264
+ if (resolved) dep.resolved = resolved;
265
+ yield dep;
266
+ }
267
+
268
+ // For v9, also process snapshots for peer variants
269
+ // (they might have different resolution info)
270
+ if (detected.era === 'v9') {
271
+ for (const [spec, _snapshot] of Object.entries(snapshots)) {
272
+ const { name, version } = parseSpecForEra(spec);
273
+ if (!name || !version) continue;
274
+
275
+ // Create dedup key
276
+ const key = `${name}@${version}`;
277
+ if (seen.has(key)) continue;
278
+ seen.add(key);
279
+
280
+ // Snapshots don't have resolution info, check if base package exists
281
+ // The base package key in v9 is just name@version (without peer suffix)
282
+ const baseKey = `${name}@${version}`;
283
+ const basePkg = packages[baseKey];
284
+
285
+ if (basePkg) {
286
+ const resolution = basePkg.resolution || {};
287
+ const integrity = resolution.integrity;
288
+ const resolved = resolution.tarball;
289
+
290
+ /** @type {Dependency} */
291
+ const dep = { name, version };
292
+ if (integrity) dep.integrity = integrity;
293
+ if (resolved) dep.resolved = resolved;
294
+ yield dep;
295
+ }
296
+ }
297
+ }
298
+
299
+ // Note: importers (workspace packages) are intentionally NOT yielded
300
+ // flatlock only cares about external dependencies
301
+ }
302
+
303
+ /**
304
+ * Extract workspace paths from pnpm lockfile.
305
+ *
306
+ * pnpm stores workspace packages in the `importers` section.
307
+ * Each key is a workspace path relative to the repo root.
308
+ *
309
+ * @param {string | object} input - Lockfile content string or pre-parsed object
310
+ * @returns {string[]} Array of workspace paths (e.g., ['packages/foo', 'packages/bar'])
311
+ *
312
+ * @example
313
+ * extractWorkspacePaths(lockfile)
314
+ * // => ['packages/vue', 'packages/compiler-core', ...]
315
+ */
316
+ export function extractWorkspacePaths(input) {
317
+ const lockfile = /** @type {Record<string, any>} */ (
318
+ typeof input === 'string' ? yaml.load(input) : input
319
+ );
320
+
321
+ const importers = lockfile.importers || {};
322
+ return Object.keys(importers).filter(k => k !== '.');
323
+ }
324
+
325
+ /**
326
+ * Build workspace packages map by reading package.json files.
327
+ *
328
+ * @param {string | object} input - Lockfile content string or pre-parsed object
329
+ * @param {string} repoDir - Path to repository root
330
+ * @returns {Promise<Record<string, WorkspacePackage>>} Map of workspace path to package info
331
+ *
332
+ * @example
333
+ * const workspaces = await buildWorkspacePackages(lockfile, '/path/to/repo');
334
+ * // => { 'packages/foo': { name: '@scope/foo', version: '1.0.0', dependencies: {...} } }
335
+ */
336
+ export async function buildWorkspacePackages(input, repoDir) {
337
+ const paths = extractWorkspacePaths(input);
338
+ /** @type {Record<string, WorkspacePackage>} */
339
+ const workspacePackages = {};
340
+
341
+ for (const wsPath of paths) {
342
+ const pkgJsonPath = join(repoDir, wsPath, 'package.json');
343
+ try {
344
+ const pkg = JSON.parse(await readFile(pkgJsonPath, 'utf8'));
345
+ workspacePackages[wsPath] = {
346
+ name: pkg.name,
347
+ version: pkg.version || '0.0.0',
348
+ dependencies: pkg.dependencies,
349
+ devDependencies: pkg.devDependencies,
350
+ optionalDependencies: pkg.optionalDependencies,
351
+ peerDependencies: pkg.peerDependencies
352
+ };
353
+ } catch {
354
+ // Skip workspaces with missing or invalid package.json
355
+ }
356
+ }
357
+
358
+ return workspacePackages;
359
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @fileoverview Internal/advanced pnpm parser exports
3
+ *
4
+ * This module exports version-specific parsing utilities for advanced use cases
5
+ * such as testing, debugging, or when you need fine-grained control over parsing.
6
+ *
7
+ * For normal usage, import from 'flatlock' or 'flatlock/parsers/pnpm' instead.
8
+ *
9
+ * @module flatlock/parsers/pnpm/internal
10
+ */
11
+
12
+ // Detection utilities
13
+ export {
14
+ detectVersion,
15
+ hasLeadingSlash,
16
+ usesAtSeparator,
17
+ usesInlineSpecifiers,
18
+ usesSnapshotsSplit
19
+ } from './detect.js';
20
+
21
+ // Shrinkwrap v3/v4 (2016-2019)
22
+ export {
23
+ extractPeerSuffix,
24
+ hasPeerSuffix,
25
+ parseSpecShrinkwrap
26
+ } from './shrinkwrap.js';
27
+
28
+ // v5.x (2019-2022)
29
+ export {
30
+ extractPeerSuffixV5,
31
+ hasPeerSuffixV5,
32
+ parseSpecV5
33
+ } from './v5.js';
34
+
35
+ // v6+ (2023+)
36
+ export {
37
+ extractPeerSuffixV6Plus,
38
+ hasPeerSuffixV6Plus,
39
+ parsePeerDependencies,
40
+ parseSpecV6Plus
41
+ } from './v6plus.js';
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @fileoverview Parser for pnpm shrinkwrap.yaml (v3/v4) format
3
+ *
4
+ * Shrinkwrap format (2016-2019) characteristics:
5
+ * - File: shrinkwrap.yaml
6
+ * - Version field: shrinkwrapVersion (number, typically 3 or 4)
7
+ * - Package key format: /name/version or /@scope/name/version
8
+ * - Peer dependency suffix: /peer@ver with ! escaping for scoped packages
9
+ * Example: /foo/1.0.0/bar@2.0.0+@scope!qar@3.0.0
10
+ *
11
+ * @module flatlock/parsers/pnpm/shrinkwrap
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} ParsedSpec
16
+ * @property {string|null} name - The package name (null if unparseable)
17
+ * @property {string|null} version - The package version (null if unparseable)
18
+ */
19
+
20
+ /**
21
+ * Parse a shrinkwrap.yaml package spec (v3/v4 format).
22
+ *
23
+ * Shrinkwrap format uses:
24
+ * - Slash separator between name and version: /name/version
25
+ * - Peer dependencies after another slash: /name/version/peer@ver
26
+ * - Scoped packages: /@scope/name/version
27
+ * - Scoped peer dependencies use `!` to escape the `@`: `/name/1.0.0/peer@2.0.0+@scope!qar@3.0.0`
28
+ *
29
+ * @param {string} spec - Package spec from shrinkwrap.yaml packages section
30
+ * @returns {ParsedSpec} Parsed name and version
31
+ *
32
+ * @example
33
+ * // Unscoped package
34
+ * parseSpecShrinkwrap('/lodash/4.17.21')
35
+ * // => { name: 'lodash', version: '4.17.21' }
36
+ *
37
+ * @example
38
+ * // Scoped package
39
+ * parseSpecShrinkwrap('/@babel/core/7.23.0')
40
+ * // => { name: '@babel/core', version: '7.23.0' }
41
+ *
42
+ * @example
43
+ * // With peer dependency suffix
44
+ * parseSpecShrinkwrap('/foo/1.0.0/bar@2.0.0')
45
+ * // => { name: 'foo', version: '1.0.0' }
46
+ *
47
+ * @example
48
+ * // With scoped peer dependency (`!` escapes `@`)
49
+ * parseSpecShrinkwrap('/foo/1.0.0/bar@2.0.0+@scope!qar@3.0.0')
50
+ * // => { name: 'foo', version: '1.0.0' }
51
+ *
52
+ * @example
53
+ * // Scoped package with peer deps
54
+ * parseSpecShrinkwrap('/@emotion/styled/10.0.27/react@17.0.2')
55
+ * // => { name: '@emotion/styled', version: '10.0.27' }
56
+ *
57
+ * @example
58
+ * // Multiple peer dependencies
59
+ * parseSpecShrinkwrap('/styled-components/5.3.6/react-dom@17.0.2+react@17.0.2')
60
+ * // => { name: 'styled-components', version: '5.3.6' }
61
+ *
62
+ * @example
63
+ * // Package with hyphenated name
64
+ * parseSpecShrinkwrap('/string-width/4.2.3')
65
+ * // => { name: 'string-width', version: '4.2.3' }
66
+ *
67
+ * @example
68
+ * // Scoped package with hyphenated name
69
+ * parseSpecShrinkwrap('/@babel/helper-compilation-targets/7.23.6')
70
+ * // => { name: '@babel/helper-compilation-targets', version: '7.23.6' }
71
+ *
72
+ * @example
73
+ * // link: protocol - skipped
74
+ * parseSpecShrinkwrap('link:packages/my-pkg')
75
+ * // => { name: null, version: null }
76
+ *
77
+ * @example
78
+ * // file: protocol - skipped
79
+ * parseSpecShrinkwrap('file:../local-package')
80
+ * // => { name: null, version: null }
81
+ *
82
+ * @example
83
+ * // Null input
84
+ * parseSpecShrinkwrap(null)
85
+ * // => { name: null, version: null }
86
+ *
87
+ * @example
88
+ * // Empty string
89
+ * parseSpecShrinkwrap('')
90
+ * // => { name: null, version: null }
91
+ */
92
+ export function parseSpecShrinkwrap(spec) {
93
+ // Handle null/undefined input
94
+ if (spec == null || typeof spec !== 'string') {
95
+ return { name: null, version: null };
96
+ }
97
+
98
+ // Skip special protocols
99
+ if (spec.startsWith('link:') || spec.startsWith('file:')) {
100
+ return { name: null, version: null };
101
+ }
102
+
103
+ // Remove leading slash if present
104
+ const cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
105
+
106
+ // Handle empty string after removing slash
107
+ if (!cleaned) {
108
+ return { name: null, version: null };
109
+ }
110
+
111
+ // Split by slash
112
+ const parts = cleaned.split('/');
113
+
114
+ // Determine if this is a scoped package
115
+ // Scoped packages start with @ and have format: @scope/name/version[/peer-suffix]
116
+ // Unscoped packages have format: name/version[/peer-suffix]
117
+
118
+ if (cleaned.startsWith('@')) {
119
+ // Scoped package: @scope/name/version[/peer-suffix]
120
+ // parts[0] = '@scope', parts[1] = 'name', parts[2] = 'version', parts[3+] = peer suffix
121
+
122
+ if (parts.length < 3) {
123
+ // Not enough parts for scoped package
124
+ return { name: null, version: null };
125
+ }
126
+
127
+ const scope = parts[0]; // e.g., '@babel'
128
+ const pkgName = parts[1]; // e.g., 'core'
129
+ const version = parts[2]; // e.g., '7.23.0'
130
+
131
+ // Validate scope format
132
+ if (!scope.startsWith('@') || !scope.slice(1)) {
133
+ return { name: null, version: null };
134
+ }
135
+
136
+ // Validate we have both name and version
137
+ if (!pkgName || !version) {
138
+ return { name: null, version: null };
139
+ }
140
+
141
+ // The version might contain additional peer suffix parts that got split
142
+ // In shrinkwrap v3/v4, peer suffixes come after another slash
143
+ // But the version itself should be the semver string
144
+
145
+ return {
146
+ name: `${scope}/${pkgName}`,
147
+ version: version
148
+ };
149
+ }
150
+
151
+ // Unscoped package: name/version[/peer-suffix]
152
+ // parts[0] = 'name', parts[1] = 'version', parts[2+] = peer suffix
153
+
154
+ if (parts.length < 2) {
155
+ // Not enough parts
156
+ return { name: null, version: null };
157
+ }
158
+
159
+ const name = parts[0];
160
+ const version = parts[1];
161
+
162
+ // Validate we have both name and version
163
+ if (!name || !version) {
164
+ return { name: null, version: null };
165
+ }
166
+
167
+ return { name, version };
168
+ }
169
+
170
+ /**
171
+ * Check if a spec has peer dependency suffix (shrinkwrap v3/v4 format).
172
+ *
173
+ * In shrinkwrap v3/v4, peer dependencies are appended after the version
174
+ * with another slash: /name/version/peer@ver+peer2@ver
175
+ *
176
+ * @param {string} spec - Package spec from shrinkwrap.yaml
177
+ * @returns {boolean} True if the spec has peer dependency suffix
178
+ *
179
+ * @example
180
+ * hasPeerSuffix('/lodash/4.17.21') // => false
181
+ * hasPeerSuffix('/foo/1.0.0/bar@2.0.0') // => true
182
+ * hasPeerSuffix('/@babel/core/7.23.0') // => false
183
+ * hasPeerSuffix('/@emotion/styled/10.0.27/react@17.0.2') // => true
184
+ */
185
+ export function hasPeerSuffix(spec) {
186
+ if (spec == null || typeof spec !== 'string') {
187
+ return false;
188
+ }
189
+
190
+ // Remove leading slash
191
+ const cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
192
+
193
+ // Count slashes
194
+ const slashCount = (cleaned.match(/\//g) || []).length;
195
+
196
+ // Scoped packages have 2+ slashes (scope/name/version), peer adds more
197
+ // Unscoped packages have 1+ slash (name/version), peer adds more
198
+
199
+ if (cleaned.startsWith('@')) {
200
+ // Scoped: needs > 2 slashes for peer suffix
201
+ return slashCount > 2;
202
+ }
203
+
204
+ // Unscoped: needs > 1 slash for peer suffix
205
+ return slashCount > 1;
206
+ }
207
+
208
+ /**
209
+ * Extract the peer dependency suffix from a shrinkwrap spec.
210
+ *
211
+ * @param {string} spec - Package spec from shrinkwrap.yaml
212
+ * @returns {string|null} The peer suffix or null if none
213
+ *
214
+ * @example
215
+ * extractPeerSuffix('/lodash/4.17.21') // => null
216
+ * extractPeerSuffix('/foo/1.0.0/bar@2.0.0') // => 'bar@2.0.0'
217
+ * extractPeerSuffix('/foo/1.0.0/bar@2.0.0+@scope!qar@3.0.0') // => 'bar@2.0.0+@scope!qar@3.0.0'
218
+ */
219
+ export function extractPeerSuffix(spec) {
220
+ if (spec == null || typeof spec !== 'string') {
221
+ return null;
222
+ }
223
+
224
+ // Remove leading slash
225
+ const cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
226
+ const parts = cleaned.split('/');
227
+
228
+ if (cleaned.startsWith('@')) {
229
+ // Scoped: @scope/name/version[/peer-suffix...]
230
+ if (parts.length <= 3) {
231
+ return null;
232
+ }
233
+ return parts.slice(3).join('/');
234
+ }
235
+
236
+ // Unscoped: name/version[/peer-suffix...]
237
+ if (parts.length <= 2) {
238
+ return null;
239
+ }
240
+ return parts.slice(2).join('/');
241
+ }