feat(patching): version ranges and strict patches (#9236)

* feat: replace a config

This is a test commit to see if CI fails

* feat: `strictPatches`

* docs: future instruction

* feat: `strictPatches` → `allowPatchFailure`

* docs(changeset): correction

* test: getOptionsFromRootManifest

* fix: allowFailure

* feat: groupPatchedDependencies

* fix: update code after merge

* fix: star spec

* feat: error on invalid ranges

* fix: eslint

* docs: change task

* feat(patching): version ranges

* fix: `verifyPatches`

* refactor: move types to `@pnpm/patching.types`

* docs(changeset): add missing package

* refactor: move `verifyPatches` to `@pnpm/patching.config`

* test: fix

* feat: change error message of unused patches

* refactor: exact options into an interface

* test(patching): version range

* test(patching): allowPatchFailure

* docs: change wording

* docs: change wording

* test(patching): version range error

* test(patching): legacy behavior

* test: don't use star

* test(patching): strict versionless

* test: strictPatches

* chore(deps): `@pnpm/logger` must be peer

* docs: fix grammar

* refactor: rename `blank` to `all`

* refactor: use string

* refactor: use array for `PatchGroup.range`

* refactor: stop re-exporting `allPatchKeys`

* feat: re-export `PatchGroupRangeItem`

* refactor: move error creation into a class

* docs: replace "versionless" with "name-only"

* docs: coherent wordings

* test: exact version overrides range conflict

* test: tweak

* docs: consistent wordings

* docs: correct wordings

* refactor: rename `allowPatchFailure` to `ignorePatchFailures`

* feat: replace `strictPatches` with `ignorePatchFailures`

* docs: legacy behavior

* feat: introduce `allowUnusedPatches`

* docs(changeset): update

* docs: remove outdated comment

* docs: backward-compatibility
This commit is contained in:
Khải
2025-03-13 18:13:06 +07:00
committed by GitHub
parent 4885c3708b
commit 5f7be645e4
35 changed files with 1525 additions and 101 deletions

View File

@@ -0,0 +1,36 @@
---
"@pnpm/resolve-dependencies": major
"@pnpm/headless": major
"@pnpm/deps.graph-builder": major
"@pnpm/patching.config": major
"@pnpm/patching.types": minor
"@pnpm/core": patch
"pnpm": minor
---
Add an ability to patch dependencies by version ranges. Exact versions override version ranges, which in turn override name-only patches. Version range `*` is the same as name-only, except that patch application failure will not be ignored.
For example:
```yaml
patchedDependencies:
foo: patches/foo-1.patch
foo@^2.0.0: patches/foo-2.patch
foo@2.1.0: patches/foo-3.patch
```
The above configuration would apply `patches/foo-3.patch` to `foo@2.1.0`, `patches/foo-2.patch` to all `foo` versions which satisfy `^2.0.0` except `2.1.0`, and `patches/foo-1.patch` to the remaining `foo` versions.
> [!WARNING]
> The version ranges should not overlap. If you want to specialize a sub range, make sure to exclude it from the other keys. For example:
>
> ```yaml
> # pnpm-workspace.yaml
> patchedDependencies:
> # the specialized sub range
> 'foo@2.2.0-2.8.0': patches/foo.2.2.0-2.8.0.patch
> # the more general patch, excluding the sub range above
> 'foo@>=2.0.0 <2.2.0 || >2.8.0': 'patches/foo.gte2.patch
> ```
>
> In most cases, however, it's sufficient to just define an exact version to override the range.

View File

@@ -0,0 +1,14 @@
---
"@pnpm/resolve-dependencies": major
"@pnpm/config": minor
"@pnpm/core": major
"@pnpm/types": minor
"@pnpm/headless": minor
"@pnpm/patching.config": minor
"@pnpm/patching.types": patch
"@pnpm/plugin-commands-script-runners": patch
"@pnpm/build-modules": minor
"pnpm": minor
---
Rename `pnpm.allowNonAppliedPatches` to `pnpm.allowUnusedPatches`. The old name is still supported but it would print a deprecation warning message.

View File

@@ -0,0 +1,20 @@
---
"@pnpm/resolve-dependencies": major
"@pnpm/config": minor
"@pnpm/core": major
"@pnpm/types": minor
"@pnpm/headless": minor
"@pnpm/patching.config": minor
"@pnpm/patching.types": patch
"@pnpm/plugin-commands-script-runners": patch
"@pnpm/build-modules": minor
"pnpm": minor
---
Add `pnpm.ignorePatchFailures` to manage whether pnpm would ignore patch application failures.
If `ignorePatchFailures` is not set, pnpm would throw an error when patches with exact versions or version ranges fail to apply, and it would ignore failures from name-only patches.
If `ignorePatchFailures` is explicitly set to `false`, pnpm would throw an error when any type of patch fails to apply.
If `ignorePatchFailures` is explicitly set to `true`, pnpm would print a warning when any type of patch fails to apply.

View File

@@ -59,6 +59,9 @@
"realpath-missing": "catalog:",
"which": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": ">=5.1.0 <1001.0.0"
},
"devDependencies": {
"@pnpm/config": "workspace:*",
"@pnpm/prepare": "workspace:*",

View File

@@ -9,11 +9,14 @@ import {
type PnpmSettings,
} from '@pnpm/types'
import mapValues from 'ramda/src/map'
import omit from 'ramda/src/omit'
import pick from 'ramda/src/pick'
import { globalWarn } from '@pnpm/logger'
export type OptionsFromRootManifest = {
allowedDeprecatedVersions?: AllowedDeprecatedVersions
allowNonAppliedPatches?: boolean
allowUnusedPatches?: boolean
ignorePatchFailures?: boolean
overrides?: Record<string, string>
neverBuiltDependencies?: string[]
onlyBuiltDependencies?: string[]
@@ -29,11 +32,13 @@ export type OptionsFromRootManifest = {
export function getOptionsFromRootManifest (manifestDir: string, manifest: ProjectManifest): OptionsFromRootManifest {
const settings: OptionsFromRootManifest = getOptionsFromPnpmSettings(manifestDir, {
...pick([
'allowNonAppliedPatches',
'allowedDeprecatedVersions',
'allowNonAppliedPatches',
'allowUnusedPatches',
'configDependencies',
'ignoredBuiltDependencies',
'ignoredOptionalDependencies',
'ignorePatchFailures',
'neverBuiltDependencies',
'onlyBuiltDependencies',
'onlyBuiltDependenciesFile',
@@ -55,7 +60,8 @@ export function getOptionsFromRootManifest (manifestDir: string, manifest: Proje
}
export function getOptionsFromPnpmSettings (manifestDir: string, pnpmSettings: PnpmSettings, manifest?: ProjectManifest): OptionsFromRootManifest {
const settings: OptionsFromRootManifest = { ...pnpmSettings }
const renamedKeys = ['allowNonAppliedPatches'] as const satisfies Array<keyof PnpmSettings>
const settings: OptionsFromRootManifest = omit(renamedKeys, pnpmSettings)
if (settings.overrides) {
if (Object.keys(settings.overrides).length === 0) {
delete settings.overrides
@@ -73,6 +79,13 @@ export function getOptionsFromPnpmSettings (manifestDir: string, pnpmSettings: P
settings.patchedDependencies[dep] = path.join(manifestDir, patchFile)
}
}
if (pnpmSettings.allowNonAppliedPatches != null) {
globalWarn('allowNonAppliedPatches is deprecated, use allowUnusedPatches instead.')
settings.allowUnusedPatches ??= pnpmSettings.allowNonAppliedPatches
}
if (pnpmSettings.ignorePatchFailures != null) {
settings.ignorePatchFailures = pnpmSettings.ignorePatchFailures
}
return settings
}

View File

@@ -93,6 +93,62 @@ test('getOptionsFromRootManifest() should return the list from onlyBuiltDependen
expect(options.onlyBuiltDependencies).toStrictEqual(['electron'])
})
test('getOptionsFromRootManifest() should derive allowUnusedPatches from allowNonAppliedPatches (legacy behavior)', () => {
expect(getOptionsFromRootManifest(process.cwd(), {
pnpm: {
allowNonAppliedPatches: false,
},
})).toStrictEqual({
allowUnusedPatches: false,
})
expect(getOptionsFromRootManifest(process.cwd(), {
pnpm: {
allowNonAppliedPatches: true,
},
})).toStrictEqual({
allowUnusedPatches: true,
})
})
test('allowUnusedPatches should override allowNonAppliedPatches', () => {
expect(getOptionsFromRootManifest(process.cwd(), {
pnpm: {
allowNonAppliedPatches: false,
allowUnusedPatches: false,
},
})).toStrictEqual({
allowUnusedPatches: false,
})
expect(getOptionsFromRootManifest(process.cwd(), {
pnpm: {
allowNonAppliedPatches: true,
allowUnusedPatches: false,
},
})).toStrictEqual({
allowUnusedPatches: false,
})
expect(getOptionsFromRootManifest(process.cwd(), {
pnpm: {
allowNonAppliedPatches: false,
allowUnusedPatches: false,
},
})).toStrictEqual({
allowUnusedPatches: false,
})
expect(getOptionsFromRootManifest(process.cwd(), {
pnpm: {
allowNonAppliedPatches: true,
allowUnusedPatches: false,
},
})).toStrictEqual({
allowUnusedPatches: false,
})
})
test('getOptionsFromRootManifest() should return patchedDependencies', () => {
const options = getOptionsFromRootManifest(process.cwd(), {
pnpm: {

View File

@@ -15,8 +15,8 @@ import {
import { logger } from '@pnpm/logger'
import { type IncludedDependencies } from '@pnpm/modules-yaml'
import { packageIsInstallable } from '@pnpm/package-is-installable'
import { getPatchInfo } from '@pnpm/patching.config'
import { type PatchFile, type PatchInfo } from '@pnpm/patching.types'
import { type PatchGroupRecord, getPatchInfo } from '@pnpm/patching.config'
import { type PatchInfo } from '@pnpm/patching.types'
import { type DepPath, type SupportedArchitectures, type Registries, type PkgIdWithPatchHash, type ProjectId } from '@pnpm/types'
import {
type PkgRequestFetchResult,
@@ -63,7 +63,7 @@ export interface LockfileToDepGraphOptions {
lockfileDir: string
nodeVersion: string
pnpmVersion: string
patchedDependencies?: Record<string, PatchFile>
patchedDependencies?: PatchGroupRecord
registries: Registries
sideEffectsCacheRead: boolean
skipped: Set<DepPath>

View File

@@ -23,6 +23,7 @@ export async function buildModules<T extends string> (
rootDepPaths: T[],
opts: {
allowBuild?: (pkgName: string) => boolean
ignorePatchFailures?: boolean
ignoredBuiltDependencies?: string[]
childConcurrency?: number
depsToBuild?: Set<string>
@@ -103,6 +104,7 @@ async function buildDependency<T extends string> (
depPath: T,
depGraph: DependenciesGraph<T>,
opts: {
ignorePatchFailures?: boolean
extraBinPaths?: string[]
extraNodePaths?: string[]
extraEnv?: Record<string, string>
@@ -138,7 +140,10 @@ async function buildDependency<T extends string> (
let isPatched = false
if (depNode.patch) {
const { file, strict } = depNode.patch
isPatched = applyPatchToDir({ allowFailure: !strict, patchedDir: depNode.dir, patchFilePath: file.path })
// `strict` is a legacy property which was kept to preserve backward compatibility.
// Once a major version of pnpm is released, `strict` should be removed completely.
const allowFailure: boolean = opts.ignorePatchFailures ?? !strict
isPatched = applyPatchToDir({ allowFailure, patchedDir: depNode.dir, patchFilePath: file.path })
}
const hasSideEffects = !opts.ignoreScripts && await runPostinstallHooks({
depPath,

View File

@@ -144,7 +144,9 @@ export interface PnpmSettings {
ignoredOptionalDependencies?: string[]
peerDependencyRules?: PeerDependencyRules
allowedDeprecatedVersions?: AllowedDeprecatedVersions
allowNonAppliedPatches?: boolean
allowNonAppliedPatches?: boolean // deprecated: use allowUnusedPatches instead
allowUnusedPatches?: boolean
ignorePatchFailures?: boolean
patchedDependencies?: Record<string, string>
updateConfig?: {
ignoreDependencies?: string[]

View File

@@ -31,10 +31,17 @@
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\""
},
"dependencies": {
"@pnpm/patching.types": "workspace:*"
"@pnpm/dependency-path": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/patching.types": "workspace:*",
"semver": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": ">=5.1.0 <1001.0.0"
},
"devDependencies": {
"@pnpm/patching.config": "workspace:*"
"@pnpm/patching.config": "workspace:*",
"@types/semver": "catalog:"
},
"engines": {
"node": ">=18.12"

View File

@@ -0,0 +1,16 @@
import { type PatchGroupRecord } from '@pnpm/patching.types'
export function * allPatchKeys (patchedDependencies: PatchGroupRecord): Generator<string> {
for (const name in patchedDependencies) {
const group = patchedDependencies[name]
for (const version in group.exact) {
yield group.exact[version].key
}
for (const item of group.range) {
yield item.patch.key
}
if (group.all) {
yield group.all.key
}
}
}

View File

@@ -0,0 +1,39 @@
import { PnpmError } from '@pnpm/error'
import { type ExtendedPatchInfo, type PatchGroupRangeItem, type PatchGroupRecord } from '@pnpm/patching.types'
import { satisfies } from 'semver'
class PatchKeyConflictError extends PnpmError {
constructor (
pkgName: string,
pkgVersion: string,
satisfied: Array<Pick<PatchGroupRangeItem, 'version'>>
) {
const pkgId = `${pkgName}@${pkgVersion}`
const satisfiedVersions = satisfied.map(({ version }) => version)
const message = `Unable to choose between ${satisfied.length} version ranges to patch ${pkgId}: ${satisfiedVersions.join(', ')}`
super('PATCH_KEY_CONFLICT', message, {
hint: `Explicitly set the exact version (${pkgId}) to resolve conflict`,
})
}
}
export function getPatchInfo (
patchFileGroups: PatchGroupRecord | undefined,
pkgName: string,
pkgVersion: string
): ExtendedPatchInfo | undefined {
if (!patchFileGroups?.[pkgName]) return undefined
const exactVersion = patchFileGroups[pkgName].exact[pkgVersion]
if (exactVersion) return exactVersion
const satisfied = patchFileGroups[pkgName].range.filter(item => satisfies(pkgVersion, item.version))
if (satisfied.length > 1) {
throw new PatchKeyConflictError(pkgName, pkgVersion, satisfied)
}
if (satisfied.length === 1) {
return satisfied[0].patch
}
return patchFileGroups[pkgName].all
}

View File

@@ -0,0 +1,49 @@
import * as dp from '@pnpm/dependency-path'
import { PnpmError } from '@pnpm/error'
import { type PatchFile, type PatchGroup, type PatchGroupRecord } from '@pnpm/patching.types'
import { validRange } from 'semver'
export function groupPatchedDependencies (patchedDependencies: Record<string, PatchFile>): PatchGroupRecord {
const result: PatchGroupRecord = {}
function getGroup (name: string): PatchGroup {
let group: PatchGroup | undefined = result[name]
if (group) return group
group = {
exact: {},
range: [],
all: undefined,
}
result[name] = group
return group
}
for (const key in patchedDependencies) {
const file = patchedDependencies[key]
const { name, version, nonSemverVersion } = dp.parse(key)
if (name && version) {
getGroup(name).exact[version] = { strict: true, file, key }
continue
}
if (name && nonSemverVersion) {
if (!validRange(nonSemverVersion)) {
throw new PnpmError('PATCH_NON_SEMVER_RANGE', `${nonSemverVersion} is not a valid semantic version range.`)
}
if (nonSemverVersion.trim() === '*') {
getGroup(name).all = { strict: true, file, key }
} else {
getGroup(name).range.push({
version: nonSemverVersion,
patch: { strict: true, file, key },
})
}
continue
}
// Set `strict` to `false` to preserve backward compatibility.
getGroup(key).all = { strict: false, file, key }
}
return result
}

View File

@@ -1,29 +1,11 @@
import { type PatchFile, type PatchInfo } from '@pnpm/patching.types'
export interface ExtendedPatchInfo extends PatchInfo {
key: string
}
export function getPatchInfo (
patchedDependencies: Record<string, PatchFile> | undefined,
pkgName: string,
pkgVersion: string
): ExtendedPatchInfo | undefined {
if (!patchedDependencies) return undefined
const pkgNameAndVersion = `${pkgName}@${pkgVersion}`
if (patchedDependencies[pkgNameAndVersion]) {
return {
file: patchedDependencies[pkgNameAndVersion],
key: pkgNameAndVersion,
strict: true,
}
}
if (patchedDependencies[pkgName]) {
return {
file: patchedDependencies[pkgName],
key: pkgName,
strict: false,
}
}
return undefined
}
export {
type ExtendedPatchInfo,
type PatchFile,
type PatchInfo,
type PatchGroup,
type PatchGroupRangeItem,
type PatchGroupRecord,
} from '@pnpm/patching.types'
export { groupPatchedDependencies } from './groupPatchedDependencies'
export { getPatchInfo } from './getPatchInfo'
export { type VerifyPatchesOptions, verifyPatches } from './verifyPatches'

View File

@@ -0,0 +1,31 @@
import { PnpmError } from '@pnpm/error'
import { globalWarn } from '@pnpm/logger'
import { type PatchGroupRecord } from '@pnpm/patching.types'
import { allPatchKeys } from './allPatchKeys'
export interface VerifyPatchesOptions {
patchedDependencies: PatchGroupRecord
appliedPatches: Set<string>
allowUnusedPatches: boolean
}
export function verifyPatches ({
patchedDependencies,
appliedPatches,
allowUnusedPatches,
}: VerifyPatchesOptions): void {
const unusedPatches: string[] = []
for (const patchKey of allPatchKeys(patchedDependencies)) {
if (!appliedPatches.has(patchKey)) unusedPatches.push(patchKey)
}
if (!unusedPatches.length) return
const message = `The following patches were not used: ${unusedPatches.join(', ')}`
if (allowUnusedPatches) {
globalWarn(message)
return
}
throw new PnpmError('UNUSED_PATCH', message, {
hint: 'Either remove them from "patchedDependencies" or update them to match packages in your dependencies.',
})
}

View File

@@ -0,0 +1,219 @@
import { getPatchInfo } from '../src/getPatchInfo'
import { type PatchGroupRecord } from '../src/index'
test('getPatchInfo(undefined, ...) returns undefined', () => {
expect(getPatchInfo(undefined, 'foo', '1.0.0')).toBeUndefined()
})
test('getPatchInfo() returns an exact version patch if the name and version match', () => {
const patchedDependencies = {
foo: {
exact: {
'1.0.0': {
file: {
path: 'patches/foo@1.0.0.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@1.0.0',
strict: true,
},
},
range: [],
all: undefined,
},
} satisfies PatchGroupRecord
expect(getPatchInfo(patchedDependencies, 'foo', '1.0.0')).toStrictEqual(patchedDependencies.foo.exact['1.0.0'])
expect(getPatchInfo(patchedDependencies, 'foo', '1.1.0')).toBeUndefined()
expect(getPatchInfo(patchedDependencies, 'foo', '2.0.0')).toBeUndefined()
expect(getPatchInfo(patchedDependencies, 'bar', '1.0.0')).toBeUndefined()
})
test('getPatchInfo() returns a range version patch if the name matches and the version satisfied', () => {
const patchedDependencies = {
foo: {
exact: {},
range: [{
version: '1',
patch: {
file: {
path: 'patches/foo@1.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@1',
strict: true,
},
}],
all: undefined,
},
} satisfies PatchGroupRecord
expect(getPatchInfo(patchedDependencies, 'foo', '1.0.0')).toStrictEqual(patchedDependencies.foo.range[0].patch)
expect(getPatchInfo(patchedDependencies, 'foo', '1.1.0')).toStrictEqual(patchedDependencies.foo.range[0].patch)
expect(getPatchInfo(patchedDependencies, 'foo', '2.0.0')).toBeUndefined()
expect(getPatchInfo(patchedDependencies, 'bar', '1.0.0')).toBeUndefined()
})
test('getPatchInfo() returns name-only patch if the name matches', () => {
const patchedDependencies = {
foo: {
exact: {},
range: [],
all: {
file: {
path: 'patches/foo.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo',
strict: true,
},
},
} satisfies PatchGroupRecord
expect(getPatchInfo(patchedDependencies, 'foo', '1.0.0')).toStrictEqual(patchedDependencies.foo.all)
expect(getPatchInfo(patchedDependencies, 'foo', '1.1.0')).toStrictEqual(patchedDependencies.foo.all)
expect(getPatchInfo(patchedDependencies, 'foo', '2.0.0')).toStrictEqual(patchedDependencies.foo.all)
expect(getPatchInfo(patchedDependencies, 'bar', '1.0.0')).toBeUndefined()
})
test('exact version patches override version range patches, version range patches override name-only patches', () => {
const patchedDependencies = {
foo: {
exact: {
'1.0.0': {
file: {
path: 'patches/foo@1.0.0.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@1.0.0',
strict: true,
},
'1.1.0': {
file: {
path: 'patches/foo@1.1.0.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@1.1.0',
strict: true,
},
},
range: [
{
version: '1',
patch: {
file: {
path: 'patches/foo@1.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@1',
strict: true,
},
},
{
version: '2',
patch: {
file: {
path: 'patches/foo@2.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@2',
strict: true,
},
},
],
all: {
file: {
path: 'patches/foo.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo',
strict: true,
},
},
} satisfies PatchGroupRecord
expect(getPatchInfo(patchedDependencies, 'foo', '1.0.0')).toStrictEqual(patchedDependencies.foo.exact['1.0.0'])
expect(getPatchInfo(patchedDependencies, 'foo', '1.1.0')).toStrictEqual(patchedDependencies.foo.exact['1.1.0'])
expect(getPatchInfo(patchedDependencies, 'foo', '1.1.1')).toStrictEqual(patchedDependencies.foo.range[0].patch)
expect(getPatchInfo(patchedDependencies, 'foo', '2.0.0')).toStrictEqual(patchedDependencies.foo.range[1].patch)
expect(getPatchInfo(patchedDependencies, 'foo', '2.1.0')).toStrictEqual(patchedDependencies.foo.range[1].patch)
expect(getPatchInfo(patchedDependencies, 'foo', '3.0.0')).toStrictEqual(patchedDependencies.foo.all)
expect(getPatchInfo(patchedDependencies, 'bar', '1.0.0')).toBeUndefined()
})
test('getPatchInfo(_, name, version) throws an error when name@version matches more than one version range patches', () => {
const patchedDependencies = {
foo: {
exact: {},
range: [
{
version: '>=1.0.0 <3.0.0',
patch: {
file: {
path: 'patches/foo_a.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@>=1.0.0 <3.0.0',
strict: true,
},
},
{
version: '>=2.0.0',
patch: {
file: {
path: 'patches/foo_b.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@>=2.0.0',
strict: true,
},
},
],
all: undefined,
},
} satisfies PatchGroupRecord
expect(() => getPatchInfo(patchedDependencies, 'foo', '2.1.0')).toThrow(expect.objectContaining({
code: 'ERR_PNPM_PATCH_KEY_CONFLICT',
message: 'Unable to choose between 2 version ranges to patch foo@2.1.0: >=1.0.0 <3.0.0, >=2.0.0',
hint: 'Explicitly set the exact version (foo@2.1.0) to resolve conflict',
}))
})
test('getPatchInfo(_, name, version) does not throw an error when name@version matches an exact version patch and more than one version range patches', () => {
const patchedDependencies = {
foo: {
exact: {
'2.1.0': {
file: {
path: 'patches/foo_a.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@>=1.0.0 <3.0.0',
strict: true,
},
},
range: [
{
version: '>=1.0.0 <3.0.0',
patch: {
file: {
path: 'patches/foo_b.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@>=1.0.0 <3.0.0',
strict: true,
},
},
{
version: '>=2.0.0',
patch: {
file: {
path: 'patches/foo_c.patch',
hash: '00000000000000000000000000000000',
},
key: 'foo@>=2.0.0',
strict: true,
},
},
],
all: undefined,
},
} satisfies PatchGroupRecord
expect(getPatchInfo(patchedDependencies, 'foo', '2.1.0')).toStrictEqual(patchedDependencies.foo.exact['2.1.0'])
})

View File

@@ -0,0 +1,119 @@
import { type ExtendedPatchInfo, type PatchFile, type PatchGroupRecord } from '@pnpm/patching.types'
import { groupPatchedDependencies } from '../src/groupPatchedDependencies'
function sanitizePatchGroupRecord (patchGroups: PatchGroupRecord): PatchGroupRecord {
for (const name in patchGroups) {
patchGroups[name].range.sort((a, b) => a.version.localeCompare(b.version))
}
return patchGroups
}
const _groupPatchedDependencies: typeof groupPatchedDependencies = patchedDependencies => sanitizePatchGroupRecord(groupPatchedDependencies(patchedDependencies))
test('groups patchedDependencies according to names, match types, and versions', () => {
const patchedDependencies = {
'exact-version-only@0.0.0': {
hash: '00000000000000000000000000000000',
path: 'patches/exact-version-only@2.10.patch',
},
'exact-version-only@1.2.3': {
hash: '00000000000000000000000000000000',
path: 'patches/exact-version-only@1.2.3.patch',
},
'exact-version-only@2.1.0': {
hash: '00000000000000000000000000000000',
path: 'patches/exact-version-only@2.10.patch',
},
'version-range-only@~1.2.0': {
hash: '00000000000000000000000000000000',
path: 'patches/version-range-only@~1.2.0.patch',
},
'version-range-only@4': {
hash: '00000000000000000000000000000000',
path: 'patches/version-range-only@4.patch',
},
'star-version-range@*': {
hash: '00000000000000000000000000000000',
path: 'patches/star-version-range.patch',
},
'without-versions': {
hash: '00000000000000000000000000000000',
path: 'patches/without-versions.patch',
},
'mixed-style@0.1.2': {
hash: '00000000000000000000000000000000',
path: 'patches/mixed-style@0.1.2.patch',
},
'mixed-style@1.x.x': {
hash: '00000000000000000000000000000000',
path: 'patches/mixed-style@1.x.x.patch',
},
'mixed-style': {
hash: '00000000000000000000000000000000',
path: 'patches/mixed-style.patch',
},
} satisfies Record<string, PatchFile>
const info = (strict: boolean, key: keyof typeof patchedDependencies): ExtendedPatchInfo => ({
strict,
key,
file: patchedDependencies[key],
})
expect(_groupPatchedDependencies(patchedDependencies)).toStrictEqual({
'exact-version-only': {
exact: {
'0.0.0': info(true, 'exact-version-only@0.0.0'),
'1.2.3': info(true, 'exact-version-only@1.2.3'),
'2.1.0': info(true, 'exact-version-only@2.1.0'),
},
range: [],
all: undefined,
},
'version-range-only': {
exact: {},
range: [
{
version: '~1.2.0',
patch: info(true, 'version-range-only@~1.2.0'),
},
{
version: '4',
patch: info(true, 'version-range-only@4'),
},
],
all: undefined,
},
'star-version-range': {
exact: {},
range: [],
all: info(true, 'star-version-range@*'),
},
'without-versions': {
exact: {},
range: [],
all: info(false, 'without-versions'),
},
'mixed-style': {
exact: {
'0.1.2': info(true, 'mixed-style@0.1.2'),
},
range: [
{
version: '1.x.x',
patch: info(true, 'mixed-style@1.x.x'),
},
],
all: info(false, 'mixed-style'),
},
} as PatchGroupRecord)
})
test('errors on invalid version range', async () => {
expect(() => _groupPatchedDependencies({
'foo@link:packages/foo': {
hash: '00000000000000000000000000000000',
path: 'patches/foo.patch',
},
})).toThrow(expect.objectContaining({
code: 'ERR_PNPM_PATCH_NON_SEMVER_RANGE',
}))
})

View File

@@ -1,11 +1,15 @@
import { getPatchInfo } from '../src/index'
import { type PatchFile } from '@pnpm/patching.types'
import { getPatchInfo, groupPatchedDependencies } from '../src/index'
const _getPatchInfo = (patchedDependencies: Record<string, PatchFile>, name: string, version: string) =>
getPatchInfo(groupPatchedDependencies(patchedDependencies), name, version)
test('getPatchInfo(undefined, ...) returns undefined', () => {
expect(getPatchInfo(undefined, 'foo', '1.0.0')).toBeUndefined()
})
test('getPatchInfo(_, name, version) returns strict=true if name@version exists', () => {
expect(getPatchInfo({
expect(_getPatchInfo({
'foo@1.0.0': {
path: 'patches/foo@1.0.0.patch',
hash: '00000000000000000000000000000000',
@@ -21,7 +25,7 @@ test('getPatchInfo(_, name, version) returns strict=true if name@version exists'
})
test('getPatchInfo(_, name, version) returns strict=false if name exists and name@version does not exist', () => {
expect(getPatchInfo({
expect(_getPatchInfo({
foo: {
path: 'patches/foo.patch',
hash: '00000000000000000000000000000000',
@@ -37,7 +41,7 @@ test('getPatchInfo(_, name, version) returns strict=false if name exists and nam
})
test('getPatchInfo(_, name, version) prioritizes name@version over name if both exist', () => {
expect(getPatchInfo({
expect(_getPatchInfo({
foo: {
path: 'patches/foo.patch',
hash: '00000000000000000000000000000000',
@@ -57,7 +61,7 @@ test('getPatchInfo(_, name, version) prioritizes name@version over name if both
})
test('getPatchInfo(_, name, version) does not access wrong name', () => {
expect(getPatchInfo({
expect(_getPatchInfo({
'bar@1.0.0': {
path: 'patches/bar@1.0.0.patch',
hash: '00000000000000000000000000000000',
@@ -66,7 +70,7 @@ test('getPatchInfo(_, name, version) does not access wrong name', () => {
})
test('getPatchInfo(_, name, version) does not access wrong version', () => {
expect(getPatchInfo({
expect(_getPatchInfo({
'foo@2.0.0': {
path: 'patches/foo@2.0.0.patch',
hash: '00000000000000000000000000000000',

View File

@@ -9,6 +9,12 @@
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../packages/dependency-path"
},
{
"path": "../../packages/error"
},
{
"path": "../types"
}

View File

@@ -3,7 +3,30 @@ export interface PatchFile {
hash: string
}
// TODO: replace all occurrences of PatchInfo with PatchFile before the next major version is released
export interface PatchInfo {
strict: boolean
file: PatchFile
}
export interface ExtendedPatchInfo extends PatchInfo {
key: string
}
export interface PatchGroupRangeItem {
version: string
patch: ExtendedPatchInfo
}
/** A group of {@link ExtendedPatchInfo}s which correspond to a package name. */
export interface PatchGroup {
/** Maps exact versions to {@link ExtendedPatchInfo}. */
exact: Record<string, ExtendedPatchInfo>
/** Pairs of version ranges and {@link ExtendedPatchInfo}. */
range: PatchGroupRangeItem[]
/** The {@link ExtendedPatchInfo} without exact versions or version ranges. */
all: ExtendedPatchInfo | undefined
}
/** Maps package names to their corresponding groups. */
export type PatchGroupRecord = Record<string, PatchGroup>

View File

@@ -92,6 +92,7 @@
"@pnpm/package-requester": "workspace:*",
"@pnpm/parse-overrides": "workspace:*",
"@pnpm/parse-wanted-dependency": "workspace:*",
"@pnpm/patching.config": "workspace:*",
"@pnpm/pkg-manager.direct-dep-linker": "workspace:*",
"@pnpm/read-modules-dir": "workspace:*",
"@pnpm/read-project-manifest": "workspace:*",

View File

@@ -61,7 +61,7 @@ export async function getPeerDependencyIssues (
{
currentLockfile: ctx.currentLockfile,
allowedDeprecatedVersions: {},
allowNonAppliedPatches: false,
allowUnusedPatches: false,
catalogs: opts.catalogs,
defaultUpdateDepth: -1,
dedupePeerDependents: opts.dedupePeerDependents,

View File

@@ -112,7 +112,8 @@ export interface StrictInstallOptions {
enableModulesDir: boolean
modulesCacheMaxAge: number
allowedDeprecatedVersions: AllowedDeprecatedVersions
allowNonAppliedPatches: boolean
ignorePatchFailures?: boolean
allowUnusedPatches: boolean
preferSymlinkedExecutables: boolean
resolutionMode: 'highest' | 'time-based' | 'lowest-direct'
resolvePeersFromWorkspaceRoot: boolean
@@ -169,7 +170,8 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
}
return {
allowedDeprecatedVersions: {},
allowNonAppliedPatches: false,
allowUnusedPatches: false,
ignorePatchFailures: undefined,
autoInstallPeers: true,
autoInstallPeersFromHighestMatch: false,
childConcurrency: 5,

View File

@@ -36,7 +36,6 @@ import {
writeLockfiles,
writeWantedLockfile,
cleanGitBranchLockfiles,
type PatchFile,
type CatalogSnapshots,
} from '@pnpm/lockfile.fs'
import { writePnpFile } from '@pnpm/lockfile-to-pnp'
@@ -46,6 +45,7 @@ import { getPreferredVersionsFromLockfileAndManifests } from '@pnpm/lockfile.pre
import { logger, globalInfo, streamParser } from '@pnpm/logger'
import { getAllDependenciesFromManifest, getAllUniqueSpecs } from '@pnpm/manifest-utils'
import { writeModulesManifest } from '@pnpm/modules-yaml'
import { type PatchGroupRecord, groupPatchedDependencies } from '@pnpm/patching.config'
import { safeReadProjectManifestOnly } from '@pnpm/read-project-manifest'
import {
getWantedDependencies,
@@ -367,6 +367,7 @@ export async function mutateModules (
path: path.join(opts.lockfileDir, patchFile.path),
}), patchedDependencies)
: undefined
const patchGroups = patchedDependenciesWithResolvedPath && groupPatchedDependencies(patchedDependenciesWithResolvedPath)
const frozenLockfile = opts.frozenLockfile ||
opts.frozenLockfileIfExists && ctx.existsNonEmptyWantedLockfile
let outdatedLockfileSettings = false
@@ -418,7 +419,7 @@ export async function mutateModules (
const frozenInstallResult = await tryFrozenInstall({
frozenLockfile,
needsFullResolution,
patchedDependenciesWithResolvedPath,
patchGroups,
upToDateLockfileMajorVersion,
})
if (frozenInstallResult !== null) {
@@ -543,7 +544,7 @@ export async function mutateModules (
pruneVirtualStore,
scriptsOpts,
updateLockfileMinorVersion: true,
patchedDependencies: patchedDependenciesWithResolvedPath,
patchedDependencies: patchGroups,
})
return {
@@ -582,12 +583,12 @@ export async function mutateModules (
async function tryFrozenInstall ({
frozenLockfile,
needsFullResolution,
patchedDependenciesWithResolvedPath,
patchGroups,
upToDateLockfileMajorVersion,
}: {
frozenLockfile: boolean
needsFullResolution: boolean
patchedDependenciesWithResolvedPath?: Record<string, PatchFile>
patchGroups?: PatchGroupRecord
upToDateLockfileMajorVersion: boolean
}): Promise<InnerInstallResult | { needsFullResolution: boolean } | null> {
const isFrozenInstallPossible =
@@ -692,7 +693,7 @@ Note that in CI environments, this setting is enabled by default.`,
pnpmVersion: opts.packageManager.name === 'pnpm' ? opts.packageManager.version : '',
},
currentHoistedLocations: ctx.modulesFile?.hoistedLocations,
patchedDependencies: patchedDependenciesWithResolvedPath,
patchedDependencies: patchGroups,
selectedProjectDirs: projects.map((project) => project.rootDir),
allProjects: ctx.projects,
prunedAt: ctx.modulesFile?.prunedAt,
@@ -919,7 +920,7 @@ type InstallFunction = (
projects: ImporterToUpdate[],
ctx: PnpmContext,
opts: Omit<StrictInstallOptions, 'patchedDependencies'> & {
patchedDependencies?: Record<string, PatchFile>
patchedDependencies?: PatchGroupRecord
makePartialCurrentLockfile: boolean
needsFullResolution: boolean
neverBuiltDependencies?: string[]
@@ -1031,7 +1032,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
projects,
{
allowedDeprecatedVersions: opts.allowedDeprecatedVersions,
allowNonAppliedPatches: opts.allowNonAppliedPatches,
allowUnusedPatches: opts.allowUnusedPatches,
autoInstallPeers: opts.autoInstallPeers,
autoInstallPeersFromHighestMatch: opts.autoInstallPeersFromHighestMatch,
catalogs: opts.catalogs,
@@ -1203,6 +1204,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
}
ignoredBuilds = (await buildModules(dependenciesGraph, rootNodes, {
allowBuild,
ignorePatchFailures: opts.ignorePatchFailures,
ignoredBuiltDependencies: opts.ignoredBuiltDependencies,
childConcurrency: opts.childConcurrency,
depsStateCache,

View File

@@ -14,7 +14,7 @@ import { testDefaults } from '../utils'
const f = fixtures(__dirname)
test('patch package', async () => {
test('patch package with exact version', async () => {
const reporter = sinon.spy()
const project = prepareEmpty()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
@@ -109,7 +109,102 @@ test('patch package', async () => {
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched')
})
test('patch package reports warning if not all patches are applied and allowNonAppliedPatches is set', async () => {
test('patch package with version range', async () => {
const reporter = sinon.spy()
const project = prepareEmpty()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
const patchedDependencies = {
'is-positive@1': patchPath,
}
const opts = testDefaults({
neverBuiltDependencies: undefined,
onlyBuiltDependencies: [],
fastUnpack: false,
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
patchedDependencies,
reporter,
}, {}, {}, { packageImportMethod: 'hardlink' })
await install({
dependencies: {
'is-positive': '1.0.0',
},
}, opts)
expect(reporter.calledWithMatch({
packageNames: [],
level: 'debug',
name: 'pnpm:ignored-scripts',
} as IgnoredScriptsLog)).toBeTruthy()
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched')
const patchFileHash = await createHexHashFromFile(patchPath)
const lockfile = project.readLockfile()
expect(lockfile.patchedDependencies).toStrictEqual({
'is-positive@1': {
path: path.relative(process.cwd(), patchedDependencies['is-positive@1']).replaceAll('\\', '/'),
hash: patchFileHash,
},
})
expect(lockfile.snapshots[`is-positive@1.0.0(patch_hash=${patchFileHash})`]).toBeTruthy()
const filesIndexFile = path.join(opts.storeDir, 'index/c7/1ccf199e0fdae37aad13946b937d67bcd35fa111b84d21b3a19439cfdc2812-is-positive@1.0.0.json')
const filesIndex = loadJsonFile.sync<PackageFilesIndex>(filesIndexFile)
const sideEffectsKey = `${ENGINE_NAME};patch=${patchFileHash}`
const patchedFileIntegrity = filesIndex.sideEffects?.[sideEffectsKey].added?.['index.js']?.integrity
expect(patchedFileIntegrity).toBeTruthy()
const originalFileIntegrity = filesIndex.files['index.js'].integrity
expect(originalFileIntegrity).toBeTruthy()
// The integrity of the original file differs from the integrity of the patched file
expect(originalFileIntegrity).not.toEqual(patchedFileIntegrity)
// The same with frozen lockfile
rimraf('node_modules')
await install({
dependencies: {
'is-positive': '1.0.0',
},
}, {
...opts,
frozenLockfile: true,
})
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched')
// The same with frozen lockfile and hoisted node_modules
rimraf('node_modules')
await install({
dependencies: {
'is-positive': '1.0.0',
},
}, {
...opts,
frozenLockfile: true,
nodeLinker: 'hoisted',
})
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).toContain('// patched')
process.chdir('..')
fs.mkdirSync('project2')
process.chdir('project2')
await install({
dependencies: {
'is-positive': '1.0.0',
},
}, testDefaults({
fastUnpack: false,
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
offline: true,
}, {}, {}, { packageImportMethod: 'hardlink' }))
// The original file did not break, when a patched version was created
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched')
})
test('patch package reports warning if not all patches are applied and allowUnusedPatches is set', async () => {
prepareEmpty()
const reporter = jest.fn()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
@@ -123,7 +218,7 @@ test('patch package reports warning if not all patches are applied and allowNonA
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
patchedDependencies,
allowNonAppliedPatches: true,
allowUnusedPatches: true,
reporter,
}, {}, {}, { packageImportMethod: 'hardlink' })
await install({
@@ -134,7 +229,7 @@ test('patch package reports warning if not all patches are applied and allowNonA
expect(reporter).toHaveBeenCalledWith(
expect.objectContaining({
level: 'warn',
message: 'The following patches were not applied: is-negative@1.0.0',
message: 'The following patches were not used: is-negative@1.0.0',
})
)
})
@@ -159,7 +254,7 @@ test('patch package throws an exception if not all patches are applied', async (
'is-positive': '1.0.0',
},
}, opts)
).rejects.toThrow('The following patches were not applied: is-negative@1.0.0')
).rejects.toThrow('The following patches were not used: is-negative@1.0.0')
})
test('the patched package is updated if the patch is modified', async () => {
@@ -399,7 +494,7 @@ test('patch package when the patched package has no dependencies and appears mul
].sort())
})
test('patch package should fail when the patch could not be applied', async () => {
test('patch package should fail when the exact version patch fails to apply', async () => {
prepareEmpty()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
@@ -420,3 +515,103 @@ test('patch package should fail when the patch could not be applied', async () =
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched')
})
test('patch package should fail when the version range patch fails to apply', async () => {
prepareEmpty()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
const patchedDependencies = {
'is-positive@>=3': patchPath,
}
const opts = testDefaults({
fastUnpack: false,
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
patchedDependencies,
}, {}, {}, { packageImportMethod: 'hardlink' })
await expect(install({
dependencies: {
'is-positive': '3.1.0',
},
}, opts)).rejects.toThrow(/Could not apply patch/)
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched')
})
test('patch package should print a warning when the patch fails to apply and ignorePatchFailures is set to true', async () => {
prepareEmpty()
const reporter = jest.fn()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
const patchedDependencies = {
'is-positive@3.1.0': patchPath,
}
const opts = testDefaults({
fastUnpack: false,
ignorePatchFailures: true,
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
patchedDependencies,
reporter,
}, {}, {}, { packageImportMethod: 'hardlink' })
await install({
dependencies: {
'is-positive': '3.1.0',
},
}, opts)
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched')
expect(reporter).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringMatching(/Could not apply patch/),
}))
})
test('patch package should print a warning when the name-only patch fails to apply (legacy behavior)', async () => {
prepareEmpty()
const reporter = jest.fn()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
const patchedDependencies = {
'is-positive': patchPath,
}
const opts = testDefaults({
fastUnpack: false,
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
patchedDependencies,
reporter,
}, {}, {}, { packageImportMethod: 'hardlink' })
await install({
dependencies: {
'is-positive': '3.1.0',
},
}, opts)
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched')
expect(reporter).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringMatching(/Could not apply patch/),
}))
})
test('patch package should fail when the name-only range patch fails to apply and ignorePatchFailures is explicitly set to false', async () => {
prepareEmpty()
const patchPath = path.join(f.find('patch-pkg'), 'is-positive@1.0.0.patch')
const patchedDependencies = {
'is-positive': patchPath,
}
const opts = testDefaults({
fastUnpack: false,
ignorePatchFailures: false,
sideEffectsCacheRead: true,
sideEffectsCacheWrite: true,
patchedDependencies,
}, {}, {}, { packageImportMethod: 'hardlink' })
await expect(install({
dependencies: {
'is-positive': '3.1.0',
},
}, opts)).rejects.toThrow(/Could not apply patch/)
expect(fs.readFileSync('node_modules/is-positive/index.js', 'utf8')).not.toContain('// patched')
})

View File

@@ -126,6 +126,9 @@
{
"path": "../../packages/which-version-is-pinned"
},
{
"path": "../../patching/config"
},
{
"path": "../../pkg-manifest/manifest-utils"
},

View File

@@ -28,7 +28,6 @@ import { linkBins, linkBinsOfPackages } from '@pnpm/link-bins'
import {
getLockfileImporterId,
type LockfileObject,
type PatchFile,
readCurrentLockfile,
readWantedLockfile,
writeLockfiles,
@@ -50,6 +49,7 @@ import {
type Modules,
writeModulesManifest,
} from '@pnpm/modules-yaml'
import { type PatchGroupRecord } from '@pnpm/patching.config'
import { type HoistingLimits } from '@pnpm/real-hoist'
import { readPackageJsonFromDir } from '@pnpm/read-package-json'
import { readProjectManifestOnly, safeReadProjectManifestOnly } from '@pnpm/read-project-manifest'
@@ -107,6 +107,7 @@ export interface Project {
}
export interface HeadlessOptions {
ignorePatchFailures?: boolean
neverBuiltDependencies?: string[]
ignoredBuiltDependencies?: string[]
onlyBuiltDependencies?: string[]
@@ -143,7 +144,7 @@ export interface HeadlessOptions {
modulesDir?: string
virtualStoreDir?: string
virtualStoreDirMaxLength: number
patchedDependencies?: Record<string, PatchFile>
patchedDependencies?: PatchGroupRecord
scriptsPrependNodePath?: boolean | 'warn-only'
scriptShell?: string
shellEmulator?: boolean
@@ -531,6 +532,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
ignoredBuilds = (await buildModules(graph, Array.from(directNodes), {
allowBuild,
ignoredBuiltDependencies: opts.ignoredBuiltDependencies,
ignorePatchFailures: opts.ignorePatchFailures,
childConcurrency: opts.childConcurrency,
extraBinPaths,
extraEnv,

View File

@@ -3,7 +3,6 @@ import path from 'path'
import {
type LockfileObject,
type PackageSnapshot,
type PatchFile,
type ProjectSnapshot,
} from '@pnpm/lockfile.fs'
import {
@@ -13,7 +12,7 @@ import {
} from '@pnpm/lockfile.utils'
import { type IncludedDependencies } from '@pnpm/modules-yaml'
import { packageIsInstallable } from '@pnpm/package-is-installable'
import { getPatchInfo } from '@pnpm/patching.config'
import { type PatchGroupRecord, getPatchInfo } from '@pnpm/patching.config'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import { type DepPath, type SupportedArchitectures, type ProjectId, type Registries } from '@pnpm/types'
import {
@@ -44,7 +43,7 @@ export interface LockfileToHoistedDepGraphOptions {
nodeVersion: string
pnpmVersion: string
registries: Registries
patchedDependencies?: Record<string, PatchFile>
patchedDependencies?: PatchGroupRecord
sideEffectsCacheRead: boolean
skipped: Set<string>
storeController: StoreController

View File

@@ -1,9 +1,7 @@
import path from 'path'
import { PnpmError } from '@pnpm/error'
import {
packageManifestLogger,
} from '@pnpm/core-loggers'
import { globalWarn } from '@pnpm/logger'
import {
type LockfileObject,
type ProjectSnapshot,
@@ -13,6 +11,7 @@ import {
getSpecFromPackageManifest,
type PinnedVersion,
} from '@pnpm/manifest-utils'
import { verifyPatches } from '@pnpm/patching.config'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import {
type DependenciesField,
@@ -116,7 +115,7 @@ export async function resolveDependencies (
preserveWorkspaceProtocol: boolean
saveWorkspaceProtocol: 'rolling' | boolean
lockfileIncludeTarballUrl?: boolean
allowNonAppliedPatches?: boolean
allowUnusedPatches?: boolean
}
): Promise<ResolveDependenciesResult> {
const _toResolveImporter = toResolveImporter.bind(null, {
@@ -148,9 +147,9 @@ export async function resolveDependencies (
Object.keys(opts.wantedLockfile.importers).length === importers.length
) {
verifyPatches({
patchedDependencies: Object.keys(opts.patchedDependencies),
patchedDependencies: opts.patchedDependencies,
appliedPatches,
allowNonAppliedPatches: opts.allowNonAppliedPatches,
allowUnusedPatches: opts.allowUnusedPatches,
})
}
@@ -328,29 +327,6 @@ export async function resolveDependencies (
}
}
function verifyPatches (
{
patchedDependencies,
appliedPatches,
allowNonAppliedPatches,
}: {
patchedDependencies: string[]
appliedPatches: Set<string>
allowNonAppliedPatches: boolean
}
): void {
const nonAppliedPatches: string[] = patchedDependencies.filter((patchKey) => !appliedPatches.has(patchKey))
if (!nonAppliedPatches.length) return
const message = `The following patches were not applied: ${nonAppliedPatches.join(', ')}`
if (allowNonAppliedPatches) {
globalWarn(message)
return
}
throw new PnpmError('PATCH_NOT_APPLIED', message, {
hint: 'Either remove them from "patchedDependencies" or update them to match packages in your dependencies.',
})
}
function addDirectDependenciesToLockfile (
newManifest: ProjectManifest,
projectSnapshot: ProjectSnapshot,

View File

@@ -16,7 +16,7 @@ import {
pkgSnapshotToResolution,
} from '@pnpm/lockfile.utils'
import { logger } from '@pnpm/logger'
import { getPatchInfo } from '@pnpm/patching.config'
import { type PatchGroupRecord, getPatchInfo } from '@pnpm/patching.config'
import { pickRegistryForPackage } from '@pnpm/pick-registry-for-package'
import {
type DirectoryResolution,
@@ -42,7 +42,7 @@ import {
} from '@pnpm/types'
import * as dp from '@pnpm/dependency-path'
import { getPreferredVersionsFromLockfileAndManifests } from '@pnpm/lockfile.preferred-versions'
import { type PatchFile, type PatchInfo } from '@pnpm/patching.types'
import { type PatchInfo } from '@pnpm/patching.types'
import normalizePath from 'normalize-path'
import exists from 'path-exists'
import pDefer from 'p-defer'
@@ -148,7 +148,7 @@ export interface ResolutionContext {
resolvedPkgsById: ResolvedPkgsById
outdatedDependencies: Record<PkgResolutionId, string>
childrenByParentId: ChildrenByParentId
patchedDependencies?: Record<string, PatchFile>
patchedDependencies?: PatchGroupRecord
pendingNodes: PendingNode[]
wantedLockfile: LockfileObject
currentLockfile: LockfileObject

View File

@@ -1,7 +1,7 @@
import { resolveFromCatalog } from '@pnpm/catalogs.resolver'
import { type Catalogs } from '@pnpm/catalogs.types'
import { type LockfileObject } from '@pnpm/lockfile.types'
import { type PatchFile } from '@pnpm/patching.types'
import { type PatchGroupRecord } from '@pnpm/patching.config'
import { type PreferredVersions, type Resolution, type WorkspacePackages } from '@pnpm/resolver-base'
import { type StoreController } from '@pnpm/store-controller-types'
import {
@@ -98,7 +98,7 @@ export interface ResolveDependenciesOptions {
autoInstallPeers?: boolean
autoInstallPeersFromHighestMatch?: boolean
allowedDeprecatedVersions: AllowedDeprecatedVersions
allowNonAppliedPatches: boolean
allowUnusedPatches: boolean
catalogs?: Catalogs
currentLockfile: LockfileObject
dedupePeerDependents?: boolean
@@ -112,7 +112,7 @@ export interface ResolveDependenciesOptions {
}
nodeVersion?: string
registries: Registries
patchedDependencies?: Record<string, PatchFile>
patchedDependencies?: PatchGroupRecord
pnpmVersion: string
preferredVersions?: PreferredVersions
preferWorkspacePackages?: boolean

21
pnpm-lock.yaml generated
View File

@@ -1460,6 +1460,9 @@ importers:
'@pnpm/git-utils':
specifier: workspace:*
version: link:../../packages/git-utils
'@pnpm/logger':
specifier: '>=5.1.0 <1001.0.0'
version: 1000.0.0
'@pnpm/matcher':
specifier: workspace:*
version: link:../matcher
@@ -4150,13 +4153,28 @@ importers:
patching/config:
dependencies:
'@pnpm/dependency-path':
specifier: workspace:*
version: link:../../packages/dependency-path
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/logger':
specifier: '>=5.1.0 <1001.0.0'
version: 1000.0.0
'@pnpm/patching.types':
specifier: workspace:*
version: link:../types
semver:
specifier: 'catalog:'
version: 7.7.1
devDependencies:
'@pnpm/patching.config':
specifier: workspace:*
version: 'link:'
'@types/semver':
specifier: 'catalog:'
version: 7.5.3
patching/plugin-commands-patching:
dependencies:
@@ -4456,6 +4474,9 @@ importers:
'@pnpm/parse-wanted-dependency':
specifier: workspace:*
version: link:../../packages/parse-wanted-dependency
'@pnpm/patching.config':
specifier: workspace:*
version: link:../../patching/config
'@pnpm/pkg-manager.direct-dep-linker':
specifier: workspace:*
version: link:../direct-dep-linker

View File

@@ -0,0 +1,11 @@
diff --git a/index.js b/index.js
index 8e020ca..ff3aee4 100644
--- a/index.js
+++ b/index.js
@@ -5,5 +5,6 @@ module.exports = function (n) {
throw new TypeError('Expected a number');
}
+ // patched
return n >= 0;
};

View File

@@ -0,0 +1,68 @@
import { preparePackages } from '@pnpm/prepare'
import { fixtures } from '@pnpm/test-fixtures'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpmSync } from '../utils'
const f = fixtures(__dirname)
test('allowUnusedPatches=false errors on unused patches', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
},
{
name: 'bar',
version: '0.0.0',
private: true,
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
allowUnusedPatches: false,
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive': patchFile,
},
})
// pnpm install should fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_UNUSED_PATCH')
expect(stdout.toString()).toContain('The following patches were not used: is-positive')
})
test('allowUnusedPatches=true warns about unused patches', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
},
{
name: 'bar',
version: '0.0.0',
private: true,
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
allowUnusedPatches: true,
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive': patchFile,
},
})
// pnpm install should not fail
const { stdout } = execPnpmSync(['install'], { expectSuccess: true })
// pnpm install should print a warning regarding unused patches
expect(stdout.toString()).toContain('The following patches were not used: is-positive')
})

View File

@@ -0,0 +1,500 @@
import fs from 'fs'
import { preparePackages } from '@pnpm/prepare'
import { fixtures } from '@pnpm/test-fixtures'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpmSync } from '../utils'
const f = fixtures(__dirname)
describe('ignorePatchFailures=undefined (necessary for backward-compatibility)', () => {
test('errors on exact version patch failures', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@1.0.0': patchFile, // should succeed
'is-positive@3.1.0': patchFile, // should fail
},
})
// pnpm install should fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_PATCH_FAILED')
const errorLines = stdout.toString().split('\n').filter(line => line.includes('ERR_PNPM_PATCH_FAILED'))
expect(errorLines).toStrictEqual([expect.stringContaining(patchFile)])
expect(errorLines).toStrictEqual([expect.stringContaining('is-positive@3.1.0')])
})
test('errors on version range patch failures', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@>=1': patchFile,
},
})
// pnpm install should fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_PATCH_FAILED')
const errorLines = stdout.toString().split('\n').filter(line => line.includes('ERR_PNPM_PATCH_FAILED'))
expect(errorLines).toStrictEqual([expect.stringContaining(patchFile)])
expect(errorLines).toStrictEqual([expect.stringContaining('is-positive@3.1.0')])
})
test('errors on star version range patch failures', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@*': patchFile,
},
})
// pnpm install should fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_PATCH_FAILED')
const errorLines = stdout.toString().split('\n').filter(line => line.includes('ERR_PNPM_PATCH_FAILED'))
expect(errorLines).toStrictEqual([expect.stringContaining(patchFile)])
expect(errorLines).toStrictEqual([expect.stringContaining('is-positive@3.1.0')])
})
test('allows name-only patches to fail with a warning (legacy behavior)', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive': patchFile,
},
})
// pnpm install should not fail
const { stdout } = execPnpmSync(['install'], { expectSuccess: true })
// pnpm install should print a warning about patch application failure
expect(stdout.toString()).toContain('Could not apply patch')
// the patch should apply to is-positive@1.0.0
expect(fs.readFileSync('foo/node_modules/is-positive/index.js', 'utf-8')).toContain('// patched')
// the patch should not apply to is-positive@3.2.1
expect(fs.readFileSync('bar/node_modules/is-positive/index.js', 'utf-8')).not.toContain('// patched')
})
})
describe('ignorePatchFailures=true', () => {
test('allows exact version patches to fail with a warning', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@1.0.0': patchFile, // should succeed
'is-positive@3.1.0': patchFile, // should fail
},
ignorePatchFailures: true,
})
// pnpm install should not fail
const { stdout } = execPnpmSync(['install'], { expectSuccess: true })
// pnpm install should print a warning about patch application failure
expect(stdout.toString()).toContain('Could not apply patch')
// the patch should apply to is-positive@1.0.0
expect(fs.readFileSync('foo/node_modules/is-positive/index.js', 'utf-8')).toContain('// patched')
// the patch should not apply to is-positive@3.2.1
expect(fs.readFileSync('bar/node_modules/is-positive/index.js', 'utf-8')).not.toContain('// patched')
})
test('allows version range patches to fail with a warning', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@>=1': patchFile,
},
ignorePatchFailures: true,
})
// pnpm install should not fail
const { stdout } = execPnpmSync(['install'], { expectSuccess: true })
// pnpm install should print a warning about patch application failure
expect(stdout.toString()).toContain('Could not apply patch')
// the patch should apply to is-positive@1.0.0
expect(fs.readFileSync('foo/node_modules/is-positive/index.js', 'utf-8')).toContain('// patched')
// the patch should not apply to is-positive@3.2.1
expect(fs.readFileSync('bar/node_modules/is-positive/index.js', 'utf-8')).not.toContain('// patched')
})
test('allows star version range patches to fail with a warning', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@*': patchFile,
},
ignorePatchFailures: true,
})
// pnpm install should not fail
const { stdout } = execPnpmSync(['install'], { expectSuccess: true })
// pnpm install should print a warning about patch application failure
expect(stdout.toString()).toContain('Could not apply patch')
// the patch should apply to is-positive@1.0.0
expect(fs.readFileSync('foo/node_modules/is-positive/index.js', 'utf-8')).toContain('// patched')
// the patch should not apply to is-positive@3.2.1
expect(fs.readFileSync('bar/node_modules/is-positive/index.js', 'utf-8')).not.toContain('// patched')
})
test('allows name-only patches to fail with a warning', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive': patchFile,
},
ignorePatchFailures: true,
})
// pnpm install should not fail
const { stdout } = execPnpmSync(['install'], { expectSuccess: true })
// pnpm install should print a warning about patch application failure
expect(stdout.toString()).toContain('Could not apply patch')
// the patch should apply to is-positive@1.0.0
expect(fs.readFileSync('foo/node_modules/is-positive/index.js', 'utf-8')).toContain('// patched')
// the patch should not apply to is-positive@3.2.1
expect(fs.readFileSync('bar/node_modules/is-positive/index.js', 'utf-8')).not.toContain('// patched')
})
})
describe('ignorePatchFailures=false', () => {
test('errors on exact version patch failures', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@1.0.0': patchFile, // should succeed
'is-positive@3.1.0': patchFile, // should fail
},
ignorePatchFailures: false,
})
// pnpm install should fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_PATCH_FAILED')
const errorLines = stdout.toString().split('\n').filter(line => line.includes('ERR_PNPM_PATCH_FAILED'))
expect(errorLines).toStrictEqual([expect.stringContaining(patchFile)])
expect(errorLines).toStrictEqual([expect.stringContaining('is-positive@3.1.0')])
})
test('errors on version range patch failures', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@>=1': patchFile,
},
ignorePatchFailures: false,
})
// pnpm install not fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_PATCH_FAILED')
const errorLines = stdout.toString().split('\n').filter(line => line.includes('ERR_PNPM_PATCH_FAILED'))
expect(errorLines).toStrictEqual([expect.stringContaining(patchFile)])
expect(errorLines).toStrictEqual([expect.stringContaining('is-positive@3.1.0')])
})
test('errors on star version range patch failures', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive@*': patchFile,
},
ignorePatchFailures: false,
})
// pnpm install not fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_PATCH_FAILED')
const errorLines = stdout.toString().split('\n').filter(line => line.includes('ERR_PNPM_PATCH_FAILED'))
expect(errorLines).toStrictEqual([expect.stringContaining(patchFile)])
expect(errorLines).toStrictEqual([expect.stringContaining('is-positive@3.1.0')])
})
test('allows name-only patches to fail with a warning', async () => {
preparePackages([
{
name: 'foo',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0', // is-positive@1.0.0.patch should succeed
},
},
{
name: 'bar',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '3.1.0', // is-positive@1.0.0.patch should fail
},
},
])
const patchFile = f.find('patch-pkg/is-positive@1.0.0.patch')
writeYamlFile('pnpm-workspace.yaml', {
packages: ['**', '!store/**'],
patchedDependencies: {
'is-positive': patchFile,
},
ignorePatchFailures: false,
})
// pnpm install should fail
const { status, stdout } = execPnpmSync(['install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_PATCH_FAILED')
const errorLines = stdout.toString().split('\n').filter(line => line.includes('ERR_PNPM_PATCH_FAILED'))
expect(errorLines).toStrictEqual([expect.stringContaining(patchFile)])
expect(errorLines).toStrictEqual([expect.stringContaining('is-positive@3.1.0')])
})
})