diff --git a/.changeset/fix-peer-suffix-link-path.md b/.changeset/fix-peer-suffix-link-path.md new file mode 100644 index 0000000000..12efe5a79d --- /dev/null +++ b/.changeset/fix-peer-suffix-link-path.md @@ -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). diff --git a/installing/deps-resolver/package.json b/installing/deps-resolver/package.json index 3bc3573d5b..a4bce98638 100644 --- a/installing/deps-resolver/package.json +++ b/installing/deps-resolver/package.json @@ -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:", diff --git a/installing/deps-resolver/src/linkPathToPeerVersion.ts b/installing/deps-resolver/src/linkPathToPeerVersion.ts new file mode 100644 index 0000000000..a976402c22 --- /dev/null +++ b/installing/deps-resolver/src/linkPathToPeerVersion.ts @@ -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 ? '' : '+' +} diff --git a/installing/deps-resolver/src/resolvePeers.ts b/installing/deps-resolver/src/resolvePeers.ts index 2d3736e323..8eb530d155 100644 --- a/installing/deps-resolver/src/resolvePeers.ts +++ b/installing/deps-resolver/src/resolvePeers.ts @@ -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 ( if (typeof peerNodeId === 'string' && peerNodeId.startsWith('link:')) { return { name: alias, - version: filenamify(peerNodeId.slice(5), { replacement: '+' }), + version: linkPathToPeerVersion(peerNodeId.slice(5)), } } if (ctx.dedupePeers) { diff --git a/installing/deps-resolver/test/linkPathToPeerVersion.test.ts b/installing/deps-resolver/test/linkPathToPeerVersion.test.ts new file mode 100644 index 0000000000..68a8e7c09c --- /dev/null +++ b/installing/deps-resolver/test/linkPathToPeerVersion.test.ts @@ -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) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d29b523e2..4b0fbd049c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0880dcab72..70ea7e54b1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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