fix: preserve pnpm 10 peer suffix encoding for linked paths (#11297)

* fix: preserve pnpm 10 peer suffix encoding for linked paths

The filenamify upgrade from v4 to v7 changed the peer-suffix "version"
token for linked dependency paths: `../packages/b` became `+packages+b`
instead of `packages+b`, causing lockfile churn for workspaces with
packages linked from outside the workspace root.

Replace the filenamify call with a small inline encoder that reproduces
v4's output for link paths, and drop the now-unused dependency.

Closes #11272.

* chore: avoid cspell-flagged word in peer suffix comment

* test: cover linkPathToPeerVersion and clarify its lossy encoding

Address Copilot review feedback on #11297:
- Correct the comment — any leading run of `.` is dropped, not just
  `./` and `../` segments (so `.hidden/pkg` becomes `hidden+pkg`).
- Export the helper and add a focused test that pins the exact token
  output for link paths, so future lockfile-breaking regressions get
  caught by the test suite.

* refactor: extract linkPathToPeerVersion into its own file
This commit is contained in:
Zoltan Kochan
2026-04-19 12:57:16 +02:00
committed by GitHub
parent 7d9aae9662
commit c86c423bdc
7 changed files with 91 additions and 24 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/installing.deps-resolver": patch
"pnpm": patch
---
Restore the peer suffix encoding used by pnpm 10 for linked dependency paths. A `filenamify` upgrade changed how leading `./` and `../` segments were normalized, producing peer suffixes like `(b@+packages+b)` instead of `(b@packages+b)` for linked packages outside the workspace root, causing lockfile churn [#11272](https://github.com/pnpm/pnpm/issues/11272).

View File

@@ -58,7 +58,6 @@
"@pnpm/util.lex-comparator": "catalog:",
"@pnpm/workspace.spec-parser": "workspace:*",
"@yarnpkg/core": "catalog:",
"filenamify": "catalog:",
"graph-cycles": "catalog:",
"is-inner-link": "catalog:",
"is-subdir": "catalog:",

View File

@@ -0,0 +1,51 @@
// Converts a link: path into a stable, filename-safe token used as the
// peer's "version" inside peer-suffix hashes. The output must stay stable
// across pnpm versions so that lockfiles don't churn; it replicates what
// filenamify v4 produced for these paths in pnpm <= 10.
//
// Note: this encoding is lossy and can collide. Any leading run of `.`
// characters is dropped, and `/`, `\`, and literal `+` all collapse into
// a single `+`. For example, `packages/b`, `./packages/b`, and
// `../packages/b` all produce `packages+b`, and `.hidden/pkg` produces
// `hidden+pkg`. The only way to make this collision-free is to hash the
// normalized link target (or switch to a lossless escape encoding),
// either of which would change every link-path peer suffix in existing
// lockfiles. We accept the (extremely rare in practice) collision for
// lockfile stability; see https://github.com/pnpm/pnpm/issues/11272.
export function linkPathToPeerVersion (relPath: string): string {
// Drop leading dots: v4 replaced `^\.+` with '+' and then stripOuter removed it.
let i = 0
while (i < relPath.length && relPath[i] === '.') i++
let out = ''
let lastWasPlus = true // pretend we just emitted '+' so leading '+' chars are suppressed
for (; i < relPath.length; i++) {
const c = relPath.charCodeAt(i)
// Reserved filename chars, C0 controls, and literal '+' all collapse into a single '+'.
const replace = c < 32 ||
c === 34 /* " */ || c === 42 /* * */ || c === 43 /* + */ ||
c === 47 /* / */ || c === 58 /* : */ || c === 60 /* < */ ||
c === 62 /* > */ || c === 63 /* ? */ || c === 92 /* \ */ ||
c === 124 /* | */
if (replace) {
if (!lastWasPlus) {
out += '+'
lastWasPlus = true
}
} else {
out += relPath[i]
lastWasPlus = false
}
}
// Trim trailing '+' and '.' (v4 stripped trailing periods and outer replacement).
let end = out.length
while (end > 0) {
const ch = out.charCodeAt(end - 1)
if (ch !== 43 /* + */ && ch !== 46 /* . */) break
end--
}
if (end > 0) return out.slice(0, end)
// Empty result with something consumed collapses to a single '+'.
return relPath.length === 0 ? '' : '+'
}

View File

@@ -10,13 +10,13 @@ import type {
ProjectRootDir,
} from '@pnpm/types'
import * as semverUtils from '@yarnpkg/core/semverUtils'
import filenamify from 'filenamify'
import { analyzeGraph, type Graph } from 'graph-cycles'
import pDefer, { type DeferredPromise } from 'p-defer'
import { partition, pick } from 'ramda'
import semver from 'semver'
import { dedupeInjectedDeps } from './dedupeInjectedDeps.js'
import { linkPathToPeerVersion } from './linkPathToPeerVersion.js'
import { mergePeers } from './mergePeers.js'
import type { NodeId } from './nextNodeId.js'
import type {
@@ -977,7 +977,7 @@ function peerNodeIdToPeerId<T extends PartialResolvedPackage> (
if (typeof peerNodeId === 'string' && peerNodeId.startsWith('link:')) {
return {
name: alias,
version: filenamify(peerNodeId.slice(5), { replacement: '+' }),
version: linkPathToPeerVersion(peerNodeId.slice(5)),
}
}
if (ctx.dedupePeers) {

View File

@@ -0,0 +1,32 @@
import { linkPathToPeerVersion } from '../lib/linkPathToPeerVersion.js'
// These outputs are lockfile-format: changing any of them breaks existing
// v9 lockfiles. See https://github.com/pnpm/pnpm/issues/11272.
test.each([
// The case from #11272: link target outside the workspace root.
['../packages/b', 'packages+b'],
['./packages/b', 'packages+b'],
['packages/b', 'packages+b'],
['../../a/b', '..+a+b'],
['a/b/c', 'a+b+c'],
['abc', 'abc'],
// Leading dots collapse and are stripped.
['..', '+'],
['...', '+'],
['.hidden/pkg', 'hidden+pkg'],
// Windows-style separators and mixed reserved characters.
['..\\packages\\b', 'packages+b'],
['a/b\\c', 'a+b+c'],
// Literal '+' characters collapse with adjacent separators.
['foo+bar', 'foo+bar'],
['foo++bar', 'foo+bar'],
['+foo', 'foo'],
['foo+', 'foo'],
// Trailing dots are stripped.
['foo.', 'foo'],
['abc...', 'abc'],
// Empty input stays empty.
['', ''],
])('linkPathToPeerVersion(%j) === %j', (input, expected) => {
expect(linkPathToPeerVersion(input)).toBe(expected)
})

20
pnpm-lock.yaml generated
View File

@@ -556,9 +556,6 @@ catalogs:
fast-glob:
specifier: ^3.3.3
version: 3.3.3
filenamify:
specifier: ^7.0.1
version: 7.0.1
find-up:
specifier: ^8.0.0
version: 8.0.0
@@ -5572,9 +5569,6 @@ importers:
'@yarnpkg/core':
specifier: 'catalog:'
version: 4.5.0(typanion@3.14.0)
filenamify:
specifier: 'catalog:'
version: 7.0.1
graph-cycles:
specifier: 'catalog:'
version: 3.0.0
@@ -13095,14 +13089,6 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
filename-reserved-regex@4.0.0:
resolution: {integrity: sha512-9ZT504KxEQDamsOogZImAWGEN24R1uFAxU3ZS4AZqn2ooidmN68Olh7n4/RcA4lLatZztjA0ZSuxeLHVoCc8JA==}
engines: {node: '>=20'}
filenamify@7.0.1:
resolution: {integrity: sha512-9b4rfnaX2MkJCgp27wypV6DAMvj4WMOSgJ+TdcpJIO84Dql+Cv6iJjdG4XDTLubOWkfNiBv3joO59sau/TXw+Q==}
engines: {node: '>=20'}
fill-keys@1.0.2:
resolution: {integrity: sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==}
engines: {node: '>=0.10.0'}
@@ -21536,12 +21522,6 @@ snapshots:
dependencies:
flat-cache: 4.0.1
filename-reserved-regex@4.0.0: {}
filenamify@7.0.1:
dependencies:
filename-reserved-regex: 4.0.0
fill-keys@1.0.2:
dependencies:
is-object: 1.0.2

View File

@@ -192,7 +192,6 @@ catalog:
exists-link: 2.0.0
fast-deep-equal: ^3.1.3
fast-glob: ^3.3.3
filenamify: ^7.0.1
find-up: ^8.0.0
fs-extra: ^11.3.1
fuse-native: ^2.2.6