fix: render peer dependency issues on strict error (#11450)

Fixes #11439.

When `strictPeerDependencies: true` causes `ERR_PNPM_PEER_DEP_ISSUES`, the peer dependency issues are again rendered inline — using the **same format as `pnpm peers check`** — so users (and CI tools like Renovate) can see what failed without running another command.

The non-strict warning path is unchanged: it still emits the short "Run `pnpm peers check`" hint.

### Behavior

`strictPeerDependencies: true`:
```
 ERR_PNPM_PEER_DEP_ISSUES  Unmet peer dependencies

✕ unmet peer react
  Installed: 17.0.2
  Wanted:
    ^18.2.0:
      react-dom@18.2.0
hint: To disable failing on peer dependency issues, add the following to pnpm-workspace.yaml in your project root:

  strictPeerDependencies: false
```

`strictPeerDependencies: false` (unchanged):
```
 WARN  Issues with peer dependencies found. Run "pnpm peers check" to list them.
```

### Implementation

- Added a new `@pnpm/deps.inspection.peers-issues-renderer` package at `deps/inspection/peers-issues-renderer/`, alongside its data producer `@pnpm/deps.inspection.peers-checker`. It exposes a single `renderPeerIssues()` that emits the flat issue list previously inlined in `pnpm peers check`.
- Removed the duplicated formatter from `deps/inspection/commands/src/peers.ts` and made the `pnpm peers check` command consume the new renderer.
- `cli/default-reporter/src/reportError.ts`: `reportPeerDependencyIssuesError` now calls the shared `renderPeerIssues()` and prefixes the hint block with the rendered output. Tests strip ANSI escapes before substring assertions so they stay correct under `FORCE_COLOR=1`.

Result: a single renderer is shared between the install error and the `pnpm peers check` command — output is identical between the two paths.
This commit is contained in:
Zoltan Kochan
2026-05-04 19:44:30 +02:00
committed by GitHub
parent 81817ec55a
commit 55de4febeb
17 changed files with 438 additions and 64 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/deps.inspection.peers-issues-renderer": minor
"@pnpm/deps.inspection.commands": patch
"@pnpm/cli.default-reporter": patch
"pnpm": patch
---
When `strictPeerDependencies` is `true`, the `ERR_PNPM_PEER_DEP_ISSUES` error once again renders the peer dependency issues inline using the same format as `pnpm peers check`, so users (and CI tools like Renovate) can see what failed without running `pnpm peers check` separately [#11439](https://github.com/pnpm/pnpm/issues/11439).

View File

@@ -36,6 +36,7 @@
"@pnpm/cli.meta": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/deps.inspection.peers-issues-renderer": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/installing.dedupe.issues-renderer": "workspace:*",
"@pnpm/installing.dedupe.types": "workspace:*",

View File

@@ -1,5 +1,6 @@
import type { Config } from '@pnpm/config.reader'
import type { Log } from '@pnpm/core-loggers'
import { renderPeerIssues } from '@pnpm/deps.inspection.peers-issues-renderer'
import type { PnpmError } from '@pnpm/error'
import { renderDedupeCheckIssues } from '@pnpm/installing.dedupe.issues-renderer'
import type { DedupeCheckIssues } from '@pnpm/installing.dedupe.types'
@@ -461,9 +462,7 @@ function reportPeerDependencyIssuesError (
msg: { issuesByProjects: PeerDependencyIssuesByProjects }
): ErrorInfo {
const hasMissingPeers = getHasMissingPeers(msg.issuesByProjects)
const hints: string[] = [
'Run "pnpm peers check" to list the peer dependency issues.',
]
const hints: string[] = []
if (hasMissingPeers) {
hints.push(`To auto-install peer dependencies, add the following to "pnpm-workspace.yaml" in your project root:
@@ -471,11 +470,12 @@ function reportPeerDependencyIssuesError (
}
hints.push(`To disable failing on peer dependency issues, add the following to pnpm-workspace.yaml in your project root:
strictPeerDependencies: false
`)
strictPeerDependencies: false`)
const formattedHints = hints.map((hint) => `hint: ${hint}`).join('\n')
const rendered = renderPeerIssues(msg.issuesByProjects)
return {
title: err.message,
body: hints.map((hint) => `hint: ${hint}`).join('\n'),
body: rendered ? `${rendered}\n${formattedHints}` : formattedHints,
}
}

View File

@@ -1,3 +1,5 @@
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { expect, test } from '@jest/globals'
import { toOutput$ } from '@pnpm/cli.default-reporter'
import { peerDependencyIssuesLogger } from '@pnpm/core-loggers'
@@ -44,7 +46,7 @@ test('print peer dependency issues warning', async () => {
expect.assertions(1)
const output = await firstValueFrom(output$)
expect(output).toContain('pnpm peers check')
expect(stripAnsi(output)).toContain('pnpm peers check')
})
test('print peer dependency issues error', async () => {
@@ -81,8 +83,10 @@ test('print peer dependency issues error', async () => {
})
logger.error(err, err)
expect.assertions(1)
expect.assertions(3)
const output = await firstValueFrom(output$)
expect(output).toContain('pnpm peers check')
const output = stripAnsi(await firstValueFrom(output$))
expect(output).toContain('unmet peer a')
expect(output).toContain('Installed: 2')
expect(output).toContain('b@1.0.0')
})

View File

@@ -25,6 +25,9 @@
{
"path": "../../core/types"
},
{
"path": "../../deps/inspection/peers-issues-renderer"
},
{
"path": "../../installing/dedupe/issues-renderer"
},

View File

@@ -41,6 +41,7 @@
"@pnpm/deps.inspection.list": "workspace:*",
"@pnpm/deps.inspection.outdated": "workspace:*",
"@pnpm/deps.inspection.peers-checker": "workspace:*",
"@pnpm/deps.inspection.peers-issues-renderer": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/global.commands": "workspace:*",
"@pnpm/global.packages": "workspace:*",

View File

@@ -2,8 +2,8 @@ import { FILTERING, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
import { checkPeerDependencies } from '@pnpm/deps.inspection.peers-checker'
import { renderPeerIssues } from '@pnpm/deps.inspection.peers-issues-renderer'
import type { PeerDependencyIssuesByProjects } from '@pnpm/types'
import chalk from 'chalk'
import { isEmpty, pick } from 'ramda'
import { renderHelp } from 'render-help'
@@ -117,7 +117,7 @@ async function checkCmd (
}
return {
output: renderPeerIssuesFlat(issues),
output: `Issues with peer dependencies found\n\n${renderPeerIssues(issues)}`,
exitCode: 1,
}
}
@@ -129,55 +129,3 @@ function hasNoIssues (issues: PeerDependencyIssuesByProjects): boolean {
isEmpty(projectIssues.missing)
)
}
function renderPeerIssuesFlat (issuesByProjects: PeerDependencyIssuesByProjects): string {
const sections: string[] = []
for (const [, { bad, missing, conflicts, intersections }] of Object.entries(issuesByProjects)) {
for (const [peerName, issues] of Object.entries(bad)) {
const foundVersion = issues[0].foundVersion
const header = `${chalk.yellowBright('✕ unmet peer')} ${chalk.bold(peerName)}`
const installed = ` ${chalk.cyan('Installed:')} ${chalk.dim(foundVersion)}`
sections.push(`${header}\n${installed}\n${formatRequiredBy(issues)}`)
}
for (const [peerName, issues] of Object.entries(missing)) {
if (!intersections[peerName] && !conflicts.includes(peerName)) continue
const conflict = conflicts.includes(peerName)
const header = conflict
? `${chalk.red('✕ conflicting peer')} ${chalk.bold(peerName)}`
: `${chalk.red('✕ missing peer')} ${chalk.bold(peerName)}`
sections.push(`${header}\n${formatRequiredBy(issues)}`)
}
}
if (sections.length === 0) return ''
return `Issues with peer dependencies found\n\n${sections.join('\n\n')}`
}
function formatRequiredBy (issues: Array<{ parents: Array<{ name: string, version: string }>, wantedRange: string }>): string {
const byRange = new Map<string, Set<string>>()
for (const issue of issues) {
const declaring = issue.parents[issue.parents.length - 1]
const pkg = `${declaring.name}@${declaring.version}`
if (!byRange.has(issue.wantedRange)) {
byRange.set(issue.wantedRange, new Set())
}
byRange.get(issue.wantedRange)!.add(pkg)
}
const lines: string[] = [` ${chalk.cyan('Wanted:')}`]
for (const [range, pkgs] of byRange) {
lines.push(` ${chalk.cyanBright(formatRange(range))}${chalk.cyan(':')}`)
for (const pkg of pkgs) {
lines.push(` ${chalk.dim(pkg)}`)
}
}
return lines.join('\n')
}
function formatRange (range: string): string {
if (range.includes(' ') || range === '*') {
return `"${range}"`
}
return range
}

View File

@@ -89,6 +89,9 @@
},
{
"path": "../peers-checker"
},
{
"path": "../peers-issues-renderer"
}
]
}

