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.
@@ -1,39 +1,190 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
1
3
  import { parseSyml } from '@yarnpkg/parsers';
2
4
 
5
+ /** @typedef {import('./types.js').Dependency} Dependency */
6
+
3
7
  /**
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
8
+ * @typedef {Object} WorkspacePackage
9
+ * @property {string} name
10
+ * @property {string} version
11
+ * @property {Record<string, string>} [dependencies]
12
+ * @property {Record<string, string>} [devDependencies]
13
+ * @property {Record<string, string>} [optionalDependencies]
14
+ * @property {Record<string, string>} [peerDependencies]
10
15
  */
11
16
 
12
17
  /**
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"
18
+ * Extract package name from yarn berry resolution field.
19
+ *
20
+ * The resolution field is the CANONICAL identifier and should be used instead of the key.
21
+ * Keys can contain npm aliases (e.g., "string-width-cjs@npm:string-width@^4.2.0") while
22
+ * the resolution always contains the actual package name (e.g., "string-width@npm:4.2.3").
23
+ *
24
+ * @param {string} resolution - Resolution field from lockfile entry
25
+ * @returns {string | null} Package name or null if parsing fails
26
+ *
27
+ * @example
28
+ * // Unscoped npm package
29
+ * parseResolution('lodash@npm:4.17.21')
30
+ * // => 'lodash'
31
+ *
32
+ * @example
33
+ * // Scoped npm package
34
+ * parseResolution('@babel/core@npm:7.24.0')
35
+ * // => '@babel/core'
36
+ *
37
+ * @example
38
+ * // Aliased package - resolution shows the REAL package name
39
+ * // (key was "string-width-cjs@npm:string-width@^4.2.0")
40
+ * parseResolution('string-width@npm:4.2.3')
41
+ * // => 'string-width'
42
+ *
43
+ * @example
44
+ * // Scoped aliased package - resolution shows the REAL package name
45
+ * // (key was "@babel-baseline/core@npm:@babel/core@7.24.4")
46
+ * parseResolution('@babel/core@npm:7.24.4')
47
+ * // => '@babel/core'
48
+ *
49
+ * @example
50
+ * // Patch protocol (nested protocols)
51
+ * parseResolution('pkg@patch:pkg@npm:1.0.0#./patch')
52
+ * // => 'pkg'
53
+ *
54
+ * @example
55
+ * // Scoped package with patch protocol
56
+ * parseResolution('@scope/pkg@patch:@scope/pkg@npm:1.0.0#./fix.patch')
57
+ * // => '@scope/pkg'
58
+ *
59
+ * @example
60
+ * // Workspace protocol
61
+ * parseResolution('my-pkg@workspace:packages/my-pkg')
62
+ * // => 'my-pkg'
63
+ *
64
+ * @example
65
+ * // Scoped workspace package
66
+ * parseResolution('@myorg/utils@workspace:packages/utils')
67
+ * // => '@myorg/utils'
68
+ *
69
+ * @example
70
+ * // Git protocol
71
+ * parseResolution('my-lib@git:github.com/user/repo#commit-hash')
72
+ * // => 'my-lib'
73
+ *
74
+ * @example
75
+ * // Null/empty input
76
+ * parseResolution(null)
77
+ * // => null
78
+ *
79
+ * @example
80
+ * // Empty string
81
+ * parseResolution('')
82
+ * // => null
83
+ *
84
+ * @example
85
+ * // Portal protocol (symlink to external package)
86
+ * parseResolution('@scope/external@portal:../external-pkg')
87
+ * // => '@scope/external'
88
+ */
89
+ export function parseResolution(resolution) {
90
+ if (!resolution) return null;
91
+
92
+ // Resolution format: name@protocol:version or @scope/name@protocol:version
93
+ // Examples:
94
+ // "lodash@npm:4.17.21"
95
+ // "@babel/core@npm:7.24.0"
96
+ // "pkg@patch:pkg@npm:1.0.0#./patch"
97
+
98
+ // Handle scoped packages: @scope/name@protocol:version
99
+ if (resolution.startsWith('@')) {
100
+ const slashIndex = resolution.indexOf('/');
101
+ if (slashIndex !== -1) {
102
+ // Find the @ after the scope/name
103
+ const afterSlash = resolution.indexOf('@', slashIndex);
104
+ if (afterSlash !== -1) {
105
+ return resolution.slice(0, afterSlash);
106
+ }
107
+ }
108
+ }
109
+
110
+ // Handle unscoped packages: name@protocol:version
111
+ const atIndex = resolution.indexOf('@');
112
+ if (atIndex !== -1) {
113
+ return resolution.slice(0, atIndex);
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Extract package name from yarn berry key (fallback for when resolution is unavailable).
121
+ *
122
+ * WARNING: Keys can contain npm aliases. Prefer parseResolution() when possible.
123
+ * The key may return an alias name instead of the real package name.
34
124
  *
35
125
  * @param {string} key - Lockfile entry key
36
- * @returns {string} Package name
126
+ * @returns {string} Package name (may be alias name, not canonical name)
127
+ *
128
+ * @example
129
+ * // Simple unscoped package
130
+ * parseLockfileKey('lodash@npm:^4.17.21')
131
+ * // => 'lodash'
132
+ *
133
+ * @example
134
+ * // Scoped package
135
+ * parseLockfileKey('@babel/core@npm:^7.24.0')
136
+ * // => '@babel/core'
137
+ *
138
+ * @example
139
+ * // Multiple version ranges (comma-separated) - takes first entry
140
+ * parseLockfileKey('@types/node@npm:^18.0.0, @types/node@npm:^20.0.0')
141
+ * // => '@types/node'
142
+ *
143
+ * @example
144
+ * // npm alias - returns the ALIAS name (not real package)
145
+ * // Use parseResolution() for the real package name
146
+ * parseLockfileKey('string-width-cjs@npm:string-width@^4.2.0')
147
+ * // => 'string-width-cjs'
148
+ *
149
+ * @example
150
+ * // Scoped npm alias
151
+ * parseLockfileKey('@babel-baseline/core@npm:@babel/core@7.24.4')
152
+ * // => '@babel-baseline/core'
153
+ *
154
+ * @example
155
+ * // Workspace protocol
156
+ * parseLockfileKey('my-pkg@workspace:packages/my-pkg')
157
+ * // => 'my-pkg'
158
+ *
159
+ * @example
160
+ * // Scoped workspace package
161
+ * parseLockfileKey('@myorg/utils@workspace:.')
162
+ * // => '@myorg/utils'
163
+ *
164
+ * @example
165
+ * // Portal protocol
166
+ * parseLockfileKey('external-pkg@portal:../some/path')
167
+ * // => 'external-pkg'
168
+ *
169
+ * @example
170
+ * // Link protocol
171
+ * parseLockfileKey('linked-pkg@link:./local')
172
+ * // => 'linked-pkg'
173
+ *
174
+ * @example
175
+ * // Patch protocol (complex nested format)
176
+ * parseLockfileKey('pkg@patch:pkg@npm:1.0.0#./patches/fix.patch')
177
+ * // => 'pkg'
178
+ *
179
+ * @example
180
+ * // Scoped patch
181
+ * parseLockfileKey('@scope/pkg@patch:@scope/pkg@npm:1.0.0#./fix.patch')
182
+ * // => '@scope/pkg'
183
+ *
184
+ * @example
185
+ * // File protocol
186
+ * parseLockfileKey('local-pkg@file:../local-package')
187
+ * // => 'local-pkg'
37
188
  */
38
189
  export function parseLockfileKey(key) {
39
190
  // Keys can have multiple comma-separated entries, take the first one
@@ -73,29 +224,37 @@ export function parseLockfileKey(key) {
73
224
 
74
225
  /**
75
226
  * Parse yarn.lock v2+ (berry)
76
- * @param {string} content - Lockfile content
227
+ * @param {string | object} input - Lockfile content string or pre-parsed object
77
228
  * @param {Object} [_options] - Parser options (unused, reserved for future use)
78
229
  * @returns {Generator<Dependency>}
79
230
  */
80
- export function* fromYarnBerryLock(content, _options = {}) {
81
- const lockfile = parseSyml(content);
231
+ export function* fromYarnBerryLock(input, _options = {}) {
232
+ const lockfile = typeof input === 'string' ? parseSyml(input) : input;
82
233
 
83
234
  for (const [key, pkg] of Object.entries(lockfile)) {
84
235
  // Skip metadata
85
236
  if (key === '__metadata') continue;
86
237
 
87
- const name = parseLockfileKey(key);
88
238
  const { version, checksum, resolution } = pkg;
89
239
 
90
- // Check if this is a link (workspace:, portal:, or link: protocol)
240
+ // Check if this is a local/workspace entry (workspace:, portal:, or link: protocol)
241
+ // The protocol appears after @ in both key and resolution: "pkg@workspace:..."
91
242
  const link =
92
- resolution?.startsWith('workspace:') ||
93
- resolution?.startsWith('portal:') ||
94
- resolution?.startsWith('link:');
243
+ key.includes('@workspace:') ||
244
+ key.includes('@portal:') ||
245
+ key.includes('@link:') ||
246
+ resolution?.includes('@workspace:') ||
247
+ resolution?.includes('@portal:') ||
248
+ resolution?.includes('@link:');
95
249
 
96
250
  // Skip workspace/link entries - flatlock only cares about external dependencies
97
251
  if (link) continue;
98
252
 
253
+ // Use the resolution field for the package name - it's the canonical identifier
254
+ // Keys can contain npm aliases (e.g., "string-width-cjs@npm:string-width@^4.2.0")
255
+ // but resolution always has the actual package name (e.g., "string-width@npm:4.2.3")
256
+ const name = parseResolution(resolution) || parseLockfileKey(key);
257
+
99
258
  if (name && version) {
100
259
  /** @type {Dependency} */
101
260
  const dep = { name, version };
@@ -105,3 +264,79 @@ export function* fromYarnBerryLock(content, _options = {}) {
105
264
  }
106
265
  }
107
266
  }
267
+
268
+ /**
269
+ * Extract workspace paths from yarn berry lockfile.
270
+ *
271
+ * Yarn berry workspace entries use `@workspace:` protocol in keys.
272
+ * Keys can have multiple comma-separated descriptors.
273
+ *
274
+ * @param {string | object} input - Lockfile content string or pre-parsed object
275
+ * @returns {string[]} Array of workspace paths (e.g., ['packages/foo', 'packages/bar'])
276
+ *
277
+ * @example
278
+ * extractWorkspacePaths(lockfile)
279
+ * // => ['packages/babel-core', 'packages/babel-parser', ...]
280
+ */
281
+ export function extractWorkspacePaths(input) {
282
+ const lockfile = typeof input === 'string' ? parseSyml(input) : input;
283
+ const paths = new Set();
284
+
285
+ for (const key of Object.keys(lockfile)) {
286
+ if (key === '__metadata') continue;
287
+ if (!key.includes('@workspace:')) continue;
288
+
289
+ // Keys can have multiple comma-separated descriptors:
290
+ // "@babel/types@workspace:*, @babel/types@workspace:^, @babel/types@workspace:packages/babel-types"
291
+ const descriptors = key.split(', ');
292
+ for (const desc of descriptors) {
293
+ if (!desc.includes('@workspace:')) continue;
294
+
295
+ const wsIndex = desc.indexOf('@workspace:');
296
+ const path = desc.slice(wsIndex + '@workspace:'.length);
297
+
298
+ // Skip wildcards (*, ^) and root workspace (.)
299
+ if (path && path !== '.' && path !== '*' && path !== '^' && path.includes('/')) {
300
+ paths.add(path);
301
+ }
302
+ }
303
+ }
304
+
305
+ return [...paths];
306
+ }
307
+
308
+ /**
309
+ * Build workspace packages map by reading package.json files.
310
+ *
311
+ * @param {string | object} input - Lockfile content string or pre-parsed object
312
+ * @param {string} repoDir - Path to repository root
313
+ * @returns {Promise<Record<string, WorkspacePackage>>} Map of workspace path to package info
314
+ *
315
+ * @example
316
+ * const workspaces = await buildWorkspacePackages(lockfile, '/path/to/repo');
317
+ * // => { 'packages/foo': { name: '@scope/foo', version: '1.0.0', dependencies: {...} } }
318
+ */
319
+ export async function buildWorkspacePackages(input, repoDir) {
320
+ const paths = extractWorkspacePaths(input);
321
+ /** @type {Record<string, WorkspacePackage>} */
322
+ const workspacePackages = {};
323
+
324
+ for (const wsPath of paths) {
325
+ const pkgJsonPath = join(repoDir, wsPath, 'package.json');
326
+ try {
327
+ const pkg = JSON.parse(await readFile(pkgJsonPath, 'utf8'));
328
+ workspacePackages[wsPath] = {
329
+ name: pkg.name,
330
+ version: pkg.version || '0.0.0',
331
+ dependencies: pkg.dependencies,
332
+ devDependencies: pkg.devDependencies,
333
+ optionalDependencies: pkg.optionalDependencies,
334
+ peerDependencies: pkg.peerDependencies
335
+ };
336
+ } catch {
337
+ // Skip workspaces with missing or invalid package.json
338
+ }
339
+ }
340
+
341
+ return workspacePackages;
342
+ }
@@ -1,16 +1,19 @@
1
1
  import yarnLockfile from '@yarnpkg/lockfile';
2
2
 
3
- const { parse } = yarnLockfile;
3
+ /** @typedef {import('./types.js').Dependency} Dependency */
4
4
 
5
5
  /**
6
- * @typedef {Object} Dependency
7
- * @property {string} name - Package name
8
- * @property {string} version - Resolved version
9
- * @property {string} [integrity] - Integrity hash
10
- * @property {string} [resolved] - Resolution URL
11
- * @property {boolean} [link] - True if this is a symlink
6
+ * @typedef {Object} YarnClassicParseResult
7
+ * @property {'success' | 'merge' | 'conflict'} type - Parse result type
8
+ * @property {Record<string, any>} object - Parsed lockfile object
12
9
  */
13
10
 
11
+ /**
12
+ * The yarn classic parse function (handles CJS/ESM interop)
13
+ * @type {(content: string) => YarnClassicParseResult}
14
+ */
15
+ export const parseYarnClassic = yarnLockfile.default?.parse || yarnLockfile.parse;
16
+
14
17
  /**
15
18
  * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16
19
  * !! WARNING: DO NOT MODIFY THIS FUNCTION !!
@@ -27,14 +30,68 @@ const { parse } = yarnLockfile;
27
30
  *
28
31
  * Extract package name from yarn classic key.
29
32
  *
30
- * Examples:
31
- * "lodash@^4.17.21" → "lodash"
32
- * "@babel/core@^7.0.0" → "@babel/core"
33
- * "lodash@^4.17.21, lodash@^4.0.0" → "lodash" (multiple version ranges)
34
- * "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3" → "@babel/traverse--for-generate-function-map"
35
- *
36
33
  * @param {string} key - Lockfile entry key
37
34
  * @returns {string} Package name
35
+ *
36
+ * @example
37
+ * // Simple unscoped package with semver range
38
+ * parseLockfileKey('lodash@^4.17.21')
39
+ * // => 'lodash'
40
+ *
41
+ * @example
42
+ * // Scoped package
43
+ * parseLockfileKey('@babel/core@^7.0.0')
44
+ * // => '@babel/core'
45
+ *
46
+ * @example
47
+ * // Multiple version ranges (comma-separated) - takes first entry
48
+ * parseLockfileKey('lodash@^4.17.21, lodash@^4.0.0')
49
+ * // => 'lodash'
50
+ *
51
+ * @example
52
+ * // Multiple ranges for scoped package
53
+ * parseLockfileKey('@types/node@^18.0.0, @types/node@^20.0.0')
54
+ * // => '@types/node'
55
+ *
56
+ * @example
57
+ * // npm: alias protocol - returns the ALIAS name
58
+ * parseLockfileKey('@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3')
59
+ * // => '@babel/traverse--for-generate-function-map'
60
+ *
61
+ * @example
62
+ * // Unscoped alias
63
+ * parseLockfileKey('string-width-cjs@npm:string-width@^4.2.0')
64
+ * // => 'string-width-cjs'
65
+ *
66
+ * @example
67
+ * // Exact version
68
+ * parseLockfileKey('typescript@5.3.3')
69
+ * // => 'typescript'
70
+ *
71
+ * @example
72
+ * // Git URL specifier
73
+ * parseLockfileKey('my-lib@github:user/repo#v1.0.0')
74
+ * // => 'my-lib'
75
+ *
76
+ * @example
77
+ * // Tarball URL
78
+ * parseLockfileKey('custom-pkg@https://example.com/pkg.tgz')
79
+ * // => 'custom-pkg'
80
+ *
81
+ * @example
82
+ * // Package with prerelease version
83
+ * parseLockfileKey('@next/env@^14.0.0-canary.0')
84
+ * // => '@next/env'
85
+ *
86
+ * @example
87
+ * // Package without @ (bare name, edge case)
88
+ * parseLockfileKey('lodash')
89
+ * // => 'lodash'
90
+ *
91
+ * @example
92
+ * // Deeply scoped alias pointing to scoped package
93
+ * parseLockfileKey('@myorg/my-alias@npm:@original/package@^1.0.0')
94
+ * // => '@myorg/my-alias'
38
95
  */
39
96
  export function parseLockfileKey(key) {
40
97
  // Keys can have multiple version ranges: "pkg@^1.0.0, pkg@^2.0.0"
@@ -72,19 +129,22 @@ export function parseLockfileKey(key) {
72
129
 
73
130
  /**
74
131
  * Parse yarn.lock v1 (classic)
75
- * @param {string} content - Lockfile content
132
+ * @param {string | object} input - Lockfile content string or pre-parsed object
76
133
  * @param {Object} [_options] - Parser options (unused, reserved for future use)
77
134
  * @returns {Generator<Dependency>}
78
135
  */
79
- export function* fromYarnClassicLock(content, _options = {}) {
80
- const parsed = parse(content);
81
-
82
- if (parsed.type !== 'success') {
83
- throw new Error(`Failed to parse yarn.lock: ${parsed.type}`);
136
+ export function* fromYarnClassicLock(input, _options = {}) {
137
+ let lockfile;
138
+ if (typeof input === 'string') {
139
+ const result = parseYarnClassic(input);
140
+ if (result.type !== 'success' && result.type !== 'merge') {
141
+ throw new Error('Failed to parse yarn.lock');
142
+ }
143
+ lockfile = result.object;
144
+ } else {
145
+ lockfile = input;
84
146
  }
85
147
 
86
- const lockfile = parsed.object;
87
-
88
148
  for (const [key, pkg] of Object.entries(lockfile)) {
89
149
  const name = parseLockfileKey(key);
90
150
  const { version, integrity, resolved } = pkg;