flatlock 1.1.0 → 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 (49) hide show
  1. package/README.md +54 -1
  2. package/bin/flatlock-cmp.js +71 -45
  3. package/dist/compare.d.ts +25 -3
  4. package/dist/compare.d.ts.map +1 -1
  5. package/dist/detect.d.ts.map +1 -1
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/parsers/index.d.ts +2 -2
  9. package/dist/parsers/npm.d.ts +64 -37
  10. package/dist/parsers/npm.d.ts.map +1 -1
  11. package/dist/parsers/pnpm/detect.d.ts +136 -0
  12. package/dist/parsers/pnpm/detect.d.ts.map +1 -0
  13. package/dist/parsers/pnpm/index.d.ts +120 -0
  14. package/dist/parsers/pnpm/index.d.ts.map +1 -0
  15. package/dist/parsers/pnpm/internal.d.ts +5 -0
  16. package/dist/parsers/pnpm/internal.d.ts.map +1 -0
  17. package/dist/parsers/pnpm/shrinkwrap.d.ts +129 -0
  18. package/dist/parsers/pnpm/shrinkwrap.d.ts.map +1 -0
  19. package/dist/parsers/pnpm/v5.d.ts +139 -0
  20. package/dist/parsers/pnpm/v5.d.ts.map +1 -0
  21. package/dist/parsers/pnpm/v6plus.d.ts +212 -0
  22. package/dist/parsers/pnpm/v6plus.d.ts.map +1 -0
  23. package/dist/parsers/pnpm.d.ts +1 -59
  24. package/dist/parsers/pnpm.d.ts.map +1 -1
  25. package/dist/parsers/types.d.ts +23 -0
  26. package/dist/parsers/types.d.ts.map +1 -0
  27. package/dist/parsers/yarn-berry.d.ts +141 -52
  28. package/dist/parsers/yarn-berry.d.ts.map +1 -1
  29. package/dist/parsers/yarn-classic.d.ts +79 -33
  30. package/dist/parsers/yarn-classic.d.ts.map +1 -1
  31. package/dist/set.d.ts +189 -0
  32. package/dist/set.d.ts.map +1 -0
  33. package/package.json +7 -5
  34. package/src/compare.js +385 -28
  35. package/src/detect.js +3 -4
  36. package/src/index.js +9 -2
  37. package/src/parsers/index.js +10 -2
  38. package/src/parsers/npm.js +64 -16
  39. package/src/parsers/pnpm/detect.js +198 -0
  40. package/src/parsers/pnpm/index.js +289 -0
  41. package/src/parsers/pnpm/internal.js +41 -0
  42. package/src/parsers/pnpm/shrinkwrap.js +241 -0
  43. package/src/parsers/pnpm/v5.js +225 -0
  44. package/src/parsers/pnpm/v6plus.js +290 -0
  45. package/src/parsers/pnpm.js +11 -89
  46. package/src/parsers/types.js +10 -0
  47. package/src/parsers/yarn-berry.js +183 -36
  48. package/src/parsers/yarn-classic.js +81 -21
  49. package/src/set.js +618 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @fileoverview Parser for pnpm-lock.yaml v6+ format (v6.0 and v9.0)
