flatlock 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +55 -2
  2. package/bin/flatlock-cmp.js +109 -356
  3. package/dist/compare.d.ts +85 -0
  4. package/dist/compare.d.ts.map +1 -0
  5. package/dist/detect.d.ts +33 -0
  6. package/dist/detect.d.ts.map +1 -0
  7. package/dist/index.d.ts +60 -20
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/parsers/index.d.ts +5 -0
  10. package/dist/parsers/index.d.ts.map +1 -0
  11. package/dist/parsers/npm.d.ts +109 -0
  12. package/dist/parsers/npm.d.ts.map +1 -0
  13. package/dist/parsers/pnpm/detect.d.ts +136 -0
  14. package/dist/parsers/pnpm/detect.d.ts.map +1 -0
  15. package/dist/parsers/pnpm/index.d.ts +120 -0
  16. package/dist/parsers/pnpm/index.d.ts.map +1 -0
  17. package/dist/parsers/pnpm/internal.d.ts +5 -0
  18. package/dist/parsers/pnpm/internal.d.ts.map +1 -0
  19. package/dist/parsers/pnpm/shrinkwrap.d.ts +129 -0
  20. package/dist/parsers/pnpm/shrinkwrap.d.ts.map +1 -0
  21. package/dist/parsers/pnpm/v5.d.ts +139 -0
  22. package/dist/parsers/pnpm/v5.d.ts.map +1 -0
  23. package/dist/parsers/pnpm/v6plus.d.ts +212 -0
  24. package/dist/parsers/pnpm/v6plus.d.ts.map +1 -0
  25. package/dist/parsers/pnpm.d.ts +2 -0
  26. package/dist/parsers/pnpm.d.ts.map +1 -0
  27. package/dist/parsers/types.d.ts +23 -0
  28. package/dist/parsers/types.d.ts.map +1 -0
  29. package/dist/parsers/yarn-berry.d.ts +154 -0
  30. package/dist/parsers/yarn-berry.d.ts.map +1 -0
  31. package/dist/parsers/yarn-classic.d.ts +110 -0
  32. package/dist/parsers/yarn-classic.d.ts.map +1 -0
  33. package/dist/result.d.ts +12 -0
  34. package/dist/result.d.ts.map +1 -0
  35. package/dist/set.d.ts +189 -0
  36. package/dist/set.d.ts.map +1 -0
  37. package/package.json +18 -7
  38. package/src/compare.js +620 -0
  39. package/src/detect.js +8 -7
  40. package/src/index.js +33 -15
  41. package/src/parsers/index.js +12 -4
  42. package/src/parsers/npm.js +70 -23
  43. package/src/parsers/pnpm/detect.js +198 -0
  44. package/src/parsers/pnpm/index.js +289 -0
  45. package/src/parsers/pnpm/internal.js +41 -0
  46. package/src/parsers/pnpm/shrinkwrap.js +241 -0
  47. package/src/parsers/pnpm/v5.js +225 -0
  48. package/src/parsers/pnpm/v6plus.js +290 -0
  49. package/src/parsers/pnpm.js +12 -77
  50. package/src/parsers/types.js +10 -0
  51. package/src/parsers/yarn-berry.js +187 -38
  52. package/src/parsers/yarn-classic.js +85 -24
  53. package/src/result.js +2 -2
  54. package/src/set.js +618 -0
  55. package/src/types.d.ts +54 -0
