@vzn/vx-lockfile 0.0.636 → 0.0.637

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/pnpm.ts CHANGED
@@ -11,10 +11,9 @@
11
11
  //
12
12
  // Bun.YAML is the parser: no dependency, and the file is plain YAML.
13
13
 
14
- import { reachDigests } from '@vzn/vx'
15
- import { pruneEntries, pruneSections, yamlKey } from './blocks.js'
16
- import { devOnly, record } from './json.js'
17
- import type { PruneScope } from './scope.js'
14
+ import { reachDigests, type LockfileImporter } from '@vzn/vx'
15
+ import { record } from './json.js'
16
+ import { joinPosix } from './posix.js'
18
17
 
19
18
  export interface Lockfile {
20
19
  readonly version: string
@@ -257,7 +256,8 @@ function buildGraph(lock: Lockfile): Graph {
257
256
  const link = (from: number, dir: string, name: string, version: string): void => {
258
257
  if (version.startsWith('link:')) {
259
258
  const target = joinPosix(dir, version.slice('link:'.length))
260
- if (lock.importers.has(target)) edges[from]!.push(index.get(importerNode(target))!)
259
+ if (target !== undefined && lock.importers.has(target))
260
+ edges[from]!.push(index.get(importerNode(target))!)
261
261
  else material[from] += `\nlink\0${name}\0${version}`
262
262
  return
263
263
  }
@@ -278,113 +278,23 @@ function buildGraph(lock: Lockfile): Graph {
278
278
  }
279
279
 
280
280
  /**
281
- * The lockfile cut to the importers at `dirs` (`.` the root) and the
282
- * packages they reach; every other section as written — catalogs, patches,
283
- * overrides and settings are the workspace's, and the subset's manifests
284
- * still name them. A multi-document lockfile keeps the env document whole.
281
+ * Each importer's `link:` dependencies on other importers. pnpm records
282
+ * an auto-installed peer (`autoInstallPeers`, the default) under
283
+ * `dependencies` like any other, so `peers` is empty here: the plugin
284
+ * asks the importer's manifest which of these are peers.
285
285
  */
286
- export function pruneLockfile(text: string, { dirs, dropped }: PruneScope): string {
287
- const marker = [...text.matchAll(/^---[ \t]*$/gm)].at(-1)
288
- const cut = marker === undefined ? 0 : marker.index + marker[0].length
289
- const lock = parseLockfile(text)
290
- // What production struck from each kept importer's manifest.
291
- const skip = new Map<string, Set<string>>()
292
- if (dropped.size > 0) {
293
- const docs: unknown = Bun.YAML.parse(text)
294
- const doc = record(Array.isArray(docs) ? docs.at(-1) : docs)
295
- for (const [dir, entry] of Object.entries(record(doc?.['importers']) ?? {})) {
296
- if (dirs.has(dir)) skip.set(dir, devOnly(record(entry), dropped))
297
- }
298
- }
299
- const reached = new Set<string>()
300
- const visit = (dir: string, deps: ReadonlyMap<string, string>, importer = false): void => {
286
+ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
287
+ const out = new Map<string, LockfileImporter>()
288
+ for (const [dir, deps] of lock.importers) {
289
+ const links: Record<string, string> = {}
301
290
  for (const [name, version] of deps) {
302
- if (importer && skip.get(dir)?.has(name) === true) continue
303
- if (version.startsWith('link:')) {
304
- const target = joinPosix(dir, version.slice('link:'.length))
305
- if (lock.importers.has(target) && !dirs.has(target)) {
306
- throw new Error(`pnpm-lock.yaml: ${dir} links ${target}, which the subset leaves out`)
307
- }
308
- continue
309
- }
310
- const key = snapshotKey(lock, name, version)
311
- if (reached.has(key)) continue
312
- reached.add(key)
313
- visit('.', lock.snapshots.get(key) ?? new Map())
314
- }
315
- }
316
- for (const [dir, deps] of lock.importers) if (dirs.has(dir)) visit(dir, deps, true)
317
- const v9 = Number.parseInt(lock.version, 10) >= 9
318
- const packages = new Set([...reached].map((key) => (v9 ? packageKey(key) : key)))
319
- return (
320
- text.slice(0, cut) +
321
- dropImporterDev(
322
- pruneSections(text.slice(cut), {
323
- importers: (dir) => dirs.has(dir),
324
- packages: (key) => packages.has(key),
325
- snapshots: (key) => reached.has(key),
326
- }),
327
- skip,
328
- )
329
- )
330
- }
331
-
332
- /**
333
- * Each importer's `devDependencies` without the `dropped` names, cut as
334
- * lines like every other prune: the subsection goes when it empties, and
335
- * an importer left with nothing is `{}`, as pnpm writes one.
336
- */
337
- function dropImporterDev(text: string, skip: ReadonlyMap<string, ReadonlySet<string>>): string {
338
- if (![...skip.values()].some((s) => s.size > 0)) return text
339
- const lines = text.split('\n')
340
- const start = lines.findIndex((l) => /^importers:\s*$/.test(l))
341
- if (start === -1) return text
342
- let end = start + 1
343
- while (end < lines.length && !/^\S/.test(lines[end]!)) end++
344
- const indented = (l: string, n: number): boolean =>
345
- l.startsWith(' '.repeat(n)) && /^[^\s#]/.test(l.slice(n))
346
- const out = lines.slice(0, start + 1)
347
- let i = start + 1
348
- while (i < end) {
349
- if (!indented(lines[i]!, 2)) {
350
- out.push(lines[i++]!)
351
- continue
352
- }
353
- const key = i
354
- const dropped = skip.get(yamlKey(lines[key]!)) ?? new Set<string>()
355
- let stop = i + 1
356
- while (stop < end && !indented(lines[stop]!, 2)) stop++
357
- const body: string[] = []
358
- for (let j = key + 1; j < stop;) {
359
- if (lines[j] !== ' devDependencies:') {
360
- body.push(lines[j++]!)
361
- continue
291
+ if (!version.startsWith('link:')) continue
292
+ const target = joinPosix(dir, version.slice('link:'.length))
293
+ if (target !== undefined && target !== dir && lock.importers.has(target)) {
294
+ links[name] = target
362
295
  }
363
- let sub = j + 1
364
- while (sub < stop && (lines[sub]!.trim() === '' || /^ {5,}/.test(lines[sub]!))) sub++
365
- const kept = pruneEntries(lines, j + 1, sub, 6, (l) => !dropped.has(yamlKey(l)))
366
- if (kept.some((l) => indented(l, 6))) body.push(lines[j]!, ...kept)
367
- else body.push(...kept.filter((l) => l.trim() === ''))
368
- j = sub
369
296
  }
370
- const emptied = !body.some((l) => indented(l, 4)) && stop - key - 1 > body.length
371
- const entry = !emptied
372
- ? [lines[key]!, ...body]
373
- : [`${lines[key]!.replace(/:\s*$/, '')}: {}`, ...body.filter((l) => l.trim() === '')]
374
- out.push(...entry)
375
- i = stop
297
+ out.set(dir, { deps: links, peers: {} })
376
298
  }
377
- out.push(...lines.slice(end))
378
- return out.join('\n')
379
- }
380
-
381
- /** `packages/a` + `../b` → `packages/b`; `.` + `packages/a` → `packages/a`. POSIX, as the lockfile writes paths. */
382
- function joinPosix(base: string, rel: string): string {
383
- const parts: string[] = []
384
- for (const seg of `${base}/${rel}`.split('/')) {
385
- if (seg === '' || seg === '.') continue
386
- if (seg === '..') parts.pop()
387
- else parts.push(seg)
388
- }
389
- return parts.length === 0 ? '.' : parts.join('/')
299
+ return out
390
300
  }
package/src/posix.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `packages/a` + `../b` → `packages/b`; `.` + `packages/a` → `packages/a`.
3
+ * POSIX, as lockfiles write paths. Undefined for a path above the root: it
4
+ * dropped each `..` past it, so `link:../ext` from the root named `ext`.
5
+ */
6
+ export function joinPosix(base: string, rel: string): string | undefined {
7
+ const parts: string[] = []
8
+ for (const seg of `${base}/${rel}`.split('/')) {
9
+ if (seg === '' || seg === '.') continue
10
+ if (seg !== '..') parts.push(seg)
11
+ else if (parts.pop() === undefined) return undefined
12
+ }
13
+ return parts.length === 0 ? '.' : parts.join('/')
14
+ }
package/src/yarn.ts CHANGED
@@ -11,12 +11,14 @@
11
11
  // lockfile's root descriptors only, so a workspace's digest is the root's
12
12
  // reach through the descriptors the file resolves.
13
13
 
14
- import path from 'node:path'
15
- import { reachDigests } from '@vzn/vx'
14
+ import { reachDigests, type LockfileImporter } from '@vzn/vx'
16
15
  import { stable } from './pnpm.js'
17
- import { pruneEntries, yamlKey } from './blocks.js'
18
16
  import { depsOf, record } from './json.js'
19
- import type { PruneScope } from './scope.js'
17
+ import { joinPosix } from './posix.js'
18
+
19
+ /** What yarn() warns of a classic lockfile, after its name: the static part a test holds. */
20
+ export const CLASSIC_EDGES =
21
+ ': yarn classic (v1) records no workspaces, so yarn() gives vx no project edges — upgrade to Yarn 2+ (`yarn set version stable`)'
20
22
 
21
23
  export interface Lockfile {
22
24
  readonly generation: 'berry' | 'classic'
@@ -40,8 +42,8 @@ export interface Lockfile {
40
42
 
41
43
  export interface Entry {
42
44
  readonly deps: ReadonlyMap<string, string>
43
- /** What the entry installs: `deps` less berry's peers, which its parent provides. */
44
- readonly installs: ReadonlyMap<string, string>
45
+ /** `dependencies` alone, which a workspace entry merges its devDependencies into (berry only) */
46
+ readonly installed: ReadonlyMap<string, string>
45
47
  /** resolution + checksum / version + resolved + integrity */
46
48
  readonly resolution: string
47
49
  }
@@ -83,7 +85,7 @@ function parseBerry(text: string): Lockfile {
83
85
  )
84
86
  entries.set(resolution, {
85
87
  deps: depsOf(e, ['dependencies', 'peerDependencies']),
86
- installs: depsOf(e, ['dependencies']),
88
+ installed: depsOf(e, ['dependencies']),
87
89
  resolution: `${resolution}\0${stable(material)}`,
88
90
  })
89
91
  // Yarn joins a key's descriptors with ', '; a range's own trailing
@@ -141,7 +143,7 @@ function parseClassic(text: string): Lockfile {
141
143
  // to carry that edge, so each entry carries its own descriptors.
142
144
  entries.set(id, {
143
145
  deps,
144
- installs: deps,
146
+ installed: deps,
145
147
  resolution: `${fields['version'] ?? ''}\0${fields['resolved'] ?? ''}\0${fields['integrity'] ?? ''}\0${[...keys].sort().join(',')}`,
146
148
  })
147
149
  for (const k of keys) descriptors.set(k, id)
@@ -247,6 +249,88 @@ function resolveDescriptor(
247
249
  return lock.names.get(name) ?? []
248
250
  }
249
251
 
252
+ /**
253
+ * Each berry workspace's dependencies whose descriptor the file resolves
254
+ * to a workspace entry (a `workspace:` range, or a range Yarn's
255
+ * transparent workspaces satisfied with one), and its peers on a
256
+ * workspace of that name, which Yarn hoists whatever the range. Only a
257
+ * descriptor the file keys counts: the name fallbacks the digest takes
258
+ * (a catalog, an override) name no link. Classic records no workspaces,
259
+ * so it has none.
260
+ */
261
+ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
262
+ const out = new Map<string, LockfileImporter>()
263
+ const dirOf = new Map<string, string>()
264
+ const byName = new Map<string, string>()
265
+ for (const [dir, id] of lock.workspaces) {
266
+ dirOf.set(id, dir)
267
+ byName.set(id.slice(0, id.indexOf('@workspace:')), dir)
268
+ }
269
+ for (const [dir, id] of lock.workspaces) {
270
+ const entry = lock.entries.get(id)!
271
+ const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
272
+ const add = (kind: 'deps' | 'peers', name: string, target: string | undefined): void => {
273
+ if (target !== undefined && target !== dir) links[kind][name] = target
274
+ }
275
+ for (const [name, range] of entry.installed) {
276
+ const keyed = keyedDescriptor(lock, id, name, range)
277
+ add(
278
+ 'deps',
279
+ name,
280
+ keyed === undefined
281
+ ? range.startsWith('workspace:')
282
+ ? byName.get(name)
283
+ : undefined
284
+ : (dirOf.get(keyed) ?? linkedDir(lock, dirOf, keyed)),
285
+ )
286
+ }
287
+ for (const name of entry.deps.keys()) {
288
+ if (!entry.installed.has(name)) add('peers', name, byName.get(name))
289
+ }
290
+ out.set(dir, links)
291
+ }
292
+ return out
293
+ }
294
+
295
+ /**
296
+ * The workspace a `link:` or `portal:` entry symlinks, from its resolution
297
+ * (`b@link:../b::locator=a%40workspace%3Apackages%2Fa`): the path, from the
298
+ * locating workspace's dir. A `file:` entry is a copy packed at install,
299
+ * no link to the workspace's build.
300
+ */
301
+ function linkedDir(
302
+ lock: Lockfile,
303
+ dirOf: ReadonlyMap<string, string>,
304
+ id: string,
305
+ ): string | undefined {
306
+ const m = /^(?:@[^@/]+\/)?[^@]+@(?:link|portal):([^:]*)::locator=([^&]+)/.exec(id)
307
+ if (m === null) return undefined
308
+ let locator: string
309
+ try {
310
+ locator = decodeURIComponent(m[2]!)
311
+ } catch {
312
+ return undefined
313
+ }
314
+ const from = dirOf.get(locator)
315
+ if (from === undefined) return undefined
316
+ const target = joinPosix(from, m[1]!)
317
+ return target !== undefined && lock.workspaces.has(target) ? target : undefined
318
+ }
319
+
320
+ /** The entry a descriptor the file keys resolves to: as written, bound to `from`, or with berry's `npm:` prefix. */
321
+ function keyedDescriptor(
322
+ lock: Lockfile,
323
+ from: string,
324
+ name: string,
325
+ range: string,
326
+ ): string | undefined {
327
+ return (
328
+ lock.descriptors.get(`${name}@${range}`) ??
329
+ lock.descriptors.get(`${name}@${range}::locator=${encodeURIComponent(from)}`) ??
330
+ (range.includes(':') ? undefined : lock.descriptors.get(`${name}@npm:${range}`))
331
+ )
332
+ }
333
+
250
334
  /**
251
335
  * Every workspace's digest. Berry: one node per entry, workspaces among
252
336
  * them, keyed by dir. Classic: the file has no workspace entries, so the
@@ -284,88 +368,3 @@ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
284
368
  for (const [dir, id] of lock.workspaces) out.set(dir, fold(digests[index.get(id)!]!))
285
369
  return out
286
370
  }
287
-
288
- /**
289
- * The lockfile cut to the entries the workspaces at `dirs` install, each
290
- * entry's text as written. Berry walks from the kept workspace entries;
291
- * classic records no workspaces, so its walk starts at each kept
292
- * manifest's dependencies (a workspace sibling has no entry and is
293
- * skipped).
294
- */
295
- export function pruneLockfile(text: string, scope: PruneScope): string {
296
- const { dirs, members, manifests, dropped } = scope
297
- // Berry merges a workspace's dev dependencies into its entry's
298
- // `dependencies`, so a struck name is told by its link: a dropped
299
- // workspace package is reached through a `workspace:` range.
300
- const isDropped = (name: string, range: string): boolean =>
301
- dropped.has(name) && range.startsWith('workspace:')
302
- const lock = parseLockfile(text)
303
- const reached = new Set<string>()
304
- const visit = (id: string | undefined): void => {
305
- if (id === undefined || reached.has(id)) return
306
- const ws = id.indexOf('@workspace:')
307
- const dir = ws === -1 ? undefined : id.slice(ws + '@workspace:'.length)
308
- if (dir !== undefined && members.has(dir) && !dirs.has(dir)) {
309
- throw new Error(`yarn.lock: ${id} is a workspace the subset leaves out`)
310
- }
311
- reached.add(id)
312
- const kept = dir !== undefined && dirs.has(dir)
313
- for (const [name, range] of lock.entries.get(id)!.installs) {
314
- if (kept && isDropped(name, range)) continue
315
- for (const target of resolveDescriptor(lock, id, name, range)) visit(target)
316
- }
317
- }
318
- const berry = lock.generation === 'berry'
319
- // Berry records a workspace's dev dependencies in its entry's
320
- // `dependencies`; classic records no workspace, and its manifests are
321
- // already stripped.
322
- const keptWorkspaces = new Set<string>()
323
- if (berry) {
324
- for (const [dir, id] of lock.workspaces) {
325
- if (!dirs.has(dir)) continue
326
- keptWorkspaces.add(id)
327
- visit(id)
328
- }
329
- } else {
330
- for (const [dir, deps] of manifests) {
331
- if (!dirs.has(dir)) continue
332
- for (const [name, range] of deps) {
333
- // Classic keys a workspace's `file:` / `link:` path from the root:
334
- // `file:./vendor/x` in packages/a is `file:./packages/a/vendor/x`.
335
- const local = /^(file|link):(?!\/)(.*)$/.exec(range)
336
- const keyed =
337
- local === null || dir === '.' ? range : `${local[1]}:./${path.posix.join(dir, local[2]!)}`
338
- visit(lock.descriptors.get(`${name}@${keyed}`))
339
- }
340
- }
341
- }
342
- const lines = text.split('\n')
343
- const first = (line: string): string =>
344
- berry
345
- ? yamlKey(line).split(',')[0]!.trimStart()
346
- : unquote(line.replace(/:\s*$/, '').split(',')[0]!.trim())
347
- const pruned = pruneEntries(lines, 0, lines.length, 0, (line) => {
348
- if (line.startsWith('__metadata:')) return true
349
- const id = lock.descriptors.get(first(line))
350
- return id !== undefined && reached.has(id)
351
- })
352
- if (!berry || dropped.size === 0) return pruned.join('\n')
353
- const out: string[] = []
354
- let inKept = false
355
- for (let i = 0; i < pruned.length; i++) {
356
- const line = pruned[i]!
357
- if (/^\S/.test(line)) inKept = keptWorkspaces.has(lock.descriptors.get(first(line)) ?? '')
358
- if (!inKept || line !== ' dependencies:') {
359
- out.push(line)
360
- continue
361
- }
362
- const deps: string[] = []
363
- while (i + 1 < pruned.length && pruned[i + 1]!.startsWith(' ')) deps.push(pruned[++i]!)
364
- const kept = deps.filter((l) => {
365
- const [name, range] = Object.entries(record(Bun.YAML.parse(l.trim())) ?? {})[0] ?? []
366
- return name === undefined || !isDropped(name, String(range))
367
- })
368
- if (kept.length > 0) out.push(line, ...kept)
369
- }
370
- return out.join('\n')
371
- }
package/src/blocks.ts DELETED
@@ -1,83 +0,0 @@
1
- // Line-level pruning for the YAML-shaped lockfiles (pnpm-lock.yaml, yarn.lock
2
- // berry and classic). Rewriting the parsed document would drop the package
3
- // manager's own layout — its quoting, its blank lines, its key order — so a
4
- // pruned file is the original with whole entries cut out, and a pruned
5
- // lockfile diffs against the source as removals only.
6
-
7
- /**
8
- * `lines` with every entry at `indent` spaces between `from` and `to` that
9
- * `keep` refuses cut out. An entry is its key line and every line after it
10
- * up to the next key line at that indent; the blank lines separating
11
- * entries are kept as they were between the survivors, and those after
12
- * the last entry stay after the last survivor.
13
- */
14
- export function pruneEntries(
15
- lines: readonly string[],
16
- from: number,
17
- to: number,
18
- indent: number,
19
- keep: (keyLine: string) => boolean,
20
- ): string[] {
21
- const pad = ' '.repeat(indent)
22
- const isKey = (l: string): boolean => l.startsWith(pad) && /^[^\s#]/.test(l.slice(indent))
23
- const head: string[] = []
24
- let i = from
25
- while (i < to && !isKey(lines[i]!)) head.push(lines[i++]!)
26
- const entries: string[][] = []
27
- let separator: string[] | undefined
28
- while (i < to) {
29
- const entry = [lines[i++]!]
30
- while (i < to && !isKey(lines[i]!)) entry.push(lines[i++]!)
31
- entries.push(entry)
32
- }
33
- const blanks = (e: string[]): string[] => {
34
- let n = e.length
35
- while (n > 1 && e[n - 1]!.trim() === '') n--
36
- return e.splice(n)
37
- }
38
- const tail = entries.length === 0 ? [] : blanks(entries.at(-1)!)
39
- for (const e of entries.slice(0, -1)) {
40
- const gap = blanks(e)
41
- separator ??= gap
42
- }
43
- const kept = entries.filter((e) => keep(e[0]!))
44
- const out = [...head]
45
- for (const [n, e] of kept.entries()) {
46
- if (n > 0) out.push(...(separator ?? []))
47
- out.push(...e)
48
- }
49
- out.push(...tail)
50
- return out
51
- }
52
-
53
- /** The key a YAML mapping line opens: ` 'a@1': {}` → `a@1`, ` .:` → `.`. */
54
- export function yamlKey(line: string): string {
55
- const parsed: unknown = Bun.YAML.parse(line.trim())
56
- return parsed !== null && typeof parsed === 'object' ? (Object.keys(parsed)[0] ?? '') : ''
57
- }
58
-
59
- /**
60
- * `text` with each top-level section named in `sections` pruned to the
61
- * entries its predicate keeps; every other line as it was.
62
- */
63
- export function pruneSections(
64
- text: string,
65
- sections: Readonly<Record<string, (key: string) => boolean>>,
66
- ): string {
67
- const lines = text.split('\n')
68
- const out: string[] = []
69
- let i = 0
70
- while (i < lines.length) {
71
- const line = lines[i]!
72
- out.push(line)
73
- i++
74
- const name = /^([A-Za-z]+):\s*$/.exec(line)?.[1]
75
- const keep = name === undefined ? undefined : sections[name]
76
- if (keep === undefined) continue
77
- let end = i
78
- while (end < lines.length && !/^\S/.test(lines[end]!)) end++
79
- out.push(...pruneEntries(lines, i, end, 2, (l) => keep(yamlKey(l))))
80
- i = end
81
- }
82
- return out.join('\n')
83
- }