@vzn/vx-lockfile
Lockfile plugins for @vzn/vx: pnpm(), bun(), npm() and yarn(). Each keys every task on its own project’s resolved dependency closure from the package manager’s lockfile, not on the whole file. pnpm update foo (or bun add, npm install, yarn up) re-keys exactly the projects that reach foo — through their dependencies, transitively, and through workspace links — and vx run … --affected selects the same projects. Zero dependencies: Bun’s own YAML and JSONC parsers, and a classic-yarn reader.
Without a plugin, core folds the whole lockfile into the workspace fingerprint that every cache key sees, so one install invalidates every task in the workspace.
npm install -D @vzn/vx @vzn/vx-lockfile # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -dimport { defineWorkspace } from '@vzn/vx/config'import { pnpm } from '@vzn/vx-lockfile' // or bun, npm, yarn
export default defineWorkspace({ plugins: [pnpm()],})That is the whole setup. The plugin claims its lockfile (VxPlugin.fingerprint), so core leaves it out of the workspace fingerprint, and its key hook folds one digest per project. vx why <task> names the material as plugin @vzn/vx-lockfile/pnpm (or /bun, /npm, /yarn).
| Option | Values | Meaning |
|---|---|---|
scope | 'project' (default), 'workspace' | project: each task folds its own project’s closure. workspace: the whole file, as core folds it — the coarse key through the plugin, for a workspace not yet ready to trust the precision. |
What a project’s digest covers
Section titled “What a project’s digest covers”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:
- pnpm (
pnpm-lock.yaml, lockfile v5, v6, v9): importers → snapshots, transitively; each package by name, version and resolved peers (foo@1(react@18)is notfoo@1(react@19)), resolution and anypatchedDependenciesentry;link:folds the linked importer’s reach, and afile:directory dependency folds its own closure (v9 keys itname@file:…, v5/v6 by the barefile:…); every other top-level field butoverridesand the catalogs, whose effect is the snapshot an importer reaches (settings,packageExtensionsChecksum,pnpmfileChecksum,ignoredOptionalDependencies, v6’sonlyBuiltDependencies, 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. - bun (
bun.lock): Bun’s hoisted layout — a dependencydof the package at pathpisp/dwhen 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; eachpatchedDependenciesentry 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 butworkspaces,packages, the catalogs,overridesandpatchedDependencies(trustedDependencies, and any field Bun adds later), and any patch naming no entry, into every project — a catalog bump reaches only the projects whosecatalog:dependency resolves anew, through thepackagesentry it installs. - npm (
package-lock.json, lockfileVersion 2 and 3): thepackagesmap —p/node_modules/d, then each ancestor directory’s (a workspace nested in another’s directory resolves through the outer one’snode_modules, as Node does), thennode_modules/d; alink: trueentry folds its target workspace’s reach; rootoverridesreach a project through the entries they force (npm 10 does not write them to the lockfile). Version 1 (npm 6) has nopackagesmap and is refused. - yarn (
yarn.lock): berry (yarn 2+) resolvesname@npm:rangedescriptors to entries, workspaces included, each entry folding every field but its dependency lists (so the root workspace’sdependenciesMetareaches every project),__metadata’sversionandcacheKeyinto 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 rootresolutionsoverride (to apatch:, or another range) leaves the original descriptor out ofyarn.lock, and the entry installed in its place is reached through the package’s name, so a patch edit re-keys the workspaces that depend on it. A path range (portal:,file:,link:) reaches the entry keyed bound to the package naming it (::locator=…), not another workspace’s of the same name. Yarn’s builtin compat patches (resolve,typescript,fsevents) are reached too: the workspace asks for the plain descriptor, and the patched entry is the one Yarn installs..yarnrc.ymlitself is in core’s workspace fingerprint, so a catalog edit re-keys every project, as apnpm-workspace.yamledit does. Classic (yarn 1) records no workspaces, so it yields one digest for the root that every project folds — coarse, and honest about what the file records.
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.
A lockfile the parser cannot read refuses the run, naming the file, the reason and the install that regenerates it. That is deliberate: the alternative to reading the lockfile is keying on nothing, and a key that is missing material is a stale hit waiting to happen. Under --affected the refusal also says which side could not be read — the working tree’s copy, or the one at the base ref (a lockfile-migration commit hits the second). A lockfile holding git merge conflict markers is refused the same way: it names two installs at once, and yarn classic’s format parsed one side of it without a word.
The claim, the per-project key, the memo and the --affected diff are core’s lockfileClaim; this package is the parsers, and they are internal: it exports pnpm, bun, npm, yarn and LockfileOptions. A lockfile is parsed once per content: the digests are memoised under the cache dir (lockfile-claims/<file>.json) by the file’s xxh3 and the claimant, so a warm run pays one read, one hash and one small JSON read — never a parse — and the read happens once per run, not per task. The digest is one hash per strongly connected component of the dependency graph (lockfiles carry cycles), children first, so a 1000-importer / 3000-package lockfile digests in ~20 ms when it does change.
--affected
Section titled “--affected”vx run test --affected=origin/main after a lockfile change used to select every project. With a plugin declared at scope: 'project', core hands it the lockfile at the base ref and in the working tree; it digests both and names the projects whose digest moved. At scope: 'workspace' a change still selects every project. A lockfile that appeared or was deleted still selects everything — every project’s node_modules is in question.
vx prune
Section titled “vx prune”Declaring any of the four plugins adds vx prune (the commands seam): the workspace cut to some projects and their transitive workspace dependencies, for a Docker build that installs and builds only what it ships — turbo prune.
vx prune <project...> [--out-dir <dir>] [--docker] [--production]The out dir (default out/, relative to the cwd) gets the root package.json with workspaces rewritten to the subset’s dirs (a glob character in a dir name bracketed, a[1] → a[[]1], so the pattern matches that dir alone), pnpm-workspace.yaml with packages rewritten (every other key as written), each lockfile at the root pruned to what the subset installs, the install config (.npmrc, .yarnrc.yml, .pnpmfile.cjs, bunfig.toml, .nvmrc, .node-version, the patch files patchedDependencies names, Yarn’s yarnPath and plugins), vx.workspace.*, the root vx.config.*, and each project directory less node_modules, .git, .vx and .turbo. A workspace package a copied vx config imports (a local plugin) comes along with its closure: the config loads before any task. A relative config import that leaves the subset is warned about. --docker writes json/ — the root install files and each project’s package.json, the cacheable install layer — and full/, everything:
COPY out/json/ .RUN bun install --frozen-lockfileCOPY out/full/ .RUN bunx vx run app#buildThe pruned lockfile is the source with whole entries cut, in the package manager’s own layout, so it installs with a frozen lockfile (bun install --frozen-lockfile, pnpm install --frozen-lockfile, npm ci, yarn install --immutable) and a line only another project reaches no longer busts the install layer. Kept: the root’s and each kept workspace’s entries and every package they reach — through Bun’s and npm’s hoisted walk (a package stays at the path it held; nothing is re-hoisted), pnpm’s snapshots, Yarn’s descriptors. Workspace-wide fields (catalogs, overrides, patches, settings, trustedDependencies) stay whole: the root manifest still names them. pnpm’s env document stays whole. Yarn classic records no workspaces, so its walk starts at each kept manifest’s dependencies.
--production follows no devDependencies edge between workspace packages: a package only a dev dependency reaches is left out (turbo prune --production). Each kept manifest, the root’s too, loses the devDependencies entries naming one, its field gone when it empties, and every lockfile drops the same entries: bun’s and npm’s workspace records, pnpm’s importer ({} when it empties), Yarn berry’s workspace: link in the workspace entry; Yarn classic walks the stripped manifests. A name another field also holds is installed in production and stays. Third-party dev dependencies stay in the lockfile: an install with --production / --omit=dev / --prod skips them. For the stage that runs a built app, not the one that builds it.
Refused, with the reason: an out dir that is or contains the root, one inside a copied project, one that already has content; a project name the workspace lacks (the root is always kept); bun.lockb without bun.lock (binary — bun install --save-text-lockfile). A file: or link: dependency outside every copied project is not copied. One plugin or four, there is one prune: it prunes every lockfile at the root.
Testing
Section titled “Testing”bun test covers each parser’s digests (transitive bumps, nested versions, workspace links, peer suffixes, patches, install-wide knobs, cycles, key order, aliases, refusals) and vx run / vx why / --affected end to end. vx prune is pinned on lockfiles each package manager wrote (bun 1.4, pnpm 10, npm 10, yarn 1 and 4) and end to end: the pruned lockfile is installed frozen and offline by bun always, and by pnpm, npm and yarn 1 where they are on PATH (yarn 1’s --frozen-lockfile passes a lockfile missing entries, so its check is an install that leaves the file byte-identical). vx’s own repository declares bun().