A patched dependency's hash appears in a lockfile twice: in
`patchedDependencies`, and as a `(patch_hash=...)` segment on every dependency
path referring to that package. The freshness gate compared only the first
against the configured patches, which stands in for the second in every
lockfile pnpm writes but not in one a hand edit or mis-resolved merge conflict
left disagreeing with itself. That lockfile read as up to date: resolution was
skipped, the stale paths were never rewritten, the install reused
already-linked content rather than the current patch, and `--frozen-lockfile`
accepted it.
Add `checkPatchedDepPaths` (`check_patched_dep_paths` in pacquet), which
compares every dependency path naming a patched package, and every path
carrying a segment, in the importer entries, snapshot keys and dependency
edges against the patch `patchedDependencies` gives that package. A path
missing the segment its patch calls for disagrees as much as one carrying the
wrong hash:
- a normal install forces a full resolution;
- `--frozen-lockfile` raises ERR_PNPM_INCONSISTENT_PATCH_HASH, or
ERR_PNPM_UNCHECKABLE_PATCH_HASH when it cannot tell;
- `verifyDepsBeforeRun` reports it where it compares the lockfile settings.
It returns three values, not a predicate: judging a path needs the package's
version and the patch set, and this lockfile state can be missing either.
Calling that stale would send the user after the wrong defect and calling it up
to date would hide a real one, so `indeterminate` forces the resolver without
naming a cause. A `patchedDependencies` key that does not resolve leaves only
its own package undecided, and any definite disagreement outranks it. Paths
for unpatched packages are skipped before they are built, and repeated paths
reuse their verdict, so large lockfiles pay little for the walk.
Derive the package identity as every other patch consumer does, so this check
and the install cannot disagree about which patch applies: a git, tarball,
`file:` or registry-qualified path holds its resolution where the version goes,
while patches match the version `packages:` records. In pacquet,
`name_version_from_package_key` moves from `pnpm-deps-restorer` to
`pnpm-lockfile` so the frozen installer, the fast-update path and this check
share one definition.
That move uncovered a pre-existing divergence, fixed here: pacquet rendered a
registry-qualified key's whole `registry:version` slot where pnpm's `parse`
reports the bare semver, so such a patch matched in one stack and missed in the
other. The frozen installer's lookup was affected too.
So a more actionable cause wins, the check runs last in
`check_lockfile_settings`. A non-frozen install skips it for a lockfile with
merge conflicts, which is re-resolved anyway; a frozen install checks an
autofixed lockfile too, since it installs it as is. The fast-update path no
longer treats an agreeing `patchedDependencies` map as sufficient on its own.
A suffix is read from the end, as pnpm's `parse` reads it: the trailing run
of balanced, back-to-back segments, with the package's own hash first. A
marker anywhere else, or in a suffix whose parentheses do not balance, makes
the path uncheckable. Unless peers are deduped, a peer segment is the peer's
whole dependency path, so each peer segment carrying a marker is judged as its
own path, and so is an unmarked one naming a registry version of a patched
package behind a registry parent. Peers are walked level by level, so nesting
depth never reaches the call stack.
`groupPatchedDependencies` now builds its record without a prototype, so a
`patchedDependencies` key named after an `Object.prototype` member gets its
own group.
The importer fast path in pnpm declines a locked version the range admits
when its key is patched: the bare version it would write points at no
snapshot. pacquet already declined it. pacquet's patch fast-update path now
checks the paths before absorbing drift in `patchedDependencies`.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>