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 authored and GitHub committed 2025-03-13 12:13:06 +01:00
1 parent 4885c3708b
commit 5f7be645e4
35 files changed
+1525 -101

No files matched your search

+9 -2
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"
+16
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
}
}
}
+39
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
}
@@ -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
}
+11 -29
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'
+31
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.',
})
}
+219
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'])
})
@@ -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',
}))
})
+10 -6
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',
+6
View File
@@ -9,6 +9,12 @@
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../packages/dependency-path"
},
{
"path": "../../packages/error"
},
{
"path": "../types"
}
+23
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>