View File

@@ -0,0 +1,13 @@
# @pnpm/deps.inspection.peers-issues-renderer
> Visualizes peer dependency issues
## Installation
```
pnpm add @pnpm/deps.inspection.peers-issues-renderer
```
## License
[MIT](LICENSE)

View File

@@ -0,0 +1,47 @@
{
"name": "@pnpm/deps.inspection.peers-issues-renderer",
"version": "1100.0.0-0",
"description": "Visualizes peer dependency issues",
"keywords": [
"pnpm",
"pnpm11"
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/deps/inspection/peers-issues-renderer",
"homepage": "https://github.com/pnpm/pnpm/tree/main/deps/inspection/peers-issues-renderer#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"test": "pn compile && pn .test",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"prepublishOnly": "tsgo --build",
"compile": "tsgo --build && pn lint --fix",
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@pnpm/types": "workspace:*",
"chalk": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/deps.inspection.peers-issues-renderer": "workspace:*"
},
"engines": {
"node": ">=22.13"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

View File

@@ -0,0 +1,68 @@
import type { BadPeerDependencyIssue, PeerDependencyIssuesByProjects } from '@pnpm/types'
import chalk from 'chalk'
export function renderPeerIssues (issuesByProjects: PeerDependencyIssuesByProjects): string {
const sections: string[] = []
for (const [, { bad, missing, conflicts, intersections }] of Object.entries(issuesByProjects)) {
for (const [peerName, issues] of Object.entries(bad)) {
const header = `${chalk.yellowBright('✕ unmet peer')} ${chalk.bold(peerName)}`
for (const [foundVersion, group] of groupByFoundVersion(issues)) {
const installed = ` ${chalk.cyan('Installed:')} ${chalk.dim(foundVersion)}`
sections.push(`${header}\n${installed}\n${formatRequiredBy(group)}`)
}
}
for (const [peerName, issues] of Object.entries(missing)) {
if (!intersections[peerName] && !conflicts.includes(peerName)) continue
const conflict = conflicts.includes(peerName)
const header = conflict
? `${chalk.red('✕ conflicting peer')} ${chalk.bold(peerName)}`
: `${chalk.red('✕ missing peer')} ${chalk.bold(peerName)}`
sections.push(`${header}\n${formatRequiredBy(issues)}`)
}
}
if (sections.length === 0) return ''
return sections.join('\n\n')
}
function formatRequiredBy (issues: Array<{ parents: Array<{ name: string, version: string }>, wantedRange: string }>): string {
const byRange = new Map<string, Set<string>>()
for (const issue of issues) {
const declaring = issue.parents[issue.parents.length - 1]
const pkg = declaring ? `${declaring.name}@${declaring.version}` : '<unknown>'
if (!byRange.has(issue.wantedRange)) {
byRange.set(issue.wantedRange, new Set())
}
byRange.get(issue.wantedRange)!.add(pkg)
}
const lines: string[] = [` ${chalk.cyan('Wanted:')}`]
for (const [range, pkgs] of byRange) {
lines.push(` ${chalk.cyanBright(formatRange(range))}${chalk.cyan(':')}`)
for (const pkg of pkgs) {
lines.push(` ${chalk.dim(pkg)}`)
}
}
return lines.join('\n')
}
function formatRange (range: string): string {
if (range.includes(' ') || range === '*') {
return `"${range}"`
}
return range
}
function groupByFoundVersion (issues: BadPeerDependencyIssue[]): Map<string, BadPeerDependencyIssue[]> {
const groups = new Map<string, BadPeerDependencyIssue[]>()
for (const issue of issues) {
const list = groups.get(issue.foundVersion)
if (list) {
list.push(issue)
} else {
groups.set(issue.foundVersion, [issue])
}
}
return groups
}

View File

@@ -0,0 +1,58 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renderPeerIssues() formats version ranges with spaces or "*" with quotes 1`] = `
"✕ missing peer a
Wanted:
"*":
z@1.0.0
✕ missing peer b
Wanted:
"1 || 2":
z@1.0.0"
`;
exports[`renderPeerIssues() handles missing parents gracefully 1`] = `
"✕ missing peer foo
Wanted:
">=1.0.0 <3.0.0":
<unknown>"
`;
exports[`renderPeerIssues() renders bad peer dependencies 1`] = `
"✕ unmet peer a
Installed: 2
Wanted:
3:
b@1.0.0"
`;
exports[`renderPeerIssues() renders conflicting peer dependencies 1`] = `
"✕ conflicting peer a
Wanted:
^1.0.0:
b@1.0.0
^2.0.0:
c@1.0.0"
`;
exports[`renderPeerIssues() renders missing peer dependencies that are required 1`] = `
"✕ missing peer a
Wanted:
^1.0.0:
b@1.0.0"
`;
exports[`renderPeerIssues() splits bad peer dependencies by foundVersion 1`] = `
"✕ unmet peer a
Installed: 1.0.0
Wanted:
^2.0.0:
b@1.0.0
✕ unmet peer a
Installed: 2.0.0
Wanted:
^3.0.0:
c@1.0.0"
`;

View File

@@ -0,0 +1,156 @@
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { expect, test } from '@jest/globals'
import { renderPeerIssues } from '@pnpm/deps.inspection.peers-issues-renderer'
test('renderPeerIssues() returns an empty string when there are no issues', () => {
expect(renderPeerIssues({
'.': {
missing: {},
bad: {},
conflicts: [],
intersections: {},
},
})).toBe('')
})
test('renderPeerIssues() renders bad peer dependencies', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {},
bad: {
a: [
{
parents: [
{ name: 'b', version: '1.0.0' },
],
foundVersion: '2',
resolvedFrom: [],
optional: false,
wantedRange: '3',
},
],
},
conflicts: [],
intersections: {},
},
}))).toMatchSnapshot()
})
test('renderPeerIssues() splits bad peer dependencies by foundVersion', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {},
bad: {
a: [
{
parents: [{ name: 'b', version: '1.0.0' }],
foundVersion: '1.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^2.0.0',
},
{
parents: [{ name: 'c', version: '1.0.0' }],
foundVersion: '2.0.0',
resolvedFrom: [],
optional: false,
wantedRange: '^3.0.0',
},
],
},
conflicts: [],
intersections: {},
},
}))).toMatchSnapshot()
})
test('renderPeerIssues() renders missing peer dependencies that are required', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {
a: [
{
parents: [
{ name: 'b', version: '1.0.0' },
],
optional: false,
wantedRange: '^1.0.0',
},
],
},
bad: {},
conflicts: [],
intersections: { a: '^1.0.0' },
},
}))).toMatchSnapshot()
})
test('renderPeerIssues() renders conflicting peer dependencies', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {
a: [
{
parents: [{ name: 'b', version: '1.0.0' }],
optional: false,
wantedRange: '^1.0.0',
},
{
parents: [{ name: 'c', version: '1.0.0' }],
optional: false,
wantedRange: '^2.0.0',
},
],
},
bad: {},
conflicts: ['a'],
intersections: {},
},
}))).toMatchSnapshot()
})
test('renderPeerIssues() formats version ranges with spaces or "*" with quotes', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {
a: [
{
parents: [{ name: 'z', version: '1.0.0' }],
optional: false,
wantedRange: '*',
},
],
b: [
{
parents: [{ name: 'z', version: '1.0.0' }],
optional: false,
wantedRange: '1 || 2',
},
],
},
bad: {},
conflicts: [],
intersections: { a: '*', b: '1 || 2' },
},
}))).toMatchSnapshot()
})
test('renderPeerIssues() handles missing parents gracefully', () => {
expect(stripAnsi(renderPeerIssues({
'.': {
missing: {
foo: [
{
parents: [],
optional: false,
wantedRange: '>=1.0.0 <3.0.0',
},
],
},
bad: {},
conflicts: [],
intersections: { foo: '^1.0.0' },
},
}))).toMatchSnapshot()
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../../core/types"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../../__typings__/**/*.d.ts"
]
}

22
pnpm-lock.yaml generated
View File

@@ -2245,6 +2245,9 @@ importers:
'@pnpm/core-loggers':
specifier: workspace:*
version: link:../../core/core-loggers
'@pnpm/deps.inspection.peers-issues-renderer':
specifier: workspace:*
version: link:../../deps/inspection/peers-issues-renderer
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error
@@ -3357,6 +3360,9 @@ importers:
'@pnpm/deps.inspection.peers-checker':
specifier: workspace:*
version: link:../peers-checker
'@pnpm/deps.inspection.peers-issues-renderer':
specifier: workspace:*
version: link:../peers-issues-renderer
'@pnpm/error':
specifier: workspace:*
version: link:../../../core/error
@@ -3626,6 +3632,22 @@ importers:
specifier: 'catalog:'
version: 7.7.1
deps/inspection/peers-issues-renderer:
dependencies:
'@pnpm/types':
specifier: workspace:*
version: link:../../../core/types
chalk:
specifier: 'catalog:'
version: 5.6.2
devDependencies:
'@jest/globals':
specifier: 'catalog:'
version: 30.3.0
'@pnpm/deps.inspection.peers-issues-renderer':
specifier: workspace:*
version: 'link:'
deps/inspection/tree-builder:
dependencies:
'@pnpm/config.matcher':