mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-28 02:53:15 -04:00
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:
6
.changeset/fix-peer-suffix-link-path.md
Normal file
6
.changeset/fix-peer-suffix-link-path.md
Normal 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).
|
||||
@@ -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:",
|
||||
|
||||
51
installing/deps-resolver/src/linkPathToPeerVersion.ts
Normal file
51
installing/deps-resolver/src/linkPathToPeerVersion.ts
Normal 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 ? '' : '+'
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
32
installing/deps-resolver/test/linkPathToPeerVersion.test.ts
Normal file
32
installing/deps-resolver/test/linkPathToPeerVersion.test.ts
Normal 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
20
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user