@vzn/vx-lockfile 0.0.641 → 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 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: 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. `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).
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` names that `bun()`, `npm()` or berry `yarn()` finds no importer for (added after the last install) is a warning at `vx lock` naming the install that adds it: until then core names it by its path, so a `dependsOn` naming it finds no project.
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.641",
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.641"
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 = 15
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
- extraFiles: (t) => bunLock().patchFiles(t),
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
- const importersOf = async (
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<ReadonlyMap<string, LockfileImporter> | undefined> => {
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
- return (await manager.manifestImporters(text, ctx.workspaceRoot, manifest)) ?? importers
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
- affected: (change, ctx) =>
244
- claims.find((c) => c.file === change.file)?.claim.fingerprint.affected(change, ctx),
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 importers = await importersOf(ctx, manifest)
273
- if (importers === undefined) return []
274
- // A workspace the root manifest names and the lockfile lacks is no
275
- // project until an install adds it: lerna's 31 added Nx projects
276
- // failed \`vx lock\` as "no such project" and nothing said why
277
- // (Growth, 2026-10-16). pnpm reads pnpm-workspace.yaml instead.
278
- if (manager.name !== 'pnpm') {
279
- const missing = (
280
- await workspaceDirs(ctx.workspaceRoot, await manifest(ctx.workspaceRoot))
281
- ).filter((d) => !importers.has(d))
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
- let file = manager.files[0]!
284
- for (const f of manager.files)
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
- out[file] = Bun.hash.xxHash3(bytes).toString(16).padStart(16, '0')
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
- /** An importer's `package.json`, or null where it has none or it does not parse. */
355
- async function manifestOf(dir: string): Promise<Record<string, unknown> | null> {
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
- const pkg: unknown = await Bun.file(path.join(dir, 'package.json')).json()
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
- * The directories a root manifest's `workspaces` globs (an array, or
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
- if (!Array.isArray(list)) return []
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 list) {
495
+ for (const pattern of globs) {
394
496
  if (typeof pattern !== 'string' || pattern.startsWith('!')) continue
395
- const glob = new Bun.Glob(`${pattern.replace(/^\.\//, '').replace(/\/+$/, '')}/package.json`)
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 !== '.' && !dir.split('/').includes('node_modules')) dirs.add(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(record(d!['packages']) ?? {})) {
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: p !== '' && !p.startsWith('node_modules/') && !p.includes('/node_modules/'),
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
- return { version, packages, global }
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(lock: Lockfile): ReadonlyMap<string, string> {
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(lock: Lockfile): ReadonlyMap<string, string> {
205
- const g = buildGraph(lock)
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
- function buildGraph(lock: Lockfile): Graph {
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
- node(key, `${key}\0${resolution}\0${patch}`)
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 = joinPosix(dir, version.slice('link:'.length))
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 material[from] += `\nlink\0${name}\0${version}`
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 entry of Yarn's builtin compat patch of it
31
- * (`resolve`, `typescript`, `fsevents`; berry only). The workspace keeps
32
- * asking for the plain descriptor, which keys the unpatched entry, while
33
- * the patched one is what Yarn installs (item 1074).
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 builtinPatches: ReadonlyMap<string, string>
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 builtinPatches = new Map<string, string>()
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 builtin = /@patch:(.+)#optional!builtin<[^>]*>$/.exec(descriptor)
94
- if (builtin !== null) {
97
+ const patch = /@patch:(.+?)#/.exec(descriptor)
98
+ if (patch !== null) {
95
99
  let inner: string
96
100
  try {
97
- inner = decodeURIComponent(builtin[1]!)
101
+ inner = decodeURIComponent(patch[1]!)
98
102
  } catch {
99
- inner = builtin[1]!
103
+ inner = patch[1]!
100
104
  }
101
- builtinPatches.set(inner, resolution)
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
- builtinPatches,
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
- builtinPatches: new Map(),
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 Yarn compat-patches reaches the patched entry it installs
261
- // as well as the plain one it keys (item 1074).
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.builtinPatches.get(descriptor)
264
- return patched === undefined || patched === id ? [id] : [id, patched]
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
- if (from === undefined) return undefined
356
- const target = joinPosix(from, m[1]!)
357
- return target !== undefined && lock.workspaces.has(target) ? target : undefined
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(lock: Lockfile): ReadonlyMap<string, string> {
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
- material.push(e.resolution)
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) {