3
+ *
4
+ * pnpm-lock.yaml v6+ format (2023+) characteristics:
5
+ * - File: pnpm-lock.yaml
6
+ * - Version field: lockfileVersion (string like '6.0', '9.0')
7
+ * - Package key format:
8
+ * - v6: /name@version or /@scope/name@version (with leading slash)
9
+ * - v9: name@version or @scope/name@version (no leading slash)
10
+ * - Peer dependency suffix: (peer@ver) in parentheses
11
+ * Example: /@babel/core@7.23.0(@types/node@20.0.0)
12
+ *
13
+ * Key differences from v5:
14
+ * - Uses @ separator between name and version instead of /
15
+ * - Peer suffix uses parentheses () instead of underscore _
16
+ * - Peer names are human-readable (no hashing)
17
+ * - v9 additionally removes leading slash from keys
18
+ *
19
+ * @module flatlock/parsers/pnpm/v6plus
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} ParsedSpec
24
+ * @property {string|null} name - The package name (null if unparseable)
25
+ * @property {string|null} version - The package version (null if unparseable)
26
+ */
27
+
28
+ /**
29
+ * Parse a pnpm-lock.yaml v6+ package spec.
30
+ *
31
+ * v6/v9 format uses:
32
+ * - @ separator between name and version: name@version
33
+ * - Leading slash in v6: /name@version, no slash in v9: name@version
34
+ * - Peer dependencies in parentheses: name@version(peer@ver)
35
+ * - Scoped packages: @scope/name@version
36
+ * - Multiple peers: name@version(peer1@ver)(peer2@ver)
37
+ *
38
+ * @param {string} spec - Package spec from pnpm-lock.yaml packages section
39
+ * @returns {ParsedSpec} Parsed name and version
40
+ *
41
+ * @example
42
+ * // Unscoped package (v6 format with leading slash)
43
+ * parseSpecV6Plus('/lodash@4.17.21')
44
+ * // => { name: 'lodash', version: '4.17.21' }
45
+ *
46
+ * @example
47
+ * // Unscoped package (v9 format without leading slash)
48
+ * parseSpecV6Plus('lodash@4.17.21')
49
+ * // => { name: 'lodash', version: '4.17.21' }
50
+ *
51
+ * @example
52
+ * // Scoped package (v6)
53
+ * parseSpecV6Plus('/@babel/core@7.23.0')
54
+ * // => { name: '@babel/core', version: '7.23.0' }
55
+ *
56
+ * @example
57
+ * // Scoped package (v9)
58
+ * parseSpecV6Plus('@babel/core@7.23.0')
59
+ * // => { name: '@babel/core', version: '7.23.0' }
60
+ *
61
+ * @example
62
+ * // With peer dependency suffix
63
+ * parseSpecV6Plus('/@babel/core@7.23.0(@types/node@20.0.0)')
64
+ * // => { name: '@babel/core', version: '7.23.0' }
65
+ *
66
+ * @example
67
+ * // With multiple peer dependencies
68
+ * parseSpecV6Plus('/@aleph-alpha/config-css@0.18.4(@unocss/core@66.5.2)(postcss@8.5.6)')
69
+ * // => { name: '@aleph-alpha/config-css', version: '0.18.4' }
70
+ *
71
+ * @example
72
+ * // Prerelease version
73
+ * parseSpecV6Plus('/unusual-pkg@1.0.0-beta.1')
74
+ * // => { name: 'unusual-pkg', version: '1.0.0-beta.1' }
75
+ *
76
+ * @example
77
+ * // Package with hyphenated name
78
+ * parseSpecV6Plus('/string-width@4.2.3')
79
+ * // => { name: 'string-width', version: '4.2.3' }
80
+ *
81
+ * @example
82
+ * // Scoped package with hyphenated name
83
+ * parseSpecV6Plus('@babel/helper-compilation-targets@7.23.6')
84
+ * // => { name: '@babel/helper-compilation-targets', version: '7.23.6' }
85
+ *
86
+ * @example
87
+ * // Complex nested peer dependencies (v9)
88
+ * parseSpecV6Plus('@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0)')
89
+ * // => { name: '@testing-library/react', version: '14.0.0' }
90
+ *
91
+ * @example
92
+ * // link: protocol - skipped
93
+ * parseSpecV6Plus('link:packages/my-pkg')
94
+ * // => { name: null, version: null }
95
+ *
96
+ * @example
97
+ * // file: protocol - skipped
98
+ * parseSpecV6Plus('file:../local-package')
99
+ * // => { name: null, version: null }
100
+ *
101
+ * @example
102
+ * // Null input
103
+ * parseSpecV6Plus(null)
104
+ * // => { name: null, version: null }
105
+ *
106
+ * @example
107
+ * // Build metadata version
108
+ * parseSpecV6Plus('esbuild@0.19.12+sha512.abc123')
109
+ * // => { name: 'esbuild', version: '0.19.12+sha512.abc123' }
110
+ */
111
+ export function parseSpecV6Plus(spec) {
112
+ // Handle null/undefined input
113
+ if (spec == null || typeof spec !== 'string') {
114
+ return { name: null, version: null };
115
+ }
116
+
117
+ // Skip special protocols
118
+ if (spec.startsWith('link:') || spec.startsWith('file:')) {
119
+ return { name: null, version: null };
120
+ }
121
+
122
+ // Remove leading slash if present (v6 has it, v9 doesn't)
123
+ let cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
124
+
125
+ // Handle empty string
126
+ if (!cleaned) {
127
+ return { name: null, version: null };
128
+ }
129
+
130
+ // Strip peer dependency suffixes FIRST (before looking for @ separator)
131
+ // v6+/v9 format uses parentheses: "@babel/core@7.23.0(@types/node@20.0.0)"
132
+ const parenIndex = cleaned.indexOf('(');
133
+ if (parenIndex !== -1) {
134
+ cleaned = cleaned.slice(0, parenIndex);
135
+ }
136
+
137
+ // Find the last @ which separates name from version
138
+ // For scoped packages like "@babel/core@7.23.0", we need the last @
139
+ const lastAtIndex = cleaned.lastIndexOf('@');
140
+
141
+ // If we found an @ that's not at position 0, use v6+ parsing
142
+ if (lastAtIndex > 0) {
143
+ const name = cleaned.slice(0, lastAtIndex);
144
+ const version = cleaned.slice(lastAtIndex + 1);
145
+
146
+ // Validate we have both name and version
147
+ if (!name || !version) {
148
+ return { name: null, version: null };
149
+ }
150
+
151
+ return { name, version };
152
+ }
153
+
154
+ // No valid @ separator found (or @ is at position 0 meaning just a scope)
155
+ return { name: null, version: null };
156
+ }
157
+
158
+ /**
159
+ * Check if a spec has peer dependency suffix (v6+ format).
160
+ *
161
+ * In v6+, peer dependencies are in parentheses: name@version(peer@ver)
162
+ *
163
+ * @param {string} spec - Package spec from pnpm-lock.yaml
164
+ * @returns {boolean} True if the spec has peer dependency suffix
165
+ *
166
+ * @example
167
+ * hasPeerSuffixV6Plus('/lodash@4.17.21') // => false
168
+ * hasPeerSuffixV6Plus('/@babel/core@7.23.0(@types/node@20.0.0)') // => true
169
+ * hasPeerSuffixV6Plus('lodash@4.17.21') // => false
170
+ * hasPeerSuffixV6Plus('@emotion/styled@10.0.27(react@17.0.2)') // => true
171
+ */
172
+ export function hasPeerSuffixV6Plus(spec) {
173
+ if (spec == null || typeof spec !== 'string') {
174
+ return false;
175
+ }
176
+
177
+ return spec.includes('(') && spec.includes(')');
178
+ }
179
+
180
+ /**
181
+ * Extract the peer dependency suffix from a v6+ spec.
182
+ *
183
+ * @param {string} spec - Package spec from pnpm-lock.yaml v6+
184
+ * @returns {string|null} The peer suffix (including parentheses) or null if none
185
+ *
186
+ * @example
187
+ * extractPeerSuffixV6Plus('/lodash@4.17.21') // => null
188
+ * extractPeerSuffixV6Plus('/@babel/core@7.23.0(@types/node@20.0.0)') // => '(@types/node@20.0.0)'
189
+ * extractPeerSuffixV6Plus('/@pkg@1.0.0(peer1@2.0.0)(peer2@3.0.0)') // => '(peer1@2.0.0)(peer2@3.0.0)'
190
+ */
191
+ export function extractPeerSuffixV6Plus(spec) {
192
+ if (spec == null || typeof spec !== 'string') {
193
+ return null;
194
+ }
195
+
196
+ const parenIndex = spec.indexOf('(');
197
+ if (parenIndex === -1) {
198
+ return null;
199
+ }
200
+
201
+ return spec.slice(parenIndex);
202
+ }
203
+
204
+ /**
205
+ * Parse peer dependencies from a v6+ peer suffix.
206
+ *
207
+ * @param {string} peerSuffix - The peer suffix like '(peer1@1.0.0)(peer2@2.0.0)'
208
+ * @returns {Array<{name: string, version: string}>} Array of parsed peer dependencies
209
+ *
210
+ * @example
211
+ * // Single scoped peer
212
+ * parsePeerDependencies('(@types/node@20.0.0)')
213
+ * // => [{ name: '@types/node', version: '20.0.0' }]
214
+ *
215
+ * @example
216
+ * // Multiple unscoped peers
217
+ * parsePeerDependencies('(react@18.2.0)(typescript@5.3.3)')
218
+ * // => [{ name: 'react', version: '18.2.0' }, { name: 'typescript', version: '5.3.3' }]
219
+ *
220
+ * @example
221
+ * // Single unscoped peer
222
+ * parsePeerDependencies('(lodash@4.17.21)')
223
+ * // => [{ name: 'lodash', version: '4.17.21' }]
224
+ *
225
+ * @example
226
+ * // Multiple scoped peers
227
+ * parsePeerDependencies('(@babel/core@7.23.0)(@types/react@18.2.0)')
228
+ * // => [{ name: '@babel/core', version: '7.23.0' }, { name: '@types/react', version: '18.2.0' }]
229
+ *
230
+ * @example
231
+ * // Mixed scoped and unscoped peers
232
+ * parsePeerDependencies('(react@18.2.0)(@types/react@18.2.0)')
233
+ * // => [{ name: 'react', version: '18.2.0' }, { name: '@types/react', version: '18.2.0' }]
234
+ *
235
+ * @example
236
+ * // React ecosystem peers (common pattern)
237
+ * parsePeerDependencies('(react-dom@18.2.0)(react@18.2.0)')
238
+ * // => [{ name: 'react-dom', version: '18.2.0' }, { name: 'react', version: '18.2.0' }]
239
+ *
240
+ * @example
241
+ * // Many peers (complex component library)
242
+ * parsePeerDependencies('(@unocss/core@66.5.2)(postcss@8.5.6)(typescript@5.3.3)')
243
+ * // => [{ name: '@unocss/core', version: '66.5.2' }, { name: 'postcss', version: '8.5.6' }, { name: 'typescript', version: '5.3.3' }]
244
+ *
245
+ * @example
246
+ * // Prerelease peer version
247
+ * parsePeerDependencies('(next@14.0.0-canary.0)')
248
+ * // => [{ name: 'next', version: '14.0.0-canary.0' }]
249
+ *
250
+ * @example
251
+ * // Empty/null input
252
+ * parsePeerDependencies(null)
253
+ * // => []
254
+ *
255
+ * @example
256
+ * // No parentheses (invalid)
257
+ * parsePeerDependencies('react@18.2.0')
258
+ * // => []
259
+ *
260
+ * @example
261
+ * // Deeply scoped peer
262
+ * parsePeerDependencies('(@babel/helper-compilation-targets@7.23.6)')
263
+ * // => [{ name: '@babel/helper-compilation-targets', version: '7.23.6' }]
264
+ */
265
+ export function parsePeerDependencies(peerSuffix) {
266
+ if (peerSuffix == null || typeof peerSuffix !== 'string') {
267
+ return [];
268
+ }
269
+
270
+ const peers = [];
271
+
272
+ // Match each (name@version) group
273
+ const regex = /\(([^)]+)\)/g;
274
+
275
+ for (const match of peerSuffix.matchAll(regex)) {
276
+ const peerSpec = match[1];
277
+ const lastAtIndex = peerSpec.lastIndexOf('@');
278
+
279
+ if (lastAtIndex > 0) {
280
+ const name = peerSpec.slice(0, lastAtIndex);
281
+ const version = peerSpec.slice(lastAtIndex + 1);
282
+
283
+ if (name && version) {
284
+ peers.push({ name, version });
285
+ }
286
+ }
287
+ }
288
+
289
+ return peers;
290
+ }
@@ -1,94 +1,16 @@
1
- import yaml from 'js-yaml';
2
-
3
- /**
4
- * @typedef {Object} Dependency
5
- * @property {string} name - Package name
6
- * @property {string} version - Resolved version
7
- * @property {string} [integrity] - Integrity hash
8
- * @property {string} [resolved] - Resolution URL
9
- * @property {boolean} [link] - True if this is a symlink
10
- */
11
-
12
1
  /**
13
- * Parse pnpm package spec to extract name and version
14
- * Examples:
15
- * "/@babel/core@7.23.0" → { name: "@babel/core", version: "7.23.0" }
16
- * "/lodash@4.17.21" → { name: "lodash", version: "4.17.21" }
17
- * "link:packages/foo" → { name: null, version: null } (skip these)
2
+ * @fileoverview pnpm lockfile parser - re-exports from modular implementation
18
3
  *
19
- * @param {string} spec - Package spec from pnpm lockfile
20
- * @returns {{ name: string | null, version: string | null }}
21
- */
22
- // Internal function - also exported for compare.js (not part of public API)
23
- export function parseSpec(spec) {
24
- // Skip special protocols
25
- if (spec.startsWith('link:') || spec.startsWith('file:')) {
26
- return { name: null, version: null };
27
- }
28
-
29
- // Remove leading slash if present
30
- const cleaned = spec.startsWith('/') ? spec.slice(1) : spec;
31
-
32
- // Find the last @ which separates name from version
33
- // For scoped packages like "@babel/core@7.23.0", we need the last @
34
- const lastAtIndex = cleaned.lastIndexOf('@');
35
-
36
- if (lastAtIndex === -1) {
37
- return { name: null, version: null };
38
- }
39
-
40
- const name = cleaned.slice(0, lastAtIndex);
41
- const versionPart = cleaned.slice(lastAtIndex + 1);
42
-
43
- // Extract version (may have additional info like "_@babel+core@7.23.0")
44
- // For peer dependencies, format can be: "lodash@4.17.21(@types/node@20.0.0)"
45
- const version = versionPart.split('(')[0];
46
-
47
- return { name, version };
48
- }
49
-
50
- /**
51
- * Extract package name from pnpm lockfile key.
52
- * Wraps parseSpec to return just the name (consistent with other parsers).
4
+ * This file maintains backward compatibility by re-exporting the pnpm parser
5
+ * from its new modular location at ./pnpm/index.js
53
6
  *
54
- * @param {string} key - pnpm lockfile key
55
- * @returns {string | null} Package name
56
- */
57
- export function parseLockfileKey(key) {
58
- return parseSpec(key).name;
59
- }
60
-
61
- /**
62
- * Parse pnpm-lock.yaml (v5.4, v6, v9)
63
- * @param {string} content - Lockfile content
64
- * @param {Object} [_options] - Parser options (unused, reserved for future use)
65
- * @returns {Generator<Dependency>}
7
+ * Supported formats:
8
+ * - shrinkwrap.yaml v3/v4 (2016-2019)
9
+ * - pnpm-lock.yaml v5.x (2019-2022)
10
+ * - pnpm-lock.yaml v6.0 (2023)
11
+ * - pnpm-lock.yaml v9.0 (2024+)
12
+ *
13
+ * @module flatlock/parsers/pnpm
66
14
  */
