@vzn/vx-lockfile 0.0.0 → 0.0.485
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 +21 -0
- package/README.md +52 -0
- package/index.ts +4 -0
- package/package.json +40 -6
- package/src/bun.ts +263 -0
- package/src/index.ts +159 -0
- package/src/npm.ts +166 -0
- package/src/pnpm.ts +292 -0
- package/src/yarn.ts +294 -0
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
package/package.json
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-lockfile",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Placeholder; real releases are published by CI.",
|
|
3
|
+
"version": "0.0.485",
|
|
5
4
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
"
|
|
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.485"
|
|
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
|
+
}
|