@vzn/vx-lockfile 0.0.640 → 0.0.642
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/README.md +9 -5
- package/package.json +2 -2
- package/src/bun.ts +25 -0
- package/src/index.ts +193 -53
- package/src/npm.ts +44 -4
- package/src/pnpm.ts +65 -7
- package/src/yarn.ts +69 -22
package/README.md
CHANGED
|
@@ -32,10 +32,10 @@ That is the whole setup. The plugin **claims** its lockfile and its manager's in
|
|
|
32
32
|
|
|
33
33
|
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:
|
|
34
34
|
|
|
35
|
-
- **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.
|
|
36
|
-
- **bun** (`bun.lock`; a `bun.lockb` alone is refused — it is binary — naming `bun install --save-text-lockfile`, which writes `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.
|
|
37
|
-
- **npm** (`npm-shrinkwrap.json` when present, as npm reads it, else `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.
|
|
38
|
-
- **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
|
|
35
|
+
- **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, or the CONTENT of a linked directory that is no importer, and a `file:` directory dependency folds its own closure (v9 keys it `name@file:…`, v5/v6 by the bare `file:…`) and the CONTENT of the directory (pnpm copies it at install and records only its path; its `node_modules` and `.git` aside), so an edit in it re-keys the projects reaching it; 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.
|
|
36
|
+
- **bun** (`bun.lock`; a `bun.lockb` alone is refused — it is binary — naming `bun install --save-text-lockfile`, which writes `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; a `file:` directory dependency with the CONTENT of the directory, as under pnpm; 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.
|
|
37
|
+
- **npm** (`npm-shrinkwrap.json` when present, as npm reads it, else `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, and one to a `file:` directory (an entry outside the root's `workspaces` globs) the CONTENT of the directory, as under pnpm, since npm links it; 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.
|
|
38
|
+
- **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, as when a root `resolutions` override rewrote it to another range: the entry installed in its place is reached through the package's name, so changing it 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; a `portal:` or `link:` to a directory that is no workspace also folds the CONTENT of the directory (Yarn symlinks it and records no hash; berry's `file:` carries its own `::hash=`). Every `patch:` entry is reached by the plain descriptor it patches: Yarn's builtin compat patches (`resolve`, `typescript`, `fsevents`) and a root `resolutions` patch over a descriptor the file still keys (what `yarn patch-commit -s` writes). The workspace asks for the plain descriptor, and the patched entry is the one Yarn installs, so adding or editing the patch re-keys it. `yarn()` claims `.yarnrc.yml` and folds it into every task, so a catalog edit re-keys every project, as a `pnpm-workspace.yaml` edit does under `pnpm()`. 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 — and no project edges (below); a `file:` directory, which classic copies with no hash, and a `link:` one, which it symlinks, fold their CONTENT into that digest.
|
|
39
39
|
|
|
40
40
|
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.
|
|
41
41
|
|
|
@@ -52,6 +52,8 @@ Files no lockfile records lay `node_modules` out anew under a byte-identical loc
|
|
|
52
52
|
| `npm()` | `.npmrc` |
|
|
53
53
|
| `yarn()` | `.yarnrc.yml` |
|
|
54
54
|
|
|
55
|
+
`pnpm()` folds `pnpm-workspace.yaml` without `patchedDependencies` and `overrides`, which pnpm 10+ keeps there: the lockfile records what each one did, per project, so a patch or an override re-keys only the projects that reach the package it changes, and `--affected` selects them through the lockfile. Any other setting in the file re-keys every project, and a file that does not parse folds whole.
|
|
56
|
+
|
|
55
57
|
A file has one claimant, so two of these plugins that share a settings file (`pnpm()` and `bun()` both claim `.npmrc`) cannot be declared together.
|
|
56
58
|
|
|
57
59
|
## `package.json` as an input
|
|
@@ -66,7 +68,9 @@ Every task with `inputs` of a project the lockfile lists, cached or not, gets it
|
|
|
66
68
|
- **peers**: a peer with no installed entry of the same name that resolves to a workspace package orders the build too, unless it would close a cycle through the order edges; then it is reach only (in project-name order, so the edge kept is stable). pnpm records an auto-installed peer under `dependencies`, so `pnpm()` asks the project's `package.json` which of those entries are peers; yarn berry resolves a peer to the workspace of its name, which Yarn hoists.
|
|
67
69
|
- Each importer with a `package.json` is a project: one with no vx config joins as a project a plugin added (its tasks come from the `project` stage, else the default `build`). The root importer is the exception: it is a project only by its own vx config. An importer with no `package.json`, and an edge to one, is skipped. A project is named by its `package.json` `name` unless its config names it, or another importer's `package.json` shares the name; then its path names it.
|
|
68
70
|
- A yarn classic (v1) lockfile records no workspaces, so `yarn()` reads them as Yarn 1 does: the directories the root `package.json` `workspaces` globs (an array or `{ packages }`) name. A dependency on a workspace's name is a link unless the lockfile keys its descriptor, which Yarn 1 writes only for the registry copy it fetched because the workspace's version missed the range.
|
|
69
|
-
- A workspace the root `package.json` `workspaces`
|
|
71
|
+
- A lockfile older than the manifests **refuses `vx lock`**, naming the install that refreshes it: a workspace the globs name (the root `package.json` `workspaces`, or `pnpm-workspace.yaml` under `pnpm()`, `!` exclusions honoured) that the file lists no importer for, such as a renamed folder, or a `dependencies`, `devDependencies` or `optionalDependencies` entry naming a workspace package that the file does not record for that importer. Either would lose edges: the package would build beside what it depends on and hit with old content. Yarn classic is exempt: its edges come from the manifests.
|
|
72
|
+
|
|
73
|
+
A workspace `package.json` that does not parse (git merge conflict markers, a syntax error, or JSON that is not an object) **refuses `vx lock`**, naming it, under every manager: read as no manifest, it lost its dependencies and its dependents hit with old bytes. Turbo and Nx refuse it too.
|
|
70
74
|
|
|
71
75
|
These edges are what was installed, so they differ from a guess over manifest ranges where the manager decided otherwise: pnpm installs a plain range (`^2.0.0`, `*`, `catalog:`) from the registry unless it is `workspace:`, yarn packs a `file:` directory as a copy, bun installs `dependencies` over a devDependency of the same key. They are the only package edges vx has: core reads none from a `package.json`, so a workspace whose tasks use `^build` declares one of these plugins (or another that returns `deps`), and without one vx refuses the `^` naming the fix. A dependency an install adds or removes reaches `--affected` as this lockfile's change.
|
|
72
76
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-lockfile",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.642",
|
|
4
4
|
"license": "MIT",
|
|
5
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
6
|
"keywords": [
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@vzn/vx": "^0.0.
|
|
38
|
+
"@vzn/vx": "^0.0.642"
|
|
39
39
|
},
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
package/src/bun.ts
CHANGED
|
@@ -231,6 +231,27 @@ export function patchFiles(text: string): string[] {
|
|
|
231
231
|
)
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The root-relative paths of `file:` dependencies (`dep@file:vendor/dep`).
|
|
236
|
+
* Bun copies the directory at install and records only its path, so an
|
|
237
|
+
* edit in it moved no key (Growth, file-dir-dep-stale.sh): the claim hashes
|
|
238
|
+
* its content like a patch's.
|
|
239
|
+
*/
|
|
240
|
+
export function fileDeps(lock: Lockfile): string[] {
|
|
241
|
+
const out = new Set<string>()
|
|
242
|
+
for (const e of lock.packages.values()) {
|
|
243
|
+
const dir = fileDir(e.id)
|
|
244
|
+
if (dir !== undefined) out.add(dir)
|
|
245
|
+
}
|
|
246
|
+
return [...out].sort()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `dep@file:vendor/dep` → `vendor/dep`. */
|
|
250
|
+
function fileDir(id: string): string | undefined {
|
|
251
|
+
const at = id.indexOf('@file:', 1)
|
|
252
|
+
return at === -1 ? undefined : id.slice(at + '@file:'.length)
|
|
253
|
+
}
|
|
254
|
+
|
|
234
255
|
/**
|
|
235
256
|
* Every workspace package's digest (by dir): the lockfile as one graph —
|
|
236
257
|
* a node per node_modules path, its material the id + resolution, an edge
|
|
@@ -260,6 +281,10 @@ export function importerDigests(
|
|
|
260
281
|
// every project reaching it with the same bytes installed (D-140). Where
|
|
261
282
|
// it sits still decides what it resolves; that is the edges.
|
|
262
283
|
for (const [p, e] of lock.packages) node(p, `${installName(p)}\0${e.id}\0${e.resolution}`)
|
|
284
|
+
for (const [p, e] of lock.packages) {
|
|
285
|
+
const dir = fileDir(e.id)
|
|
286
|
+
if (dir !== undefined) material[index.get(p)!] += `\nfile\0${files.get(dir) ?? ''}`
|
|
287
|
+
}
|
|
263
288
|
// A patch is part of the package it patches: an edit to one only `b`
|
|
264
289
|
// reaches re-keyed every workspace while it was install-wide (D-143).
|
|
265
290
|
// One that names no entry stays install-wide, so it still moves a key.
|
package/src/index.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface LockfileOptions {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** Bumps when a digest folds differently or the memo holds more (its identity). */
|
|
57
|
-
const DIGEST_VERSION =
|
|
57
|
+
const DIGEST_VERSION = 18
|
|
58
58
|
|
|
59
59
|
interface Manager {
|
|
60
60
|
readonly name: string
|
|
@@ -64,9 +64,16 @@ interface Manager {
|
|
|
64
64
|
readonly unreadable?: { readonly file: string; readonly fix: string }
|
|
65
65
|
readonly digest: (text: string, files: ReadonlyMap<string, string>) => ReadonlyMap<string, string>
|
|
66
66
|
readonly importers: (text: string) => ReadonlyMap<string, LockfileImporter>
|
|
67
|
+
/** Each importer's dependency names the file records, whatever they resolve to. */
|
|
68
|
+
readonly recorded: (text: string) => ReadonlyMap<string, ReadonlyMap<string, unknown>>
|
|
67
69
|
readonly extraFiles?: (text: string) => readonly string[]
|
|
68
70
|
/** Root files no lockfile records that lay the install out: folded whole into every task. */
|
|
69
71
|
readonly settings: readonly string[]
|
|
72
|
+
/**
|
|
73
|
+
* A settings file's bytes as the key folds them, where the lockfile
|
|
74
|
+
* records part of what it says per project; undefined folds them whole.
|
|
75
|
+
*/
|
|
76
|
+
readonly settingsText?: (file: string, text: string) => string | undefined
|
|
70
77
|
/** The lockfile files a peer under `dependencies`: the manifest says which entries are peers. */
|
|
71
78
|
readonly peersInManifest?: true
|
|
72
79
|
/**
|
|
@@ -102,9 +109,13 @@ const MANAGERS = {
|
|
|
102
109
|
pnpm: {
|
|
103
110
|
name: 'pnpm',
|
|
104
111
|
files: ['pnpm-lock.yaml'],
|
|
105
|
-
digest: (t) => pnpmLock().importerDigests(pnpmParse(t)),
|
|
112
|
+
digest: (t, files) => pnpmLock().importerDigests(pnpmParse(t), files),
|
|
106
113
|
importers: (t) => pnpmLock().importerLinks(pnpmParse(t)),
|
|
114
|
+
extraFiles: (t) => pnpmLock().fileDeps(pnpmParse(t)),
|
|
115
|
+
recorded: (t) => pnpmParse(t).importers,
|
|
107
116
|
settings: ['pnpm-workspace.yaml', '.npmrc'],
|
|
117
|
+
settingsText: (file, text) =>
|
|
118
|
+
file === 'pnpm-workspace.yaml' ? pnpmLock().workspaceSettings(text) : undefined,
|
|
108
119
|
peersInManifest: true,
|
|
109
120
|
},
|
|
110
121
|
bun: {
|
|
@@ -118,22 +129,38 @@ const MANAGERS = {
|
|
|
118
129
|
},
|
|
119
130
|
digest: (t, files) => bunLock().importerDigests(bunParse(t), files),
|
|
120
131
|
importers: (t) => bunLock().importerLinks(bunParse(t)),
|
|
121
|
-
|
|
132
|
+
recorded: (t) => bunParse(t).workspaces,
|
|
133
|
+
extraFiles: (t) => [...bunLock().patchFiles(t), ...bunLock().fileDeps(bunParse(t))],
|
|
122
134
|
settings: ['bunfig.toml', '.npmrc'],
|
|
123
135
|
},
|
|
124
136
|
npm: {
|
|
125
137
|
name: 'npm',
|
|
126
138
|
// npm reads npm-shrinkwrap.json over package-lock.json when both exist.
|
|
127
139
|
files: ['npm-shrinkwrap.json', 'package-lock.json'],
|
|
128
|
-
digest: (t) => npmLock().importerDigests(npmParse(t)),
|
|
140
|
+
digest: (t, files) => npmLock().importerDigests(npmParse(t), files),
|
|
129
141
|
importers: (t) => npmLock().importerLinks(npmParse(t)),
|
|
142
|
+
extraFiles: (t) => npmParse(t).fileDirs,
|
|
143
|
+
recorded: (t) => {
|
|
144
|
+
const out = new Map<string, ReadonlyMap<string, unknown>>()
|
|
145
|
+
for (const [p, e] of npmParse(t).packages) {
|
|
146
|
+
if (p === '' || e.isWorkspace) out.set(p === '' ? '.' : p, e.deps)
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
},
|
|
130
150
|
settings: ['.npmrc'],
|
|
131
151
|
},
|
|
132
152
|
yarn: {
|
|
133
153
|
name: 'yarn',
|
|
134
154
|
files: ['yarn.lock'],
|
|
135
|
-
digest: (t) => yarnLock().importerDigests(yarnParse(t)),
|
|
155
|
+
digest: (t, files) => yarnLock().importerDigests(yarnParse(t), files),
|
|
136
156
|
importers: (t) => yarnLock().importerLinks(yarnParse(t)),
|
|
157
|
+
extraFiles: (t) => yarnLock().fileDeps(yarnParse(t)),
|
|
158
|
+
recorded: (t) => {
|
|
159
|
+
const lock = yarnParse(t)
|
|
160
|
+
const out = new Map<string, ReadonlyMap<string, unknown>>()
|
|
161
|
+
for (const [dir, id] of lock.workspaces) out.set(dir, lock.entries.get(id)!.deps)
|
|
162
|
+
return out
|
|
163
|
+
},
|
|
137
164
|
settings: ['.yarnrc.yml'],
|
|
138
165
|
async manifestImporters(t, root, manifest) {
|
|
139
166
|
const lock = yarnParse(t)
|
|
@@ -141,7 +168,7 @@ const MANAGERS = {
|
|
|
141
168
|
const pkg = await manifest(root)
|
|
142
169
|
const manifests = new Map<string, Record<string, unknown>>()
|
|
143
170
|
if (pkg !== null) manifests.set('.', pkg)
|
|
144
|
-
for (const dir of await workspaceDirs(root, pkg)) {
|
|
171
|
+
for (const dir of await workspaceDirs(root, globsOf(pkg))) {
|
|
145
172
|
const m = await manifest(path.join(root, dir))
|
|
146
173
|
if (m !== null) manifests.set(dir, m)
|
|
147
174
|
}
|
|
@@ -215,20 +242,29 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
215
242
|
`${unreadable.file}: ${manager.name}() reads ${manager.files.join(' or ')} and finds none — ${unreadable.fix}`,
|
|
216
243
|
)
|
|
217
244
|
}
|
|
218
|
-
|
|
245
|
+
/** The importers, and the lockfile they came from unless the manifests named them. */
|
|
246
|
+
const readImporters = async (
|
|
219
247
|
ctx: { readonly workspaceRoot: string; readonly cacheDir: string },
|
|
220
248
|
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
221
|
-
): Promise<
|
|
249
|
+
): Promise<
|
|
250
|
+
{ importers: ReadonlyMap<string, LockfileImporter>; file: string | undefined } | undefined
|
|
251
|
+
> => {
|
|
222
252
|
for (const { file, claim } of claims) {
|
|
223
253
|
const importers = await claim.importers(ctx)
|
|
224
254
|
if (importers === undefined) continue
|
|
225
|
-
if (importers.size > 0 || manager.manifestImporters === undefined) return importers
|
|
255
|
+
if (importers.size > 0 || manager.manifestImporters === undefined) return { importers, file }
|
|
226
256
|
const text = await Bun.file(path.join(ctx.workspaceRoot, file)).text()
|
|
227
|
-
|
|
257
|
+
const named = await manager.manifestImporters(text, ctx.workspaceRoot, manifest)
|
|
258
|
+
return named === undefined ? { importers, file } : { importers: named, file: undefined }
|
|
228
259
|
}
|
|
229
260
|
await refuseUnreadable(ctx.workspaceRoot)
|
|
230
261
|
return undefined
|
|
231
262
|
}
|
|
263
|
+
const importersOf = async (
|
|
264
|
+
ctx: { readonly workspaceRoot: string; readonly cacheDir: string },
|
|
265
|
+
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
266
|
+
): Promise<ReadonlyMap<string, LockfileImporter> | undefined> =>
|
|
267
|
+
(await readImporters(ctx, manifest))?.importers
|
|
232
268
|
// Per run: core hands every `project` visit of one run the same array.
|
|
233
269
|
const perRun = new WeakMap<
|
|
234
270
|
readonly ProjectMeta[],
|
|
@@ -239,14 +275,26 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
239
275
|
return definePlugin(import.meta, {
|
|
240
276
|
fingerprint: {
|
|
241
277
|
files: [...manager.files, ...manager.settings],
|
|
242
|
-
// A settings file lays out every project's install: every project
|
|
243
|
-
|
|
244
|
-
|
|
278
|
+
// A settings file lays out every project's install: every project,
|
|
279
|
+
// unless all that moved is what the lockfile records per project.
|
|
280
|
+
affected: (change, ctx) => {
|
|
281
|
+
const claim = claims.find((c) => c.file === change.file)?.claim
|
|
282
|
+
if (claim !== undefined) return claim.fingerprint.affected(change, ctx)
|
|
283
|
+
// An added or removed file moves every install.
|
|
284
|
+
const folded = (bytes: Uint8Array | null): string | undefined =>
|
|
285
|
+
bytes === null
|
|
286
|
+
? undefined
|
|
287
|
+
: manager.settingsText?.(change.file, new TextDecoder().decode(bytes))
|
|
288
|
+
const before = folded(change.before)
|
|
289
|
+
return before !== undefined && before === folded(change.after) ? [] : undefined
|
|
290
|
+
},
|
|
291
|
+
extraFiles: (file, bytes) =>
|
|
292
|
+
claims.find((c) => c.file === file)?.claim.fingerprint.extraFiles?.(file, bytes) ?? [],
|
|
245
293
|
},
|
|
246
294
|
async key(task, ctx) {
|
|
247
295
|
let settings = settingsPerRun.get(ctx)
|
|
248
296
|
if (settings === undefined) {
|
|
249
|
-
settings = readSettings(ctx.workspaceRoot, manager.settings)
|
|
297
|
+
settings = readSettings(ctx.workspaceRoot, manager.settings, manager.settingsText)
|
|
250
298
|
settingsPerRun.set(ctx, settings)
|
|
251
299
|
}
|
|
252
300
|
let deps: Readonly<Record<string, string>> | undefined
|
|
@@ -268,26 +316,32 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
268
316
|
: { ...deps, ...folded }
|
|
269
317
|
},
|
|
270
318
|
async discover(ctx) {
|
|
271
|
-
const manifest = manifestReader()
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
319
|
+
const manifest = manifestReader(ctx.workspaceRoot)
|
|
320
|
+
const read = await readImporters(ctx, manifest)
|
|
321
|
+
if (read === undefined) return []
|
|
322
|
+
const { importers, file } = read
|
|
323
|
+
// A lockfile older than the manifests drops edges: a workspace it does
|
|
324
|
+
// not list yet (a renamed folder, lerna's 31 added Nx projects) is
|
|
325
|
+
// named by its path with none, and a dependency added to package.json
|
|
326
|
+
// before the install orders nothing. Either ran a package beside what
|
|
327
|
+
// it builds on and hit with old content (Growth, 2026-10-16), so the
|
|
328
|
+
// lock refuses until the install, as a key from a stale lockfile names
|
|
329
|
+
// an install that is not on disk.
|
|
330
|
+
if (file !== undefined) {
|
|
331
|
+
const text = await Bun.file(path.join(ctx.workspaceRoot, file)).text()
|
|
332
|
+
const missing = await staleness(
|
|
333
|
+
ctx.workspaceRoot,
|
|
334
|
+
importers,
|
|
335
|
+
manager.recorded(text),
|
|
336
|
+
await workspaceDirs(
|
|
337
|
+
ctx.workspaceRoot,
|
|
338
|
+
await workspaceGlobs(ctx.workspaceRoot, manager.name, manifest),
|
|
339
|
+
),
|
|
340
|
+
manifest,
|
|
341
|
+
)
|
|
282
342
|
if (missing.length > 0) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (await Bun.file(path.join(ctx.workspaceRoot, f)).exists()) {
|
|
286
|
-
file = f
|
|
287
|
-
break
|
|
288
|
-
}
|
|
289
|
-
ctx.warn(
|
|
290
|
-
`${file} does not list the workspace${missing.length > 1 ? 's' : ''} ${listed(missing)} that package.json names — run \`${manager.name} install\` to add ${missing.length > 1 ? 'them; until then each is' : 'it; until then it is'} named by its path, and a dependsOn naming it finds no project`,
|
|
343
|
+
throw new UserError(
|
|
344
|
+
`${file} is out of date: it does not list ${listed(missing)} — run \`${manager.name} install\``,
|
|
291
345
|
)
|
|
292
346
|
}
|
|
293
347
|
}
|
|
@@ -303,7 +357,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
303
357
|
async project(config, ctx) {
|
|
304
358
|
let importers = perRun.get(ctx.projects)
|
|
305
359
|
if (importers === undefined) {
|
|
306
|
-
importers = importersOf(ctx, manifestReader())
|
|
360
|
+
importers = importersOf(ctx, manifestReader(ctx.workspaceRoot))
|
|
307
361
|
perRun.set(ctx.projects, importers)
|
|
308
362
|
}
|
|
309
363
|
if ((await importers)?.has(importerOf(ctx.workspaceRoot, ctx.dir)) !== true) return
|
|
@@ -332,6 +386,7 @@ function listed(names: readonly string[]): string {
|
|
|
332
386
|
async function readSettings(
|
|
333
387
|
workspaceRoot: string,
|
|
334
388
|
files: readonly string[],
|
|
389
|
+
settingsText?: (file: string, text: string) => string | undefined,
|
|
335
390
|
): Promise<Record<string, string>> {
|
|
336
391
|
const out: Record<string, string> = {}
|
|
337
392
|
for (const file of files) {
|
|
@@ -341,7 +396,11 @@ async function readSettings(
|
|
|
341
396
|
} catch {
|
|
342
397
|
continue
|
|
343
398
|
}
|
|
344
|
-
|
|
399
|
+
const text = settingsText?.(file, new TextDecoder().decode(bytes))
|
|
400
|
+
out[file] = Bun.hash
|
|
401
|
+
.xxHash3(text ?? bytes)
|
|
402
|
+
.toString(16)
|
|
403
|
+
.padStart(16, '0')
|
|
345
404
|
}
|
|
346
405
|
return out
|
|
347
406
|
}
|
|
@@ -351,56 +410,137 @@ function importerOf(workspaceRoot: string, dir: string): string {
|
|
|
351
410
|
return path.relative(workspaceRoot, dir).split(path.sep).join('/') || '.'
|
|
352
411
|
}
|
|
353
412
|
|
|
354
|
-
/**
|
|
355
|
-
|
|
413
|
+
/**
|
|
414
|
+
* An importer's `package.json`, or null where it has none. One that does
|
|
415
|
+
* not parse refuses, named: read as none, a conflicted manifest lost its
|
|
416
|
+
* dependencies and its dependents hit with old bytes (Turbo and Nx refuse).
|
|
417
|
+
*/
|
|
418
|
+
async function manifestOf(root: string, dir: string): Promise<Record<string, unknown> | null> {
|
|
419
|
+
const file = path.join(dir, 'package.json')
|
|
420
|
+
let text: string
|
|
356
421
|
try {
|
|
357
|
-
|
|
358
|
-
return pkg !== null && typeof pkg === 'object' && !Array.isArray(pkg)
|
|
359
|
-
? (pkg as Record<string, unknown>)
|
|
360
|
-
: null
|
|
422
|
+
text = await Bun.file(file).text()
|
|
361
423
|
} catch {
|
|
362
424
|
return null
|
|
363
425
|
}
|
|
426
|
+
const name = path.relative(root, file).split(path.sep).join('/')
|
|
427
|
+
if (CONFLICT.test(text))
|
|
428
|
+
throw new UserError(`${name}: holds git merge conflict markers — resolve them`)
|
|
429
|
+
let pkg: unknown
|
|
430
|
+
try {
|
|
431
|
+
pkg = JSON.parse(text)
|
|
432
|
+
} catch (err) {
|
|
433
|
+
throw new UserError(`${name}: not JSON (${err instanceof Error ? err.message : String(err)})`)
|
|
434
|
+
}
|
|
435
|
+
if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
|
|
436
|
+
throw new UserError(`${name}: not a JSON object`)
|
|
437
|
+
}
|
|
438
|
+
return pkg as Record<string, unknown>
|
|
364
439
|
}
|
|
365
440
|
|
|
366
441
|
/** `manifestOf`, each directory read once. */
|
|
367
|
-
function manifestReader(): (dir: string) => Promise<Record<string, unknown> | null> {
|
|
442
|
+
function manifestReader(root: string): (dir: string) => Promise<Record<string, unknown> | null> {
|
|
368
443
|
const read = new Map<string, Promise<Record<string, unknown> | null>>()
|
|
369
444
|
return (dir) => {
|
|
370
445
|
let pkg = read.get(dir)
|
|
371
446
|
if (pkg === undefined) {
|
|
372
|
-
pkg = manifestOf(dir)
|
|
447
|
+
pkg = manifestOf(root, dir)
|
|
373
448
|
read.set(dir, pkg)
|
|
374
449
|
}
|
|
375
450
|
return pkg
|
|
376
451
|
}
|
|
377
452
|
}
|
|
378
453
|
|
|
379
|
-
/**
|
|
380
|
-
|
|
381
|
-
* Yarn 1's `{ packages }`) name that hold a `package.json`, root-relative
|
|
382
|
-
* POSIX, sorted; none under `node_modules`.
|
|
383
|
-
*/
|
|
384
|
-
async function workspaceDirs(root: string, pkg: Record<string, unknown> | null): Promise<string[]> {
|
|
454
|
+
/** A root manifest's `workspaces` globs: an array, or Yarn 1's `{ packages }`. */
|
|
455
|
+
function globsOf(pkg: Record<string, unknown> | null): readonly unknown[] {
|
|
385
456
|
const field = pkg?.['workspaces']
|
|
386
457
|
const list = Array.isArray(field)
|
|
387
458
|
? field
|
|
388
459
|
: field !== null && typeof field === 'object'
|
|
389
460
|
? (field as Record<string, unknown>)['packages']
|
|
390
461
|
: undefined
|
|
391
|
-
|
|
462
|
+
return Array.isArray(list) ? list : []
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The workspace globs the manager reads: pnpm's from pnpm-workspace.yaml, the others' from package.json. */
|
|
466
|
+
async function workspaceGlobs(
|
|
467
|
+
root: string,
|
|
468
|
+
manager: string,
|
|
469
|
+
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
470
|
+
): Promise<readonly unknown[]> {
|
|
471
|
+
if (manager !== 'pnpm') return globsOf(await manifest(root))
|
|
472
|
+
let doc: unknown
|
|
473
|
+
try {
|
|
474
|
+
doc = Bun.YAML.parse(await Bun.file(path.join(root, 'pnpm-workspace.yaml')).text())
|
|
475
|
+
} catch {
|
|
476
|
+
return []
|
|
477
|
+
}
|
|
478
|
+
const list =
|
|
479
|
+
doc !== null && typeof doc === 'object'
|
|
480
|
+
? (doc as Record<string, unknown>)['packages']
|
|
481
|
+
: undefined
|
|
482
|
+
return Array.isArray(list) ? list : []
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The directories the globs name that hold a `package.json`, root-relative
|
|
487
|
+
* POSIX, sorted; none a `!` glob excludes, none under `node_modules`.
|
|
488
|
+
*/
|
|
489
|
+
async function workspaceDirs(root: string, globs: readonly unknown[]): Promise<string[]> {
|
|
490
|
+
const strip = (p: string): string => p.replace(/^\.\//, '').replace(/\/+$/, '')
|
|
491
|
+
const excluded = globs
|
|
492
|
+
.filter((p): p is string => typeof p === 'string' && p.startsWith('!'))
|
|
493
|
+
.map((p) => new Bun.Glob(strip(p.slice(1))))
|
|
392
494
|
const dirs = new Set<string>()
|
|
393
|
-
for (const pattern of
|
|
495
|
+
for (const pattern of globs) {
|
|
394
496
|
if (typeof pattern !== 'string' || pattern.startsWith('!')) continue
|
|
395
|
-
const glob = new Bun.Glob(`${pattern
|
|
497
|
+
const glob = new Bun.Glob(`${strip(pattern)}/package.json`)
|
|
396
498
|
for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
|
|
397
499
|
const dir = path.dirname(file).split(path.sep).join('/')
|
|
398
|
-
if (dir
|
|
500
|
+
if (dir === '.' || dir.split('/').includes('node_modules')) continue
|
|
501
|
+
if (!excluded.some((g) => g.match(dir))) dirs.add(dir)
|
|
399
502
|
}
|
|
400
503
|
}
|
|
401
504
|
return [...dirs].sort()
|
|
402
505
|
}
|
|
403
506
|
|
|
507
|
+
/**
|
|
508
|
+
* What the manifests name that the lockfile does not: each workspace it
|
|
509
|
+
* lists no importer for, and each importer's dependency (any field but
|
|
510
|
+
* peers, which a manager may leave out) on a workspace package's name that
|
|
511
|
+
* it records no entry for.
|
|
512
|
+
*/
|
|
513
|
+
async function staleness(
|
|
514
|
+
root: string,
|
|
515
|
+
importers: ReadonlyMap<string, LockfileImporter>,
|
|
516
|
+
recorded: ReadonlyMap<string, ReadonlyMap<string, unknown>>,
|
|
517
|
+
dirs: readonly string[],
|
|
518
|
+
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
519
|
+
): Promise<string[]> {
|
|
520
|
+
const dirOf = (importer: string): string => (importer === '.' ? root : path.join(root, importer))
|
|
521
|
+
const missing = dirs.filter((d) => !importers.has(d)).map((d) => `the workspace ${d}`)
|
|
522
|
+
const names = new Set<string>()
|
|
523
|
+
for (const d of new Set([...importers.keys(), ...dirs])) {
|
|
524
|
+
const n = (await manifest(dirOf(d)))?.['name']
|
|
525
|
+
if (typeof n === 'string') names.add(n)
|
|
526
|
+
}
|
|
527
|
+
for (const [importer, deps] of recorded) {
|
|
528
|
+
const pkg = await manifest(dirOf(importer))
|
|
529
|
+
if (pkg === null) continue
|
|
530
|
+
const own = pkg['name']
|
|
531
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
532
|
+
const declared = pkg[field]
|
|
533
|
+
if (declared === null || typeof declared !== 'object') continue
|
|
534
|
+
for (const name of Object.keys(declared)) {
|
|
535
|
+
if (name !== own && names.has(name) && !deps.has(name)) {
|
|
536
|
+
missing.push(`${importer}'s dependency on ${name}`)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return [...new Set(missing)]
|
|
542
|
+
}
|
|
543
|
+
|
|
404
544
|
/**
|
|
405
545
|
* One entry per importer with a `package.json` (core reads none): a
|
|
406
546
|
* directory that is no project yet becomes one, the root aside, which is
|
package/src/npm.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface Lockfile {
|
|
|
16
16
|
readonly packages: ReadonlyMap<string, Entry>
|
|
17
17
|
/** Material every workspace folds. */
|
|
18
18
|
readonly global: string
|
|
19
|
+
/** Directories `file:` dependencies link to: not the root, not a workspace. */
|
|
20
|
+
readonly fileDirs: readonly string[]
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface Entry {
|
|
@@ -46,8 +48,10 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
46
48
|
`package-lock.json: lockfileVersion ${version} has no \`packages\` map — npm 7+ writes version 2 or 3`,
|
|
47
49
|
)
|
|
48
50
|
}
|
|
51
|
+
const raws = record(d!['packages']) ?? {}
|
|
52
|
+
const globs = workspaceGlobs(raws[''])
|
|
49
53
|
const packages = new Map<string, Entry>()
|
|
50
|
-
for (const [p, raw] of Object.entries(
|
|
54
|
+
for (const [p, raw] of Object.entries(raws)) {
|
|
51
55
|
const e = record(raw) ?? {}
|
|
52
56
|
const link = e['link'] === true && typeof e['resolved'] === 'string' ? e['resolved'] : undefined
|
|
53
57
|
packages.set(p, {
|
|
@@ -59,14 +63,39 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
59
63
|
.join('\0')
|
|
60
64
|
: '',
|
|
61
65
|
link,
|
|
62
|
-
isWorkspace:
|
|
66
|
+
isWorkspace:
|
|
67
|
+
p !== '' &&
|
|
68
|
+
!p.startsWith('node_modules/') &&
|
|
69
|
+
!p.includes('/node_modules/') &&
|
|
70
|
+
(globs === undefined || globs.some((g) => g.match(p))),
|
|
63
71
|
peers: peerOnly(e),
|
|
64
72
|
})
|
|
65
73
|
}
|
|
66
74
|
// Root `overrides` are not folded: what an override forced is the entry a
|
|
67
75
|
// workspace reaches, and npm 10 does not write them here at all (D-142).
|
|
68
76
|
const global = JSON.stringify({ lockfileVersion: version })
|
|
69
|
-
|
|
77
|
+
const fileDirs = new Set<string>()
|
|
78
|
+
for (const e of packages.values()) {
|
|
79
|
+
if (e.link !== undefined && e.link !== '' && packages.get(e.link)?.isWorkspace !== true) {
|
|
80
|
+
fileDirs.add(e.link)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { version, packages, global, fileDirs: [...fileDirs].sort() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The root's `workspaces` globs: what tells a workspace from a `file:`
|
|
88
|
+
* directory, both entries outside node_modules that a link resolves to.
|
|
89
|
+
* Taking every such entry for a workspace made `vendor/dep` a project, so
|
|
90
|
+
* an edit under it selected nothing. `undefined` when the root names none.
|
|
91
|
+
*/
|
|
92
|
+
function workspaceGlobs(root: unknown): Bun.Glob[] | undefined {
|
|
93
|
+
const ws = record(root)?.['workspaces']
|
|
94
|
+
const list = Array.isArray(ws) ? ws : record(ws)?.['packages']
|
|
95
|
+
if (!Array.isArray(list)) return undefined
|
|
96
|
+
return list
|
|
97
|
+
.filter((g): g is string => typeof g === 'string')
|
|
98
|
+
.map((g) => new Bun.Glob(g.replace(/^\.\//, '').replace(/\/+$/, '')))
|
|
70
99
|
}
|
|
71
100
|
|
|
72
101
|
/**
|
|
@@ -96,7 +125,10 @@ function resolve(lock: Lockfile, from: string, name: string): string | undefined
|
|
|
96
125
|
* an edge per resolved dependency, a link's edge to its target — folded
|
|
97
126
|
* by core's `reachDigests`.
|
|
98
127
|
*/
|
|
99
|
-
export function importerDigests(
|
|
128
|
+
export function importerDigests(
|
|
129
|
+
lock: Lockfile,
|
|
130
|
+
files: ReadonlyMap<string, string> = new Map(),
|
|
131
|
+
): ReadonlyMap<string, string> {
|
|
100
132
|
const index = new Map<string, number>()
|
|
101
133
|
const material: string[] = []
|
|
102
134
|
const edges: number[][] = []
|
|
@@ -109,6 +141,14 @@ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
|
|
|
109
141
|
material.push(`${p === '' || e.isWorkspace ? p : installName(p)}\0${e.resolution}`)
|
|
110
142
|
edges.push([])
|
|
111
143
|
}
|
|
144
|
+
// npm links a `file:` directory, so what a task reads is its content now,
|
|
145
|
+
// which the lockfile does not record: an edit to it replayed a stale hit.
|
|
146
|
+
for (const dir of lock.fileDirs) {
|
|
147
|
+
const own = `\nfile\0${files.get(dir) ?? ''}`
|
|
148
|
+
const i = index.get(dir)
|
|
149
|
+
if (i !== undefined) material[i] += own
|
|
150
|
+
else for (const [p, e] of lock.packages) if (e.link === dir) material[index.get(p)!] += own
|
|
151
|
+
}
|
|
112
152
|
for (const [p, e] of lock.packages) {
|
|
113
153
|
const from = index.get(p)!
|
|
114
154
|
if (e.link !== undefined) {
|
package/src/pnpm.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface Lockfile {
|
|
|
25
25
|
readonly resolutions: ReadonlyMap<string, string>
|
|
26
26
|
/** `name` or `name@version` → patch hash */
|
|
27
27
|
readonly patches: ReadonlyMap<string, string>
|
|
28
|
+
/** package key → the root-relative directory a `file:` dependency installs from */
|
|
29
|
+
readonly directories: ReadonlyMap<string, string>
|
|
28
30
|
/** Material every importer folds: the lockfile version and the install-wide knobs. */
|
|
29
31
|
readonly global: string
|
|
30
32
|
}
|
|
@@ -79,10 +81,15 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
79
81
|
|
|
80
82
|
const snapshots = new Map<string, ReadonlyMap<string, string>>()
|
|
81
83
|
const resolutions = new Map<string, string>()
|
|
84
|
+
const directories = new Map<string, string>()
|
|
82
85
|
const packages = record(doc['packages']) ?? {}
|
|
83
86
|
for (const [key, entry] of Object.entries(packages)) {
|
|
84
87
|
const e = record(entry) ?? {}
|
|
85
88
|
resolutions.set(key, resolutionOf(e))
|
|
89
|
+
const r = record(e['resolution'])
|
|
90
|
+
if (r?.['type'] === 'directory' && typeof r['directory'] === 'string') {
|
|
91
|
+
directories.set(key, r['directory'])
|
|
92
|
+
}
|
|
86
93
|
if (major < 9) snapshots.set(key, depsOf(e))
|
|
87
94
|
}
|
|
88
95
|
if (major >= 9) {
|
|
@@ -112,7 +119,7 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
112
119
|
? { lockfileVersion: version, rest }
|
|
113
120
|
: { lockfileVersion: version, rest, leading },
|
|
114
121
|
)
|
|
115
|
-
return { version, importers, snapshots, resolutions, patches, global }
|
|
122
|
+
return { version, importers, snapshots, resolutions, patches, directories, global }
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
/** `dependencies` + `devDependencies` + `optionalDependencies` of an entry, as name → version. */
|
|
@@ -146,6 +153,28 @@ function scalar(v: unknown): string {
|
|
|
146
153
|
: ''
|
|
147
154
|
}
|
|
148
155
|
|
|
156
|
+
/**
|
|
157
|
+
* pnpm-workspace.yaml as every task folds it: without `patchedDependencies`
|
|
158
|
+
* and `overrides`, whose effect the lockfile records per project (pnpm 10+
|
|
159
|
+
* keeps them here). Undefined for a file that does not parse to a mapping,
|
|
160
|
+
* folded whole.
|
|
161
|
+
*/
|
|
162
|
+
export function workspaceSettings(text: string): string | undefined {
|
|
163
|
+
let doc: unknown
|
|
164
|
+
try {
|
|
165
|
+
doc = Bun.YAML.parse(text)
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
const map = record(doc)
|
|
170
|
+
if (map === undefined) return undefined
|
|
171
|
+
const rest: Yaml = {}
|
|
172
|
+
for (const [k, v] of Object.entries(map)) {
|
|
173
|
+
if (k !== 'patchedDependencies' && k !== 'overrides') rest[k] = v
|
|
174
|
+
}
|
|
175
|
+
return `pnpm-workspace\0${stable(rest)}`
|
|
176
|
+
}
|
|
177
|
+
|
|
149
178
|
/** JSON with sorted keys, so YAML key order cannot move a digest. */
|
|
150
179
|
export function stable(v: unknown): string {
|
|
151
180
|
return JSON.stringify(v, (_k, val: unknown) =>
|
|
@@ -201,8 +230,11 @@ function packageName(lock: Lockfile, snapshot: string): string {
|
|
|
201
230
|
* importer's node, so what project A can import through workspace
|
|
202
231
|
* package B is B's whole reach.
|
|
203
232
|
*/
|
|
204
|
-
export function importerDigests(
|
|
205
|
-
|
|
233
|
+
export function importerDigests(
|
|
234
|
+
lock: Lockfile,
|
|
235
|
+
files: ReadonlyMap<string, string> = new Map(),
|
|
236
|
+
): ReadonlyMap<string, string> {
|
|
237
|
+
const g = buildGraph(lock, files)
|
|
206
238
|
const hashes = reachDigests(g)
|
|
207
239
|
const out = new Map<string, string>()
|
|
208
240
|
// The global digest rides as DATA: Bun's xxHash3 reads only the low 32
|
|
@@ -228,7 +260,28 @@ function importerNode(dir: string): string {
|
|
|
228
260
|
return `importer\0${dir}`
|
|
229
261
|
}
|
|
230
262
|
|
|
231
|
-
|
|
263
|
+
/**
|
|
264
|
+
* The directories `file:` dependencies install from, and those a `link:`
|
|
265
|
+
* names that is no importer. pnpm copies the first at install and links
|
|
266
|
+
* the second, and records only their path, so an edit in one moved no key
|
|
267
|
+
* (Growth, file-dir-dep-stale.sh): the claim hashes its content.
|
|
268
|
+
*/
|
|
269
|
+
export function fileDeps(lock: Lockfile): string[] {
|
|
270
|
+
const out = new Set(lock.directories.values())
|
|
271
|
+
for (const [dir, deps] of lock.importers) {
|
|
272
|
+
for (const version of deps.values()) {
|
|
273
|
+
const target = linkTarget(lock, dir, version)
|
|
274
|
+
if (target !== undefined && !lock.importers.has(target)) out.add(target)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return [...out].sort()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function linkTarget(lock: Lockfile, dir: string, version: string): string | undefined {
|
|
281
|
+
return version.startsWith('link:') ? joinPosix(dir, version.slice('link:'.length)) : undefined
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
|
|
232
285
|
const index = new Map<string, number>()
|
|
233
286
|
const material: string[] = []
|
|
234
287
|
const edges: number[][] = []
|
|
@@ -246,7 +299,9 @@ function buildGraph(lock: Lockfile): Graph {
|
|
|
246
299
|
const resolution = lock.resolutions.get(key) ?? lock.resolutions.get(packageKey(key)) ?? ''
|
|
247
300
|
const patch =
|
|
248
301
|
lock.patches.get(packageKey(key)) ?? lock.patches.get(packageName(lock, key)) ?? ''
|
|
249
|
-
|
|
302
|
+
const dir = lock.directories.get(key) ?? lock.directories.get(packageKey(key))
|
|
303
|
+
const content = dir === undefined ? '' : `\0file\0${files.get(dir) ?? ''}`
|
|
304
|
+
node(key, `${key}\0${resolution}\0${patch}${content}`)
|
|
250
305
|
}
|
|
251
306
|
// Each importer folds its own dir: with one material for all, two
|
|
252
307
|
// importers that link each other were told apart by their edges alone,
|
|
@@ -255,10 +310,13 @@ function buildGraph(lock: Lockfile): Graph {
|
|
|
255
310
|
for (const dir of lock.importers.keys()) node(importerNode(dir), importerNode(dir))
|
|
256
311
|
const link = (from: number, dir: string, name: string, version: string): void => {
|
|
257
312
|
if (version.startsWith('link:')) {
|
|
258
|
-
const target =
|
|
313
|
+
const target = linkTarget(lock, dir, version)
|
|
259
314
|
if (target !== undefined && lock.importers.has(target))
|
|
260
315
|
edges[from]!.push(index.get(importerNode(target))!)
|
|
261
|
-
else
|
|
316
|
+
else {
|
|
317
|
+
const content = target === undefined ? '' : `\0file\0${files.get(target) ?? ''}`
|
|
318
|
+
material[from] += `\nlink\0${name}\0${version}${content}`
|
|
319
|
+
}
|
|
262
320
|
return
|
|
263
321
|
}
|
|
264
322
|
const key = snapshotKey(lock, name, version)
|
package/src/yarn.ts
CHANGED
|
@@ -27,12 +27,16 @@ export interface Lockfile {
|
|
|
27
27
|
/** package name → every entry a descriptor of that name resolves to (berry only) */
|
|
28
28
|
readonly names: ReadonlyMap<string, readonly string[]>
|
|
29
29
|
/**
|
|
30
|
-
* `name@npm:range` → the
|
|
31
|
-
* (`resolve`, `typescript`, `fsevents
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* `name@npm:range` → the entries of every `patch:` over it (berry only):
|
|
31
|
+
* Yarn's builtin compat patches (`resolve`, `typescript`, `fsevents`,
|
|
32
|
+
* item 1074) and a root `resolutions` patch (`yarn patch-commit -s`).
|
|
33
|
+
* The workspace keeps asking for the plain descriptor, which keys the
|
|
34
|
+
* unpatched entry, while a patched one is what Yarn installs. A
|
|
35
|
+
* workspace asking for the plain descriptor beside one that names the
|
|
36
|
+
* patch itself reaches it too: a patch edit re-keys it once too often,
|
|
37
|
+
* never too seldom.
|
|
34
38
|
*/
|
|
35
|
-
readonly
|
|
39
|
+
readonly patches: ReadonlyMap<string, readonly string[]>
|
|
36
40
|
readonly global: string
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -66,7 +70,7 @@ function parseBerry(text: string): Lockfile {
|
|
|
66
70
|
const descriptors = new Map<string, string>()
|
|
67
71
|
const workspaces = new Map<string, string>()
|
|
68
72
|
const names = new Map<string, string[]>()
|
|
69
|
-
const
|
|
73
|
+
const patches = new Map<string, string[]>()
|
|
70
74
|
for (const [keys, raw] of Object.entries(d)) {
|
|
71
75
|
if (keys === '__metadata') continue
|
|
72
76
|
const e = record(raw) ?? {}
|
|
@@ -90,15 +94,17 @@ function parseBerry(text: string): Lockfile {
|
|
|
90
94
|
for (const k of keys.split(',')) {
|
|
91
95
|
const descriptor = k.trimStart()
|
|
92
96
|
descriptors.set(descriptor, resolution)
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
97
|
+
const patch = /@patch:(.+?)#/.exec(descriptor)
|
|
98
|
+
if (patch !== null) {
|
|
95
99
|
let inner: string
|
|
96
100
|
try {
|
|
97
|
-
inner = decodeURIComponent(
|
|
101
|
+
inner = decodeURIComponent(patch[1]!)
|
|
98
102
|
} catch {
|
|
99
|
-
inner =
|
|
103
|
+
inner = patch[1]!
|
|
100
104
|
}
|
|
101
|
-
|
|
105
|
+
const ids = patches.get(inner)
|
|
106
|
+
if (ids === undefined) patches.set(inner, [resolution])
|
|
107
|
+
else if (!ids.includes(resolution)) ids.push(resolution)
|
|
102
108
|
}
|
|
103
109
|
const name = descriptor.slice(0, descriptor.indexOf('@', 1))
|
|
104
110
|
const ids = names.get(name)
|
|
@@ -115,7 +121,7 @@ function parseBerry(text: string): Lockfile {
|
|
|
115
121
|
descriptors,
|
|
116
122
|
workspaces,
|
|
117
123
|
names,
|
|
118
|
-
|
|
124
|
+
patches,
|
|
119
125
|
global: JSON.stringify({ version: meta['version'], cacheKey: meta['cacheKey'] }),
|
|
120
126
|
}
|
|
121
127
|
}
|
|
@@ -184,7 +190,7 @@ function parseClassic(text: string): Lockfile {
|
|
|
184
190
|
descriptors,
|
|
185
191
|
workspaces: new Map(),
|
|
186
192
|
names: new Map(),
|
|
187
|
-
|
|
193
|
+
patches: new Map(),
|
|
188
194
|
global: 'classic',
|
|
189
195
|
}
|
|
190
196
|
}
|
|
@@ -257,11 +263,11 @@ function resolveDescriptor(
|
|
|
257
263
|
name: string,
|
|
258
264
|
range: string,
|
|
259
265
|
): readonly string[] {
|
|
260
|
-
// A descriptor
|
|
261
|
-
// as
|
|
266
|
+
// A patched descriptor reaches the patched entries Yarn installs as well
|
|
267
|
+
// as the plain one it keys (item 1074).
|
|
262
268
|
const withPatch = (descriptor: string, id: string): readonly string[] => {
|
|
263
|
-
const patched = lock.
|
|
264
|
-
return patched === undefined
|
|
269
|
+
const patched = lock.patches.get(descriptor)
|
|
270
|
+
return patched === undefined ? [id] : [id, ...patched.filter((p) => p !== id)]
|
|
265
271
|
}
|
|
266
272
|
const direct = lock.descriptors.get(`${name}@${range}`)
|
|
267
273
|
if (direct !== undefined) return withPatch(`${name}@${range}`, direct)
|
|
@@ -343,6 +349,12 @@ function linkedDir(
|
|
|
343
349
|
dirOf: ReadonlyMap<string, string>,
|
|
344
350
|
id: string,
|
|
345
351
|
): string | undefined {
|
|
352
|
+
const target = linkPath(dirOf, id)
|
|
353
|
+
return target !== undefined && lock.workspaces.has(target) ? target : undefined
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The root-relative directory a berry `link:` or `portal:` entry symlinks. */
|
|
357
|
+
function linkPath(dirOf: ReadonlyMap<string, string>, id: string): string | undefined {
|
|
346
358
|
const m = /^(?:@[^@/]+\/)?[^@]+@(?:link|portal):([^:]*)::locator=([^&]+)/.exec(id)
|
|
347
359
|
if (m === null) return undefined
|
|
348
360
|
let locator: string
|
|
@@ -352,9 +364,37 @@ function linkedDir(
|
|
|
352
364
|
return undefined
|
|
353
365
|
}
|
|
354
366
|
const from = dirOf.get(locator)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
367
|
+
return from === undefined ? undefined : joinPosix(from, m[1]!)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Each entry's directory whose content is what a task reads and the file
|
|
372
|
+
* pins nowhere: berry's `link:` / `portal:` to a directory that is no
|
|
373
|
+
* workspace (a symlink), and classic's `file:` (a copy, no hash) and
|
|
374
|
+
* `link:` (a symlink). Berry's
|
|
375
|
+
* `file:` carries its own `::hash=`.
|
|
376
|
+
*/
|
|
377
|
+
function contentDirs(lock: Lockfile): Map<string, string> {
|
|
378
|
+
const out = new Map<string, string>()
|
|
379
|
+
if (lock.generation === 'classic') {
|
|
380
|
+
for (const id of lock.entries.keys()) {
|
|
381
|
+
const m = /^.[^@]*@(?:file|link):(.+)$/.exec(id)
|
|
382
|
+
if (m !== null) out.set(id, m[1]!)
|
|
383
|
+
}
|
|
384
|
+
return out
|
|
385
|
+
}
|
|
386
|
+
const dirOf = new Map<string, string>()
|
|
387
|
+
for (const [dir, id] of lock.workspaces) dirOf.set(id, dir)
|
|
388
|
+
for (const id of lock.entries.keys()) {
|
|
389
|
+
const target = linkPath(dirOf, id)
|
|
390
|
+
if (target !== undefined && target !== '.' && !lock.workspaces.has(target)) out.set(id, target)
|
|
391
|
+
}
|
|
392
|
+
return out
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The directories `contentDirs` names, for the claim to hash. */
|
|
396
|
+
export function fileDeps(lock: Lockfile): string[] {
|
|
397
|
+
return [...new Set(contentDirs(lock).values())].sort()
|
|
358
398
|
}
|
|
359
399
|
|
|
360
400
|
/** The entry a descriptor the file keys resolves to: as written, bound to `from`, or with berry's `npm:` prefix. */
|
|
@@ -377,13 +417,20 @@ function keyedDescriptor(
|
|
|
377
417
|
* one digest is the root's (`.`) over every entry the file resolves —
|
|
378
418
|
* coarse, and honest about what classic records.
|
|
379
419
|
*/
|
|
380
|
-
export function importerDigests(
|
|
420
|
+
export function importerDigests(
|
|
421
|
+
lock: Lockfile,
|
|
422
|
+
files: ReadonlyMap<string, string> = new Map(),
|
|
423
|
+
): ReadonlyMap<string, string> {
|
|
381
424
|
const index = new Map<string, number>()
|
|
382
425
|
const material: string[] = []
|
|
383
426
|
const edges: number[][] = []
|
|
427
|
+
const dirs = contentDirs(lock)
|
|
384
428
|
for (const [id, e] of lock.entries) {
|
|
385
429
|
index.set(id, material.length)
|
|
386
|
-
|
|
430
|
+
const dir = dirs.get(id)
|
|
431
|
+
material.push(
|
|
432
|
+
dir === undefined ? e.resolution : `${e.resolution}\nfile\0${files.get(dir) ?? ''}`,
|
|
433
|
+
)
|
|
387
434
|
edges.push([])
|
|
388
435
|
}
|
|
389
436
|
for (const [id, e] of lock.entries) {
|