67
- export function* fromPnpmLock(content, _options = {}) {
68
- const lockfile = /** @type {{ packages?: Record<string, any> }} */ (yaml.load(content));
69
- const packages = lockfile.packages || {};
70
-
71
- for (const [spec, pkg] of Object.entries(packages)) {
72
- const { name, version } = parseSpec(spec);
73
-
74
- // Skip if we couldn't parse name/version
75
- if (!name || !version) continue;
76
-
77
- const resolution = pkg.resolution || {};
78
- const integrity = resolution.integrity;
79
- const resolved = resolution.tarball;
80
- const link = spec.startsWith('link:') || resolution.type === 'directory';
81
-
82
- // Skip workspace/link entries - flatlock only cares about external dependencies
83
- if (link) continue;
84
-
85
- /** @type {Dependency} */
86
- const dep = { name, version };
87
- if (integrity) dep.integrity = integrity;
88
- if (resolved) dep.resolved = resolved;
89
- yield dep;
90
- }
91
15
 
92
- // Note: importers (workspace packages) are intentionally NOT yielded
93
- // flatlock only cares about external dependencies
94
- }
16
+ export * from './pnpm/index.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @typedef {Object} Dependency
3
+ * @property {string} name - Package name
4
+ * @property {string} version - Resolved version
5
+ * @property {string} [integrity] - Integrity hash
6
+ * @property {string} [resolved] - Resolution URL
7
+ * @property {boolean} [link] - True if this is a symlink
8
+ */
9
+
10
+ export {};
@@ -1,39 +1,178 @@
1
1
  import { parseSyml } from '@yarnpkg/parsers';
