Obsolete child symlinks in a virtual-store slot survive `pnpm install --force`
Verify latest release
- I verified that the issue exists in the latest pnpm release
pnpm version
12.4.2
Reproduction steps
pnpm install --force leaves behind a child symlink inside a virtual-store slot after the package stops declaring that dependency. packageExtensions gives debug a dependency on is-odd and then takes it away, which is the shortest way to shrink a snapshot's child set without republishing anything.
mkdir -p /tmp/pnpm-alias-repro && cd /tmp/pnpm-alias-repro
echo '{"name":"repro","version":"1.0.0","dependencies":{"debug":"4.3.4"}}' > package.json
cat > pnpm-workspace.yaml <<'EOF'
packageExtensions:
debug:
dependencies:
is-odd: 3.0.1
EOF
pnpm install
ls node_modules/.pnpm/[email protected]/node_modules # debug is-odd ms
# Take the extension away again.
printf '{}\n' > pnpm-workspace.yaml
pnpm install --force
ls node_modules/.pnpm/[email protected]/node_modulesSwap the second pnpm install --force for a plain pnpm install to see the control.
Describe the Bug
With --force the slot keeps debug is-odd ms, while pnpm-lock.yaml records only ms as the snapshot's dependency. debug can still require('is-odd') even though nothing declares it, and the stale link survives every later install, because by then the snapshot is unchanged.
The two runs differ only in the flag:
| second install | slot children |
|---|---|
pnpm install |
debug ms |
pnpm install --force |
debug is-odd ms |
removed_aliases_by_key (create_virtual_store.rs) is built from current_entries.snapshots, and LockfileEntries::of_previous_install (lockfile/src/lib.rs) returns empty entries under --force. With no previous snapshot to diff against, the map is empty, SlotLink.removed_aliases is &[] for every slot, and remove_obsolete_children unlinks nothing.
Nothing else covers it. The child links sit at <slot>/node_modules/<alias>, siblings of <slot>/node_modules/<name>, so the slot's own re-import does not touch them. remove_optional_children and remove_all_children only consider aliases the wanted snapshot names, and PruneStaleModules is scoped to importer-level direct deps and hoist links.
This is the same root cause as pnpm/pnpm#15030, which was about a slot's own files rather than its child links. Withholding the previous install's records under --force leaves a reader unable to see a change rather than telling it one happened, so every reader has to state its own --force rule. The skip in snapshot_plan.rs does, via !probe.policy.force, and so does the slot-replacement decision once pnpm/pnpm#15030 is fixed. This one does not.
Two ways to close it: give the alias diff its own --force rule, or stop emptying the entries in of_previous_install and let each reader gate on config.force, since both other readers already do.
Scope note: pnpm 11 is not a clean counter-example. linkAllModules receives the real current lockfile, but only when relinkChangedDependenciesOnly is set, so it usually skips this cleanup too. That makes this a pacquet-internal inconsistency rather than a parity break, and I have not checked which pnpm 12 release first had it.
Expected Behavior
is-odd is unlinked from the slot, because the resolved snapshot no longer lists it. That is what a plain pnpm install does. --force is documented to reinstall every package the lockfile names, so it should be at least as thorough as the install that does not use it.
Which Node.js version are you using?
24.21.0
Which operating systems have you used?
- macOS
Written by an agent (Claude Code, claude-opus-5).
Source: pnpm/pnpm