@@ -0,0 +1,289 @@
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 yaml from 'js-yaml';
15
+
16
+ import { detectVersion } from './detect.js';
17
+ import { parseSpecShrinkwrap } from './shrinkwrap.js';
18
+ import { parseSpecV5 } from './v5.js';
19
+ import { parseSpecV6Plus } from './v6plus.js';
20
+
21
+ /** @typedef {import('../types.js').Dependency} Dependency */
22
+
23
+ // Public API: detectVersion for users who need to inspect lockfile version
24
+ export { detectVersion } from './detect.js';
25
+
26
+ // Version-specific internals available via 'flatlock/parsers/pnpm/internal'
27
+
28
+ /**
29
+ * Parse pnpm package spec to extract name and version.
30
+ *
31
+ * This is the unified parser that auto-detects the format based on the spec pattern.
32
+ * It supports all pnpm lockfile versions without requiring version context.
33
+ *
34
+ * Detection heuristics:
35
+ * 1. If spec contains '(' -> v6+ format (peer deps in parentheses)
36
+ * 2. If spec contains '@' after position 0 and no '/' after the '@' -> v6+ format
37
+ * 3. Otherwise -> v5 or earlier format (slash separator)
38
+ *
39
+ * @param {string} spec - Package spec from pnpm lockfile
40
+ * @returns {{ name: string | null, version: string | null }}
41
+ *
42
+ * @example
43
+ * // v5 format - unscoped package
44
+ * parseSpec('/lodash/4.17.21')
45
+ * // => { name: 'lodash', version: '4.17.21' }
46
+ *
47
+ * @example
48
+ * // v5 format - scoped package
49
+ * parseSpec('/@babel/core/7.23.0')
50
+ * // => { name: '@babel/core', version: '7.23.0' }
51
+ *
52
+ * @example
53
+ * // v5 format - with peer dependency suffix (underscore)
54
+ * parseSpec('/styled-jsx/3.0.9_react@17.0.2')
55
+ * // => { name: 'styled-jsx', version: '3.0.9' }
56
+ *
57
+ * @example
58
+ * // v6 format - unscoped package (with leading slash)
59
+ * parseSpec('/lodash@4.17.21')
60
+ * // => { name: 'lodash', version: '4.17.21' }
61
+ *
62
+ * @example
63
+ * // v6 format - scoped package
64
+ * parseSpec('/@babel/core@7.23.0')
65
+ * // => { name: '@babel/core', version: '7.23.0' }
66
+ *
67
+ * @example
68
+ * // v9 format - unscoped package (no leading slash)
69
+ * parseSpec('lodash@4.17.21')
70
+ * // => { name: 'lodash', version: '4.17.21' }
71
+ *
72
+ * @example
73
+ * // v9 format - scoped package (no leading slash)
74
+ * parseSpec('@babel/core@7.23.0')
75
+ * // => { name: '@babel/core', version: '7.23.0' }
76
+ *
77
+ * @example
78
+ * // v9 format - with peer dependency suffix (parentheses)
79
+ * parseSpec('@babel/core@7.23.0(@types/node@20.0.0)')
80
+ * // => { name: '@babel/core', version: '7.23.0' }
81
+ *
82
+ * @example
83
+ * // v9 format - multiple peer dependencies
84
+ * parseSpec('@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0)')
85
+ * // => { name: '@testing-library/react', version: '14.0.0' }
86
+ *
87
+ * @example
88
+ * // Shrinkwrap v3/v4 format - with peer suffix (slash)
89
+ * parseSpec('/foo/1.0.0/bar@2.0.0')
90
+ * // => { name: 'foo', version: '1.0.0' }
91
+ *
92
+ * @example
93
+ * // link: protocol - skipped (returns null)
94
+ * parseSpec('link:packages/my-pkg')
95
+ * // => { name: null, version: null }
96
+ *
97
+ * @example
98
+ * // file: protocol - skipped (returns null)
99
+ * parseSpec('file:../local-package')
100
+ * // => { name: null, version: null }
101
+ *
102
+ * @example
103
+ * // Null input
104
+ * parseSpec(null)
105
+ * // => { name: null, version: null }
106
+ *
107
+ * @example
108
+ * // Prerelease version
109
+ * parseSpec('@verdaccio/ui-theme@6.0.0-6-next.50')
110
+ * // => { name: '@verdaccio/ui-theme', version: '6.0.0-6-next.50' }
111
+ */
112
+ export function parseSpec(spec) {
113
+ // Handle null/undefined input
114
+ if (spec == null || typeof spec !== 'string') {
115
+ return { name: null, version: null };
116
+ }
117
+
118
+ // Skip special protocols
119
+ if (spec.startsWith('link:') || spec.startsWith('file:')) {
120
+ return { name: null, version: null };
121
+ }
122
+
123
+ // Detect format based on spec pattern
124
+ // v6+ uses parentheses for peer deps and @ separator
125
+ // v5 and earlier use _ for peer deps and / separator
126
+
127
+ // Check for v6+ parentheses peer suffix
128
+ if (spec.includes('(')) {
129
+ return parseSpecV6Plus(spec);
130
+ }
131
+
132
+ // Remove leading slash for analysis
133
+ const cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
134
+
135
+ // Check for v6+ @ separator format
136
+ // In v6+, the format is name@version where @ separates name from version
137
+ // We need to find @ that isn't at position 0 (which would be a scope)
138
+ // And check that it's not part of a v5 peer suffix (which comes after _)
139
+
140
+ // First strip any v5 peer suffix for cleaner analysis
141
+ const withoutV5Peer = cleaned.split('_')[0];
142
+
143
+ // Find the last @ in the cleaned string
144
+ const lastAtIndex = withoutV5Peer.lastIndexOf('@');
145
+
146
+ if (lastAtIndex > 0) {
147
+ // Check if this is v6+ format by seeing if there's a / after the @
148
+ // In v6+: @babel/core@7.23.0 - the last @ separates name@version
149
+ // In v5: @babel/core/7.23.0 - no @ after the scope
150
+
151
+ const afterAt = withoutV5Peer.slice(lastAtIndex + 1);
152
+
153
+ // If there's no / in the part after the last @, it's likely v6+ format
154
+ // (the part after @ is just the version like "7.23.0")
155
+ if (!afterAt.includes('/')) {
156
+ return parseSpecV6Plus(spec);
157
+ }
158
+ }
159
+
160
+ // Fall back to v5 format (also handles shrinkwrap v3/v4 for basic cases)
161
+ // Note: shrinkwrap v3/v4 peer suffix with / is handled differently,
162
+ // but parseSpecV5 will still extract the correct name/version
163
+ // because it stops at _ (v5) and the shrinkwrap / peer suffix
164
+ // comes after the version anyway
165
+
166
+ return parseSpecV5(spec);
167
+ }
168
+
169
+ /**
170
+ * Extract package name from pnpm lockfile key.
171
+ * Wraps parseSpec to return just the name (consistent with other parsers).
172
+ *
173
+ * @param {string} key - pnpm lockfile key
174
+ * @returns {string | null} Package name
175
+ *
176
+ * @example
177
+ * parseLockfileKey('/@babel/core@7.23.0') // => '@babel/core'
178
+ * parseLockfileKey('/lodash/4.17.21') // => 'lodash'
179
+ */
180
+ export function parseLockfileKey(key) {
181
+ return parseSpec(key).name;
182
+ }
183
+
184
+ /**
185
+ * Parse pnpm lockfile (shrinkwrap.yaml, pnpm-lock.yaml v5.x, v6, v9)
186
+ *
187
+ * @param {string | object} input - Lockfile content string or pre-parsed object
188
+ * @param {Object} [_options] - Parser options (unused, reserved for future use)
189
+ * @returns {Generator<Dependency>}
190
+ *
191
+ * @example
192
+ * // Parse from string
193
+ * const deps = [...fromPnpmLock(yamlContent)];
194
+ *
195
+ * @example
196
+ * // Parse from pre-parsed object
197
+ * const lockfile = yaml.load(content);
198
+ * const deps = [...fromPnpmLock(lockfile)];
199
+ */
200
+ export function* fromPnpmLock(input, _options = {}) {
201
+ const lockfile = /** @type {Record<string, any>} */ (
202
+ typeof input === 'string' ? yaml.load(input) : input
203
+ );
204
+
205
+ // Detect version to determine where to look for packages
206
+ const detected = detectVersion(lockfile);
207
+
208
+ // Select era-specific parser
209
+ // Each era has different peer suffix formats that parseSpec can't auto-detect
210
+ const parseSpecForEra =
211
+ detected.era === 'shrinkwrap'
212
+ ? parseSpecShrinkwrap
213
+ : detected.era === 'v5'
214
+ ? parseSpecV5
215
+ : parseSpecV6Plus;
216
+
217
+ // Get packages object - location varies by version
218
+ // v5, v6: packages section directly
219
+ // v9: packages section has metadata, snapshots has relationships
220
+ // For dependency extraction, we primarily need packages (for resolution info)
221
+ const packages = lockfile.packages || {};
222
+
223
+ // For v9, we should also look at snapshots for additional entries
224
+ // that might only be in snapshots (peer variants)
225
+ const snapshots = lockfile.snapshots || {};
226
+
227
+ // Track seen packages to avoid duplicates (v9 has same package in both sections)
228
+ const seen = new Set();
229
+
230
+ // Process packages section
231
+ for (const [spec, pkg] of Object.entries(packages)) {
232
+ // Skip if we couldn't parse name/version
233
+ const { name, version } = parseSpecForEra(spec);
234
+ if (!name || !version) continue;
235
+
236
+ // Create dedup key
237
+ const key = `${name}@${version}`;
238
+ if (seen.has(key)) continue;
239
+ seen.add(key);
240
+
241
+ const resolution = pkg.resolution || {};
242
+ const integrity = resolution.integrity;
243
+ const resolved = resolution.tarball;
244
+ const link = spec.startsWith('link:') || resolution.type === 'directory';
245
+
246
+ // Skip workspace/link entries - flatlock only cares about external dependencies
247
+ if (link) continue;
248
+
249
+ /** @type {Dependency} */
250
+ const dep = { name, version };
251
+ if (integrity) dep.integrity = integrity;
252
+ if (resolved) dep.resolved = resolved;
253
+ yield dep;
254
+ }
255
+
256
+ // For v9, also process snapshots for peer variants
257
+ // (they might have different resolution info)
258
+ if (detected.era === 'v9') {
259
+ for (const [spec, _snapshot] of Object.entries(snapshots)) {
260
+ const { name, version } = parseSpecForEra(spec);
261
+ if (!name || !version) continue;
262
+
263
+ // Create dedup key
264
+ const key = `${name}@${version}`;
265
+ if (seen.has(key)) continue;
266
+ seen.add(key);
267
+
268
+ // Snapshots don't have resolution info, check if base package exists
269
+ // The base package key in v9 is just name@version (without peer suffix)
270
+ const baseKey = `${name}@${version}`;
271
+ const basePkg = packages[baseKey];
272
+
273
+ if (basePkg) {
274
+ const resolution = basePkg.resolution || {};
275
+ const integrity = resolution.integrity;
276
+ const resolved = resolution.tarball;
277
+
278
+ /** @type {Dependency} */
279
+ const dep = { name, version };
280
+ if (integrity) dep.integrity = integrity;
281
+ if (resolved) dep.resolved = resolved;
282
+ yield dep;
283
+ }
284
+ }
285
+ }
286
+
287
+ // Note: importers (workspace packages) are intentionally NOT yielded
288
+ // flatlock only cares about external dependencies
289
+ }
@@ -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
+ }