2
2
 
3
+ /** @typedef {import('./types.js').Dependency} Dependency */
4
+
3
5
  /**
4
- * @typedef {Object} Dependency
5
- * @property {string} name - Package name
6
- * @property {string} version - Resolved version
7
- * @property {string} [integrity] - Integrity hash
8
- * @property {string} [resolved] - Resolution URL
9
- * @property {boolean} [link] - True if this is a symlink
6
+ * Extract package name from yarn berry resolution field.
7
+ *
8
+ * The resolution field is the CANONICAL identifier and should be used instead of the key.
9
+ * Keys can contain npm aliases (e.g., "string-width-cjs@npm:string-width@^4.2.0") while
10
+ * the resolution always contains the actual package name (e.g., "string-width@npm:4.2.3").
11
+ *
12
+ * @param {string} resolution - Resolution field from lockfile entry
13
+ * @returns {string | null} Package name or null if parsing fails
14
+ *
15
+ * @example
16
+ * // Unscoped npm package
17
+ * parseResolution('lodash@npm:4.17.21')
18
+ * // => 'lodash'
19
+ *
20
+ * @example
21
+ * // Scoped npm package
22
+ * parseResolution('@babel/core@npm:7.24.0')
23
+ * // => '@babel/core'
24
+ *
25
+ * @example
26
+ * // Aliased package - resolution shows the REAL package name
27
+ * // (key was "string-width-cjs@npm:string-width@^4.2.0")
28
+ * parseResolution('string-width@npm:4.2.3')
29
+ * // => 'string-width'
30
+ *
31
+ * @example
32
+ * // Scoped aliased package - resolution shows the REAL package name
33
+ * // (key was "@babel-baseline/core@npm:@babel/core@7.24.4")
34
+ * parseResolution('@babel/core@npm:7.24.4')
35
+ * // => '@babel/core'
36
+ *
37
+ * @example
38
+ * // Patch protocol (nested protocols)
39
+ * parseResolution('pkg@patch:pkg@npm:1.0.0#./patch')
40
+ * // => 'pkg'
41
+ *
42
+ * @example
43
+ * // Scoped package with patch protocol
44
+ * parseResolution('@scope/pkg@patch:@scope/pkg@npm:1.0.0#./fix.patch')
45
+ * // => '@scope/pkg'
46
+ *
47
+ * @example
48
+ * // Workspace protocol
49
+ * parseResolution('my-pkg@workspace:packages/my-pkg')
50
+ * // => 'my-pkg'
51
+ *
52
+ * @example
53
+ * // Scoped workspace package
54
+ * parseResolution('@myorg/utils@workspace:packages/utils')
55
+ * // => '@myorg/utils'
56
+ *
57
+ * @example
58
+ * // Git protocol
59
+ * parseResolution('my-lib@git:github.com/user/repo#commit-hash')
60
+ * // => 'my-lib'
61
+ *
62
+ * @example
63
+ * // Null/empty input
64
+ * parseResolution(null)
65
+ * // => null
66
+ *
67
+ * @example
68
+ * // Empty string
69
+ * parseResolution('')
70
+ * // => null
71
+ *
72
+ * @example
73
+ * // Portal protocol (symlink to external package)
74
+ * parseResolution('@scope/external@portal:../external-pkg')
75
+ * // => '@scope/external'
10
76
  */
