Context
PR #11689 anchors engineName() to a project-wide pinned Node version (engines.runtime / devEngines.runtime on the root manifest) instead of pnpm's own process.version, by computing the version once per install via findRuntimeNodeVersion(Object.keys(lockfile.packages ?? {})) and threading it through every site that calls calcDepState / calcGraphNodeHash / iterateHashedGraphNodes.
That change closes the cache-key divergence between @pnpm/exe (SEA-bundled Node) and the shell node that actually runs lifecycle scripts for the common case. It does not close the equivalent divergence for a different case the resolver already handles, which is the subject of this issue.
The gap
installing/deps-resolver/src/resolveDependencies.ts:1477-1479 runs the same engines.runtime → dependencies.node desugar against every resolved dependency's manifest, not just the project's:
if (pkg.engines?.runtime != null) {
convertEnginesRuntimeToDependencies(pkg, 'engines', 'dependencies')
}
So when a third-party package declares engines.runtime: node@22, the resolver injects dependencies.node: 'runtime:22.0.0' into that one package's manifest, the npm-resolver downstream emits a node@runtime:22.0.0 snapshot, and the snapshot becomes a real child of the pinning package in the lockfile. Pnpm's bins/linker/src/index.ts:229-237 then routes lifecycle-script spawns for that package through the pinned Node binary at <pkgDir>/node_modules/node/bin/node (or node.exe).
The deps half of the hash already reflects this. calcDepGraphHash walks the pinning package's children, finds node@runtime:22.0.0, mixes its id into the inner {id, deps} object, and any snapshot that transitively reaches the pinning package picks up the same contribution. Pacquet's port (virtual_store_layout::collect_children + lockfile_to_dep_graph) does the equivalent walk over snapshot.dependencies. Symmetric on both sides, and already captured.
The engine half doesn't. calcGraphNodeHash uses a single install-wide engine string for every snapshot's outer {engine, deps} payload. So:
- Pinning package: scripts actually run on its pinned Node (node22 above); the engine field reads whatever the install-wide default is — system node24 in the common case, or whatever the project pinned. Hash misrepresents the script-runner Node.
- Non-pinning sibling: scripts run on system node24; engine field reads node24. Correct.
The pinning package's slot still has a distinct GVS path from non-pinning siblings — different deps means different deps hash means different outer hash — so installs don't collide on disk. What's wrong is the side-effects-cache key prefix (<platform>;<arch>;node<major>;deps=…). It encodes the system Node major even though the cached side-effects came from a build that ran on the pinned Node. A reinstall on the same system reads the cached artifact via the system-Node-anchored key and reuses build output that was produced on a different Node. Stale side-effects cache, silently.
This is a pnpm bug today; PR #11689 is symmetric to pnpm so pacquet doesn't widen it, but pacquet also doesn't close it.
What the fix needs to look like
The engine resolution moves from "once per install" to "once per snapshot". Sketch:
function snapshotEngine(snapshot: LockfilePackageSnapshot, projectPin: string | undefined): string {
// The desugar at resolveDependencies.ts:1477-1479 writes
// `dependencies.node: 'runtime:<version>'` into the pinning
// snapshot. Read it back the same way `findRuntimeNodeVersion`
// reads the project's importer pin: any child with `node` as
// its alias and a value starting with `runtime:`.
const ownPin = readOwnRuntimePin(snapshot)
return engineName(ownPin ?? projectPin)
}
Threaded through calcGraphNodeHash and calcDepState as a per-snapshot value instead of the current ctx-level nodeVersion. Same change on the pacquet side: each snapshot consults its own dependencies map for a node entry with a runtime: prefix before falling back to find_runtime_node_major(snapshots).
The shape mirrors the install-time policy that's already there — bins/linker reads the dep's own engines.runtime per-package — so the engine string in the hash would just be tracking what the bin linker already does on a per-package basis.
Scope
deps/graph-hasher: replace nodeVersion?: string with a per-snapshot lookup helper, or take a nodeVersionFor: (snapshot) => string | undefined callback.
deps/graph-builder, installing/deps-resolver, installing/deps-restorer (both index.ts and linkHoistedModules.ts), installing/deps-installer/install/link.ts, building/during-install, building/after-install: switch each calcGraphNodeHash / calcDepState call from forwarding the install-wide nodeVersion to consulting the per-snapshot helper.
engine.runtime.system-node-version: extend findRuntimeNodeVersion with a sibling that reads from a single snapshot's dependencies map rather than from a flat key list.
- Pacquet: mirror the per-snapshot lookup at the
calc_graph_node_hash call site in package-manager/src/virtual_store_layout.rs::new. The existing find_runtime_node_major(snapshots) helper becomes the fallback case; the new helper reads each snapshot's dependencies for a node entry with Prefix::Runtime and prefers that.
The unit tests need a fixture where two siblings declare different engines.runtime pins to surface the divergence; the existing parity tests in pacquet/crates/cli/tests/pnpm_compatibility.rs would extend with a third case along those lines.
PR #11689 already handles the common case (project-level pin) and matches pnpm's current behaviour for the dep-level pin. Bundling the per-snapshot refactor would roughly double the diff and the test surface (need cross-pinning fixtures on both sides) while only addressing a corner of the cache-key contract that pnpm currently gets wrong in the same way. Cleaner to land #11689 first and address this incrementally.
Written by an agent (Claude Code, claude-opus-4-7).
Context
PR #11689 anchors
engineName()to a project-wide pinned Node version (engines.runtime/devEngines.runtimeon the root manifest) instead of pnpm's ownprocess.version, by computing the version once per install viafindRuntimeNodeVersion(Object.keys(lockfile.packages ?? {}))and threading it through every site that callscalcDepState/calcGraphNodeHash/iterateHashedGraphNodes.That change closes the cache-key divergence between
@pnpm/exe(SEA-bundled Node) and the shellnodethat actually runs lifecycle scripts for the common case. It does not close the equivalent divergence for a different case the resolver already handles, which is the subject of this issue.The gap
installing/deps-resolver/src/resolveDependencies.ts:1477-1479runs the sameengines.runtime→dependencies.nodedesugar against every resolved dependency's manifest, not just the project's:So when a third-party package declares
engines.runtime: node@22, the resolver injectsdependencies.node: 'runtime:22.0.0'into that one package's manifest, the npm-resolver downstream emits anode@runtime:22.0.0snapshot, and the snapshot becomes a real child of the pinning package in the lockfile. Pnpm'sbins/linker/src/index.ts:229-237then routes lifecycle-script spawns for that package through the pinned Node binary at<pkgDir>/node_modules/node/bin/node(ornode.exe).The deps half of the hash already reflects this.
calcDepGraphHashwalks the pinning package's children, findsnode@runtime:22.0.0, mixes itsidinto the inner{id, deps}object, and any snapshot that transitively reaches the pinning package picks up the same contribution. Pacquet's port (virtual_store_layout::collect_children+lockfile_to_dep_graph) does the equivalent walk oversnapshot.dependencies. Symmetric on both sides, and already captured.The engine half doesn't.
calcGraphNodeHashuses a single install-wide engine string for every snapshot's outer{engine, deps}payload. So:The pinning package's slot still has a distinct GVS path from non-pinning siblings — different deps means different deps hash means different outer hash — so installs don't collide on disk. What's wrong is the side-effects-cache key prefix (
<platform>;<arch>;node<major>;deps=…). It encodes the system Node major even though the cached side-effects came from a build that ran on the pinned Node. A reinstall on the same system reads the cached artifact via the system-Node-anchored key and reuses build output that was produced on a different Node. Stale side-effects cache, silently.This is a pnpm bug today; PR #11689 is symmetric to pnpm so pacquet doesn't widen it, but pacquet also doesn't close it.
What the fix needs to look like
The engine resolution moves from "once per install" to "once per snapshot". Sketch:
Threaded through
calcGraphNodeHashandcalcDepStateas a per-snapshot value instead of the current ctx-levelnodeVersion. Same change on the pacquet side: each snapshot consults its owndependenciesmap for anodeentry with aruntime:prefix before falling back tofind_runtime_node_major(snapshots).The shape mirrors the install-time policy that's already there —
bins/linkerreads the dep's ownengines.runtimeper-package — so the engine string in the hash would just be tracking what the bin linker already does on a per-package basis.Scope
deps/graph-hasher: replacenodeVersion?: stringwith a per-snapshot lookup helper, or take anodeVersionFor: (snapshot) => string | undefinedcallback.deps/graph-builder,installing/deps-resolver,installing/deps-restorer(both index.ts and linkHoistedModules.ts),installing/deps-installer/install/link.ts,building/during-install,building/after-install: switch eachcalcGraphNodeHash/calcDepStatecall from forwarding the install-widenodeVersionto consulting the per-snapshot helper.engine.runtime.system-node-version: extendfindRuntimeNodeVersionwith a sibling that reads from a single snapshot'sdependenciesmap rather than from a flat key list.calc_graph_node_hashcall site inpackage-manager/src/virtual_store_layout.rs::new. The existingfind_runtime_node_major(snapshots)helper becomes the fallback case; the new helper reads each snapshot'sdependenciesfor anodeentry withPrefix::Runtimeand prefers that.The unit tests need a fixture where two siblings declare different
engines.runtimepins to surface the divergence; the existing parity tests inpacquet/crates/cli/tests/pnpm_compatibility.rswould extend with a third case along those lines.Why not in #11689
PR #11689 already handles the common case (project-level pin) and matches pnpm's current behaviour for the dep-level pin. Bundling the per-snapshot refactor would roughly double the diff and the test surface (need cross-pinning fixtures on both sides) while only addressing a corner of the cache-key contract that pnpm currently gets wrong in the same way. Cleaner to land #11689 first and address this incrementally.
Written by an agent (Claude Code, claude-opus-4-7).