@vzn/vx-lockfile 0.0.0 → 0.0.484

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vx contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @vzn/vx-lockfile
2
+
3
+ Lockfile plugins for [`@vzn/vx`](https://github.com/vznjs/vx): `pnpm()`, `bun()`, `npm()` and `yarn()`. Each keys every task on its **own project's resolved dependency closure** from the package manager's lockfile, not on the whole file. `pnpm update foo` (or `bun add`, `npm install`, `yarn up`) re-keys exactly the projects that reach `foo` — through their dependencies, transitively, and through workspace links — and `vx run … --affected` selects the same projects. Zero dependencies: Bun's own YAML and JSONC parsers, and a classic-yarn reader.
4
+
5
+ Without a plugin, core folds the whole lockfile into the workspace fingerprint that every cache key sees, so one install invalidates every task in the workspace.
6
+
7
+ ## Usage
8
+
9
+ ```sh
10
+ npm install -D @vzn/vx @vzn/vx-lockfile # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
11
+ ```
12
+
13
+ ```ts
14
+ // vx.workspace.ts
15
+ import { defineWorkspace } from '@vzn/vx/config'
16
+ import { pnpm } from '@vzn/vx-lockfile' // or bun, npm, yarn
17
+
18
+ export default defineWorkspace({
19
+ plugins: [pnpm()],
20
+ })
21
+ ```
22
+
23
+ That is the whole setup. The plugin **claims** its lockfile (`VxPlugin.fingerprint`), so core leaves it out of the workspace fingerprint, and its `key` hook folds one digest per project. `vx why <task>` names the material as `plugin @vzn/vx-lockfile/pnpm` (or `/bun`, `/npm`, `/yarn`).
24
+
25
+ | Option | Values | Meaning |
26
+ | ------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27
+ | `scope` | `'project'` (default), `'workspace'` | `project`: each task folds its own project's closure. `workspace`: the whole file, as core folds it — the coarse key through the plugin, for a workspace not yet ready to trust the precision. |
28
+
29
+ ## What a project's digest covers
30
+
31
+ Every package the project can reach, by resolved identity (name, version, integrity), and under bun and npm by the name it installs under, not where it sits, so a re-hoist of one version re-keys nothing, plus the install-wide material every project folds — and **the root package's own closure**. The root's `dependencies` and `devDependencies` reach every task: their bins run from the root `node_modules/.bin`, which vx puts on every task's PATH, and Node's resolution walks up to the root `node_modules` (`@types/*`, a plugin a root tool's config names). So a root devDependency bump (`pnpm add -Dw oxlint@next`) re-keys every project and `--affected` selects every project; a package only project A reaches still moves only A. A root `workspace:` dependency folds that package's closure into every project too. Per manager:
32
+
33
+ - **pnpm** (`pnpm-lock.yaml`, lockfile v5, v6, v9): importers → snapshots, transitively; each package by name, version **and resolved peers** (`foo@1(react@18)` is not `foo@1(react@19)`), resolution and any `patchedDependencies` entry; `link:` folds the linked importer's reach, and a `file:` directory dependency folds its own closure (v9 keys it `name@file:…`, v5/v6 by the bare `file:…`); every other top-level field but `overrides` and the catalogs, whose effect is the snapshot an importer reaches (`settings`, `packageExtensionsChecksum`, `pnpmfileChecksum`, `ignoredOptionalDependencies`, v6's `onlyBuiltDependencies`, and any field pnpm adds later) and the lockfile version into every project. A multi-document lockfile (pnpm 10.x and 11 write the env lockfile — `configDependencies`, the package manager's own install — ahead of the project's) reads its last document as the lockfile and folds the ones before it into every project.
34
+ - **bun** (`bun.lock`): Bun's hoisted layout — a dependency `d` of the package at path `p` is `p/d` when that key exists, else the nearest ancestor's, else the root's, so a nested version counts for the package it is nested under and no other; `workspace:` entries fold the linked package's reach; each `patchedDependencies` entry and the CONTENT of its patch file (bun.lock records only its path) with the package it patches, so a patch edit re-keys only the projects reaching it; every top-level field but `workspaces`, `packages`, the catalogs, `overrides` and `patchedDependencies` (`trustedDependencies`, and any field Bun adds later), and any patch naming no entry, into every project — a catalog bump reaches only the projects whose `catalog:` dependency resolves anew, through the `packages` entry it installs.
35
+ - **npm** (`package-lock.json`, lockfileVersion 2 and 3): the `packages` map — `p/node_modules/d`, then each ancestor directory's (a workspace nested in another's directory resolves through the outer one's `node_modules`, as Node does), then `node_modules/d`; a `link: true` entry folds its target workspace's reach; root `overrides` reach a project through the entries they force (npm 10 does not write them to the lockfile). Version 1 (npm 6) has no `packages` map and is refused.
36
+ - **yarn** (`yarn.lock`): berry (yarn 2+) resolves `name@npm:range` descriptors to entries, workspaces included, each entry folding every field but its dependency lists (so the root workspace's `dependenciesMeta` reaches every project), `__metadata`'s `version` and `cacheKey` into every project. A Yarn 4 catalog dependency (`catalog:`, `catalog:<name>`) is recorded as that literal and its range lives in `.yarnrc.yml`, so it reaches every entry of its package: a bump of any of them re-keys the workspace. So does a descriptor the file does not key: a root `resolutions` override (to a `patch:`, or another range) leaves the original descriptor out of `yarn.lock`, and the entry installed in its place is reached through the package's name, so a patch edit re-keys the workspaces that depend on it. A path range (`portal:`, `file:`, `link:`) reaches the entry keyed bound to the package naming it (`::locator=…`), not another workspace's of the same name. Yarn's builtin compat patches (`resolve`, `typescript`, `fsevents`) are reached too: the workspace asks for the plain descriptor, and the patched entry is the one Yarn installs. `.yarnrc.yml` itself is in core's workspace fingerprint, so a catalog edit re-keys every project, as a `pnpm-workspace.yaml` edit does. Classic (yarn 1) records no workspaces, so it yields one digest for the root that every project folds — coarse, and honest about what the file records.
37
+
38
+ A project the lockfile has no entry for folds the root's digest alone — the only `node_modules` it can resolve from. A phantom dependency (imported, never declared by the project or the root — a sibling's package hoisted to the root) is not in any closure; declare it.
39
+
40
+ A lockfile the parser cannot read **refuses the run**, naming the file, the reason and the install that regenerates it. That is deliberate: the alternative to reading the lockfile is keying on nothing, and a key that is missing material is a stale hit waiting to happen. Under `--affected` the refusal also says which side could not be read — the working tree's copy, or the one at the base ref (a lockfile-migration commit hits the second). A lockfile holding git merge conflict markers is refused the same way: it names two installs at once, and yarn classic's format parsed one side of it without a word.
41
+
42
+ ## Cost
43
+
44
+ The claim, the per-project key, the memo and the `--affected` diff are core's `lockfileClaim`; this package is the parsers, and they are internal: it exports `pnpm`, `bun`, `npm`, `yarn` and `LockfileOptions`. A lockfile is parsed **once per content**: the digests are memoised under the cache dir (`lockfile-claims/<file>.json`) by the file's xxh3, so a warm run pays one read, one hash and one small JSON read — never a parse — and the read happens once per run, not per task. The digest is one hash per strongly connected component of the dependency graph (lockfiles carry cycles), children first, so a 1000-importer / 3000-package lockfile digests in ~20 ms when it does change.
45
+
46
+ ## `--affected`
47
+
48
+ `vx run test --affected=origin/main` after a lockfile change used to select every project. With a plugin declared at `scope: 'project'`, core hands it the lockfile at the base ref and in the working tree; it digests both and names the projects whose digest moved. At `scope: 'workspace'` a change still selects every project. A lockfile that appeared or was deleted still selects everything — every project's `node_modules` is in question.
49
+
50
+ ## Testing
51
+
52
+ `bun test` covers each parser's digests (transitive bumps, nested versions, workspace links, peer suffixes, patches, install-wide knobs, cycles, key order, aliases, refusals) and `vx run` / `vx why` / `--affected` end to end. vx's own repository declares `bun()`.
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Root entry shim: Bun 1.4.0's `--compile` binaries resolve an on-disk
2
+ // package by `<pkg>/index.ts` and ignore package.json `exports` / `main`
3
+ // (see packages/vx/index.ts). Same module as the exports map names.
4
+ export * from './src/index.js'
package/package.json CHANGED
@@ -1,13 +1,47 @@
1
1
  {
2
2
  "name": "@vzn/vx-lockfile",
3
- "version": "0.0.0",
4
- "description": "Placeholder; real releases are published by CI.",
3
+ "version": "0.0.484",
5
4
  "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/vznjs/vx.git"
5
+ "description": "Lockfile plugins for vx — pnpm(), bun(), npm(), yarn(): each keys every task on its project's own resolved dependency closure from the package manager's lockfile, so a lockfile change re-keys only the projects it reaches; --affected follows the same answer.",
6
+ "keywords": [
7
+ "vx",
8
+ "vx-plugin",
9
+ "monorepo",
10
+ "lockfile",
11
+ "pnpm",
12
+ "bun",
13
+ "npm",
14
+ "yarn"
15
+ ],
16
+ "type": "module",
17
+ "main": "./src/index.ts",
18
+ "types": "./src/index.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/index.ts",
22
+ "import": "./src/index.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "index.ts",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.4"
9
33
  },
10
34
  "publishConfig": {
11
35
  "access": "public"
12
- }
36
+ },
37
+ "peerDependencies": {
38
+ "@vzn/vx": "^0.0.484"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/vznjs/vx.git",
43
+ "directory": "packages/vx-lockfile"
44
+ },
45
+ "homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-lockfile#readme",
46
+ "bugs": "https://github.com/vznjs/vx/issues"
13
47
  }
