From ce5d5a57cb2b3225affbf414d34bd540e648aa83 Mon Sep 17 00:00:00 2001 From: luo2430 <127001012+luo2430@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:59:29 +0800 Subject: [PATCH] fix(pnpm): resolve relative patchedDependencies against lockfileDir (#12762) Ensure patchedDependencies paths are resolved relative to the lockfile directory before computing patch hashes. This fixes cases where relative patch paths were incorrectly resolved against the workspace root instead of the lockfile location. --- .changeset/patched-deps-lockfile-dir.md | 8 ++++ pacquet/crates/config/src/lib.rs | 2 - pacquet/crates/patching/src/resolve.rs | 6 ++- pnpm11/deps/status/src/checkDepsStatus.ts | 4 +- .../deps-installer/src/install/index.ts | 14 +++---- .../deps-installer/test/install/patch.ts | 40 ++++++++++++++++++- pnpm11/lockfile/settings-checker/src/index.ts | 1 + .../src/resolvePatchedDependencies.ts | 11 +++++ 8 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 .changeset/patched-deps-lockfile-dir.md create mode 100644 pnpm11/lockfile/settings-checker/src/resolvePatchedDependencies.ts diff --git a/.changeset/patched-deps-lockfile-dir.md b/.changeset/patched-deps-lockfile-dir.md new file mode 100644 index 0000000000..ece0a5883f --- /dev/null +++ b/.changeset/patched-deps-lockfile-dir.md @@ -0,0 +1,8 @@ +--- +"@pnpm/lockfile.settings-checker": minor +"@pnpm/deps.status": patch +"@pnpm/installing.deps-installer": patch +"pnpm": patch +--- + +Relative paths in `patchedDependencies` are now resolved against the lockfile directory when computing patch file hashes, so running `pnpm install` from a subdirectory no longer fails with `ENOENT` looking for the patch file in the wrong location [#12762](https://github.com/pnpm/pnpm/pull/12762). diff --git a/pacquet/crates/config/src/lib.rs b/pacquet/crates/config/src/lib.rs index 57b701fa55..b81bddb585 100644 --- a/pacquet/crates/config/src/lib.rs +++ b/pacquet/crates/config/src/lib.rs @@ -1665,8 +1665,6 @@ impl Config { /// `patchedDependencies` map the lockfile records: each configured /// key mapped to its patch file's SHA-256 hex digest. /// - /// The configured patch paths are resolved against the manifest - /// dir before hashing. /// Distinct from [`Self::resolved_patched_dependencies`], which /// groups the same entries by package name for the resolver — this /// keeps the user's verbatim keys so the lockfile is byte-faithful diff --git a/pacquet/crates/patching/src/resolve.rs b/pacquet/crates/patching/src/resolve.rs index 20920a7b58..9e7ac78506 100644 --- a/pacquet/crates/patching/src/resolve.rs +++ b/pacquet/crates/patching/src/resolve.rs @@ -46,7 +46,11 @@ impl From for ResolvePatchedDependenciesError { /// Each raw path is resolved against `workspace_dir`, hashed into a /// [`PatchInput`] entry, and then grouped. pnpm v11 reads install /// settings (including `patchedDependencies`) from the *workspace* -/// manifest, not from `package.json`'s `pnpm` field. +/// manifest, not from `package.json`'s `pnpm` field, and anchors +/// still-relative patch paths on `lockfileDir` when hashing and +/// applying patches. Pacquet's lockfile always lives at the workspace +/// root, so `workspace_dir` is that same base; if pacquet ever gains a +/// separate `lockfileDir`, this base must follow it to stay in parity. /// /// [`BTreeMap`]: std::collections::BTreeMap /// [`PatchGroup::range`]: crate::PatchGroup::range diff --git a/pnpm11/deps/status/src/checkDepsStatus.ts b/pnpm11/deps/status/src/checkDepsStatus.ts index 0f73a83ae6..96f04ec96b 100644 --- a/pnpm11/deps/status/src/checkDepsStatus.ts +++ b/pnpm11/deps/status/src/checkDepsStatus.ts @@ -23,6 +23,7 @@ import { calcPatchHashes, createOverridesMapFromParsed, getOutdatedLockfileSetting, + resolvePatchedDependencies, } from '@pnpm/lockfile.settings-checker' import { getWorkspacePackagesByDirectory, @@ -660,11 +661,12 @@ async function assertWantedLockfileUpToDate ( wantedLockfileDir, } = opts + const resolvedPatchedDeps = resolvePatchedDependencies(config.patchedDependencies, wantedLockfileDir) const [ patchedDependencies, pnpmfileChecksum, ] = await Promise.all([ - calcPatchHashes(config.patchedDependencies ?? {}), + calcPatchHashes(resolvedPatchedDeps ?? {}), config.hooks?.calculatePnpmfileChecksum?.(), ]) diff --git a/pnpm11/installing/deps-installer/src/install/index.ts b/pnpm11/installing/deps-installer/src/install/index.ts index cc19ff13cb..1517908887 100644 --- a/pnpm11/installing/deps-installer/src/install/index.ts +++ b/pnpm11/installing/deps-installer/src/install/index.ts @@ -62,6 +62,7 @@ import { calcPatchHashes, createOverridesMapFromParsed, getOutdatedLockfileSetting, + resolvePatchedDependencies, } from '@pnpm/lockfile.settings-checker' import { PACKAGE_MAP_FILENAME, writePackageMap, writePnpFile } from '@pnpm/lockfile.to-pnp' import { allProjectsAreUpToDate, satisfiesPackageManifest } from '@pnpm/lockfile.verification' @@ -638,21 +639,18 @@ export async function mutateModules ( } const packageExtensionsChecksum = hashObjectNullableWithPrefix(opts.packageExtensions) const pnpmfileChecksum = await opts.hooks.calculatePnpmfileChecksum?.() + const resolvedPatchedDeps = resolvePatchedDependencies(opts.patchedDependencies, opts.lockfileDir) const patchedDependencies = opts.ignorePackageManifest ? ctx.wantedLockfile.patchedDependencies - : (opts.patchedDependencies ? await calcPatchHashes(opts.patchedDependencies) : {}) - const patchGroupInput = opts.patchedDependencies + : (resolvedPatchedDeps ? await calcPatchHashes(resolvedPatchedDeps) : {}) + const patchGroupInput = resolvedPatchedDeps ? Object.fromEntries( Object.entries(patchedDependencies ?? {}).map(([key, hash]) => { - let patchFilePath = opts.patchedDependencies![key] - ? path.resolve(opts.lockfileDir, opts.patchedDependencies![key]) - : undefined + let patchFilePath: string | undefined = resolvedPatchedDeps[key] if (!patchFilePath) { const lastAt = key.lastIndexOf('@') const pkgName = lastAt > 0 ? key.slice(0, lastAt) : key - if (opts.patchedDependencies![pkgName]) { - patchFilePath = path.resolve(opts.lockfileDir, opts.patchedDependencies![pkgName]) - } + patchFilePath = resolvedPatchedDeps[pkgName] } return [key, { hash, patchFilePath }] }) diff --git a/pnpm11/installing/deps-installer/test/install/patch.ts b/pnpm11/installing/deps-installer/test/install/patch.ts index 70cca54ac8..e324f79607 100644 --- a/pnpm11/installing/deps-installer/test/install/patch.ts +++ b/pnpm11/installing/deps-installer/test/install/patch.ts @@ -2,15 +2,17 @@ import fs from 'node:fs' import path from 'node:path' import { afterAll, expect, jest, test } from '@jest/globals' -import { ENGINE_NAME } from '@pnpm/constants' +import { ENGINE_NAME, WANTED_LOCKFILE } from '@pnpm/constants' import { createHexHashFromFile } from '@pnpm/crypto.hash' import { install } from '@pnpm/installing.deps-installer' +import type { LockfileFile } from '@pnpm/lockfile.types' import { prepareEmpty } from '@pnpm/prepare' import type { PackageFilesIndex } from '@pnpm/store.cafs' import { StoreIndex, storeIndexKey } from '@pnpm/store.index' import { fixtures } from '@pnpm/test-fixtures' import { getIntegrity } from '@pnpm/testing.registry-mock' import { rimrafSync } from '@zkochan/rimraf' +import { readYamlFileSync } from 'read-yaml-file' import { testDefaults } from '../utils/index.js' @@ -570,3 +572,39 @@ test('patch package should fail when the name-only range patch fails to apply', expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched') }) + +test('patch with relative paths resolved against lockfileDir', async () => { + prepareEmpty() + // The lockfile and the patches dir live in the parent of the project dir + // (and of the cwd), so the still-relative patchedDependencies paths can + // only be read when resolved against lockfileDir. + const lockfileDir = path.resolve('..') + const patchesDir = path.join(lockfileDir, 'patches') + fs.mkdirSync(patchesDir, { recursive: true }) + fs.copyFileSync( + path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch'), + path.join(patchesDir, 'is-positive@1.0.0.patch') + ) + + const patchedDependencies = { + 'is-positive@1.0.0': 'patches/is-positive@1.0.0.patch', + } + const opts = testDefaults({ + fastUnpack: false, + patchedDependencies, + lockfileDir, + }, {}, {}, { packageImportMethod: 'hardlink' }) + await install({ + dependencies: { + 'is-positive': '1.0.0', + }, + }, opts) + + expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched') + + const patchFileHash = await createHexHashFromFile(path.join(patchesDir, 'is-positive@1.0.0.patch')) + const lockfile = readYamlFileSync(path.join(lockfileDir, WANTED_LOCKFILE)) + expect(lockfile.patchedDependencies).toStrictEqual({ + 'is-positive@1.0.0': patchFileHash, + }) +}) diff --git a/pnpm11/lockfile/settings-checker/src/index.ts b/pnpm11/lockfile/settings-checker/src/index.ts index 4bc88b9270..0091087320 100644 --- a/pnpm11/lockfile/settings-checker/src/index.ts +++ b/pnpm11/lockfile/settings-checker/src/index.ts @@ -1,3 +1,4 @@ export { calcPatchHashes } from './calcPatchHashes.js' export { createOverridesMapFromParsed } from './createOverridesMapFromParsed.js' export { type ChangedField, getOutdatedLockfileSetting } from './getOutdatedLockfileSetting.js' +export { resolvePatchedDependencies } from './resolvePatchedDependencies.js' diff --git a/pnpm11/lockfile/settings-checker/src/resolvePatchedDependencies.ts b/pnpm11/lockfile/settings-checker/src/resolvePatchedDependencies.ts new file mode 100644 index 0000000000..f8283833b6 --- /dev/null +++ b/pnpm11/lockfile/settings-checker/src/resolvePatchedDependencies.ts @@ -0,0 +1,11 @@ +import path from 'node:path' + +import { map as mapValues } from 'ramda' + +export function resolvePatchedDependencies ( + patchedDependencies: Record | undefined, + baseDir: string +): Record | undefined { + if (!patchedDependencies) return undefined + return mapValues((patchFile) => path.resolve(baseDir, patchFile), patchedDependencies) +}