77
+ export function parseResolution(resolution) {
78
+ if (!resolution) return null;
79
+
80
+ // Resolution format: name@protocol:version or @scope/name@protocol:version
81
+ // Examples:
82
+ // "lodash@npm:4.17.21"
83
+ // "@babel/core@npm:7.24.0"
84
+ // "pkg@patch:pkg@npm:1.0.0#./patch"
85
+
86
+ // Handle scoped packages: @scope/name@protocol:version
87
+ if (resolution.startsWith('@')) {
88
+ const slashIndex = resolution.indexOf('/');
89
+ if (slashIndex !== -1) {
90
+ // Find the @ after the scope/name
91
+ const afterSlash = resolution.indexOf('@', slashIndex);
92
+ if (afterSlash !== -1) {
93
+ return resolution.slice(0, afterSlash);
94
+ }
95
+ }
96
+ }
97
+
98
+ // Handle unscoped packages: name@protocol:version
99
+ const atIndex = resolution.indexOf('@');
100
+ if (atIndex !== -1) {
101
+ return resolution.slice(0, atIndex);
102
+ }
103
+
104
+ return null;
105
+ }
11
106
 
12
107
  /**
13
- * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14
- * !! WARNING: DO NOT MODIFY THIS FUNCTION !!
15
- * !! AI AGENTS (Claude, Copilot, etc.): DO NOT TOUCH THIS CODE !!
16
- * !! !!
17
- * !! This is YARN BERRY LOCKFILE KEY parsing, NOT npm spec parsing. !!
18
- * !! Yarn berry lockfile keys have their own format: !!
19
- * !! - Protocol markers: @npm:, @workspace:, @patch:, @portal:, @link: !!
20
- * !! - Nested protocols: @patch:pkg@npm:version#hash !!
21
- * !! - Multiple comma-separated entries !!
22
- * !! !!
23
- * !! npm-package-arg (npa) does NOT understand these formats. !!
24
- * !! Do not "improve" this with npa. !!
25
- * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
26
- *
27
- * Extract package name from yarn berry key.
28
- *
29
- * Examples:
30
- * "lodash@npm:^4.17.21" → "lodash"
31
- * "@babel/core@npm:^7.0.0" → "@babel/core"
32
- * "@babel/core@npm:^7.0.0, @babel/core@npm:^7.12.3" → "@babel/core"
33
- * "@ngageoint/simple-features-js@patch:@ngageoint/simple-features-js@npm:1.1.0#..." → "@ngageoint/simple-features-js"
108
+ * Extract package name from yarn berry key (fallback for when resolution is unavailable).
109
+ *
110
+ * WARNING: Keys can contain npm aliases. Prefer parseResolution() when possible.
111
+ * The key may return an alias name instead of the real package name.
34
112
  *
35
113
  * @param {string} key - Lockfile entry key
36
- * @returns {string} Package name
114
+ * @returns {string} Package name (may be alias name, not canonical name)
115
+ *
116
+ * @example
117
+ * // Simple unscoped package
118
+ * parseLockfileKey('lodash@npm:^4.17.21')
119
+ * // => 'lodash'
120
+ *
121
+ * @example
122
+ * // Scoped package
123
+ * parseLockfileKey('@babel/core@npm:^7.24.0')
124
+ * // => '@babel/core'
125
+ *
126
+ * @example
127
+ * // Multiple version ranges (comma-separated) - takes first entry
128
+ * parseLockfileKey('@types/node@npm:^18.0.0, @types/node@npm:^20.0.0')
129
+ * // => '@types/node'
130
+ *
131
+ * @example
132
+ * // npm alias - returns the ALIAS name (not real package)
133
+ * // Use parseResolution() for the real package name
134
+ * parseLockfileKey('string-width-cjs@npm:string-width@^4.2.0')
135
+ * // => 'string-width-cjs'
136
+ *
137
+ * @example
138
+ * // Scoped npm alias
139
+ * parseLockfileKey('@babel-baseline/core@npm:@babel/core@7.24.4')
140
+ * // => '@babel-baseline/core'
141
+ *
142
+ * @example
143
+ * // Workspace protocol
144
+ * parseLockfileKey('my-pkg@workspace:packages/my-pkg')
145
+ * // => 'my-pkg'
146
+ *
147
+ * @example
148
+ * // Scoped workspace package
149
+ * parseLockfileKey('@myorg/utils@workspace:.')
150
+ * // => '@myorg/utils'
151
+ *
152
+ * @example
153
+ * // Portal protocol
154
+ * parseLockfileKey('external-pkg@portal:../some/path')
155
+ * // => 'external-pkg'
156
+ *
157
+ * @example
158
+ * // Link protocol
159
+ * parseLockfileKey('linked-pkg@link:./local')
160
+ * // => 'linked-pkg'
161
+ *
162
+ * @example
163
+ * // Patch protocol (complex nested format)
164
+ * parseLockfileKey('pkg@patch:pkg@npm:1.0.0#./patches/fix.patch')
165
+ * // => 'pkg'
166
+ *
167
+ * @example
168
+ * // Scoped patch
169
+ * parseLockfileKey('@scope/pkg@patch:@scope/pkg@npm:1.0.0#./fix.patch')
170
+ * // => '@scope/pkg'
171
+ *
172
+ * @example
173
+ * // File protocol
174
+ * parseLockfileKey('local-pkg@file:../local-package')
175
+ * // => 'local-pkg'
37
176
  */
