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.
This commit is contained in:
luo2430
2026-07-04 16:59:29 +08:00
committed by GitHub
parent 806c3ecd9f
commit ce5d5a57cb
8 changed files with 73 additions and 13 deletions

View File

@@ -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).

View File

@@ -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

View File

@@ -46,7 +46,11 @@ impl From<PatchNonSemverRangeError> 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

View File

@@ -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?.(),
])

View File

@@ -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 }]
})

View File

@@ -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<LockfileFile>(path.join(lockfileDir, WANTED_LOCKFILE))
expect(lockfile.patchedDependencies).toStrictEqual({
'is-positive@1.0.0': patchFileHash,
})
})

View File

@@ -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'

View File

@@ -0,0 +1,11 @@
import path from 'node:path'
import { map as mapValues } from 'ramda'
export function resolvePatchedDependencies (
patchedDependencies: Record<string, string> | undefined,
baseDir: string
): Record<string, string> | undefined {
if (!patchedDependencies) return undefined
return mapValues((patchFile) => path.resolve(baseDir, patchFile), patchedDependencies)
}