@vzn/vx-lockfile 0.0.646 → 0.0.648

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/src/yarn.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import { reachDigests, type LockfileImporter } from '@vzn/vx'
15
15
  import { depsOf, record, stable } from './json.js'
16
16
  import { joinPosix } from './posix.js'
17
+ import { parseSyml } from './syml.js'
17
18
 
18
19
  export interface Lockfile {
19
20
  readonly generation: 'berry' | 'classic'
@@ -40,6 +41,9 @@ export interface Lockfile {
40
41
  }
41
42
 
42
43
  export interface Entry {
44
+ /** The package's own name, from the resolution (berry; '' for classic) */
45
+ readonly name: string
46
+ readonly version: string
43
47
  readonly deps: ReadonlyMap<string, string>
44
48
  /** `dependencies` alone, which a workspace entry merges its devDependencies into (berry only) */
45
49
  readonly installed: ReadonlyMap<string, string>
@@ -57,9 +61,9 @@ export function parseLockfile(text: string): Lockfile {
57
61
  }
58
62
 
59
63
  function parseBerry(text: string): Lockfile {
60
- let doc: unknown
64
+ let doc: unknown = parseSyml(text)
61
65
  try {
62
- doc = Bun.YAML.parse(text)
66
+ doc ??= Bun.YAML.parse(text)
63
67
  } catch (err) {
64
68
  throw new Error(`yarn.lock: ${err instanceof Error ? err.message : String(err)}`)
65
69
  }
@@ -74,15 +78,16 @@ function parseBerry(text: string): Lockfile {
74
78
  if (keys === '__metadata') continue
75
79
  const e = record(raw) ?? {}
76
80
  const resolution = typeof e['resolution'] === 'string' ? e['resolution'] : keys
77
- // Every field of the entry but the dependencies it links, so a field
78
- // this parser has not heard of moves what reaches the entry: reading
79
- // the resolution and checksum alone dropped the root workspace's
80
- // `dependenciesMeta` (`built`, `unplugged`), which governs the whole
81
- // install (item 933).
82
- const material = Object.fromEntries(
83
- Object.entries(e).filter(([k]) => k !== 'dependencies' && k !== 'peerDependencies'),
84
- )
81
+ // Every field of the entry, so a field this parser has not heard of
82
+ // moves what reaches the entry: reading the resolution and checksum
83
+ // alone dropped the root workspace's `dependenciesMeta` (`built`,
84
+ // `unplugged`), which governs the whole install (item 933). Its
85
+ // dependency lists too: the edges say where each lands, not under
86
+ // which name, and two aliases swapping targets kept the key.
87
+ const material = e
85
88
  entries.set(resolution, {
89
+ name: resolution.slice(0, resolution.indexOf('@', 1)),
90
+ version: typeof e['version'] === 'string' ? e['version'] : '',
86
91
  deps: depsOf(e, ['dependencies', 'peerDependencies']),
87
92
  installed: depsOf(e, ['dependencies']),
88
93
  resolution: `${resolution}\0${stable(material)}`,
@@ -101,6 +106,7 @@ function parseBerry(text: string): Lockfile {
101
106
  } catch {
102
107
  inner = patch[1]!
103
108
  }
109
+ inner = npmDefault(inner)
104
110
  const ids = patches.get(inner)
105
111
  if (ids === undefined) patches.set(inner, [resolution])
106
112
  else if (!ids.includes(resolution)) ids.push(resolution)
@@ -125,6 +131,15 @@ function parseBerry(text: string): Lockfile {
125
131
  }
126
132
  }
127
133
 
134
+ // Yarn 2/3 write a patch's inner descriptor with no protocol
135
+ // (`resolve@^1.22.0`) where Yarn 4 writes `resolve@npm:^1.22.0`; npm: is
136
+ // Yarn's default, so both key the same plain descriptor (turborepo#4770).
137
+ function npmDefault(descriptor: string): string {
138
+ const at = descriptor.indexOf('@', 1)
139
+ const range = descriptor.slice(at + 1)
140
+ return range.includes(':') ? descriptor : `${descriptor.slice(0, at)}@npm:${range}`
141
+ }
142
+
128
143
  /** The classic format: an unquoted-YAML dialect with `key value` lines. */
129
144
  function parseClassic(text: string): Lockfile {
130
145
  const entries = new Map<string, Entry>()
@@ -143,6 +158,8 @@ function parseClassic(text: string): Lockfile {
143
158
  // the digest did not move (item 902). Classic has no workspace entries
144
159
  // to carry that edge, so each entry carries its own descriptors.
145
160
  entries.set(id, {
161
+ name: '',
162
+ version: fields['version'] ?? '',
146
163
  deps,
147
164
  installed: deps,
148
165
  resolution: `${fields['version'] ?? ''}\0${fields['resolved'] ?? ''}\0${fields['integrity'] ?? ''}\0${[...keys].sort().join(',')}`,
@@ -203,7 +220,8 @@ function unquote(s: string): string {
203
220
  * workspace's manifest (`manifests`: importer dir → `package.json`, `.`
204
221
  * the root). A dependency on a workspace's name is linked unless the file
205
222
  * keys its descriptor, which Yarn writes only for the registry copy it
206
- * fetched when the workspace's version misses the range. A peer on a
223
+ * fetched when the workspace's version misses the range, and a `file:`
224
+ * range is a copy, no link. A peer on a
207
225
  * workspace with no installed entry of that name is a peer link.
208
226
  */
209
227
  export function classicLinks(
@@ -220,6 +238,7 @@ export function classicLinks(
220
238
  }
221
239
  const workspace = (name: string): string | undefined =>
222
240
  count.get(name) === 1 ? dirOf.get(name) : undefined
241
+ const forced = everywhereResolutions(manifests.get('.'))
223
242
  const out = new Map<string, LockfileImporter>()
224
243
  for (const [dir, pkg] of manifests) {
225
244
  const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
@@ -228,7 +247,20 @@ export function classicLinks(
228
247
  for (const [name, range] of depsOf(pkg, [field])) {
229
248
  installed.add(name)
230
249
  const target = workspace(name)
231
- if (target !== undefined && target !== dir && !lock.descriptors.has(`${name}@${range}`)) {
250
+ // A root resolution rewrites the request before the workspace is
251
+ // asked: Yarn keys the resolution's range, never the manifest's.
252
+ const asked = forced.get(name) ?? range
253
+ // A `file:` range is a copy packed at install, as berry's (`linkedDir`).
254
+ if (asked.startsWith('file:')) continue
255
+ // A `link:` symlinks its directory whatever the name: under another
256
+ // name (`"y": "link:../c"`) the name found no workspace.
257
+ if (asked.startsWith('link:')) {
258
+ const linked = joinPosix(dir, asked.slice('link:'.length))
259
+ if (linked !== undefined && linked !== dir && manifests.has(linked))
260
+ links.deps[name] = linked
261
+ continue
262
+ }
263
+ if (target !== undefined && target !== dir && !lock.descriptors.has(`${name}@${asked}`)) {
232
264
  links.deps[name] = target
233
265
  }
234
266
  }
@@ -242,6 +274,23 @@ export function classicLinks(
242
274
  return out
243
275
  }
244
276
 
277
+ /**
278
+ * The root manifest's Yarn 1 `resolutions` that rewrite every request of a
279
+ * name (`ms`, `**\/ms`, `@s/x`), name → range, the first that names it
280
+ * winning as in Yarn. One scoped to a parent (`a/ms`, `a/**\/ms`) is left
281
+ * out: Yarn 1.22 keys its range and still hoists the workspace link over
282
+ * it, so the link stands.
283
+ */
284
+ function everywhereResolutions(root: Record<string, unknown> | undefined): Map<string, string> {
285
+ const out = new Map<string, string>()
286
+ for (const [pattern, range] of depsOf(root ?? {}, ['resolutions'])) {
287
+ const name = pattern.replace(/^(?:\*\*\/)+/, '')
288
+ if (!/^(?:@[^/]+\/)?[^/@*]+$/.test(name) || out.has(name)) continue
289
+ out.set(name, range)
290
+ }
291
+ return out
292
+ }
293
+
245
294
  /**
246
295
  * `bar` + `npm:^2` → the entry keyed `bar@npm:^2`; classic: `bar@^2`. A
247
296
  * bare range gets berry's `npm:` prefix. A `workspace:` range resolves to
@@ -265,7 +314,7 @@ function resolveDescriptor(
265
314
  // A patched descriptor reaches the patched entries Yarn installs as well
266
315
  // as the plain one it keys (item 1074).
267
316
  const withPatch = (descriptor: string, id: string): readonly string[] => {
268
- const patched = lock.patches.get(descriptor)
317
+ const patched = lock.patches.get(npmDefault(descriptor))
269
318
  return patched === undefined ? [id] : [id, ...patched.filter((p) => p !== id)]
270
319
  }
271
320
  const direct = lock.descriptors.get(`${name}@${range}`)
@@ -391,9 +440,81 @@ function contentDirs(lock: Lockfile): Map<string, string> {
391
440
  return out
392
441
  }
393
442
 
394
- /** The directories `contentDirs` names, for the claim to hash. */
395
- export function fileDeps(lock: Lockfile): string[] {
396
- return [...new Set(contentDirs(lock).values())].sort()
443
+ /**
444
+ * The directories `contentDirs` names, for the claim to hash, but a
445
+ * classic `link:` to a directory `workspace` says is one: Yarn 1 links
446
+ * that workspace as it would by its name. Berry's also `.yarnrc.yml`,
447
+ * whose `packageExtensions` the digest reads: as an extra file it joins
448
+ * the memo's identity, so an extension edit under an unchanged lockfile
449
+ * digests anew rather than replaying the old closure.
450
+ */
451
+ export function fileDeps(lock: Lockfile, workspace?: (dir: string) => boolean): string[] {
452
+ const out = new Set<string>()
453
+ for (const [id, dir] of contentDirs(lock)) {
454
+ if (workspace !== undefined && /^.[^@]*@link:/.test(id) && workspace(dir)) continue
455
+ out.add(dir)
456
+ }
457
+ const dirs = [...out].sort()
458
+ return lock.generation === 'berry' ? [...dirs, YARNRC] : dirs
459
+ }
460
+
461
+ export const YARNRC = '.yarnrc.yml'
462
+
463
+ /** One `packageExtensions` selector (`ident@range`) and the dependencies it adds. */
464
+ export interface Extension {
465
+ readonly name: string
466
+ readonly range: string
467
+ readonly deps: ReadonlyMap<string, string>
468
+ }
469
+
470
+ // Yarn's builtin list (yarnpkg-extensions) adds one dependency; the rest
471
+ // are peers, which the parent that provides them already reaches.
472
+ const BUILTIN: readonly Extension[] = [
473
+ { name: '@babel/parser', range: '*', deps: new Map([['@babel/types', '^7.8.3']]) },
474
+ ]
475
+
476
+ /**
477
+ * Yarn's builtin extensions and `.yarnrc.yml`'s `packageExtensions`
478
+ * dependencies. A file Yarn itself would refuse adds none: the entries
479
+ * an extension installed are then reached by no one, and every workspace
480
+ * folds them (`importerDigests`).
481
+ */
482
+ export function packageExtensions(rc: string | undefined): readonly Extension[] {
483
+ if (rc === undefined) return BUILTIN
484
+ let doc: unknown
485
+ try {
486
+ doc = Bun.YAML.parse(rc)
487
+ } catch {
488
+ return BUILTIN
489
+ }
490
+ const out = [...BUILTIN]
491
+ for (const [selector, raw] of Object.entries(record(record(doc)?.['packageExtensions']) ?? {})) {
492
+ const deps = depsOf(record(raw) ?? {}, ['dependencies'])
493
+ if (deps.size === 0) continue
494
+ const at = selector.indexOf('@', 1)
495
+ out.push(
496
+ at === -1
497
+ ? { name: selector, range: '*', deps }
498
+ : { name: selector.slice(0, at), range: selector.slice(at + 1), deps },
499
+ )
500
+ }
501
+ return out
502
+ }
503
+
504
+ /**
505
+ * Whether `version` is what the selector's range extends. Yarn matches
506
+ * prereleases too (`satisfiesWithPrereleases`), and a range this cannot
507
+ * read matches: an edge too many re-keys once too often, never too seldom.
508
+ */
509
+ function matches(range: string, version: string): boolean {
510
+ const r = range.startsWith('npm:') ? range.slice(4) : range
511
+ try {
512
+ if (Bun.semver.satisfies(version, r)) return true
513
+ const release = version.replace(/[-+].*$/, '')
514
+ return release !== version && Bun.semver.satisfies(release, r)
515
+ } catch {
516
+ return true
517
+ }
397
518
  }
398
519
 
399
520
  /** The entry a descriptor the file keys resolves to: as written, bound to `from`, or with berry's `npm:` prefix. */
@@ -410,6 +531,48 @@ function keyedDescriptor(
410
531
  )
411
532
  }
412
533
 
534
+ /**
535
+ * Yarn installs a `packageExtensions` dependency for each package its
536
+ * selector matches that does not declare one of that name, and records
537
+ * it on no entry (turborepo#14873): only the added package is keyed, so
538
+ * the extended package gets the edge here. Then every entry no workspace
539
+ * reaches (one an extension vx could not read installed) becomes an edge
540
+ * of every workspace: it is installed for one of them.
541
+ */
542
+ function extend(
543
+ lock: Lockfile,
544
+ index: ReadonlyMap<string, number>,
545
+ edges: number[][],
546
+ extensions: readonly Extension[],
547
+ ): void {
548
+ for (const [id, e] of lock.entries) {
549
+ for (const ext of extensions) {
550
+ if (ext.name !== e.name || !matches(ext.range, e.version)) continue
551
+ for (const [name, range] of ext.deps) {
552
+ if (e.installed.has(name)) continue
553
+ for (const target of resolveDescriptor(lock, id, name, range)) {
554
+ edges[index.get(id)!]!.push(index.get(target)!)
555
+ }
556
+ }
557
+ }
558
+ }
559
+ const roots = [...lock.workspaces.values()].map((id) => index.get(id)!)
560
+ const seen = new Uint8Array(edges.length)
561
+ const stack = [...roots]
562
+ for (const r of roots) seen[r] = 1
563
+ while (stack.length > 0) {
564
+ for (const w of edges[stack.pop()!]!) {
565
+ if (seen[w] === 0) {
566
+ seen[w] = 1
567
+ stack.push(w)
568
+ }
569
+ }
570
+ }
571
+ const orphans: number[] = []
572
+ for (let i = 0; i < seen.length; i++) if (seen[i] === 0) orphans.push(i)
573
+ if (orphans.length > 0) for (const r of roots) edges[r]!.push(...orphans)
574
+ }
575
+
413
576
  /**
414
577
  * Every workspace's digest. Berry: one node per entry, workspaces among
415
578
  * them, keyed by dir. Classic: the file has no workspace entries, so the
@@ -419,6 +582,7 @@ function keyedDescriptor(
419
582
  export function importerDigests(
420
583
  lock: Lockfile,
421
584
  files: ReadonlyMap<string, string> = new Map(),
585
+ extensions: readonly Extension[] = BUILTIN,
422
586
  ): ReadonlyMap<string, string> {
423
587
  const index = new Map<string, number>()
424
588
  const material: string[] = []
@@ -440,6 +604,19 @@ export function importerDigests(
440
604
  if (targets.length === 0) material[from] += `\nunresolved\0${name}\0${range}`
441
605
  }
442
606
  }
607
+ if (lock.generation === 'berry') {
608
+ // A `link:` or `portal:` to a workspace symlinks the workspace itself:
609
+ // its entry lists no dependencies, and as a leaf the linked
610
+ // workspace's bumps moved no key of the project linking it.
611
+ const dirOf = new Map<string, string>()
612
+ for (const [dir, id] of lock.workspaces) dirOf.set(id, dir)
613
+ for (const id of lock.entries.keys()) {
614
+ const target = linkedDir(lock, dirOf, id)
615
+ if (target !== undefined)
616
+ edges[index.get(id)!]!.push(index.get(lock.workspaces.get(target)!)!)
617
+ }
618
+ extend(lock, index, edges, extensions)
619
+ }
443
620
  const digests = reachDigests({ material, edges })
444
621
  // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
445
622
  // bits of a seed, so two lockfiles' globals could share one (item 682).