fix: peer dependency rules should not change the lockfile (#7758)

* fix: peer dependency rules should not change the lockfile

* test: fix

* fix: hide peer issues warning if there nothing to report
This commit is contained in:
Zoltan Kochan
2024-03-13 17:55:18 +01:00
committed by GitHub
parent 23ba228424
commit aa33269f9f
19 changed files with 369 additions and 434 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/hooks.read-package-hook": major
"@pnpm/core": major
"@pnpm/render-peer-issues": minor
"@pnpm/default-reporter": minor
"pnpm": minor
---
Peer dependency rules should only affect reporting, not data in the lockfile.

View File

@@ -10,6 +10,7 @@ import { reporterForClient } from './reporterForClient'
import { formatWarn } from './reporterForClient/utils/formatWarn'
import { reporterForServer } from './reporterForServer'
import { type FilterPkgsDiff } from './reporterForClient/reportSummary'
import { type PeerDependencyRules } from '@pnpm/types'
export { formatWarn }
@@ -28,6 +29,7 @@ export function initDefaultReporter (
hideProgressPrefix?: boolean
hideLifecycleOutput?: boolean
hideLifecyclePrefix?: boolean
peerDependencyRules?: PeerDependencyRules
}
context: {
argv: string[]
@@ -104,6 +106,7 @@ export function toOutput$ (
appendOnly?: boolean
logLevel?: LogLevel
outputMaxWidth?: number
peerDependencyRules?: PeerDependencyRules
streamLifecycleOutput?: boolean
aggregateOutput?: boolean
throttleProgress?: number
@@ -260,6 +263,7 @@ export function toOutput$ (
config: opts.context.config,
env: opts.context.env ?? process.env,
filterPkgsDiff: opts.filterPkgsDiff,
peerDependencyRules: opts.reportingOptions?.peerDependencyRules,
process: opts.context.process ?? process,
isRecursive: opts.context.config?.['recursive'] === true,
logLevel: opts.reportingOptions?.logLevel,

View File

@@ -4,7 +4,7 @@ import { renderDedupeCheckIssues } from '@pnpm/dedupe.issues-renderer'
import { type DedupeCheckIssues } from '@pnpm/dedupe.types'
import { type PnpmError } from '@pnpm/error'
import { renderPeerIssues } from '@pnpm/render-peer-issues'
import { type PeerDependencyIssuesByProjects } from '@pnpm/types'
import { type PeerDependencyRules, type PeerDependencyIssuesByProjects } from '@pnpm/types'
import chalk from 'chalk'
import equals from 'ramda/src/equals'
import StackTracey from 'stacktracey'
@@ -19,8 +19,9 @@ StackTracey.maxColumnWidths = {
const highlight = chalk.yellow
const colorPath = chalk.gray
export function reportError (logObj: Log, config?: Config) {
const errorInfo = getErrorInfo(logObj, config)
export function reportError (logObj: Log, config?: Config, peerDependencyRules?: PeerDependencyRules) {
const errorInfo = getErrorInfo(logObj, config, peerDependencyRules)
if (!errorInfo) return null
let output = formatErrorSummary(errorInfo.title, (logObj as LogObjWithPossibleError).err?.code)
if (logObj['pkgsStack'] != null) {
if (logObj['pkgsStack'].length > 0) {
@@ -43,10 +44,10 @@ export function reportError (logObj: Log, config?: Config) {
}
}
function getErrorInfo (logObj: Log, config?: Config): {
function getErrorInfo (logObj: Log, config?: Config, peerDependencyRules?: PeerDependencyRules): {
title: string
body?: string
} {
} | null {
if (logObj['err']) {
const err = logObj['err'] as (PnpmError & { stack: object })
switch (err.code) {
@@ -75,7 +76,7 @@ function getErrorInfo (logObj: Log, config?: Config): {
case 'ERR_PNPM_UNSUPPORTED_ENGINE':
return reportEngineError(logObj as any) // eslint-disable-line @typescript-eslint/no-explicit-any
case 'ERR_PNPM_PEER_DEP_ISSUES':
return reportPeerDependencyIssuesError(err, logObj as any) // eslint-disable-line @typescript-eslint/no-explicit-any
return reportPeerDependencyIssuesError(err, logObj as any, peerDependencyRules) // eslint-disable-line @typescript-eslint/no-explicit-any
case 'ERR_PNPM_DEDUPE_CHECK_ISSUES':
return reportDedupeCheckIssuesError(err, logObj as any) // eslint-disable-line @typescript-eslint/no-explicit-any
case 'ERR_PNPM_FETCH_401':
@@ -405,7 +406,8 @@ function hideSecureInfo (key: string, value: string) {
function reportPeerDependencyIssuesError (
err: Error,
msg: { issuesByProjects: PeerDependencyIssuesByProjects }
msg: { issuesByProjects: PeerDependencyIssuesByProjects },
peerDependencyRules?: PeerDependencyRules
) {
const hasMissingPeers = getHasMissingPeers(msg.issuesByProjects)
const hints: string[] = []
@@ -413,9 +415,11 @@ function reportPeerDependencyIssuesError (
hints.push('If you want peer dependencies to be automatically installed, add "auto-install-peers=true" to an .npmrc file at the root of your project.')
}
hints.push('If you don\'t want pnpm to fail on peer dependency issues, add "strict-peer-dependencies=false" to an .npmrc file at the root of your project.')
const rendered = renderPeerIssues(msg.issuesByProjects, { rules: peerDependencyRules })
if (!rendered) return null
return {
title: err.message,
body: `${renderPeerIssues(msg.issuesByProjects)}
body: `${rendered}
${hints.map((hint) => `hint: ${hint}`).join('\n')}
`,
}

View File

@@ -19,6 +19,7 @@ import { reportSkippedOptionalDependencies } from './reportSkippedOptionalDepend
import { reportStats } from './reportStats'
import { reportSummary, type FilterPkgsDiff } from './reportSummary'
import { reportUpdateCheck } from './reportUpdateCheck'
import { type PeerDependencyRules } from '@pnpm/types'
const PRINT_EXECUTION_TIME_IN_COMMANDS = {
install: true,
@@ -58,6 +59,7 @@ export function reporterForClient (
config?: Config
env: NodeJS.ProcessEnv
filterPkgsDiff?: FilterPkgsDiff
peerDependencyRules?: PeerDependencyRules
process: NodeJS.Process
isRecursive: boolean
logLevel?: LogLevel
@@ -94,6 +96,7 @@ export function reporterForClient (
cwd,
logLevel: opts.logLevel,
zoomOutCurrent: opts.isRecursive,
peerDependencyRules: opts.peerDependencyRules,
}
),
reportInstallChecks(log$.installCheck, { cwd }),
@@ -116,7 +119,7 @@ export function reporterForClient (
if (logLevelNumber >= LOG_LEVEL_NUMBER.warn) {
outputs.push(
reportPeerDependencyIssues(log$),
reportPeerDependencyIssues(log$, opts.peerDependencyRules),
reportDeprecations({
deprecation: log$.deprecation,
stage: log$.stage,

View File

@@ -7,6 +7,7 @@ import { filter, map } from 'rxjs/operators'
import { reportError } from '../reportError'
import { formatWarn } from './utils/formatWarn'
import { autozoom } from './utils/zooming'
import { type PeerDependencyRules } from '@pnpm/types'
// eslint-disable:object-literal-sort-keys
export const LOG_LEVEL_NUMBER: Record<LogLevel, number> = {
@@ -30,6 +31,7 @@ export function reportMisc (
logLevel?: LogLevel
config?: Config
zoomOutCurrent: boolean
peerDependencyRules?: PeerDependencyRules
}
) {
const maxLogLevel = LOG_LEVEL_NUMBER[opts.logLevel ?? 'info'] ?? LOG_LEVEL_NUMBER['info']
@@ -42,13 +44,16 @@ export function reportMisc (
case 'warn': {
return reportWarning(obj)
}
case 'error':
case 'error': {
const errorOutput = reportError(obj, opts.config, opts.peerDependencyRules)
if (!errorOutput) return Rx.NEVER
if (obj['prefix'] && obj['prefix'] !== opts.cwd) {
return Rx.of({
msg: `${obj['prefix'] as string}:` + os.EOL + reportError(obj, opts.config),
msg: `${obj['prefix'] as string}:` + os.EOL + errorOutput,
})
}
return Rx.of({ msg: reportError(obj, opts.config) })
return Rx.of({ msg: errorOutput })
}
default:
return Rx.of({ msg: obj['message'] })
}

View File

@@ -1,5 +1,6 @@
import { type PeerDependencyIssuesLog } from '@pnpm/core-loggers'
import { renderPeerIssues } from '@pnpm/render-peer-issues'
import { type PeerDependencyRules } from '@pnpm/types'
import * as Rx from 'rxjs'
import { map, take } from 'rxjs/operators'
import { formatWarn } from './utils/formatWarn'
@@ -7,12 +8,21 @@ import { formatWarn } from './utils/formatWarn'
export function reportPeerDependencyIssues (
log$: {
peerDependencyIssues: Rx.Observable<PeerDependencyIssuesLog>
}
},
peerDependencyRules?: PeerDependencyRules
) {
return log$.peerDependencyIssues.pipe(
take(1),
map((log) => Rx.of({
msg: `${formatWarn('Issues with peer dependencies found')}\n${renderPeerIssues(log.issuesByProjects)}`,
}))
map((log) => {
const renderedPeerIssues = renderPeerIssues(log.issuesByProjects, {
rules: peerDependencyRules,
})
if (!renderedPeerIssues) {
return Rx.NEVER
}
return Rx.of({
msg: `${formatWarn('Issues with peer dependencies found')}\n${renderedPeerIssues}`,
})
})
)
}

View File

@@ -1,115 +0,0 @@
import semver from 'semver'
import isEmpty from 'ramda/src/isEmpty'
import { type PeerDependencyRules, type ReadPackageHook, type PackageManifest, type ProjectManifest } from '@pnpm/types'
import { PnpmError } from '@pnpm/error'
import { parseOverrides, type VersionOverride } from '@pnpm/parse-overrides'
import { createMatcher } from '@pnpm/matcher'
import { isSubRange } from './isSubRange'
export function createPeerDependencyPatcher (
peerDependencyRules: PeerDependencyRules
): ReadPackageHook {
const ignoreMissingPatterns = [...new Set(peerDependencyRules.ignoreMissing ?? [])]
const ignoreMissingMatcher = createMatcher(ignoreMissingPatterns)
const allowAnyPatterns = [...new Set(peerDependencyRules.allowAny ?? [])]
const allowAnyMatcher = createMatcher(allowAnyPatterns)
const { allowedVersionsMatchAll, allowedVersionsByParentPkgName } = parseAllowedVersions(peerDependencyRules.allowedVersions ?? {})
const _getAllowedVersionsByParentPkg = getAllowedVersionsByParentPkg.bind(null, allowedVersionsByParentPkgName)
return (pkg) => {
if (isEmpty(pkg.peerDependencies)) return pkg
const allowedVersions = {
...allowedVersionsMatchAll,
..._getAllowedVersionsByParentPkg(pkg),
}
for (const [peerName, peerVersion] of Object.entries(pkg.peerDependencies ?? {})) {
if (
ignoreMissingMatcher(peerName) &&
!pkg.peerDependenciesMeta?.[peerName]?.optional
) {
pkg.peerDependenciesMeta = pkg.peerDependenciesMeta ?? {}
pkg.peerDependenciesMeta[peerName] = {
optional: true,
}
}
if (allowAnyMatcher(peerName)) {
pkg.peerDependencies![peerName] = '*'
continue
}
if (!allowedVersions?.[peerName] || peerVersion === '*') {
continue
}
if (allowedVersions?.[peerName].includes('*')) {
pkg.peerDependencies![peerName] = '*'
continue
}
const currentVersions = parseVersions(pkg.peerDependencies![peerName])
allowedVersions[peerName].forEach(allowedVersion => {
if (!currentVersions.includes(allowedVersion)) {
currentVersions.push(allowedVersion)
}
})
pkg.peerDependencies![peerName] = currentVersions.join(' || ')
}
return pkg
}
}
type AllowedVersionsByParentPkgName = Record<string, Array<Required<Pick<VersionOverride, 'parentPkg' | 'targetPkg'>> & { ranges: string[] }>>
function parseAllowedVersions (allowedVersions: Record<string, string>) {
const overrides = tryParseAllowedVersions(allowedVersions)
const allowedVersionsMatchAll: Record<string, string[]> = {}
const allowedVersionsByParentPkgName: AllowedVersionsByParentPkgName = {}
for (const { parentPkg, targetPkg, newPref } of overrides) {
const ranges = parseVersions(newPref)
if (!parentPkg) {
allowedVersionsMatchAll[targetPkg.name] = ranges
continue
}
if (!allowedVersionsByParentPkgName[parentPkg.name]) {
allowedVersionsByParentPkgName[parentPkg.name] = []
}
allowedVersionsByParentPkgName[parentPkg.name].push({
parentPkg,
targetPkg,
ranges,
})
}
return {
allowedVersionsMatchAll,
allowedVersionsByParentPkgName,
}
}
function tryParseAllowedVersions (allowedVersions: Record<string, string>): VersionOverride[] {
try {
return parseOverrides(allowedVersions ?? {})
} catch (err) {
throw new PnpmError('INVALID_ALLOWED_VERSION_SELECTOR',
`${(err as PnpmError).message} in pnpm.peerDependencyRules.allowedVersions`)
}
}
function getAllowedVersionsByParentPkg (
allowedVersionsByParentPkgName: AllowedVersionsByParentPkgName,
pkg: PackageManifest | ProjectManifest
): Record<string, string[]> {
if (!pkg.name || !allowedVersionsByParentPkgName[pkg.name]) return {}
return allowedVersionsByParentPkgName[pkg.name]
.reduce((acc, { targetPkg, parentPkg, ranges }) => {
if (!pkg.peerDependencies![targetPkg.name]) return acc
if (!parentPkg.pref || pkg.version &&
(isSubRange(parentPkg.pref, pkg.version) || semver.satisfies(pkg.version, parentPkg.pref))) {
acc[targetPkg.name] = ranges
}
return acc
}, {} as Record<string, string[]>)
}
function parseVersions (versions: string): string[] {
return versions.split('||').map(v => v.trim())
}

View File

@@ -2,7 +2,6 @@ import { packageExtensions as compatPackageExtensions } from '@yarnpkg/extension
import {
type PackageExtension,
type PackageManifest,
type PeerDependencyRules,
type ProjectManifest,
type ReadPackageHook,
} from '@pnpm/types'
@@ -11,7 +10,6 @@ import pipeWith from 'ramda/src/pipeWith'
import { createOptionalDependenciesRemover } from './createOptionalDependenciesRemover'
import { createPackageExtender } from './createPackageExtender'
import { createVersionsOverrider } from './createVersionsOverrider'
import { createPeerDependencyPatcher } from './createPeerDependencyPatcher'
export function createReadPackageHook (
{
@@ -20,7 +18,6 @@ export function createReadPackageHook (
overrides,
ignoredOptionalDependencies,
packageExtensions,
peerDependencyRules,
readPackageHook,
}: {
ignoreCompatibilityDb?: boolean
@@ -28,7 +25,6 @@ export function createReadPackageHook (
overrides?: Record<string, string>
ignoredOptionalDependencies?: string[]
packageExtensions?: Record<string, PackageExtension>
peerDependencyRules?: PeerDependencyRules
readPackageHook?: ReadPackageHook[] | ReadPackageHook
}
): ReadPackageHook | undefined {
@@ -50,16 +46,6 @@ export function createReadPackageHook (
if (ignoredOptionalDependencies && !isEmpty(ignoredOptionalDependencies)) {
hooks.push(createOptionalDependenciesRemover(ignoredOptionalDependencies))
}
if (
peerDependencyRules != null &&
(
!isEmpty(peerDependencyRules.ignoreMissing) ||
!isEmpty(peerDependencyRules.allowedVersions) ||
!isEmpty(peerDependencyRules.allowAny)
)
) {
hooks.push(createPeerDependencyPatcher(peerDependencyRules))
}
if (hooks.length === 0) {
return undefined

View File

@@ -1,196 +0,0 @@
import { type ProjectManifest } from '@pnpm/types'
import { createPeerDependencyPatcher } from '../lib/createPeerDependencyPatcher'
test('createPeerDependencyPatcher() ignores missing', () => {
const patcher = createPeerDependencyPatcher({
ignoreMissing: ['foo'],
})
const patchedPkg = patcher({
peerDependencies: {
foo: '*',
bar: '*',
},
}) as ProjectManifest
expect(patchedPkg.peerDependenciesMeta).toStrictEqual({
foo: {
optional: true,
},
})
})
test('createPeerDependencyPatcher() pattern matches to ignore missing', () => {
const patcher = createPeerDependencyPatcher({
ignoreMissing: ['f*r'],
})
const patchedPkg = patcher({
peerDependencies: {
foobar: '*',
bar: '*',
},
}) as ProjectManifest
expect(patchedPkg.peerDependenciesMeta).toStrictEqual({
foobar: {
optional: true,
},
})
})
test('createPeerDependencyPatcher() extends peer ranges', () => {
const patcher = createPeerDependencyPatcher({
allowedVersions: {
foo: '1',
qar: '1',
baz: '*',
},
})
const patchedPkg = patcher({
peerDependencies: {
foo: '0',
bar: '0',
qar: '*',
baz: '1',
},
}) as ProjectManifest
expect(patchedPkg.peerDependencies).toStrictEqual({
foo: '0 || 1',
bar: '0',
qar: '*',
baz: '*',
})
})
test('createPeerDependencyPatcher() ignores peer versions from allowAny', () => {
const patcher = createPeerDependencyPatcher({
allowAny: ['foo', 'bar'],
})
const patchedPkg = patcher({
peerDependencies: {
foo: '2',
bar: '2',
qar: '2',
baz: '2',
},
}) as ProjectManifest
expect(patchedPkg.peerDependencies).toStrictEqual({
foo: '*',
bar: '*',
qar: '2',
baz: '2',
})
})
test('createPeerDependencyPatcher() does not create duplicate extended ranges', async () => {
const patcher = createPeerDependencyPatcher({
allowedVersions: {
foo: '1',
same: '12',
multi: '16',
mix: '1 || 2 || 3',
partialmatch: '1',
nopadding: '^17.0.1||18.x',
},
})
const patchedPkg = patcher({
peerDependencies: {
foo: '0',
same: '12',
multi: '16 || 17',
mix: '1 || 4',
partialmatch: '16 || 1.2.1',
nopadding: '15.0.1||16',
},
})
// double apply the same patch to the same package
// this can occur in a monorepo when several packages
// all try to apply the same patch
const patchedAgainPkg = patcher(await patchedPkg) as ProjectManifest
expect(patchedAgainPkg.peerDependencies).toStrictEqual({
// the patch is applied only once (not 0 || 1 || 1)
foo: '0 || 1',
same: '12',
multi: '16 || 17',
mix: '1 || 4 || 2 || 3',
partialmatch: '16 || 1.2.1 || 1',
nopadding: '15.0.1 || 16 || ^17.0.1 || 18.x',
})
})
test('createPeerDependencyPatcher() overrides peerDependencies when parent>child selector is used', () => {
const patcher = createPeerDependencyPatcher({
allowedVersions: {
bar: '2',
'foo>bar': '1',
'foo@2>bar': '2 || 3',
'foo@>=2.3.5 <3>bar': '4',
},
})
let patchedPkg = patcher({
name: 'foo',
peerDependencies: {
bar: '0 || 1',
},
}) as ProjectManifest
expect(patchedPkg.peerDependencies).toStrictEqual({
bar: '0 || 1',
})
patchedPkg = patcher({
name: 'foo',
version: '2',
peerDependencies: {
bar: '0 || 1',
},
}) as ProjectManifest
expect(patchedPkg.peerDependencies).toStrictEqual({
bar: '0 || 1 || 2 || 3',
})
patchedPkg = patcher({
name: 'foo',
version: '3',
peerDependencies: {
bar: '0 || 1',
},
}) as ProjectManifest
expect(patchedPkg.peerDependencies).toStrictEqual({
bar: '0 || 1',
})
patchedPkg = patcher({
name: 'foo',
version: '2.3.5',
peerDependencies: {
bar: '0 || 1',
},
}) as ProjectManifest
expect(patchedPkg.peerDependencies).toStrictEqual({
bar: '0 || 1 || 4',
})
})
// corner case exists when a 'parent>child' allowedVersion selector is used without a 'child' selector
test('createPeerDependencyPatcher() corner case correctly applies override', () => {
const patcher = createPeerDependencyPatcher({
allowedVersions: {
'foo>bar': '2',
},
})
const patchedPkg = patcher({
name: 'foo',
peerDependencies: {
bar: '0 || 1',
},
}) as ProjectManifest
expect(patchedPkg.peerDependencies).toStrictEqual({
bar: '0 || 1 || 2',
})
})
test('createPeerDependencyPatcher() throws expected error if parent>child selector cannot parse', () => {
expect(() => createPeerDependencyPatcher({
allowedVersions: {
'foo > bar': '2',
},
})).toThrowError('Cannot parse the "foo > bar" selector in pnpm.peerDependencyRules.allowedVersions')
})

View File

@@ -15,37 +15,6 @@ test('createReadPackageHook() is passing directory to all hooks', async () => {
expect(hook2).toBeCalledWith(manifest, dir)
})
test('createReadPackageHook() runs the custom hook before the peer rules hook', async () => {
const hook = jest.fn((manifest) => ({
...manifest,
dependencies: { ...manifest.peerDependencies },
}))
const readPackageHook = createReadPackageHook({
ignoreCompatibilityDb: true,
lockfileDir: '/foo',
readPackageHook: [hook],
peerDependencyRules: {
allowAny: ['*'],
},
})
const manifest = {
peerDependencies: {
react: '16',
},
}
const dir = '/bar'
const updatedManifest = await readPackageHook!(manifest, dir)
expect(hook).toBeCalledWith(manifest, dir)
expect(updatedManifest).toStrictEqual({
dependencies: {
react: '16',
},
peerDependencies: {
react: '*',
},
})
})
test('createReadPackageHook() runs the custom hook before the version overrider', async () => {
const hook = jest.fn((manifest) => ({
...manifest,

View File

@@ -29,14 +29,19 @@
"homepage": "https://github.com/pnpm/pnpm/blob/main/packages/render-peer-issues#readme",
"funding": "https://opencollective.com/pnpm",
"dependencies": {
"@pnpm/error": "workspace:*",
"@pnpm/matcher": "workspace:*",
"@pnpm/parse-overrides": "workspace:*",
"@pnpm/types": "workspace:*",
"archy": "^1.0.0",
"chalk": "^4.1.2",
"cli-columns": "^4.0.0"
"cli-columns": "^4.0.0",
"semver": "^7.6.0"
},
"devDependencies": {
"@pnpm/render-peer-issues": "workspace:*",
"@types/archy": "0.0.33",
"@types/semver": "7.5.8",
"strip-ansi": "^6.0.1"
},
"exports": {

View File

@@ -1,19 +1,36 @@
import { type BadPeerDependencyIssue, type PeerDependencyIssuesByProjects } from '@pnpm/types'
import { PnpmError } from '@pnpm/error'
import { createMatcher } from '@pnpm/matcher'
import {
type BadPeerDependencyIssue,
type PeerDependencyIssuesByProjects,
type PeerDependencyRules,
} from '@pnpm/types'
import { parseOverrides, type VersionOverride } from '@pnpm/parse-overrides'
import archy from 'archy'
import chalk from 'chalk'
import cliColumns from 'cli-columns'
import semver from 'semver'
export function renderPeerIssues (
peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects,
opts?: { width?: number }
opts?: {
rules?: PeerDependencyRules
width?: number
}
) {
const ignoreMissingPatterns = [...new Set(opts?.rules?.ignoreMissing ?? [])]
const ignoreMissingMatcher = createMatcher(ignoreMissingPatterns)
const allowAnyPatterns = [...new Set(opts?.rules?.allowAny ?? [])]
const allowAnyMatcher = createMatcher(allowAnyPatterns)
const { allowedVersionsMatchAll, allowedVersionsByParentPkgName } = parseAllowedVersions(opts?.rules?.allowedVersions ?? {})
const projects = {} as Record<string, PkgNode>
for (const [projectId, { bad, missing, conflicts, intersections }] of Object.entries(peerDependencyIssuesByProjects)) {
projects[projectId] = { dependencies: {}, peerIssues: [] }
for (const [peerName, issues] of Object.entries(missing)) {
if (
!conflicts.includes(peerName) &&
intersections[peerName] == null
intersections[peerName] == null ||
ignoreMissingMatcher(peerName)
) {
continue
}
@@ -22,7 +39,21 @@ export function renderPeerIssues (
}
}
for (const [peerName, issues] of Object.entries(bad)) {
if (allowAnyMatcher(peerName)) continue
for (const issue of issues) {
if (allowedVersionsMatchAll[peerName]?.some((range) => semver.satisfies(issue.foundVersion, range))) continue
const currentParentPkg = issue.parents.at(-1)
if (currentParentPkg && allowedVersionsByParentPkgName[peerName]?.[currentParentPkg.name]) {
const allowedVersionsByParent = allowedVersionsByParentPkgName[peerName][currentParentPkg.name]
.reduce((acc, { targetPkg, parentPkg, ranges }) => {
if (!parentPkg.pref || currentParentPkg.version &&
(isSubRange(parentPkg.pref, currentParentPkg.version) || semver.satisfies(currentParentPkg.version, parentPkg.pref))) {
acc[targetPkg.name] = ranges
}
return acc
}, {} as Record<string, string[]>)
if (allowedVersionsByParent[peerName]?.some((range) => semver.satisfies(issue.foundVersion, range))) continue
}
createTree(projects[projectId], issue.parents, formatUnmetPeerMessage({
peerName,
...issue,
@@ -109,3 +140,54 @@ function toArchyData (depName: string, pkgNode: PkgNode): archy.Data {
}
return result
}
type AllowedVersionsByParentPkgName = Record<string, Record<string, Array<Required<Pick<VersionOverride, 'parentPkg' | 'targetPkg'>> & { ranges: string[] }>>>
function parseAllowedVersions (allowedVersions: Record<string, string>) {
const overrides = tryParseAllowedVersions(allowedVersions)
const allowedVersionsMatchAll: Record<string, string[]> = {}
const allowedVersionsByParentPkgName: AllowedVersionsByParentPkgName = {}
for (const { parentPkg, targetPkg, newPref } of overrides) {
const ranges = parseVersions(newPref)
if (!parentPkg) {
allowedVersionsMatchAll[targetPkg.name] = ranges
continue
}
if (!allowedVersionsByParentPkgName[targetPkg.name]) {
allowedVersionsByParentPkgName[targetPkg.name] = {}
}
if (!allowedVersionsByParentPkgName[targetPkg.name][parentPkg.name]) {
allowedVersionsByParentPkgName[targetPkg.name][parentPkg.name] = []
}
allowedVersionsByParentPkgName[targetPkg.name][parentPkg.name].push({
parentPkg,
targetPkg,
ranges,
})
}
return {
allowedVersionsMatchAll,
allowedVersionsByParentPkgName,
}
}
function tryParseAllowedVersions (allowedVersions: Record<string, string>): VersionOverride[] {
try {
return parseOverrides(allowedVersions ?? {})
} catch (err) {
throw new PnpmError('INVALID_ALLOWED_VERSION_SELECTOR',
`${(err as PnpmError).message} in pnpm.peerDependencyRules.allowedVersions`)
}
}
function parseVersions (versions: string): string[] {
return versions.split('||').map(v => v.trim())
}
function isSubRange (superRange: string | undefined, subRange: string) {
return !superRange ||
subRange === superRange ||
semver.validRange(subRange) != null &&
semver.validRange(superRange) != null &&
semver.subset(subRange, superRange)
}

View File

@@ -23,6 +23,13 @@ Peer dependencies that should be installed:
"
`;
exports[`renderPeerIssues() allowed versions 1`] = `
".
└─┬ aaa 1.0.0
└── ✕ unmet peer @foo/bar@^1.0.0: found 2.0.0
"
`;
exports[`renderPeerIssues() format correctly the version ranges with spaces and "*" 1`] = `
".
└─┬ z 1.0.0

View File

@@ -108,6 +108,185 @@ test('renderPeerIssues()', () => {
}, { width: 500 }))).toMatchSnapshot()
})
test('renderPeerIssues() ignore missing', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {
aaa: [
{
parents: [
{
name: 'xxx',
version: '1.0.0',
},
],
optional: false,
wantedRange: '>=1.0.0 <3.0.0',
},
],
'@foo/bar': [
{
parents: [
{
name: 'xxx',
version: '1.0.0',
},
],
optional: false,
wantedRange: '>=1.0.0 <3.0.0',
},
],
},
bad: {},
conflicts: [],
intersections: {
aaa: '^1.0.0',
'@foo/bar': '^1.0.0',
},
},
}, {
rules: {
ignoreMissing: ['aaa', '@foo/*'],
},
width: 500,
}))).toBe('')
})
test('renderPeerIssues() allow any version', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {},
bad: {
bbb: [
{
parents: [
{
name: 'xxx',
version: '1.0.0',
},
],
foundVersion: '2.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^1.0.0',
},
],
'@foo/bar': [
{
parents: [
{
name: 'xxx',
version: '1.0.0',
},
],
foundVersion: '2.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^1.0.0',
},
],
},
conflicts: [],
intersections: {},
},
}, {
rules: {
allowAny: ['bbb', '@foo/*'],
},
width: 500,
}))).toBe('')
})
test('renderPeerIssues() allowed versions', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {},
bad: {
bbb: [
{
parents: [
{
name: 'xxx',
version: '1.0.0',
},
],
foundVersion: '2.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^1.0.0',
},
],
'@foo/bar': [
{
parents: [
{
name: 'aaa',
version: '1.0.0',
},
],
foundVersion: '2.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^1.0.0',
},
{
parents: [
{
name: 'yyy',
version: '1.0.0',
},
{
name: 'xxx',
version: '1.0.0',
},
],
foundVersion: '2.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^1.0.0',
},
{
parents: [
{
name: 'ccc',
version: '3.0.0',
},
],
foundVersion: '3.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^1.0.0',
},
{
parents: [
{
name: 'ccc',
version: '2.3.6',
},
],
foundVersion: '4.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^1.0.0',
},
],
},
conflicts: [],
intersections: {},
},
}, {
rules: {
allowedVersions: {
bbb: '2',
'xxx>@foo/bar': '2',
'ccc@3>@foo/bar': '3',
'ccc@>=2.3.5 <3>@foo/bar': '4',
},
},
width: 500,
}))).toMatchSnapshot()
})
test('renderPeerIssues() optional peer dependencies are printed only if they are in conflict with non-optional peers', () => {
expect(stripAnsi(renderPeerIssues({
'.': {

View File

@@ -9,6 +9,15 @@
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../config/matcher"
},
{
"path": "../../config/parse-overrides"
},
{
"path": "../error"
},
{
"path": "../types"
}

View File

@@ -12,7 +12,6 @@ import {
type SupportedArchitectures,
type AllowedDeprecatedVersions,
type PackageExtension,
type PeerDependencyRules,
type ReadPackageHook,
type Registries,
} from '@pnpm/types'
@@ -105,7 +104,6 @@ export interface StrictInstallOptions {
symlink: boolean
enableModulesDir: boolean
modulesCacheMaxAge: number
peerDependencyRules: PeerDependencyRules
allowedDeprecatedVersions: AllowedDeprecatedVersions
allowNonAppliedPatches: boolean
preferSymlinkedExecutables: boolean
@@ -265,24 +263,12 @@ export function extendOptions (
...opts,
storeDir: defaultOpts.storeDir,
}
if (extendedOpts.autoInstallPeers) {
if (extendedOpts.peerDependencyRules?.ignoreMissing?.length) {
throw new PnpmError('IGNORE_MISSING_WITH_AUTO_INSTALL_PEERS', 'Cannot have both autoInstallPeers=true and setting peerDependencyRules.ignoreMissing')
}
if (extendedOpts.peerDependencyRules?.allowAny?.length) {
throw new PnpmError('ALLOW_ANY_WITH_AUTO_INSTALL_PEERS', 'Cannot have both autoInstallPeers=true and setting peerDependencyRules.allowAny')
}
if (Object.keys(extendedOpts.peerDependencyRules?.allowedVersions ?? {}).length) {
throw new PnpmError('ALLOWED_VERSIONS_WITH_AUTO_INSTALL_PEERS', 'Cannot have both autoInstallPeers=true and setting peerDependencyRules.allowedVersions')
}
}
extendedOpts.readPackageHook = createReadPackageHook({
ignoreCompatibilityDb: extendedOpts.ignoreCompatibilityDb,
readPackageHook: extendedOpts.hooks?.readPackage,
overrides: extendedOpts.overrides,
lockfileDir: extendedOpts.lockfileDir,
packageExtensions: extendedOpts.packageExtensions,
peerDependencyRules: extendedOpts.peerDependencyRules,
ignoredOptionalDependencies: extendedOpts.ignoredOptionalDependencies,
})
if (extendedOpts.lockfileOnly) {

View File

@@ -602,33 +602,3 @@ test('do not override the direct dependency with an auto installed peer dependen
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].dependencies?.rxjs.version).toStrictEqual('6.6.7')
})
test('auto install peers fails if ignoreMissing is set', async () => {
prepareEmpty()
await expect(addDependenciesToPackage({}, ['is-odd@1.0.0'], testDefaults({
autoInstallPeers: true,
peerDependencyRules: {
ignoreMissing: ['*'],
},
}))).rejects.toThrow('Cannot have both autoInstallPeers=true and setting peerDependencyRules.ignoreMissing')
})
test('auto install peers fails if allowAny is set', async () => {
prepareEmpty()
await expect(addDependenciesToPackage({}, ['is-odd@1.0.0'], testDefaults({
autoInstallPeers: true,
peerDependencyRules: {
allowAny: ['*'],
},
}))).rejects.toThrow('Cannot have both autoInstallPeers=true and setting peerDependencyRules.allowAny')
})
test('auto install peers fails if allowedVersions is set', async () => {
prepareEmpty()
await expect(addDependenciesToPackage({}, ['is-odd@1.0.0'], testDefaults({
autoInstallPeers: true,
peerDependencyRules: {
allowedVersions: { react: '*' },
},
}))).rejects.toThrow('Cannot have both autoInstallPeers=true and setting peerDependencyRules.allowedVersions')
})

44
pnpm-lock.yaml generated
View File

@@ -2776,6 +2776,15 @@ importers:
packages/render-peer-issues:
dependencies:
'@pnpm/error':
specifier: workspace:*
version: link:../error
'@pnpm/matcher':
specifier: workspace:*
version: link:../../config/matcher
'@pnpm/parse-overrides':
specifier: workspace:*
version: link:../../config/parse-overrides
'@pnpm/types':
specifier: workspace:*
version: link:../types
@@ -2788,6 +2797,9 @@ importers:
cli-columns:
specifier: ^4.0.0
version: 4.0.0
semver:
specifier: ^7.6.0
version: 7.6.0
devDependencies:
'@pnpm/render-peer-issues':
specifier: workspace:*
@@ -2795,6 +2807,9 @@ importers:
'@types/archy':
specifier: 0.0.33
version: 0.0.33
'@types/semver':
specifier: 7.5.8
version: 7.5.8
strip-ansi:
specifier: ^6.0.1
version: 6.0.1
@@ -7187,7 +7202,7 @@ packages:
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '*'
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
/@eslint-community/regexpp@4.10.0:
resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==}
@@ -7930,7 +7945,7 @@ packages:
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
'@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
eslint: '*'
eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
@@ -7940,7 +7955,7 @@ packages:
resolution: {integrity: sha512-zct/MdJnVaRRNy9e84XnVtRv9Vf91/qqe+hZJtKanjojud4wAVy/7lXxJmMyX6X6J+xc6c//YEWvpeif8cAhWA==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
eslint: '*'
eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
@@ -7954,7 +7969,7 @@ packages:
resolution: {integrity: sha512-wyOSKhuzHeU/5pcRDP2G2Ndci+4g653V43gXTpt4nbyoIOAASkGDA9JIAgbQCdCkcr1MvpSYWzxTz0olCn8+/Q==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
eslint: '*'
eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
@@ -7977,7 +7992,7 @@ packages:
resolution: {integrity: sha512-zZmTuVZvD1wpoceHvoQpOiewmWu3uP9FuTWo8vqpy2ffsmfCE8mklRPi+vmnIYAIk9t/4kOThri2QCDgor+OpQ==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
eslint: '*'
eslint: ^7.0.0 || ^8.0.0
/@typescript-eslint/visitor-keys@6.18.1:
resolution: {integrity: sha512-/kvt0C5lRqGoCfsbmm7/CwMqoSkY3zzHLIjdhHZQW3VFrnz7ATecOHR7nb7V+xn4286MBxfnQfQhAmCI0u+bJA==}
@@ -9199,13 +9214,13 @@ packages:
resolution: {integrity: sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==}
engines: {node: '>=12'}
peerDependencies:
eslint: '*'
eslint: '>=6.0.0'
/eslint-config-standard-with-typescript@39.1.1:
resolution: {integrity: sha512-t6B5Ep8E4I18uuoYeYxINyqcXb2UbC0SOOTxRtBSt2JUs+EzeXbfe2oaiPs71AIdnoWhXDO2fYOHz8df3kV84A==}
peerDependencies:
'@typescript-eslint/eslint-plugin': ^6.4.0
eslint: '*'
eslint: ^8.0.1
eslint-plugin-import: ^2.25.2
eslint-plugin-n: '^15.0.0 || ^16.0.0 '
eslint-plugin-promise: ^6.0.0
@@ -9215,7 +9230,7 @@ packages:
resolution: {integrity: sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==}
engines: {node: '>=12.0.0'}
peerDependencies:
eslint: '*'
eslint: ^8.0.1
eslint-plugin-import: ^2.25.2
eslint-plugin-n: '^15.0.0 || ^16.0.0 '
eslint-plugin-promise: ^6.0.0
@@ -9248,20 +9263,20 @@ packages:
resolution: {integrity: sha512-ODswlDSO0HJDzXU0XvgZ3lF3lS3XAZEossh15Q2UHjwrJggWeBoKqqEsLTZLXl+dh5eOAozG0zRcYtuE35oTuQ==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
eslint: '*'
eslint: '>=8'
/eslint-plugin-es@3.0.1:
resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==}
engines: {node: '>=8.10.0'}
peerDependencies:
eslint: '*'
eslint: '>=4.19.1'
/eslint-plugin-import@2.29.1:
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
@@ -9270,19 +9285,19 @@ packages:
resolution: {integrity: sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==}
engines: {node: '>=16.0.0'}
peerDependencies:
eslint: '*'
eslint: '>=7.0.0'
/eslint-plugin-node@11.1.0:
resolution: {integrity: sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==}
engines: {node: '>=8.10.0'}
peerDependencies:
eslint: '*'
eslint: '>=5.16.0'
/eslint-plugin-promise@6.1.1:
resolution: {integrity: sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '*'
eslint: ^7.0.0 || ^8.0.0
/eslint-scope@7.2.2:
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
@@ -21448,6 +21463,7 @@ time:
/@types/rimraf@3.0.2: '2021-08-18T21:02:03.570Z'
/@types/semver@7.5.3: '2023-09-25T14:19:37.089Z'
/@types/semver@7.5.7: '2024-02-11T14:35:16.597Z'
/@types/semver@7.5.8: '2024-02-24T16:35:29.020Z'
/@types/signal-exit@3.0.4: '2023-11-07T16:33:34.502Z'
/@types/sinon@10.0.20: '2023-10-18T15:02:12.573Z'
/@types/ssri@7.1.5: '2023-11-21T01:08:19.305Z'

View File

@@ -27,6 +27,7 @@ export function initReporter (
throttleProgress: 200,
hideAddedPkgsProgress: opts.config.lockfileOnly,
hideLifecyclePrefix: opts.config.reporterHidePrefix,
peerDependencyRules: opts.config.rootProjectManifest?.pnpm?.peerDependencyRules,
},
streamParser,
})
@@ -44,6 +45,7 @@ export function initReporter (
logLevel: opts.config.loglevel as LogLevel,
throttleProgress: 1000,
hideLifecyclePrefix: opts.config.reporterHidePrefix,
peerDependencyRules: opts.config.rootProjectManifest?.pnpm?.peerDependencyRules,
},
streamParser,
})