package/src/bun.ts ADDED
@@ -0,0 +1,263 @@
1
+ // bun.lock → one digest per workspace package over the packages it can
2
+ // reach. The text lockfile is JSONC: `workspaces` (root-relative dir →
3
+ // manifest: name + dependency maps) and `packages` (a node_modules path →
4
+ // `[id, registry, { dependencies, optionalDependencies, peerDependencies }, integrity]`;
5
+ // a workspace package is `[name@workspace:dir]`, a git or tarball
6
+ // package a shorter tuple). Bun hoists: a dependency `d` of the package
7
+ // at path `p` resolves to `p/d` when that key exists, else to the nearest
8
+ // ancestor's `…/d`, else to `d` at the root — the same walk Node's
9
+ // resolver makes through nested node_modules.
10
+ //
11
+ // Bun.JSONC is the parser: no dependency, and the file is Bun's own.
12
+
13
+ import { reachDigests } from '@vzn/vx'
14
+
15
+ export interface Lockfile {
16
+ readonly version: string
17
+ /** root-relative dir (`.` for the root) → dependency name → specifier */
18
+ readonly workspaces: ReadonlyMap<string, ReadonlyMap<string, string>>
19
+ /** workspace package name → its dir */
20
+ readonly workspaceDirs: ReadonlyMap<string, string>
21
+ /** node_modules path → the package there */
22
+ readonly packages: ReadonlyMap<string, Entry>
23
+ /** `patchedDependencies`: `name@version` (or `name`) → the patch file's path */
24
+ readonly patches: ReadonlyMap<string, string>
25
+ /** Material every workspace folds: the lockfile version and install-wide knobs. */
26
+ readonly global: string
27
+ }
28
+
29
+ export interface Entry {
30
+ /** `name@version`, `name@workspace:dir`, `name@github:…` */
31
+ readonly id: string
32
+ readonly deps: ReadonlyMap<string, string>
33
+ /** integrity / commit — whatever pins the bytes */
34
+ readonly resolution: string
35
+ }
36
+
37
+ type Json = Record<string, unknown>
38
+ /** The top-level fields the digest reads per workspace. */
39
+ // Read per workspace: the workspaces and packages themselves, and the fields
40
+ // whose whole effect is the `packages` entry a workspace reaches — how a
41
+ // range was written (catalogs, D-141), what an override forced (D-142) and
42
+ // what a patch changed (D-143). Folded into every workspace, one such edit
43
+ // re-keyed them all.
44
+ const PER_WORKSPACE = new Set([
45
+ 'workspaces',
46
+ 'packages',
47
+ 'catalog',
48
+ 'catalogs',
49
+ 'overrides',
50
+ 'patchedDependencies',
51
+ ])
52
+
53
+ const DEP_FIELDS = [
54
+ 'dependencies',
55
+ 'devDependencies',
56
+ 'optionalDependencies',
57
+ 'peerDependencies',
58
+ ] as const
59
+
60
+ export function parseLockfile(text: string): Lockfile {
61
+ let doc: unknown
62
+ try {
63
+ doc = Bun.JSONC.parse(text)
64
+ } catch (err) {
65
+ throw new Error(`bun.lock: ${err instanceof Error ? err.message : String(err)}`)
66
+ }
67
+ const d = record(doc)
68
+ if (d === undefined || typeof d['lockfileVersion'] !== 'number') {
69
+ throw new Error('bun.lock: not a Bun text lockfile (no lockfileVersion)')
70
+ }
71
+ const workspaces = new Map<string, ReadonlyMap<string, string>>()
72
+ const workspaceDirs = new Map<string, string>()
73
+ for (const [dir, manifest] of Object.entries(record(d['workspaces']) ?? {})) {
74
+ const m = record(manifest) ?? {}
75
+ const key = dir === '' ? '.' : dir
76
+ workspaces.set(key, depsOf(m))
77
+ if (typeof m['name'] === 'string') workspaceDirs.set(m['name'], key)
78
+ }
79
+ const packages = new Map<string, Entry>()
80
+ for (const [p, tuple] of Object.entries(record(d['packages']) ?? {})) {
81
+ if (!Array.isArray(tuple) || typeof tuple[0] !== 'string') continue
82
+ const meta = tuple.find((v) => record(v) !== undefined)
83
+ const resolution = tuple.slice(1).findLast((v) => typeof v === 'string' && v.length > 0)
84
+ packages.set(p, {
85
+ id: tuple[0],
86
+ deps: depsOf(record(meta) ?? {}),
87
+ resolution: typeof resolution === 'string' ? resolution : '',
88
+ })
89
+ }
90
+ // Every top-level field but the two read per workspace, so a field this
91
+ // parser has not heard of moves every workspace rather than none: an
92
+ // allow-list dropped `trustedDependencies`, which decides whose install
93
+ // scripts run (item 933).
94
+ const rest: Json = {}
95
+ for (const [k, v] of Object.entries(d)) if (!PER_WORKSPACE.has(k)) rest[k] = v
96
+
97
+ const patches = new Map<string, string>()
98
+ for (const [k, v] of Object.entries(record(d['patchedDependencies']) ?? {})) {
99
+ patches.set(k, typeof v === 'string' ? v : JSON.stringify(v))
100
+ }
101
+ const global = JSON.stringify(rest)
102
+ return {
103
+ version: String(d['lockfileVersion']),
104
+ workspaces,
105
+ workspaceDirs,
106
+ packages,
107
+ patches,
108
+ global,
109
+ }
110
+ }
111
+
112
+ function depsOf(m: Json): ReadonlyMap<string, string> {
113
+ const out = new Map<string, string>()
114
+ for (const field of DEP_FIELDS) {
115
+ const deps = record(m[field])
116
+ if (deps === undefined) continue
117
+ for (const [name, spec] of Object.entries(deps)) out.set(name, String(spec))
118
+ }
119
+ return out
120
+ }
121
+
122
+ function record(v: unknown): Json | undefined {
123
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : undefined
124
+ }
125
+
126
+ /**
127
+ * Where dependency `name` of the package at node_modules path `from`
128
+ * lives: the deepest `…/name` key up the path, then the root. A workspace
129
+ * package sits at its own name (`@vzn/vx`) and resolves from there.
130
+ */
131
+ function resolve(lock: Lockfile, from: string, name: string): string | undefined {
132
+ // A scoped name (`@s/x`) is one level at ANY depth: `foo/@s/y` → `foo` →
133
+ // ''. Only a root-level scope was joined, so `foo/@s/y` stepped to
134
+ // `foo/@s` and its `bar` resolved to `foo/@s/bar`, another package
135
+ // (`@s/bar`); the root `bar` it installs was never folded (item 901).
136
+ const levels = packageLevels(from)
137
+ for (let n = levels.length; ; n--) {
138
+ const base = levels.slice(0, n).join('/')
139
+ const key = base === '' ? name : `${base}/${name}`
140
+ if (lock.packages.has(key)) return key
141
+ if (n === 0) return undefined
142
+ }
143
+ }
144
+
145
+ /** A node_modules path's package levels: `foo/@s/y/z` → foo, @s/y, z. */
146
+ function packageLevels(path: string): string[] {
147
+ if (path === '') return []
148
+ const parts = path.split('/')
149
+ const out: string[] = []
150
+ for (let i = 0; i < parts.length; i++) {
151
+ const part = parts[i]!
152
+ out.push(part.startsWith('@') && i + 1 < parts.length ? `${part}/${parts[++i]}` : part)
153
+ }
154
+ return out
155
+ }
156
+
157
+ /**
158
+ * The patch files `patchedDependencies` names. bun.lock records a patch by
159
+ * PATH, never by a hash of its content, so an edited patch left the lockfile
160
+ * byte-identical and every key unmoved while the install applied the new
161
+ * one (item 1014): the claim hashes these files and hands them back.
162
+ */
163
+ export function patchFiles(text: string): string[] {
164
+ const d = record(Bun.JSONC.parse(text))
165
+ return Object.values(record(d?.['patchedDependencies']) ?? {}).filter(
166
+ (v): v is string => typeof v === 'string',
167
+ )
168
+ }
169
+
170
+ /**
171
+ * Every workspace package's digest (by dir): the lockfile as one graph —
172
+ * a node per node_modules path, its material the id + resolution, an edge
173
+ * per resolved dependency — folded by core's `reachDigests`. A workspace
174
+ * dependency (`workspace:` id) is a node like any other, so what project
175
+ * A can import through workspace package B is B's whole reach.
176
+ */
177
+ export function importerDigests(
178
+ lock: Lockfile,
179
+ files: ReadonlyMap<string, string> = new Map(),
180
+ ): ReadonlyMap<string, string> {
181
+ const index = new Map<string, number>()
182
+ const material: string[] = []
183
+ const edges: number[][] = []
184
+ const node = (id: string, own: string): number => {
185
+ let i = index.get(id)
186
+ if (i === undefined) {
187
+ i = material.length
188
+ index.set(id, i)
189
+ material.push(own)
190
+ edges.push([])
191
+ }
192
+ return i
193
+ }
194
+ // The name a package is installed under and what it is, not where: a
195
+ // re-hoist (`is-odd/is-number` → `is-number`, one version) re-keyed
196
+ // every project reaching it with the same bytes installed (D-140). Where
197
+ // it sits still decides what it resolves; that is the edges.
198
+ for (const [p, e] of lock.packages) node(p, `${installName(p)}\0${e.id}\0${e.resolution}`)
199
+ // A patch is part of the package it patches: an edit to one only `b`
200
+ // reaches re-keyed every workspace while it was install-wide (D-143).
201
+ // One that names no entry stays install-wide, so it still moves a key.
202
+ const loose: [string, string, string][] = []
203
+ for (const [key, file] of [...lock.patches].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
204
+ const own = `\npatch\0${key}\0${file}\0${files.get(file) ?? ''}`
205
+ let hit = false
206
+ for (const [p, e] of lock.packages) {
207
+ if (e.id !== key && e.id.slice(0, e.id.lastIndexOf('@')) !== key) continue
208
+ material[index.get(p)!] += own
209
+ hit = true
210
+ }
211
+ if (!hit) loose.push([key, file, files.get(file) ?? ''])
212
+ }
213
+ for (const [p, e] of lock.packages) {
214
+ const from = index.get(p)!
215
+ for (const [name, spec] of e.deps) {
216
+ const target = resolve(lock, p, name)
217
+ if (target !== undefined) edges[from]!.push(index.get(target)!)
218
+ else material[from] += `\nunresolved\0${name}\0${spec}`
219
+ }
220
+ }
221
+ // A workspace importer resolves like the package at its own name; the
222
+ // root importer ('') resolves from the root.
223
+ const importers = new Map<string, number>()
224
+ for (const [dir, deps] of lock.workspaces) {
225
+ // Its dir, as pnpm's importers fold theirs (item 1073).
226
+ const own = node(`workspace\0${dir}`, `workspace\0${dir}`)
227
+ importers.set(dir, own)
228
+ let from = ''
229
+ for (const [name, wsDir] of lock.workspaceDirs) if (wsDir === dir && dir !== '.') from = name
230
+ for (const [name, spec] of deps) {
231
+ const target = resolve(lock, from, name)
232
+ if (target !== undefined) edges[own]!.push(index.get(target)!)
233
+ else material[own] += `\nunresolved\0${name}\0${spec}`
234
+ }
235
+ }
236
+ // A workspace package's node carries no dependencies of its own — they
237
+ // live on its importer — so the node points at the importer: what a
238
+ // project can import through workspace package B is B's whole reach.
239
+ for (const [p, e] of lock.packages) {
240
+ const at = e.id.indexOf('@workspace:')
241
+ if (at === -1) continue
242
+ const target = importers.get(e.id.slice(at + '@workspace:'.length) || '.')
243
+ if (target !== undefined) edges[index.get(p)!]!.push(target)
244
+ }
245
+ const digests = reachDigests({ material, edges })
246
+ const out = new Map<string, string>()
247
+ // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
248
+ // bits of a seed, so two lockfiles' globals could share one (item 682).
249
+ const globalMaterial =
250
+ loose.length === 0 ? lock.global : `${lock.global}\0${JSON.stringify(loose)}`
251
+ const global = Bun.hash.xxHash3(globalMaterial).toString(16).padStart(16, '0')
252
+ for (const [dir, i] of importers) {
253
+ out.set(dir, Bun.hash.xxHash3(`${global}\0${digests[i]!}`).toString(16).padStart(16, '0'))
254
+ }
255
+ return out
256
+ }
257
+
258
+ /** The name a package key installs under: its last segment, scope included. */
259
+ function installName(key: string): string {
260
+ const parts = key.split('/')
261
+ const scoped = parts.length >= 2 && parts[parts.length - 2]!.startsWith('@')
262
+ return scoped ? parts.slice(-2).join('/') : parts[parts.length - 1]!
263
+ }
package/src/index.ts ADDED
@@ -0,0 +1,159 @@
1
+ // @vzn/vx-lockfile — one plugin per package manager, each keying every
2
+ // task on its project's own resolved dependency closure from the
3
+ // lockfile instead of the whole file.
4
+ //
5
+ // Core folds every lockfile at the root into the workspace fingerprint
6
+ // that every task key sees, so one install re-keys the workspace and
7
+ // `--affected` selects every project. Each plugin here CLAIMS its file
8
+ // (`VxPlugin.fingerprint`) through core's `lockfileClaim`: the file leaves
9
+ // the key digest, each task folds one digest per project — what that
10
+ // project can reach through its dependencies, by resolved identity — and
11
+ // `--affected` names the projects whose digest moved. This package is the
12
+ // parsers; the claim, the per-project key (which folds the root
13
+ // importer's digest into every project: the root's tools are on every
14
+ // task's PATH), the memo (one parse per file content), the per-run read
15
+ // and the diff are core's.
16
+ //
17
+ // Imports core only through the public `@vzn/vx` specifier.
18
+ import {
19
+ refuseUnknownOptions,
20
+ type PluginOptionKinds,
21
+ definePlugin,
22
+ lockfileClaim,
23
+ type VxPlugin,
24
+ } from '@vzn/vx'
25
+ import * as pnpmLock from './pnpm.js'
26
+ import * as bunLock from './bun.js'
27
+ import * as npmLock from './npm.js'
28
+ import * as yarnLock from './yarn.js'
29
+
30
+ export interface LockfileOptions {
31
+ /**
32
+ * `'project'` (default): each task folds its own project's dependency
33
+ * closure — a lockfile change re-keys only the projects it reaches.
34
+ * `'workspace'`: the whole file, as core folds it — the coarse key,
35
+ * through the plugin, for a workspace that wants the claim but not yet
36
+ * the precision (`vx why` names it either way).
37
+ */
38
+ readonly scope?: 'project' | 'workspace'
39
+ }
40
+
41
+ /** Bumps when a digest folds differently (the memo's identity). */
42
+ const DIGEST_VERSION = 14
43
+
44
+ interface Manager {
45
+ readonly name: string
46
+ readonly file: string
47
+ readonly digest: (text: string, files: ReadonlyMap<string, string>) => ReadonlyMap<string, string>
48
+ readonly extraFiles?: (text: string) => readonly string[]
49
+ }
50
+
51
+ const MANAGERS = {
52
+ pnpm: {
53
+ name: 'pnpm',
54
+ file: 'pnpm-lock.yaml',
55
+ digest: (t) => pnpmLock.importerDigests(pnpmLock.parseLockfile(t)),
56
+ },
57
+ bun: {
58
+ name: 'bun',
59
+ file: 'bun.lock',
60
+ digest: (t, files) => bunLock.importerDigests(bunLock.parseLockfile(t), files),
61
+ extraFiles: (t) => bunLock.patchFiles(t),
62
+ },
63
+ npm: {
64
+ name: 'npm',
65
+ file: 'package-lock.json',
66
+ digest: (t) => npmLock.importerDigests(npmLock.parseLockfile(t)),
67
+ },
68
+ yarn: {
69
+ name: 'yarn',
70
+ file: 'yarn.lock',
71
+ digest: (t) => yarnLock.importerDigests(yarnLock.parseLockfile(t)),
72
+ },
73
+ } satisfies Record<string, Manager>
74
+
75
+ const CONFLICT = /^(?:<{7}|={7}|>{7})(?: |$)/m
76
+
77
+ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
78
+ const scope = options.scope ?? 'project'
79
+ if (scope !== 'project' && scope !== 'workspace') {
80
+ throw new Error(
81
+ `@vzn/vx-lockfile: ${manager.name}() scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`,
82
+ )
83
+ }
84
+ // The parsers name their own file, so prefixing unconditionally said it
85
+ // twice — "bun.lock: bun.lock: Failed to parse JSONC" (2026-09-20).
86
+ // Prefix only what does not already name it, and say what fixes it: a
87
+ // lockfile vx cannot read is an install away from readable, and the
88
+ // alternative to reading it is a WRONG key, so the run refuses rather
89
+ // than guessing.
90
+ const refused = (err: unknown): Error => {
91
+ const message = err instanceof Error ? err.message : String(err)
92
+ const named = message.startsWith(`${manager.file}:`) ? message : `${manager.file}: ${message}`
93
+ return new Error(`${named} — regenerate it with \`${manager.name} install\``)
94
+ }
95
+ const extraFiles = manager.extraFiles
96
+ return definePlugin(
97
+ import.meta,
98
+ lockfileClaim({
99
+ file: manager.file,
100
+ part: manager.name,
101
+ version: DIGEST_VERSION,
102
+ scope,
103
+ // Read before `digest` on the key path: a malformed bun.lock refused
104
+ // there as a bare "JSONC Parse error", no file named (fuzzed, L-16).
105
+ ...(extraFiles !== undefined
106
+ ? {
107
+ extraFiles: (text: string) => {
108
+ try {
109
+ return extraFiles(text)
110
+ } catch (err) {
111
+ throw refused(err)
112
+ }
113
+ },
114
+ }
115
+ : {}),
116
+ digest: (text, files) => {
117
+ try {
118
+ // A conflicted lockfile names two installs at once. Yarn
119
+ // classic's line parser took the second side without a word, so
120
+ // the key named an install that may not be the one on disk.
121
+ if (CONFLICT.test(text))
122
+ throw new Error(`${manager.file}: holds git merge conflict markers`)
123
+ return manager.digest(text, files)
124
+ } catch (err) {
125
+ throw refused(err)
126
+ }
127
+ },
128
+ }),
129
+ )
130
+ }
131
+
132
+ /** Each option `LockfileOptions` names, with its kind: derived from the type, so the two cannot drift. */
133
+ const LOCKFILE_KEYS: PluginOptionKinds<LockfileOptions> = {
134
+ scope: 'string',
135
+ }
136
+
137
+ /** `pnpm-lock.yaml` (lockfile v5, v6, v9, one document or pnpm 11's two): importers, snapshots, peers, patches, `link:`. */
138
+ export function pnpm(options: LockfileOptions = {}): VxPlugin {
139
+ refuseUnknownOptions('pnpm()', options, LOCKFILE_KEYS)
140
+ return plugin(MANAGERS.pnpm, options)
141
+ }
142
+
143
+ /** `bun.lock` (the text lockfile): Bun's hoisted layout, nested versions, `workspace:` links. */
144
+ export function bun(options: LockfileOptions = {}): VxPlugin {
145
+ refuseUnknownOptions('bun()', options, LOCKFILE_KEYS)
146
+ return plugin(MANAGERS.bun, options)
147
+ }
148
+
149
+ /** `package-lock.json` (lockfileVersion 2, 3): the `packages` map, nested `node_modules`, workspace links. */
150
+ export function npm(options: LockfileOptions = {}): VxPlugin {
151
+ refuseUnknownOptions('npm()', options, LOCKFILE_KEYS)
152
+ return plugin(MANAGERS.npm, options)
153
+ }
154
+
155
+ /** `yarn.lock`: berry (yarn 2+, per workspace) and classic (yarn 1, one digest for the root). */
156
+ export function yarn(options: LockfileOptions = {}): VxPlugin {
157
+ refuseUnknownOptions('yarn()', options, LOCKFILE_KEYS)
158
+ return plugin(MANAGERS.yarn, options)
159
+ }
package/src/npm.ts ADDED
@@ -0,0 +1,166 @@
1
+ // package-lock.json (lockfileVersion 2 and 3) → one digest per workspace
2
+ // package over the packages it can reach. `packages` is keyed by
3
+ // node_modules path: `""` is the root manifest, `node_modules/foo` a
4
+ // hoisted package, `node_modules/foo/node_modules/bar` one nested under
5
+ // it, `packages/a` a workspace package, and `node_modules/a` a link to it
6
+ // (`link: true, resolved: "packages/a"`). A dependency `d` of the package
7
+ // at path `p` is `p/node_modules/d` when that key exists, else the nearest
8
+ // ancestor directory's, else `node_modules/d` — Node's own walk.
9
+
10
+ import { reachDigests } from '@vzn/vx'
11
+
12
+ export interface Lockfile {
13
+ readonly version: number
14
+ /** path → entry */
15
+ readonly packages: ReadonlyMap<string, Entry>
16
+ /** Material every workspace folds. */
17
+ readonly global: string
18
+ }
19
+
20
+ export interface Entry {
21
+ readonly deps: ReadonlyMap<string, string>
22
+ /** version + resolved + integrity — whatever pins the bytes; '' for a link */
23
+ readonly resolution: string
24
+ /** the path a `link: true` entry points at */
25
+ readonly link: string | undefined
26
+ readonly isWorkspace: boolean
27
+ }
28
+
29
+ type Json = Record<string, unknown>
30
+ const DEP_FIELDS = [
31
+ 'dependencies',
32
+ 'devDependencies',
33
+ 'optionalDependencies',
34
+ 'peerDependencies',
35
+ ] as const
36
+
37
+ export function parseLockfile(text: string): Lockfile {
38
+ let doc: unknown
39
+ try {
40
+ doc = JSON.parse(text)
41
+ } catch (err) {
42
+ throw new Error(`package-lock.json: ${err instanceof Error ? err.message : String(err)}`)
43
+ }
44
+ const d = record(doc)
45
+ const version = d === undefined ? undefined : d['lockfileVersion']
46
+ if (typeof version !== 'number') {
47
+ throw new Error('package-lock.json: not an npm lockfile (no lockfileVersion)')
48
+ }
49
+ if (version < 2) {
50
+ throw new Error(
51
+ `package-lock.json: lockfileVersion ${version} has no \`packages\` map — npm 7+ writes version 2 or 3`,
52
+ )
53
+ }
54
+ const packages = new Map<string, Entry>()
55
+ for (const [p, raw] of Object.entries(record(d!['packages']) ?? {})) {
56
+ const e = record(raw) ?? {}
57
+ const link = e['link'] === true && typeof e['resolved'] === 'string' ? e['resolved'] : undefined
58
+ packages.set(p, {
59
+ deps: depsOf(e),
60
+ resolution:
61
+ link === undefined
62
+ ? [e['version'], e['resolved'], e['integrity']]
63
+ .map((v) => (typeof v === 'string' ? v : ''))
64
+ .join('\0')
65
+ : '',
66
+ link,
67
+ isWorkspace: p !== '' && !p.startsWith('node_modules/') && !p.includes('/node_modules/'),
68
+ })
69
+ }
70
+ // Root `overrides` are not folded: what an override forced is the entry a
71
+ // workspace reaches, and npm 10 does not write them here at all (D-142).
72
+ const global = JSON.stringify({ lockfileVersion: version })
73
+ return { version, packages, global }
74
+ }
75
+
76
+ function depsOf(e: Json): ReadonlyMap<string, string> {
77
+ const out = new Map<string, string>()
78
+ for (const field of DEP_FIELDS) {
79
+ const deps = record(e[field])
80
+ if (deps === undefined) continue
81
+ for (const [name, spec] of Object.entries(deps)) out.set(name, String(spec))
82
+ }
83
+ return out
84
+ }
85
+
86
+ function record(v: unknown): Json | undefined {
87
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : undefined
88
+ }
89
+
90
+ /**
91
+ * Node's walk: `p/node_modules/name`, then the same under each ancestor
92
+ * directory that is not itself a `node_modules`, then `node_modules/name`.
93
+ * A workspace nested in another's directory (`packages/a/packages/n`)
94
+ * resolves through `packages/a/node_modules`, where npm nests what only it
95
+ * needs; stepping from one `/node_modules/` boundary to the next skipped
96
+ * that directory, and `n` was keyed on its spec alone (D-139).
97
+ */
98
+ function resolve(lock: Lockfile, from: string, name: string): string | undefined {
99
+ let base = from
100
+ for (;;) {
101
+ if (base !== 'node_modules' && !base.endsWith('/node_modules')) {
102
+ const key = base === '' ? `node_modules/${name}` : `${base}/node_modules/${name}`
103
+ if (lock.packages.has(key)) return key
104
+ }
105
+ if (base === '') return undefined
106
+ const i = base.lastIndexOf('/')
107
+ base = i === -1 ? '' : base.slice(0, i)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Every workspace package's digest (by dir, `.` for the root): the
113
+ * lockfile as one graph — a node per path, its material the resolution,
114
+ * an edge per resolved dependency, a link's edge to its target — folded
115
+ * by core's `reachDigests`.
116
+ */
117
+ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
118
+ const index = new Map<string, number>()
119
+ const material: string[] = []
120
+ const edges: number[][] = []
121
+ for (const [p, e] of lock.packages) {
122
+ index.set(p, material.length)
123
+ // An installed package is its install name and what it is, not where
124
+ // it sits: a re-hoist of one version re-keyed every project reaching it
125
+ // (D-140). Where it sits decides what it resolves; that is the edges.
126
+ // The root and a workspace keep their path, which is who they are.
127
+ material.push(`${p === '' || e.isWorkspace ? p : installName(p)}\0${e.resolution}`)
128
+ edges.push([])
129
+ }
130
+ for (const [p, e] of lock.packages) {
131
+ const from = index.get(p)!
132
+ if (e.link !== undefined) {
133
+ const target = index.get(e.link)
134
+ if (target !== undefined) edges[from]!.push(target)
135
+ else material[from] += `\nlink\0${e.link}`
136
+ continue
137
+ }
138
+ for (const [name, spec] of e.deps) {
139
+ const target = resolve(lock, p, name)
140
+ if (target !== undefined) edges[from]!.push(index.get(target)!)
141
+ else material[from] += `\nunresolved\0${name}\0${spec}`
142
+ }
143
+ }
144
+ const digests = reachDigests({ material, edges })
145
+ const out = new Map<string, string>()
146
+ // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
147
+ // bits of a seed, so two lockfiles' globals could share one (item 682).
148
+ const global = Bun.hash.xxHash3(lock.global).toString(16).padStart(16, '0')
149
+ for (const [p, e] of lock.packages) {
150
+ if (p !== '' && !e.isWorkspace) continue
151
+ out.set(
152
+ p === '' ? '.' : p,
153
+ Bun.hash
154
+ .xxHash3(`${global}\0${digests[index.get(p)!]!}`)
155
+ .toString(16)
156
+ .padStart(16, '0'),
157
+ )
158
+ }
159
+ return out
160
+ }
161
+
162
+ /** `node_modules/a/node_modules/@s/b` → `@s/b`. */
163
+ function installName(p: string): string {
164
+ const i = p.lastIndexOf('node_modules/')
165
+ return i === -1 ? p : p.slice(i + 'node_modules/'.length)
166
+ }
package/src/pnpm.ts ADDED
@@ -0,0 +1,292 @@
1
+ // pnpm-lock.yaml → one digest per importer (workspace project) over the
2
+ // packages that importer can actually reach: its own dependencies, their
3
+ // dependencies, and so on through `snapshots` (v9) or `packages` (v5/v6),
4
+ // with `link:` dependencies followed into the linked importer's closure.
5
+ // A package's identity is its snapshot key — name, version AND the
6
+ // resolved-peer suffix, since `foo@1(react@18)` and `foo@1(react@19)` are
7
+ // different node_modules — plus its resolution (integrity / tarball /
8
+ // commit) and any patch applied to it. Everything that changes what an
9
+ // install produces for EVERY importer (pnpmfile, package extensions,
10
+ // overrides, settings) is folded into every digest.
11
+ //
12
+ // Bun.YAML is the parser: no dependency, and the file is plain YAML.
13
+
14
+ import { reachDigests } from '@vzn/vx'
15
+
16
+ export interface Lockfile {
17
+ readonly version: string
18
+ /** importer path (`.`, `packages/a`) → dependency name → version or `link:…` */
19
+ readonly importers: ReadonlyMap<string, ReadonlyMap<string, string>>
20
+ /** snapshot key → dependency name → version (v9 `snapshots`; v5/v6 `packages`) */
21
+ readonly snapshots: ReadonlyMap<string, ReadonlyMap<string, string>>
22
+ /** package key (v9: `name@version`; v5/v6: the snapshot key) → resolution digest */
23
+ readonly resolutions: ReadonlyMap<string, string>
24
+ /** `name` or `name@version` → patch hash */
25
+ readonly patches: ReadonlyMap<string, string>
26
+ /** Material every importer folds: the lockfile version and the install-wide knobs. */
27
+ readonly global: string
28
+ }
29
+
30
+ type Yaml = Record<string, unknown>
31
+
32
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies'] as const
33
+
34
+ /**
35
+ * The top-level fields the digest reads per importer: the importers, the
36
+ * packages and snapshots they reach, their patches, and the catalogs their
37
+ * specifiers resolved. `overrides` too: what an override did is the
38
+ * snapshot an importer now reaches, and folded into every digest one
39
+ * override that only `b` reached re-keyed `a` and the root as well (probed
40
+ * on pnpm 9, D-142). (v5's lone importer, written at the top, may fold as
41
+ * global too: it is the only importer there is.)
42
+ */
43
+ const PER_IMPORTER = new Set([
44
+ 'importers',
45
+ 'packages',
46
+ 'snapshots',
47
+ 'patchedDependencies',
48
+ 'catalogs',
49
+ 'overrides',
50
+ ])
51
+
52
+ export function parseLockfile(text: string): Lockfile {
53
+ // pnpm 10.x and 11 write the env lockfile (`configDependencies`, the
54
+ // package manager's own install) as a leading YAML document; the
55
+ // project lockfile is the last one, as Turbo reads it. Read as one
56
+ // document, the pair was an array and every such repo was refused
57
+ // with "regenerate it", which regenerates the same two documents.
58
+ const parsed: unknown = Bun.YAML.parse(text)
59
+ const docs = (Array.isArray(parsed) ? parsed : [parsed]).filter((d) => d !== null)
60
+ const doc = docs.at(-1) as Yaml | undefined
61
+ const leading = docs.slice(0, -1)
62
+ if (doc === undefined || typeof doc !== 'object' || Array.isArray(doc)) {
63
+ throw new Error('pnpm-lock.yaml: not a YAML document')
64
+ }
65
+ const version = scalar(doc['lockfileVersion'])
66
+ const major = Number.parseInt(version, 10)
67
+ if (!Number.isFinite(major) || major < 5) {
68
+ throw new Error(`pnpm-lock.yaml: unsupported lockfileVersion ${JSON.stringify(version)}`)
69
+ }
70
+
71
+ const importers = new Map<string, ReadonlyMap<string, string>>()
72
+ const rawImporters = record(doc['importers'])
73
+ if (rawImporters !== undefined) {
74
+ for (const [dir, entry] of Object.entries(rawImporters)) importers.set(dir, depsOf(entry))
75
+ } else if (DEP_FIELDS.some((f) => doc[f] !== undefined)) {
76
+ // A single-package lockfile keeps its dependencies at the top level.
77
+ importers.set('.', depsOf(doc))
78
+ }
79
+
80
+ const snapshots = new Map<string, ReadonlyMap<string, string>>()
81
+ const resolutions = new Map<string, string>()
82
+ const packages = record(doc['packages']) ?? {}
83
+ for (const [key, entry] of Object.entries(packages)) {
84
+ const e = record(entry) ?? {}
85
+ resolutions.set(key, resolutionOf(e))
86
+ if (major < 9) snapshots.set(key, depsOf(e))
87
+ }
88
+ if (major >= 9) {
89
+ for (const [key, entry] of Object.entries(record(doc['snapshots']) ?? {})) {
90
+ snapshots.set(key, depsOf(entry))
91
+ }
92
+ }
93
+
94
+ const patches = new Map<string, string>()
95
+ for (const [name, entry] of Object.entries(record(doc['patchedDependencies']) ?? {})) {
96
+ const e = record(entry)
97
+ patches.set(name, e === undefined ? scalar(entry) : scalar(e['hash']) || stable(e))
98
+ }
99
+
100
+ // Every top-level field but those read per importer above, so a field
101
+ // this parser has not heard of moves every importer rather than none: an
102
+ // allow-list dropped v6's `onlyBuiltDependencies`, which decides whether
103
+ // install scripts run anywhere (item 933).
104
+ const rest: Record<string, unknown> = {}
105
+ for (const [k, v] of Object.entries(doc)) if (!PER_IMPORTER.has(k)) rest[k] = v
106
+ // The env lockfile moves every importer: a config dependency can carry
107
+ // the pnpmfile that rewrites every install, and the package manager's
108
+ // own version installs all of them. Folded only when present, so a
109
+ // one-document lockfile keys as it did.
110
+ const global = stable(
111
+ leading.length === 0
112
+ ? { lockfileVersion: version, rest }
113
+ : { lockfileVersion: version, rest, leading },
114
+ )
115
+ return { version, importers, snapshots, resolutions, patches, global }
116
+ }
117
+
118
+ /** `dependencies` + `devDependencies` + `optionalDependencies` of an entry, as name → version. */
119
+ function depsOf(entry: unknown): ReadonlyMap<string, string> {
120
+ const out = new Map<string, string>()
121
+ const e = record(entry)
122
+ if (e === undefined) return out
123
+ for (const field of DEP_FIELDS) {
124
+ const deps = record(e[field])
125
+ if (deps === undefined) continue
126
+ for (const [name, v] of Object.entries(deps)) {
127
+ // v6/v9: `{ specifier, version }`; v5 and every snapshot: the version.
128
+ const spec = record(v)
129
+ out.set(name, spec === undefined ? scalar(v) : scalar(spec['version']))
130
+ }
131
+ }
132
+ return out
133
+ }
134
+
135
+ function resolutionOf(entry: Yaml): string {
136
+ const r = record(entry['resolution'])
137
+ return r === undefined ? '' : stable(r)
138
+ }
139
+
140
+ /** A YAML scalar as text; an object or a missing value reads as ''. */
141
+ function scalar(v: unknown): string {
142
+ return typeof v === 'string'
143
+ ? v
144
+ : typeof v === 'number' || typeof v === 'boolean'
145
+ ? String(v)
146
+ : ''
147
+ }
148
+
149
+ function record(v: unknown): Yaml | undefined {
150
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Yaml) : undefined
151
+ }
152
+
153
+ /** JSON with sorted keys, so YAML key order cannot move a digest. */
154
+ export function stable(v: unknown): string {
155
+ return JSON.stringify(v, (_k, val: unknown) =>
156
+ val !== null && typeof val === 'object' && !Array.isArray(val)
157
+ ? Object.fromEntries(Object.entries(val as Yaml).sort(([a], [b]) => (a < b ? -1 : 1)))
158
+ : val,
159
+ )
160
+ }
161
+
162
+ /**
163
+ * The snapshot key a dependency `name@version` resolves to, per lockfile
164
+ * generation: `name@1.0.0(peer@2)` (v9), `/name@1.0.0(peer@2)` (v6),
165
+ * `/name/1.0.0_peer@2` (v5). A version that is already a key (`/…`) is
166
+ * used as it is. Otherwise the generation's own key comes first, then
167
+ * the version itself when the lockfile has a snapshot under it: an alias
168
+ * (`name: other@1.0.0`), and a v5/v6 `file:…`, whose key is the bare
169
+ * version. A v9 `file:…` is keyed `name@file:…` like any other, and
170
+ * taking the version first made it a leaf: a bump behind a directory
171
+ * dependency moved no digest (item 802).
172
+ */
173
+ function snapshotKey(lock: Lockfile, name: string, version: string): string {
174
+ if (version.startsWith('/')) return version
175
+ const major = Number.parseInt(lock.version, 10)
176
+ const own =
177
+ major >= 9 ? `${name}@${version}` : major >= 6 ? `/${name}@${version}` : `/${name}/${version}`
178
+ if (lock.snapshots.has(own)) return own
179
+ return lock.snapshots.has(version) ? version : own
180
+ }
181
+
182
+ /** `name@1.0.0(peer@2)` → `name@1.0.0`: the `packages` key a v9 snapshot resolves under. */
183
+ function packageKey(snapshot: string): string {
184
+ const paren = snapshot.indexOf('(')
185
+ return paren === -1 ? snapshot : snapshot.slice(0, paren)
186
+ }
187
+
188
+ /** `foo@1.0.0(peer)` / `/foo@1.0.0` → `foo`; `/@s/foo/1.0.0` (v5) → `@s/foo`. */
189
+ function packageName(lock: Lockfile, snapshot: string): string {
190
+ let s = packageKey(snapshot)
191
+ if (s.startsWith('/')) s = s.slice(1)
192
+ if (Number.parseInt(lock.version, 10) >= 6) {
193
+ const at = s.indexOf('@', 1)
194
+ return at === -1 ? s : s.slice(0, at)
195
+ }
196
+ const slash = s.lastIndexOf('/')
197
+ return slash === -1 ? s : s.slice(0, slash)
198
+ }
199
+
200
+ /**
201
+ * Every importer's digest: the lockfile as one graph (importers and
202
+ * snapshots alike), each node's material its identity, and core's
203
+ * `reachDigests` folding what every node reaches (components, so pnpm's
204
+ * cycles terminate). A `link:` dependency is an edge to the linked
205
+ * importer's node, so what project A can import through workspace
206
+ * package B is B's whole reach.
207
+ */
208
+ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
209
+ const g = buildGraph(lock)
210
+ const hashes = reachDigests(g)
211
+ const out = new Map<string, string>()
212
+ // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
213
+ // bits of a seed, so two lockfiles' globals could share one (item 682).
214
+ const global = Bun.hash.xxHash3(lock.global).toString(16).padStart(16, '0')
215
+ for (const dir of lock.importers.keys()) {
216
+ const h = Bun.hash.xxHash3(`${global}\0${hashes[g.index.get(importerNode(dir))!]!}`)
217
+ out.set(dir, h.toString(16).padStart(16, '0'))
218
+ }
219
+ return out
220
+ }
221
+
222
+ interface Graph {
223
+ /** node id → index */
224
+ readonly index: Map<string, number>
225
+ /** what each node folds of its own */
226
+ readonly material: string[]
227
+ /** out-edges, by index */
228
+ readonly edges: number[][]
229
+ }
230
+
231
+ function importerNode(dir: string): string {
232
+ return `importer\0${dir}`
233
+ }
234
+
235
+ function buildGraph(lock: Lockfile): Graph {
236
+ const index = new Map<string, number>()
237
+ const material: string[] = []
238
+ const edges: number[][] = []
239
+ const node = (id: string, own: string): number => {
240
+ let i = index.get(id)
241
+ if (i === undefined) {
242
+ i = material.length
243
+ index.set(id, i)
244
+ material.push(own)
245
+ edges.push([])
246
+ }
247
+ return i
248
+ }
249
+ for (const key of lock.snapshots.keys()) {
250
+ const resolution = lock.resolutions.get(key) ?? lock.resolutions.get(packageKey(key)) ?? ''
251
+ const patch =
252
+ lock.patches.get(packageKey(key)) ?? lock.patches.get(packageName(lock, key)) ?? ''
253
+ node(key, `${key}\0${resolution}\0${patch}`)
254
+ }
255
+ // Each importer folds its own dir: with one material for all, two
256
+ // importers that link each other were told apart by their edges alone,
257
+ // and swapping which reaches which version gave the same lines, so a
258
+ // lockfile that moved both installs keyed neither (item 1073).
259
+ for (const dir of lock.importers.keys()) node(importerNode(dir), importerNode(dir))
260
+ const link = (from: number, dir: string, name: string, version: string): void => {
261
+ if (version.startsWith('link:')) {
262
+ const target = joinPosix(dir, version.slice('link:'.length))
263
+ if (lock.importers.has(target)) edges[from]!.push(index.get(importerNode(target))!)
264
+ else material[from] += `\nlink\0${name}\0${version}`
265
+ return
266
+ }
267
+ const key = snapshotKey(lock, name, version)
268
+ // A dependency with no snapshot (a leaf the file lists only by version)
269
+ // is a node of its own, so its version still counts.
270
+ edges[from]!.push(node(key, `${key}\0${lock.resolutions.get(packageKey(key)) ?? ''}\0`))
271
+ }
272
+ for (const [dir, deps] of lock.importers) {
273
+ const from = index.get(importerNode(dir))!
274
+ for (const [name, version] of deps) link(from, dir, name, version)
275
+ }
276
+ for (const [key, deps] of lock.snapshots) {
277
+ const from = index.get(key)!
278
+ for (const [name, version] of deps) link(from, '.', name, version)
279
+ }
280
+ return { index, material, edges }
281
+ }
282
+
283
+ /** `packages/a` + `../b` → `packages/b`; `.` + `packages/a` → `packages/a`. POSIX, as the lockfile writes paths. */
284
+ function joinPosix(base: string, rel: string): string {
285
+ const parts: string[] = []
286
+ for (const seg of `${base}/${rel}`.split('/')) {
287
+ if (seg === '' || seg === '.') continue
288
+ if (seg === '..') parts.pop()
289
+ else parts.push(seg)
290
+ }
291
+ return parts.length === 0 ? '.' : parts.join('/')
292
+ }
package/src/yarn.ts ADDED
@@ -0,0 +1,294 @@
1
+ // yarn.lock → one digest per workspace over the packages it can reach.
2
+ // Two generations, one graph. Berry (yarn 2+) is YAML: each entry is keyed
3
+ // by the descriptors it satisfies (`"foo@npm:^1.0.0, foo@npm:^1.2.0":`),
4
+ // carries `resolution`, `dependencies` / `peerDependencies` (name →
5
+ // range) and `checksum`; a workspace is `"a@workspace:packages/a"`. A
6
+ // dependency `bar: "npm:^2"` resolves to the entry keyed `bar@npm:^2`.
7
+ // Classic (yarn 1) is its own text format: `"foo@^1.0.0", "foo@^1.2.0":`
8
+ // then indented `version`, `resolved`, `integrity` and `dependencies:`
9
+ // with `bar "^2"` lines; classic has no workspace entries, so workspaces
10
+ // resolve from their own `package.json` — which the digest reads from the
11
+ // lockfile's root descriptors only, so a workspace's digest is the root's
12
+ // reach through the descriptors the file resolves.
13
+
14
+ import { reachDigests } from '@vzn/vx'
15
+ import { stable } from './pnpm.js'
16
+
17
+ export interface Lockfile {
18
+ readonly generation: 'berry' | 'classic'
19
+ /** entry id (the resolution, or the first descriptor) → entry */
20
+ readonly entries: ReadonlyMap<string, Entry>
21
+ /** descriptor (`name@range`) → entry id */
22
+ readonly descriptors: ReadonlyMap<string, string>
23
+ /** workspace dir → entry id (berry only) */
24
+ readonly workspaces: ReadonlyMap<string, string>
25
+ /** package name → every entry a descriptor of that name resolves to (berry only) */
26
+ readonly names: ReadonlyMap<string, readonly string[]>
27
+ /**
28
+ * `name@npm:range` → the entry of Yarn's builtin compat patch of it
29
+ * (`resolve`, `typescript`, `fsevents`; berry only). The workspace keeps
30
+ * asking for the plain descriptor, which keys the unpatched entry, while
31
+ * the patched one is what Yarn installs (item 1074).
32
+ */
33
+ readonly builtinPatches: ReadonlyMap<string, string>
34
+ readonly global: string
35
+ }
36
+
37
+ export interface Entry {
38
+ readonly deps: ReadonlyMap<string, string>
39
+ /** resolution + checksum / version + resolved + integrity */
40
+ readonly resolution: string
41
+ }
42
+
43
+ type Json = Record<string, unknown>
44
+
45
+ export function parseLockfile(text: string): Lockfile {
46
+ const head = text.slice(0, 400)
47
+ if (/^# yarn lockfile v1/m.test(head)) return parseClassic(text)
48
+ if (/^__metadata:/m.test(head) || /^\s*version: \d/m.test(head)) return parseBerry(text)
49
+ throw new Error(
50
+ 'yarn.lock: neither a classic (`# yarn lockfile v1`) nor a berry (`__metadata`) lockfile',
51
+ )
52
+ }
53
+
54
+ function parseBerry(text: string): Lockfile {
55
+ let doc: unknown
56
+ try {
57
+ doc = Bun.YAML.parse(text)
58
+ } catch (err) {
59
+ throw new Error(`yarn.lock: ${err instanceof Error ? err.message : String(err)}`)
60
+ }
61
+ const d = record(doc)
62
+ if (d === undefined) throw new Error('yarn.lock: not a YAML document')
63
+ const entries = new Map<string, Entry>()
64
+ const descriptors = new Map<string, string>()
65
+ const workspaces = new Map<string, string>()
66
+ const names = new Map<string, string[]>()
67
+ const builtinPatches = new Map<string, string>()
68
+ for (const [keys, raw] of Object.entries(d)) {
69
+ if (keys === '__metadata') continue
70
+ const e = record(raw) ?? {}
71
+ const resolution = typeof e['resolution'] === 'string' ? e['resolution'] : keys
72
+ // Every field of the entry but the dependencies it links, so a field
73
+ // this parser has not heard of moves what reaches the entry: reading
74
+ // the resolution and checksum alone dropped the root workspace's
75
+ // `dependenciesMeta` (`built`, `unplugged`), which governs the whole
76
+ // install (item 933).
77
+ const material = Object.fromEntries(
78
+ Object.entries(e).filter(([k]) => k !== 'dependencies' && k !== 'peerDependencies'),
79
+ )
80
+ entries.set(resolution, {
81
+ deps: depsOf(e, ['dependencies', 'peerDependencies']),
82
+ resolution: `${resolution}\0${stable(material)}`,
83
+ })
84
+ // Yarn joins a key's descriptors with ', '; a range's own trailing
85
+ // space is kept (forge's `p-limit: "npm:^3.1.0 "`), or its lookup
86
+ // missed and fell back to every entry of the name.
87
+ for (const k of keys.split(',')) {
88
+ const descriptor = k.trimStart()
89
+ descriptors.set(descriptor, resolution)
90
+ const builtin = /@patch:(.+)#optional!builtin<[^>]*>$/.exec(descriptor)
91
+ if (builtin !== null) {
92
+ let inner: string
93
+ try {
94
+ inner = decodeURIComponent(builtin[1]!)
95
+ } catch {
96
+ inner = builtin[1]!
97
+ }
98
+ builtinPatches.set(inner, resolution)
99
+ }
100
+ const name = descriptor.slice(0, descriptor.indexOf('@', 1))
101
+ const ids = names.get(name)
102
+ if (ids === undefined) names.set(name, [resolution])
103
+ else if (!ids.includes(resolution)) ids.push(resolution)
104
+ }
105
+ const ws = resolution.indexOf('@workspace:')
106
+ if (ws !== -1) workspaces.set(resolution.slice(ws + '@workspace:'.length), resolution)
107
+ }
108
+ const meta = record(d['__metadata']) ?? {}
109
+ return {
110
+ generation: 'berry',
111
+ entries,
112
+ descriptors,
113
+ workspaces,
114
+ names,
115
+ builtinPatches,
116
+ global: JSON.stringify({ version: meta['version'], cacheKey: meta['cacheKey'] }),
117
+ }
118
+ }
119
+
120
+ /** The classic format: an unquoted-YAML dialect with `key value` lines. */
121
+ function parseClassic(text: string): Lockfile {
122
+ const entries = new Map<string, Entry>()
123
+ const descriptors = new Map<string, string>()
124
+ let keys: string[] | null = null
125
+ let fields: Record<string, string> = {}
126
+ let deps = new Map<string, string>()
127
+ let inDeps = false
128
+ const flush = (): void => {
129
+ if (keys === null) return
130
+ const id = keys[0]!
131
+ // The descriptors an entry satisfies are part of what the file records:
132
+ // `foo@^1.0.0` moving from the 1.0.0 entry to the 1.1.0 one (a
133
+ // deduplication, `yarn upgrade`) changes what a workspace installs
134
+ // while every entry's version, url and integrity stay as they were, and
135
+ // the digest did not move (item 902). Classic has no workspace entries
136
+ // to carry that edge, so each entry carries its own descriptors.
137
+ entries.set(id, {
138
+ deps,
139
+ resolution: `${fields['version'] ?? ''}\0${fields['resolved'] ?? ''}\0${fields['integrity'] ?? ''}\0${[...keys].sort().join(',')}`,
140
+ })
141
+ for (const k of keys) descriptors.set(k, id)
142
+ keys = null
143
+ fields = {}
144
+ deps = new Map()
145
+ inDeps = false
146
+ }
147
+ for (const raw of text.split('\n')) {
148
+ const line = raw.replace(/\r$/, '')
149
+ if (line.length === 0 || line.startsWith('#')) continue
150
+ if (!line.startsWith(' ')) {
151
+ flush()
152
+ keys = line
153
+ .replace(/:$/, '')
154
+ .split(',')
155
+ .map((k) => unquote(k.trim()))
156
+ continue
157
+ }
158
+ if (keys === null) continue
159
+ const depth = line.length - line.trimStart().length
160
+ const body = line.trim()
161
+ if (depth === 2) {
162
+ inDeps = false
163
+ if (body === 'dependencies:' || body === 'optionalDependencies:') {
164
+ inDeps = true
165
+ continue
166
+ }
167
+ const sp = body.indexOf(' ')
168
+ if (sp !== -1) fields[body.slice(0, sp)] = unquote(body.slice(sp + 1).trim())
169
+ continue
170
+ }
171
+ if (depth >= 4 && inDeps) {
172
+ const sp = body.indexOf(' ')
173
+ if (sp !== -1) deps.set(unquote(body.slice(0, sp)), unquote(body.slice(sp + 1).trim()))
174
+ }
175
+ }
176
+ flush()
177
+ return {
178
+ generation: 'classic',
179
+ entries,
180
+ descriptors,
181
+ workspaces: new Map(),
182
+ names: new Map(),
183
+ builtinPatches: new Map(),
184
+ global: 'classic',
185
+ }
186
+ }
187
+
188
+ function unquote(s: string): string {
189
+ return s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s
190
+ }
191
+
192
+ function depsOf(e: Json, fields: readonly string[]): ReadonlyMap<string, string> {
193
+ const out = new Map<string, string>()
194
+ for (const field of fields) {
195
+ const deps = record(e[field])
196
+ if (deps === undefined) continue
197
+ for (const [name, spec] of Object.entries(deps)) out.set(name, String(spec))
198
+ }
199
+ return out
200
+ }
201
+
202
+ function record(v: unknown): Json | undefined {
203
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : undefined
204
+ }
205
+
206
+ /**
207
+ * `bar` + `npm:^2` → the entry keyed `bar@npm:^2`; classic: `bar@^2`. A
208
+ * bare range gets berry's `npm:` prefix. A `workspace:` range resolves to
209
+ * the workspace entry of that name whatever the range says (`*`, `^`, a
210
+ * path) — berry lists the range among the keys, but the name is enough.
211
+ *
212
+ * A Yarn 4 catalog range (`catalog:`, `catalog:<name>`) is recorded as
213
+ * that literal, and the range the catalog names lives in `.yarnrc.yml`,
214
+ * not here: the entry it resolved to is in the file, but nothing points
215
+ * at it. So it resolves to EVERY entry of that package — a bump of any
216
+ * of them moves the workspace, never a bump of none (turborepo#12635).
217
+ * Which one the catalog names is `.yarnrc.yml`'s to say, and core folds
218
+ * that file into every key.
219
+ */
220
+ function resolveDescriptor(
221
+ lock: Lockfile,
222
+ from: string,
223
+ name: string,
224
+ range: string,
225
+ ): readonly string[] {
226
+ // A descriptor Yarn compat-patches reaches the patched entry it installs
227
+ // as well as the plain one it keys (item 1074).
228
+ const withPatch = (descriptor: string, id: string): readonly string[] => {
229
+ const patched = lock.builtinPatches.get(descriptor)
230
+ return patched === undefined || patched === id ? [id] : [id, patched]
231
+ }
232
+ const direct = lock.descriptors.get(`${name}@${range}`)
233
+ if (direct !== undefined) return withPatch(`${name}@${range}`, direct)
234
+ if (lock.generation !== 'berry') return []
235
+ // A path range (`portal:`, `file:`, `link:`) is keyed bound to the
236
+ // package that names it, `::locator=<its locator>`; unbound it fell
237
+ // back to every entry of the name, another workspace's included.
238
+ const bound = lock.descriptors.get(`${name}@${range}::locator=${encodeURIComponent(from)}`)
239
+ if (bound !== undefined) return [bound]
240
+ if (range.startsWith('workspace:')) {
241
+ for (const id of lock.workspaces.values()) if (id.startsWith(`${name}@workspace:`)) return [id]
242
+ return []
243
+ }
244
+ if (range.startsWith('catalog:')) return lock.names.get(name) ?? []
245
+ if (!range.includes(':')) {
246
+ const bare = lock.descriptors.get(`${name}@npm:${range}`)
247
+ if (bare !== undefined) return withPatch(`${name}@npm:${range}`, bare)
248
+ }
249
+ // A descriptor the file does not key: a root `resolutions` override
250
+ // rewrote it (to a `patch:`, another range), so what is installed is an
251
+ // entry nothing in the file points at. Edges to nowhere left that entry
252
+ // outside every workspace's reach, and a patch edit re-keyed nothing
253
+ // (item 903). Every entry of the name, as for a catalog: a bump of any
254
+ // of them moves the workspace, never a bump of none.
255
+ return lock.names.get(name) ?? []
256
+ }
257
+
258
+ /**
259
+ * Every workspace's digest. Berry: one node per entry, workspaces among
260
+ * them, keyed by dir. Classic: the file has no workspace entries, so the
261
+ * one digest is the root's (`.`) over every entry the file resolves —
262
+ * coarse, and honest about what classic records.
263
+ */
264
+ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
265
+ const index = new Map<string, number>()
266
+ const material: string[] = []
267
+ const edges: number[][] = []
268
+ for (const [id, e] of lock.entries) {
269
+ index.set(id, material.length)
270
+ material.push(e.resolution)
271
+ edges.push([])
272
+ }
273
+ for (const [id, e] of lock.entries) {
274
+ const from = index.get(id)!
275
+ for (const [name, range] of e.deps) {
276
+ const targets = resolveDescriptor(lock, id, name, range)
277
+ for (const target of targets) edges[from]!.push(index.get(target)!)
278
+ if (targets.length === 0) material[from] += `\nunresolved\0${name}\0${range}`
279
+ }
280
+ }
281
+ const digests = reachDigests({ material, edges })
282
+ // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
283
+ // bits of a seed, so two lockfiles' globals could share one (item 682).
284
+ const global = Bun.hash.xxHash3(lock.global).toString(16).padStart(16, '0')
285
+ const out = new Map<string, string>()
286
+ const fold = (h: string) => Bun.hash.xxHash3(`${global}\0${h}`).toString(16).padStart(16, '0')
287
+ if (lock.generation === 'classic') {
288
+ const all = ['classic', global, ...[...digests].sort()].join('\n')
289
+ out.set('.', Bun.hash.xxHash3(all).toString(16).padStart(16, '0'))
290
+ return out
291
+ }
292
+ for (const [dir, id] of lock.workspaces) out.set(dir, fold(digests[index.get(id)!]!))
293
+ return out
294
+ }