38
177
  export function parseLockfileKey(key) {
39
178
  // Keys can have multiple comma-separated entries, take the first one
@@ -73,29 +212,37 @@ export function parseLockfileKey(key) {
73
212
 
74
213
  /**
75
214
  * Parse yarn.lock v2+ (berry)
76
- * @param {string} content - Lockfile content
215
+ * @param {string | object} input - Lockfile content string or pre-parsed object
77
216
  * @param {Object} [_options] - Parser options (unused, reserved for future use)
78
217
  * @returns {Generator<Dependency>}
79
218
  */
80
- export function* fromYarnBerryLock(content, _options = {}) {
81
- const lockfile = parseSyml(content);
219
+ export function* fromYarnBerryLock(input, _options = {}) {
220
+ const lockfile = typeof input === 'string' ? parseSyml(input) : input;
82
221
 
83
222
  for (const [key, pkg] of Object.entries(lockfile)) {
84
223
  // Skip metadata
85
224
  if (key === '__metadata') continue;
86
225
 
87
- const name = parseLockfileKey(key);
88
226
  const { version, checksum, resolution } = pkg;
89
227
 
90
- // Check if this is a link (workspace:, portal:, or link: protocol)
228
+ // Check if this is a local/workspace entry (workspace:, portal:, or link: protocol)
229
+ // The protocol appears after @ in both key and resolution: "pkg@workspace:..."
91
230
  const link =
92
- resolution?.startsWith('workspace:') ||
93
- resolution?.startsWith('portal:') ||
94
- resolution?.startsWith('link:');
231
+ key.includes('@workspace:') ||
232
+ key.includes('@portal:') ||
233
+ key.includes('@link:') ||
234
+ resolution?.includes('@workspace:') ||
235
+ resolution?.includes('@portal:') ||
236
+ resolution?.includes('@link:');
95
237
 
96
238
  // Skip workspace/link entries - flatlock only cares about external dependencies
97
239
  if (link) continue;
98
240
 
241
+ // Use the resolution field for the package name - it's the canonical identifier
242
+ // Keys can contain npm aliases (e.g., "string-width-cjs@npm:string-width@^4.2.0")
243
+ // but resolution always has the actual package name (e.g., "string-width@npm:4.2.3")
244
+ const name = parseResolution(resolution) || parseLockfileKey(key);
245
+
99
246
  if (name && version) {
100
247
  /** @type {Dependency} */
101
248
  const dep = { name, version };