@vzn/vx-lockfile 0.0.645 → 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
@@ -12,9 +12,9 @@
12
12
  // one digest is the root's reach through every entry the file resolves.
13
13
 
14
14
  import { reachDigests, type LockfileImporter } from '@vzn/vx'
15
- import { stable } from './pnpm.js'
16
- import { depsOf, record } from './json.js'
15
+ import { depsOf, record, stable } from './json.js'
17
16
  import { joinPosix } from './posix.js'
17
+ import { parseSyml } from './syml.js'
18
18
 
19
19
  export interface Lockfile {
20
20
  readonly generation: 'berry' | 'classic'
@@ -41,6 +41,9 @@ export interface Lockfile {
41
41
  }
42
42
 
43
43
  export interface Entry {
44
+ /** The package's own name, from the resolution (berry; '' for classic) */
45
+ readonly name: string
46
+ readonly version: string
44
47
  readonly deps: ReadonlyMap<string, string>
45
48
  /** `dependencies` alone, which a workspace entry merges its devDependencies into (berry only) */
46
49
  readonly installed: ReadonlyMap<string, string>
@@ -58,9 +61,9 @@ export function parseLockfile(text: string): Lockfile {
58
61
  }
59
62
 
60
63
  function parseBerry(text: string): Lockfile {
61
- let doc: unknown
64
+ let doc: unknown = parseSyml(text)
62
65
  try {
63
- doc = Bun.YAML.parse(text)
66
+ doc ??= Bun.YAML.parse(text)
64
67
  } catch (err) {
65
68
  throw new Error(`yarn.lock: ${err instanceof Error ? err.message : String(err)}`)
66
69
  }
@@ -75,15 +78,16 @@ function parseBerry(text: string): Lockfile {
75
78
  if (keys === '__metadata') continue
76
79
  const e = record(raw) ?? {}
77
80
  const resolution = typeof e['resolution'] === 'string' ? e['resolution'] : keys
78
- // Every field of the entry but the dependencies it links, so a field
79
- // this parser has not heard of moves what reaches the entry: reading
80
- // the resolution and checksum alone dropped the root workspace's
81
- // `dependenciesMeta` (`built`, `unplugged`), which governs the whole
82
- // install (item 933).
83
- const material = Object.fromEntries(
84
- Object.entries(e).filter(([k]) => k !== 'dependencies' && k !== 'peerDependencies'),
85
- )
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
86
88
  entries.set(resolution, {
89
+ name: resolution.slice(0, resolution.indexOf('@', 1)),
90
+ version: typeof e['version'] === 'string' ? e['version'] : '',
87
91
  deps: depsOf(e, ['dependencies', 'peerDependencies']),
88
92
  installed: depsOf(e, ['dependencies']),
89
93
  resolution: `${resolution}\0${stable(material)}`,
@@ -102,6 +106,7 @@ function parseBerry(text: string): Lockfile {
102
106
  } catch {
103
107
  inner = patch[1]!
104
108
  }
109
+ inner = npmDefault(inner)
105
110
  const ids = patches.get(inner)
106
111
  if (ids === undefined) patches.set(inner, [resolution])
107
112
  else if (!ids.includes(resolution)) ids.push(resolution)
@@ -126,6 +131,15 @@ function parseBerry(text: string): Lockfile {
126
131
  }
127
132
  }
128
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
+
129
143
  /** The classic format: an unquoted-YAML dialect with `key value` lines. */
130
144
  function parseClassic(text: string): Lockfile {
131
145
  const entries = new Map<string, Entry>()
@@ -144,6 +158,8 @@ function parseClassic(text: string): Lockfile {
144
158
  // the digest did not move (item 902). Classic has no workspace entries
145
159
  // to carry that edge, so each entry carries its own descriptors.
146
160
  entries.set(id, {
161
+ name: '',
162
+ version: fields['version'] ?? '',
147
163
  deps,
148
164
  installed: deps,
149
165
  resolution: `${fields['version'] ?? ''}\0${fields['resolved'] ?? ''}\0${fields['integrity'] ?? ''}\0${[...keys].sort().join(',')}`,
@@ -204,7 +220,8 @@ function unquote(s: string): string {
204
220
  * workspace's manifest (`manifests`: importer dir → `package.json`, `.`
205
221
  * the root). A dependency on a workspace's name is linked unless the file
206
222
  * keys its descriptor, which Yarn writes only for the registry copy it
207
- * 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
208
225
  * workspace with no installed entry of that name is a peer link.
209
226
  */
210
227
  export function classicLinks(
@@ -221,6 +238,7 @@ export function classicLinks(
221
238
  }
222
239
  const workspace = (name: string): string | undefined =>
223
240
  count.get(name) === 1 ? dirOf.get(name) : undefined
241
+ const forced = everywhereResolutions(manifests.get('.'))
224
242
  const out = new Map<string, LockfileImporter>()
225
243
  for (const [dir, pkg] of manifests) {
226
244
  const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
@@ -229,7 +247,20 @@ export function classicLinks(
229
247
  for (const [name, range] of depsOf(pkg, [field])) {
230
248
  installed.add(name)
231
249
  const target = workspace(name)
232
- 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}`)) {
233
264
  links.deps[name] = target
234
265
  }
235
266
  }
@@ -243,6 +274,23 @@ export function classicLinks(
243
274
  return out
244
275
  }
245
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
+
246
294
  /**
247
295
  * `bar` + `npm:^2` → the entry keyed `bar@npm:^2`; classic: `bar@^2`. A
248
296
  * bare range gets berry's `npm:` prefix. A `workspace:` range resolves to
@@ -266,7 +314,7 @@ function resolveDescriptor(
266
314
  // A patched descriptor reaches the patched entries Yarn installs as well
267
315
  // as the plain one it keys (item 1074).
268
316
  const withPatch = (descriptor: string, id: string): readonly string[] => {
269
- const patched = lock.patches.get(descriptor)
317
+ const patched = lock.patches.get(npmDefault(descriptor))
270
318
  return patched === undefined ? [id] : [id, ...patched.filter((p) => p !== id)]
271
319
  }
272
320
  const direct = lock.descriptors.get(`${name}@${range}`)
@@ -392,9 +440,81 @@ function contentDirs(lock: Lockfile): Map<string, string> {
392
440
  return out
393
441
  }
394
442
 
395
- /** The directories `contentDirs` names, for the claim to hash. */
396
- export function fileDeps(lock: Lockfile): string[] {
397
- 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
+ }
398
518
  }
399
519
 
400
520
  /** The entry a descriptor the file keys resolves to: as written, bound to `from`, or with berry's `npm:` prefix. */
@@ -411,6 +531,48 @@ function keyedDescriptor(
411
531
  )
412
532
  }
413
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
+
414
576
  /**
415
577
  * Every workspace's digest. Berry: one node per entry, workspaces among
416
578
  * them, keyed by dir. Classic: the file has no workspace entries, so the
@@ -420,6 +582,7 @@ function keyedDescriptor(
420
582
  export function importerDigests(
421
583
  lock: Lockfile,
422
584
  files: ReadonlyMap<string, string> = new Map(),
585
+ extensions: readonly Extension[] = BUILTIN,
423
586
  ): ReadonlyMap<string, string> {
424
587
  const index = new Map<string, number>()
425
588
  const material: string[] = []
@@ -441,6 +604,19 @@ export function importerDigests(
441
604
  if (targets.length === 0) material[from] += `\nunresolved\0${name}\0${range}`
442
605
  }
443
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
+ }
444
620
  const digests = reachDigests({ material, edges })
445
621
  // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
446
622
  // bits of a seed, so two lockfiles' globals could share one (item 682).