#15030·pnpm

`pnpm install --force` does not re-import a package whose integrity changed

Author: TrevorBurnhamCreated Sep 17, 2026Updated Sep 17, 2026
Labelstype: bugarea: lockfileregressionarea: resolution

Last pnpm version that worked

12.4.0

pnpm version

12.4.1 (also 12.4.2 and main)

Code to reproduce the issue

A custom resolver from the pnpmfile resolvers hook resolves [email protected] to a tarball, computing the integrity from the bytes on disk. Between the two installs the tarball is replaced with different bytes at the same name@version, which is the case shouldRefreshResolution exists to handle.

bash
mkdir -p /tmp/pnpm-force-repro && cd /tmp/pnpm-force-repro

build () {
  rm -rf stage && mkdir -p stage/package
  echo '{"name":"dep-a","version":"1.0.0","main":"index.js"}' > stage/package/package.json
  echo "module.exports = '$1'" > stage/package/index.js
  ( cd stage && COPYFILE_DISABLE=1 tar -czf "../$2" package )
}
build OLD old.tgz
build NEW new.tgz

echo '{"name":"repro","version":"1.0.0","dependencies":{"dep-a":"1.0.0"}}' > package.json

cat > .pnpmfile.cjs <<'EOF'
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const served = path.join(__dirname, 'served.tgz')

module.exports = {
  resolvers: [{
    canResolve: (wanted) => wanted.alias === 'dep-a',
    resolve: async () => ({
      id: '[email protected]',
      resolution: {
        tarball: 'file:served.tgz',
        integrity: 'sha512-' + crypto.createHash('sha512')
          .update(fs.readFileSync(served)).digest('base64'),
      },
      manifest: { name: 'dep-a', version: '1.0.0' },
      resolvedVia: 'custom',
    }),
    shouldRefreshResolution: () => true,
  }],
}
EOF

cp old.tgz served.tgz
pnpm install
cat node_modules/dep-a/index.js    # module.exports = 'OLD'

cp new.tgz served.tgz              # republish different bytes at the same name@version
pnpm install --force
cat node_modules/dep-a/index.js
grep -o 'sha512-.\{12\}' pnpm-lock.yaml

Expected behavior

After the second install, node_modules/dep-a/index.js contains NEW, matching the integrity that install wrote to pnpm-lock.yaml. That is what 12.4.0 does.

Actual behavior

On 12.4.1 and later the file still contains OLD while pnpm-lock.yaml records the new integrity. The install exits 0 and reports Packages: +1 with no warning, so the next install sees no drift and the tree stays stale.

The fetch is fine. The store grows by the new package's files on every run, so the new content is downloaded and ingested. Only the import into the virtual store is skipped.

Same fixture, same store growth, one variable:

pnpm node_modules after install --force
12.2.1 NEW
12.3.2 NEW
12.4.0 NEW
12.4.1 OLD
12.4.2 OLD

--force is the trigger. On 12.4.2, any second install that reaches the resolver without --force writes NEW to both the lockfile and node_modules. So --force, documented as "relink packages an earlier install already materialized", is the only thing preventing the relink.

Reproduced with a file: tarball and with one served over HTTP, so the transport is not a factor.

Additional information

Cause

fc56dc12ec ("refactor(rust): give the install phases a shared context and a typed lockfile view", pnpm/pnpm#14737) moved the frozen path's "--force nulls the current lockfile" rule into LockfileEntries::of_previous_install and applied it to the fresh-resolve path too. At 12.4.0 that path passed the current lockfile unconditionally:

rust
current_snapshots: current_lockfile.and_then(|lockfile| lockfile.snapshots.as_ref()),
current_packages: current_lockfile.and_then(|lockfile| lockfile.packages.as_ref()),

The rule is safe where it came from and unsafe where it went, because current_entries feeds two decisions that read --force with opposite polarity:

  • The skip, current_entry_unchanged in create_virtual_store/snapshot_plan.rs, already begins with !probe.policy.force. It handles --force on its own, so emptying current_entries changes nothing here.
  • The re-import, force_import = package_content_changed(batch.current_packages, batch.packages, snapshot_key) in create_virtual_store/warm.rs, with the same call in cold.rs. That helper is current.is_some() && !integrity_equal(...) in create_virtual_store/cache_keys.rs, so an empty current makes it false.

force_import false means SlotImportSource.force is false, and repair_incomplete_dir in import_indexed_dir.rs then returns early because marker_present finds the package.json an earlier install wrote. The previous install's files stay in the slot.

Worth noting for the port: pnpm 11's equivalent, isIntegrityEqual(resolution, undefined), returns false for a missing current entry, so its forceImportPackage is true. The Rust helper returns false for the same input. Two versions, one missing value, opposite conclusions.

Suggested fix

Have the import honor --force directly rather than inferring it from a lockfile comparison: carry the reuse policy's force into WarmLinkBatch and the cold capture, and or it into force_import at both sites, plus the equivalent in link_hoisted_modules/dir_clone.rs. Since the skip already gates on policy.force, the if force { LockfileEntries::default() } branch in of_previous_install can be dropped in the same change.

That also covers a related case I ran into while narrowing this down. On both 11 and 12, pnpm install --force does not restore a virtual store slot whose files changed after the install that created them, because the completion marker is only checked for existence. Threading policy.force to the import makes --force mean what it documents.

Scope

Two nearby behaviors are not part of this regression, in case they come up. On a plain second install with the tree in sync, shouldRefreshResolution is never called on either 11 or 12: the whole-tree up-to-date gate short-circuits before the pnpmfile is loaded, so nothing is re-resolved or fetched. Deleting the store does not change that on either version. Both are the same across versions, but together they do mean the hook cannot currently trigger a refresh on its own, since the one flag that gets past the gate is the one this issue is about. I can open a separate issue for that if it is worth discussing.

Node.js version

24.21.0

Operating System

macOS


Written by an agent (Claude Code, claude-opus-5).