Out of the box, vx folds the whole lockfile into the workspace
fingerprint (Turborepo keys each package on the lockfile changes that
reach it), and the workspace fingerprint is in
every task's key. That is coarse but correct. Any pnpm install that
changes pnpm-lock.yaml invalidates every cached task, and --affected
selects every project.
It is also the single largest source of "why did everything rebuild"
in a large workspace. A patch bump to a test utility used by one
package re-runs the builds of all of them.
Key each project on what it can reach
@vzn/vx-lockfile ships one plugin per package manager: pnpm(),
bun(), npm(), yarn(). Each claims its lockfile out of the
workspace fingerprint through the fingerprint seam and instead
contributes, per task, a digest of its project's resolved dependency
closure:
// .vx/workspace.ts
import { defineWorkspace } from '@vzn/vx/config'
import { pnpm } from '@vzn/vx-lockfile'
export default defineWorkspace({ plugins: [pnpm()] })
Nothing else changes. vx why names the material as
plugin @vzn/vx-lockfile/pnpm, and the workspace fingerprint line
stops moving when the lockfile is edited.
The closure is exactly what the project's node_modules can resolve:
- its
dependencies,devDependenciesandoptionalDependencies, transitively through the lockfile's own resolution graph; - each package by name, version and resolved peers, because
foo@1(react@18)andfoo@1(react@19)are different directories on disk, plus its integrity and any patch applied to it; - a
link:orworkspace:dependency folds the linked workspace package's whole reach, since what A can import through B is B's closure; - install-wide material every project folds: the lockfile version and
fields such as pnpm's
settings. Underpnpm()andbun(), overrides, catalogs and patches count only where they reach. - the root package's own closure, folded into every project (added
2026-09-24): the root's tools run from the root
node_modules/.binon every task's PATH, so a root devDependency bump that re-keyed nothing replayed the old tool's output.
bun() does the same through Bun's hoisted layout (a nested version
counts for the package it is nested under and no other); npm()
through package-lock.json's packages map; yarn() per workspace
through berry's descriptors. Yarn classic records no workspaces, so
every project folds one root digest, which is coarse and honest about
what that file contains.
A phantom dependency, imported but never declared, is in no closure.
Declare it.
--affected follows the same claim
The plugin's affected(change) hook answers the selection question
too. vx run test --affected with a lockfile diff selects the projects
whose closure the diff reaches, not every project. The rule that keys a
task and the rule that selects it are one rule, so they cannot drift.
Measured in the repository that ships it
vx's own repository declares bun(). Measured 2026-10-08 with
vx run ci --all --dry=json before and after a version bump in
bun.lock: bumping astro, which only the docs site reaches, re-keys
that site's 6 tasks of the gate's 57; without the plugin, all 57
re-key.
The root's closure is in every key, and this repository's root links
seven of its packages as devDependencies, so bumping protobufjs
(reached through @vzn/vx-reapi) re-keys every task either way. A
dependency change costs what the dependency change touches, and the
root touches every task.
The plugin is separate from core on purpose. Core owns the fingerprint
seam, the memo and the per-run gate; the lockfile parsers are
package-manager knowledge, and package-manager knowledge changes on the
package manager's schedule. The guide is
Configure › Lockfiles.
Originally published on the vx blog. vx is open-source (MIT) task orchestration with a build cache: vznjs.github.io/vx · GitHub.
Written with AI assistance.
Top comments (0)