feat: detect conflicting peer dependencies and print nice warnings about peer dependency issues (#4102)

This commit is contained in:
Zoltan Kochan
2021-12-11 00:20:01 +02:00
committed by GitHub
parent c0b02abb02
commit ba9b2eba1c
37 changed files with 758 additions and 195 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/core-loggers": minor
"@pnpm/default-reporter": minor
---
Add peerDependencyIssuesLogger.

View File

@@ -0,0 +1,5 @@
---
"@pnpm/render-peer-issues": major
---
Initial release.

View File

@@ -0,0 +1,5 @@
---
"@pnpm/types": minor
---
Add types for peer dependency issues.

View File

@@ -2,4 +2,4 @@
"@pnpm/core": minor
---
New function added to the core API: `listMissingPeers()`.
New function added to the core API: `listPeerDependencyIssues()`.

View File

@@ -35,7 +35,7 @@
"@commitlint/prompt-cli": "^15.0.0",
"@pnpm/eslint-config": "workspace:*",
"@pnpm/meta-updater": "0.0.6",
"@pnpm/registry-mock": "^2.10.0",
"@pnpm/registry-mock": "^2.11.0",
"@pnpm/tsconfig": "workspace:*",
"@types/jest": "^26.0.24",
"@types/node": "^14.17.32",

View File

@@ -7,6 +7,7 @@ export * from './lifecycleLogger'
export * from './linkLogger'
export * from './packageImportMethodLogger'
export * from './packageManifestLogger'
export * from './peerDependencyIssues'
export * from './progressLogger'
export * from './registryLogger'
export * from './removalLogger'

View File

@@ -8,6 +8,7 @@ import {
LinkLog,
PackageImportMethodLog,
PackageManifestLog,
PeerDependencyIssuesLog,
ProgressLog,
RegistryLog,
RequestRetryLog,
@@ -32,6 +33,7 @@ export type Log =
| LinkLog
| PackageManifestLog
| PackageImportMethodLog
| PeerDependencyIssuesLog
| ProgressLog
| RegistryLog
| RequestRetryLog

View File

@@ -0,0 +1,11 @@
import baseLogger, {
LogBase,
Logger,
} from '@pnpm/logger'
import { PeerDependencyIssues } from '@pnpm/types'
export const peerDependencyIssuesLogger = baseLogger('peer-dependency-issues') as Logger<PeerDependencyIssuesMessage>
export type PeerDependencyIssuesMessage = PeerDependencyIssues
export type PeerDependencyIssuesLog = {name: 'pnpm:peer-dependency-issues'} & LogBase & PeerDependencyIssuesMessage

View File

@@ -62,6 +62,7 @@
"ramda": "^0.27.1",
"run-groups": "^3.0.1",
"semver": "^7.3.4",
"semver-intersect": "^1.4.0",
"version-selector-type": "^3.0.0"
},
"devDependencies": {

View File

@@ -1,8 +1,9 @@
import link from './link'
export * from './install'
export { PeerDependencyIssuesError } from './install/reportPeerDependencyIssues'
export * from './link'
export * from './listMissingPeers'
export * from './listPeerDependencyIssues'
export {
link,
}

View File

@@ -961,12 +961,10 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
await opts.storeController.close()
if (peerDependencyIssues.length > 0) {
reportPeerDependencyIssues(peerDependencyIssues, {
lockfileDir: opts.lockfileDir,
strictPeerDependencies: opts.strictPeerDependencies,
})
}
reportPeerDependencyIssues(peerDependencyIssues, {
lockfileDir: opts.lockfileDir,
strictPeerDependencies: opts.strictPeerDependencies,
})
return {
newLockfile,

View File

@@ -1,61 +1,26 @@
import PnpmError from '@pnpm/error'
import logger from '@pnpm/logger'
import { PeerDependencyIssue, PeerDependencyIssueLocation } from '@pnpm/resolve-dependencies'
import { peerDependencyIssuesLogger } from '@pnpm/core-loggers'
import { PeerDependencyIssues } from '@pnpm/types'
import isEmpty from 'ramda/src/isEmpty'
export default function (
peerDependencyIssues: PeerDependencyIssue[],
peerDependencyIssues: PeerDependencyIssues,
opts: {
lockfileDir: string
strictPeerDependencies: boolean
}
) {
for (const peerDependencyIssue of peerDependencyIssues) {
const message = peerDependencyIssueMessage({
peerDependencyIssue,
...opts,
})
if (opts.strictPeerDependencies) {
const code = peerDependencyIssue.foundPeerVersion ? 'INVALID_PEER_DEPENDENCY' : 'MISSING_PEER_DEPENDENCY'
const err = new PnpmError(code, message)
if (peerDependencyIssues.length === 1) {
throw err
}
logger.error(err)
} else {
logger.warn({
message,
prefix: peerDependencyIssue.rootDir,
})
}
}
if (isEmpty(peerDependencyIssues.bad) && isEmpty(peerDependencyIssues.missing)) return
if (opts.strictPeerDependencies) {
throw new PnpmError('PEER_DEPENDENCY', `${peerDependencyIssues.length} peer dependency issues found.`)
throw new PeerDependencyIssuesError(peerDependencyIssues)
}
peerDependencyIssuesLogger.debug(peerDependencyIssues)
}
function peerDependencyIssueMessage (
opts: {
lockfileDir: string
peerDependencyIssue: PeerDependencyIssue
export class PeerDependencyIssuesError extends PnpmError {
issues: PeerDependencyIssues
constructor (issues: PeerDependencyIssues) {
super('PEER_DEP_ISSUES', 'Unmet peer dependencies')
this.issues = issues
}
) {
const friendlyPath = locationToFriendlyPath(opts.peerDependencyIssue.location)
if (opts.peerDependencyIssue.foundPeerVersion) {
return `${friendlyPath ? `${friendlyPath}: ` : ''}${packageFriendlyId(opts.peerDependencyIssue.pkg)} \
requires a peer of ${opts.peerDependencyIssue.wantedPeer.name}@${opts.peerDependencyIssue.wantedPeer.range} but version ${opts.peerDependencyIssue.foundPeerVersion} was installed.`
}
return `${friendlyPath ? `${friendlyPath}: ` : ''}${packageFriendlyId(opts.peerDependencyIssue.pkg)} \
requires a peer of ${opts.peerDependencyIssue.wantedPeer.name}@${opts.peerDependencyIssue.wantedPeer.range} but none was installed.`
}
function packageFriendlyId (manifest: {name: string, version: string}) {
return `${manifest.name}@${manifest.version}`
}
function locationToFriendlyPath (location: PeerDependencyIssueLocation) {
const result = location.parents.map(({ name }) => name)
if (location.projectPath) {
result.unshift(location.projectPath)
}
return result.join(' > ')
}

View File

@@ -1,10 +1,12 @@
import resolveDependencies from '@pnpm/resolve-dependencies'
import getWantedDependencies from '@pnpm/resolve-dependencies/lib/getWantedDependencies'
import { PeerDependencyIssues } from '@pnpm/types'
import getContext, { GetContextOptions, ProjectOptions } from '@pnpm/get-context'
import { createReadPackageHook } from './install'
import { getPreferredVersionsFromLockfile } from './install/getPreferredVersions'
import { InstallOptions } from './install/extendInstallOptions'
import { DEFAULT_REGISTRIES } from '@pnpm/normalize-registries'
import { intersect } from 'semver-intersect'
export type ListMissingPeersOptions = Partial<GetContextOptions>
& Pick<InstallOptions, 'hooks'
@@ -19,7 +21,7 @@ export type ListMissingPeersOptions = Partial<GetContextOptions>
>
& Pick<GetContextOptions, 'storeDir'>
export async function listMissingPeers (
export async function listPeerDependencyIssues (
projects: ProjectOptions[],
opts: ListMissingPeersOptions
) {
@@ -76,7 +78,34 @@ export async function listMissingPeers (
}
)
const conflicts = getPeerDependencyConflicts(peerDependencyIssues)
await waitTillAllFetchingsFinish()
return peerDependencyIssues
return {
issues: peerDependencyIssues,
conflicts,
}
}
function getPeerDependencyConflicts (peerDependencyIssues: PeerDependencyIssues) {
const missingPeers = new Map<string, string[]>()
for (const [peerName, issues] of Object.entries(peerDependencyIssues.missing)) {
missingPeers.set(peerName, issues.map(({ wantedRange }) => wantedRange))
}
const conflicts = [] as string[]
for (const [peerName, ranges] of missingPeers) {
if (!intersectSafe(ranges)) {
conflicts.push(peerName)
}
}
return conflicts
}
function intersectSafe (ranges: string[]) {
try {
return intersect(...ranges)
} catch {
return false
}
}

View File

@@ -11,6 +11,7 @@ import {
install,
MutatedProject,
mutateModules,
PeerDependencyIssuesError,
} from '@pnpm/core'
import rimraf from '@zkochan/rimraf'
import exists from 'path-exists'
@@ -160,11 +161,25 @@ test('warning is reported when cannot resolve peer dependency for top-level depe
await addDependenciesToPackage({}, ['ajv-keywords@1.5.0'], await testDefaults({ reporter }))
expect(reporter).toBeCalledWith(
expect(reporter).toHaveBeenCalledWith(
expect.objectContaining({
level: 'warn',
name: 'pnpm',
message: 'ajv-keywords@1.5.0 requires a peer of ajv@>=4.10.0 but none was installed.',
level: 'debug',
name: 'pnpm:peer-dependency-issues',
bad: {},
missing: {
ajv: [{
location: {
projectPath: '',
parents: [
{
name: 'ajv-keywords',
version: '1.5.0',
},
],
},
wantedRange: '>=4.10.0',
}],
},
})
)
})
@@ -172,11 +187,30 @@ test('warning is reported when cannot resolve peer dependency for top-level depe
test('strict-peer-dependencies: error is thrown when cannot resolve peer dependency for top-level dependency', async () => {
prepareEmpty()
const reporter = sinon.spy()
let err!: PeerDependencyIssuesError
try {
await addDependenciesToPackage({}, ['ajv-keywords@1.5.0'], await testDefaults({ strictPeerDependencies: true }))
} catch (_err: any) { // eslint-disable-line
err = _err
}
await expect(
addDependenciesToPackage({}, ['ajv-keywords@1.5.0'], await testDefaults({ reporter, strictPeerDependencies: true }))
).rejects.toThrow(/ajv-keywords@1.5.0 requires a peer of ajv@>=4.10.0 but none was installed./)
expect(err?.issues).toStrictEqual({
bad: {},
missing: {
ajv: [{
location: {
projectPath: '',
parents: [
{
name: 'ajv-keywords',
version: '1.5.0',
},
],
},
wantedRange: '>=4.10.0',
}],
},
})
})
test('peer dependency is resolved from the dependencies of the workspace root project', async () => {
@@ -220,9 +254,9 @@ test('peer dependency is resolved from the dependencies of the workspace root pr
},
], await testDefaults({ reporter }))
expect(reporter).not.toHaveBeenCalledWith({
message: 'ajv-keywords@1.5.0 requires a peer of ajv@>=4.10.0 but none was installed.',
})
expect(reporter).not.toHaveBeenCalledWith(expect.objectContaining({
name: 'pnpm:peer-dependency-issues',
}))
{
const lockfile = await projects.root.readLockfile()
@@ -269,41 +303,118 @@ test('warning is reported when cannot resolve peer dependency for non-top-level
prepareEmpty()
await addDistTag({ package: 'abc-parent-with-ab', version: '1.0.0', distTag: 'latest' })
const reporter = sinon.spy()
const reporter = jest.fn()
await addDependenciesToPackage({}, ['abc-grand-parent-without-c'], await testDefaults({ reporter }))
const logMatcher = sinon.match({
message: 'abc-grand-parent-without-c > abc-parent-with-ab: abc@1.0.0 requires a peer of peer-c@^1.0.0 but none was installed.',
})
const reportedTimes = reporter.withArgs(logMatcher).callCount
expect(reportedTimes).toBe(1)
expect(reporter).toHaveBeenCalledWith(
expect.objectContaining({
level: 'debug',
name: 'pnpm:peer-dependency-issues',
bad: {},
missing: {
'peer-c': [{
location: {
projectPath: '',
parents: [
{
name: 'abc-grand-parent-without-c',
version: '1.0.0',
},
{
name: 'abc-parent-with-ab',
version: '1.0.0',
},
{
name: 'abc',
version: '1.0.0',
},
],
},
wantedRange: '^1.0.0',
}],
},
})
)
})
test('warning is reported when bad version of resolved peer dependency for non-top-level dependency', async () => {
await addDistTag({ package: 'abc-parent-with-ab', version: '1.0.0', distTag: 'latest' })
prepareEmpty()
const reporter = sinon.spy()
const reporter = jest.fn()
await addDependenciesToPackage({}, ['abc-grand-parent-without-c', 'peer-c@2'], await testDefaults({ reporter }))
const logMatcher = sinon.match({
message: 'abc-grand-parent-without-c > abc-parent-with-ab: abc@1.0.0 requires a peer of peer-c@^1.0.0 but version 2.0.0 was installed.',
})
const reportedTimes = reporter.withArgs(logMatcher).callCount
expect(reportedTimes).toBe(1)
expect(reporter).toHaveBeenCalledWith(
expect.objectContaining({
level: 'debug',
name: 'pnpm:peer-dependency-issues',
bad: {
'peer-c': [{
location: {
projectPath: '',
parents: [
{
name: 'abc-grand-parent-without-c',
version: '1.0.0',
},
{
name: 'abc-parent-with-ab',
version: '1.0.0',
},
{
name: 'abc',
version: '1.0.0',
},
],
},
foundVersion: '2.0.0',
wantedRange: '^1.0.0',
}],
},
missing: {},
})
)
})
test('strict-peer-dependencies: error is thrown when bad version of resolved peer dependency for non-top-level dependency', async () => {
await addDistTag({ package: 'abc-parent-with-ab', version: '1.0.0', distTag: 'latest' })
prepareEmpty()
const reporter = sinon.spy()
let err!: PeerDependencyIssuesError
try {
await addDependenciesToPackage({}, ['abc-grand-parent-without-c', 'peer-c@2'], await testDefaults({ strictPeerDependencies: true }))
} catch (_err: any) { // eslint-disable-line
err = _err
}
await expect(
addDependenciesToPackage({}, ['abc-grand-parent-without-c', 'peer-c@2'], await testDefaults({ reporter, strictPeerDependencies: true }))
).rejects.toThrow('abc-grand-parent-without-c > abc-parent-with-ab: abc@1.0.0 requires a peer of peer-c@^1.0.0 but version 2.0.0 was installed.')
expect(err?.issues).toStrictEqual({
bad: {
'peer-c': [{
location: {
projectPath: '',
parents: [
{
name: 'abc-grand-parent-without-c',
version: '1.0.0',
},
{
name: 'abc-parent-with-ab',
version: '1.0.0',
},
{
name: 'abc',
version: '1.0.0',
},
],
},
foundVersion: '2.0.0',
wantedRange: '^1.0.0',
}],
},
missing: {},
})
})
test('top peer dependency is linked on subsequent install', async () => {
@@ -865,36 +976,45 @@ test('peer dependency is saved', async () => {
test('warning is not reported when cannot resolve optional peer dependency', async () => {
const project = prepareEmpty()
const reporter = sinon.spy()
const reporter = jest.fn()
await addDependenciesToPackage({}, ['abc-optional-peers@1.0.0', 'peer-c@2.0.0'], await testDefaults({ reporter }))
{
const logMatcher = sinon.match({
message: 'abc-optional-peers@1.0.0 requires a peer of peer-a@^1.0.0 but none was installed.',
expect(reporter).toHaveBeenCalledWith(
expect.objectContaining({
level: 'debug',
name: 'pnpm:peer-dependency-issues',
bad: {
'peer-c': [{
location: {
projectPath: '',
parents: [
{
name: 'abc-optional-peers',
version: '1.0.0',
},
],
},
foundVersion: '2.0.0',
wantedRange: '^1.0.0',
}],
},
missing: {
'peer-a': [{
location: {
projectPath: '',
parents: [
{
name: 'abc-optional-peers',
version: '1.0.0',
},
],
},
wantedRange: '^1.0.0',
}],
},
})
const reportedTimes = reporter.withArgs(logMatcher).callCount
expect(reportedTimes).toBe(1)
}
{
const logMatcher = sinon.match({
message: 'abc-optional-peers@1.0.0 requires a peer of peer-b@^1.0.0 but none was installed.',
})
const reportedTimes = reporter.withArgs(logMatcher).callCount
expect(reportedTimes).toBe(0)
}
{
const logMatcher = sinon.match({
message: 'abc-optional-peers@1.0.0 requires a peer of peer-c@^1.0.0 but version 2.0.0 was installed.',
})
const reportedTimes = reporter.withArgs(logMatcher).callCount
expect(reportedTimes).toBe(1)
}
)
const lockfile = await project.readLockfile()
@@ -911,27 +1031,31 @@ test('warning is not reported when cannot resolve optional peer dependency', asy
test('warning is not reported when cannot resolve optional peer dependency (specified by meta field only)', async () => {
const project = prepareEmpty()
const reporter = sinon.spy()
const reporter = jest.fn()
await addDependenciesToPackage({}, ['abc-optional-peers-meta-only@1.0.0', 'peer-c@2.0.0'], await testDefaults({ reporter }))
{
const logMatcher = sinon.match({
message: 'abc-optional-peers-meta-only@1.0.0 requires a peer of peer-a@^1.0.0 but none was installed.',
expect(reporter).toHaveBeenCalledWith(
expect.objectContaining({
level: 'debug',
name: 'pnpm:peer-dependency-issues',
bad: {},
missing: {
'peer-a': [{
location: {
projectPath: '',
parents: [
{
name: 'abc-optional-peers-meta-only',
version: '1.0.0',
},
],
},
wantedRange: '^1.0.0',
}],
},
})
const reportedTimes = reporter.withArgs(logMatcher).callCount
expect(reportedTimes).toBe(1)
}
{
const logMatcher = sinon.match({
message: 'abc-optional-peers-meta-only@1.0.0 requires a peer of peer-b@^1.0.0 but none was installed.',
})
const reportedTimes = reporter.withArgs(logMatcher).callCount
expect(reportedTimes).toBe(0)
}
)
const lockfile = await project.readLockfile()

View File

@@ -1,20 +0,0 @@
import { listMissingPeers } from '@pnpm/core'
import { prepareEmpty } from '@pnpm/prepare'
import { testDefaults } from './utils'
test('cannot resolve peer dependency for top-level dependency', async () => {
prepareEmpty()
const peerDependencyIssues = await listMissingPeers([
{
manifest: {
dependencies: {
'ajv-keywords': '1.5.0',
},
},
rootDir: process.cwd(),
},
], await testDefaults())
expect(peerDependencyIssues.length).toBe(1)
})

View File

@@ -0,0 +1,38 @@
import { listPeerDependencyIssues } from '@pnpm/core'
import { prepareEmpty } from '@pnpm/prepare'
import { testDefaults } from './utils'
test('cannot resolve peer dependency for top-level dependency', async () => {
prepareEmpty()
const peerDependencyIssues = await listPeerDependencyIssues([
{
manifest: {
dependencies: {
'ajv-keywords': '1.5.0',
},
},
rootDir: process.cwd(),
},
], await testDefaults())
expect(peerDependencyIssues.issues.missing).toHaveProperty('ajv')
})
test('a conflict is detected when the same peer is required with ranges that do not overlap', async () => {
prepareEmpty()
const peerDependencyIssues = await listPeerDependencyIssues([
{
manifest: {
dependencies: {
'has-foo100-peer': '1.0.0',
'has-foo101-peer': '1.0.0',
},
},
rootDir: process.cwd(),
},
], await testDefaults())
expect(peerDependencyIssues.conflicts.length).toBe(1)
})

View File

@@ -33,6 +33,7 @@
"@pnpm/config": "workspace:13.6.1",
"@pnpm/core-loggers": "workspace:6.0.6",
"@pnpm/error": "workspace:2.0.0",
"@pnpm/render-peer-issues": "workspace:0.0.0",
"@pnpm/types": "workspace:7.6.0",
"ansi-diff": "^1.1.1",
"boxen": "^5.0.0",

View File

@@ -102,6 +102,7 @@ export function toOutput$ (
const registryPushStream = new Rx.Subject<logs.RegistryLog>()
const rootPushStream = new Rx.Subject<logs.RootLog>()
const packageManifestPushStream = new Rx.Subject<logs.PackageManifestLog>()
const peerDependencyIssuesPushStream = new Rx.Subject<logs.PeerDependencyIssuesLog>()
const linkPushStream = new Rx.Subject<logs.LinkLog>()
const otherPushStream = new Rx.Subject<logs.Log>()
const hookPushStream = new Rx.Subject<logs.HookLog>()
@@ -139,6 +140,9 @@ export function toOutput$ (
case 'pnpm:package-import-method':
packageImportMethodPushStream.next(log)
break
case 'pnpm:peer-dependency-issues':
peerDependencyIssuesPushStream.next(log)
break
case 'pnpm:install-check':
installCheckPushStream.next(log)
break
@@ -193,6 +197,7 @@ export function toOutput$ (
other,
packageImportMethod: Rx.from(packageImportMethodPushStream),
packageManifest: Rx.from(packageManifestPushStream),
peerDependencyIssues: Rx.from(peerDependencyIssuesPushStream),
progress: Rx.from(progressPushStream),
registry: Rx.from(registryPushStream),
requestRetry: Rx.from(requestRetryPushStream),

View File

@@ -1,6 +1,8 @@
import { Config } from '@pnpm/config'
import { Log } from '@pnpm/core-loggers'
import PnpmError from '@pnpm/error'
import renderPeerIssues from '@pnpm/render-peer-issues'
import { PeerDependencyIssues } from '@pnpm/types'
import chalk from 'chalk'
import equals from 'ramda/src/equals'
import StackTracey from 'stacktracey'
@@ -55,6 +57,8 @@ function getErrorInfo (logObj: Log, config?: Config): {
return reportLifecycleError(logObj as any) // eslint-disable-line @typescript-eslint/no-explicit-any
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
case 'ERR_PNPM_FETCH_401':
case 'ERR_PNPM_FETCH_403':
return reportAuthError(err, logObj as any, config) // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -388,3 +392,13 @@ function hideSecureInfo (key: string, value: string) {
if (key.endsWith('_auth') || key.endsWith('_authToken')) return `${value.substring(0, 4)}[hidden]`
return value
}
function reportPeerDependencyIssuesError (
err: Error,
msg: { issues: PeerDependencyIssues }
) {
return {
title: err.message,
body: renderPeerIssues(msg.issues),
}
}

View File

@@ -10,6 +10,7 @@ import reportHooks from './reportHooks'
import reportInstallChecks from './reportInstallChecks'
import reportLifecycleScripts from './reportLifecycleScripts'
import reportMisc from './reportMisc'
import reportPeerDependencyIssues from './reportPeerDependencyIssues'
import reportProgress from './reportProgress'
import reportRequestRetry from './reportRequestRetry'
import reportScope from './reportScope'
@@ -32,6 +33,7 @@ export default function (
registry: Rx.Observable<logs.RegistryLog>
root: Rx.Observable<logs.RootLog>
packageManifest: Rx.Observable<logs.PackageManifestLog>
peerDependencyIssues: Rx.Observable<logs.PeerDependencyIssuesLog>
requestRetry: Rx.Observable<logs.RequestRetryLog>
link: Rx.Observable<logs.LinkLog>
other: Rx.Observable<logs.Log>
@@ -64,6 +66,7 @@ export default function (
cwd,
throttle,
}),
reportPeerDependencyIssues(log$),
reportLifecycleScripts(log$, {
appendOnly: opts.appendOnly === true || opts.streamLifecycleOutput,
cwd,

View File

@@ -0,0 +1,18 @@
import { PeerDependencyIssuesLog } from '@pnpm/core-loggers'
import renderPeerIssues from '@pnpm/render-peer-issues'
import * as Rx from 'rxjs'
import { map, take } from 'rxjs/operators'
import formatWarn from './utils/formatWarn'
export default (
log$: {
peerDependencyIssues: Rx.Observable<PeerDependencyIssuesLog>
}
) => {
return log$.peerDependencyIssues.pipe(
take(1),
map((log) => Rx.of({
msg: `${formatWarn('Issues with peer dependencies found')}\n${renderPeerIssues(log)}`,
}))
)
}

View File

@@ -0,0 +1,88 @@
import { peerDependencyIssuesLogger } from '@pnpm/core-loggers'
import { toOutput$ } from '@pnpm/default-reporter'
import logger, {
createStreamParser,
} from '@pnpm/logger'
import { take } from 'rxjs/operators'
test('print peer dependency issues warning', (done) => {
const output$ = toOutput$({
context: {
argv: ['install'],
},
streamParser: createStreamParser(),
})
peerDependencyIssuesLogger.debug({
missing: {},
bad: {
a: [
{
location: {
parents: [
{
name: 'b',
version: '1.0.0',
},
],
projectPath: '',
},
foundVersion: '2',
wantedRange: '3',
},
],
},
})
expect.assertions(1)
output$.pipe(take(1)).subscribe({
complete: () => done(),
error: done,
next: output => {
expect(output).toContain('<ROOT>')
},
})
})
test('print peer dependency issues error', (done) => {
const output$ = toOutput$({
context: { argv: ['install'] },
streamParser: createStreamParser(),
})
const err = new Error('some error')
err['code'] = 'ERR_PNPM_PEER_DEP_ISSUES'
err['issues'] = {
missing: {},
bad: {
a: [
{
location: {
parents: [
{
name: 'b',
version: '1.0.0',
},
],
projectPath: '',
},
wantedRange: '3',
},
],
},
}
logger.error(err, err)
expect.assertions(1)
expect.assertions(1)
output$.pipe(take(1)).subscribe({
complete: () => done(),
error: done,
next: output => {
expect(output).toContain('<ROOT>')
},
})
})

View File

@@ -18,6 +18,9 @@
{
"path": "../error"
},
{
"path": "../render-peer-issues"
},
{
"path": "../types"
}

View File

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

View File

@@ -0,0 +1 @@
module.exports = require('../../jest.config')

View File

@@ -0,0 +1,40 @@
{
"name": "@pnpm/render-peer-issues",
"description": "Visualizes peer dependency issues",
"version": "0.0.0",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib",
"!*.map"
],
"keywords": [
"pnpm6"
],
"license": "MIT",
"engines": {
"node": ">=12.17"
},
"repository": "https://github.com/pnpm/pnpm/blob/master/packages/render-peer-issues",
"scripts": {
"_test": "jest",
"test": "pnpm run compile && pnpm run _test",
"lint": "eslint src/**/*.ts test/**/*.ts",
"prepublishOnly": "pnpm run compile",
"compile": "rimraf lib tsconfig.tsbuildinfo && tsc --build && pnpm run lint -- --fix"
},
"homepage": "https://github.com/pnpm/pnpm/blob/master/packages/render-peer-issues#readme",
"funding": "https://opencollective.com/pnpm",
"dependencies": {
"@pnpm/types": "workspace:7.6.0",
"archy": "^1.0.0",
"chalk": "^4.1.0"
},
"devDependencies": {
"@types/archy": "0.0.31",
"strip-ansi": "^6.0.0"
}
}

View File

@@ -0,0 +1,62 @@
import { PeerDependencyIssues } from '@pnpm/types'
import archy from 'archy'
import chalk from 'chalk'
const ROOT_LABEL = '<ROOT>'
export default function (peerDependencyIssues: PeerDependencyIssues) {
const projects = {} as Record<string, PkgNode>
for (const [peerName, issues] of Object.entries(peerDependencyIssues.missing)) {
for (const issue of issues) {
const projectPath = issue.location.projectPath || ROOT_LABEL
if (!projects[projectPath]) {
projects[projectPath] = { dependencies: {}, peerIssues: [] }
}
createTree(projects[projectPath], issue.location.parents, `${chalk.red('✕ missing peer')} ${peerName}@"${issue.wantedRange}"`)
}
}
for (const [peerName, issues] of Object.entries(peerDependencyIssues.bad)) {
for (const issue of issues) {
const projectPath = issue.location.projectPath || ROOT_LABEL
if (!projects[projectPath]) {
projects[projectPath] = { dependencies: {}, peerIssues: [] }
}
// eslint-disable-next-line
createTree(projects[projectPath], issue.location.parents, `${chalk.red('✕ unmet peer')} ${peerName}@"${issue.wantedRange}": found ${issue.foundVersion}`)
}
}
return Object.entries(projects)
.sort(([projectKey1], [projectKey2]) => projectKey1.localeCompare(projectKey2))
.map(([projectKey, project]) => archy(toArchyData(projectKey, project))).join('')
}
interface PkgNode {
peerIssues: string[]
dependencies: Record<string, PkgNode>
}
function createTree (pkgNode: PkgNode, pkgs: Array<{ name: string, version: string }>, issueText: string) {
const [pkg, ...rest] = pkgs
if (!pkgNode.dependencies[pkg.name]) {
pkgNode.dependencies[pkg.name] = { dependencies: {}, peerIssues: [] }
}
if (rest.length === 0) {
pkgNode.dependencies[pkg.name].peerIssues.push(issueText)
return
}
createTree(pkgNode.dependencies[pkg.name], rest, issueText)
}
function toArchyData (depName: string, pkgNode: PkgNode): archy.Data {
const result: Required<archy.Data> = {
label: depName,
nodes: [],
}
for (const wantedPeer of pkgNode.peerIssues) {
result.nodes.push(wantedPeer)
}
for (const [depName, node] of Object.entries(pkgNode.dependencies)) {
result.nodes.push(toArchyData(depName, node))
}
return result
}

View File

@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renderPeerIssues() 1`] = `
"/packages/0
└─┬ zzz
└── ✕ missing peer ddd@\\"^1.0.0\\"
<ROOT>
└─┬ xxx
├── ✕ unmet peer bbb@\\"^1.0.0\\": found 2
└─┬ yyy
├── ✕ missing peer aaa@\\"^1.0.0\\"
└── ✕ unmet peer ccc@\\"^1.0.0\\": found 2
"
`;

View File

@@ -0,0 +1,77 @@
import renderPeerIssues from '@pnpm/render-peer-issues'
import stripAnsi from 'strip-ansi'
test('renderPeerIssues()', () => {
expect(stripAnsi(renderPeerIssues({
missing: {
aaa: [
{
location: {
parents: [
{
name: 'xxx',
version: '1.0.0',
},
{
name: 'yyy',
version: '1.0.0',
},
],
projectPath: '',
},
wantedRange: '^1.0.0',
},
],
ddd: [
{
location: {
parents: [
{
name: 'zzz',
version: '1.0.0',
},
],
projectPath: '/packages/0',
},
wantedRange: '^1.0.0',
},
],
},
bad: {
bbb: [
{
location: {
parents: [
{
name: 'xxx',
version: '1.0.0',
},
],
projectPath: '',
},
foundVersion: '2',
wantedRange: '^1.0.0',
},
],
ccc: [
{
location: {
parents: [
{
name: 'xxx',
version: '1.0.0',
},
{
name: 'yyy',
version: '1.0.0',
},
],
projectPath: '',
},
foundVersion: '2',
wantedRange: '^1.0.0',
},
],
},
}))).toMatchSnapshot()
})

View File

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

View File

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

View File

@@ -31,8 +31,6 @@ import resolveDependencyTree, {
import resolvePeers, {
GenericDependenciesGraph,
GenericDependenciesGraphNode,
PeerDependencyIssue,
PeerDependencyIssueLocation,
} from './resolvePeers'
import toResolveImporter from './toResolveImporter'
import updateLockfile from './updateLockfile'
@@ -44,8 +42,6 @@ export type DependenciesGraphNode = GenericDependenciesGraphNode & ResolvedPacka
export {
LinkedDependency,
PeerDependencyIssue,
PeerDependencyIssueLocation,
ResolvedPackage,
}

View File

@@ -1,6 +1,6 @@
import crypto from 'crypto'
import path from 'path'
import { Dependencies } from '@pnpm/types'
import { Dependencies, PeerDependencyIssues } from '@pnpm/types'
import { depPathToFilename } from 'dependency-path'
import { KeyValuePair } from 'ramda'
import fromPairs from 'ramda/src/fromPairs'
@@ -15,22 +15,6 @@ import {
} from './resolveDependencies'
import { createNodeId, splitNodeId } from './nodeIdUtils'
export interface PeerDependencyIssueLocation {
parents: Array<{ name: string, version: string }>
projectPath: string
}
export interface PeerDependencyIssue {
location: PeerDependencyIssueLocation
pkg: PartialResolvedPackage
rootDir: string
foundPeerVersion?: string
wantedPeer: {
name: string
range: string
}
}
export interface GenericDependenciesGraphNode {
// at this point the version is really needed only for logging
modules: string
@@ -73,14 +57,17 @@ export default function<T extends PartialResolvedPackage> (
): {
dependenciesGraph: GenericDependenciesGraph<T>
dependenciesByProjectId: {[id: string]: {[alias: string]: string}}
peerDependencyIssues: PeerDependencyIssue[]
peerDependencyIssues: PeerDependencyIssues
} {
const depGraph: GenericDependenciesGraph<T> = {}
const pathsByNodeId = {}
const _createPkgsByName = createPkgsByName.bind(null, opts.dependenciesTree)
const rootProject = opts.projects.length > 1 ? opts.projects.find(({ id }) => id === '.') : null
const rootPkgsByName = rootProject == null ? {} : _createPkgsByName(rootProject)
const peerDependencyIssues: PeerDependencyIssue[] = []
const peerDependencyIssues: PeerDependencyIssues = {
bad: {},
missing: {},
}
for (const { directNodeIdsByAlias, topParents, rootDir } of opts.projects) {
const pkgsByName = {
@@ -172,7 +159,7 @@ function resolvePeersOfNode<T extends PartialResolvedPackage> (
pathsByNodeId: {[nodeId: string]: string}
depGraph: GenericDependenciesGraph<T>
virtualStoreDir: string
peerDependencyIssues: PeerDependencyIssue[]
peerDependencyIssues: PeerDependencyIssues
peersCache: PeersCache
purePkgs: Set<string> // pure packages are those that don't rely on externally resolved peers
rootDir: string
@@ -352,7 +339,7 @@ function resolvePeersOfChildren<T extends PartialResolvedPackage> (
parentPkgs: ParentRefs,
ctx: {
pathsByNodeId: {[nodeId: string]: string}
peerDependencyIssues: PeerDependencyIssue[]
peerDependencyIssues: PeerDependencyIssues
peersCache: PeersCache
virtualStoreDir: string
purePkgs: Set<string>
@@ -390,7 +377,7 @@ function resolvePeers<T extends PartialResolvedPackage> (
resolvedPackage: T
dependenciesTree: DependenciesTree<T>
rootDir: string
peerDependencyIssues: PeerDependencyIssue[]
peerDependencyIssues: PeerDependencyIssues
}
): PeersResolution {
const resolvedPeers: {[alias: string]: string} = {}
@@ -407,38 +394,36 @@ function resolvePeers<T extends PartialResolvedPackage> (
) {
continue
}
ctx.peerDependencyIssues.push({
if (!ctx.peerDependencyIssues.missing[peerName]) {
ctx.peerDependencyIssues.missing[peerName] = []
}
ctx.peerDependencyIssues.missing[peerName].push({
location: getLocationFromNodeId({
dependenciesTree: ctx.dependenciesTree,
nodeId: ctx.nodeId,
lockfileDir: ctx.lockfileDir,
rootDir: ctx.rootDir,
pkg: ctx.resolvedPackage,
}),
pkg: ctx.resolvedPackage,
rootDir: ctx.rootDir,
wantedPeer: {
name: peerName,
range: peerVersionRange,
},
wantedRange: peerVersionRange,
})
continue
}
if (!semver.satisfies(resolved.version, peerVersionRange, { loose: true })) {
ctx.peerDependencyIssues.push({
if (!ctx.peerDependencyIssues.bad[peerName]) {
ctx.peerDependencyIssues.bad[peerName] = []
}
ctx.peerDependencyIssues.bad[peerName].push({
location: getLocationFromNodeId({
dependenciesTree: ctx.dependenciesTree,
nodeId: ctx.nodeId,
lockfileDir: ctx.lockfileDir,
rootDir: ctx.rootDir,
pkg: ctx.resolvedPackage,
}),
pkg: ctx.resolvedPackage,
rootDir: ctx.rootDir,
foundPeerVersion: resolved.version,
wantedPeer: {
name: peerName,
range: peerVersionRange,
},
foundVersion: resolved.version,
wantedRange: peerVersionRange,
})
}
@@ -453,10 +438,12 @@ function getLocationFromNodeId<T> (
lockfileDir,
nodeId,
rootDir,
pkg,
}: {
dependenciesTree: DependenciesTree<T>
lockfileDir: string
nodeId: string
pkg: PartialResolvedPackage
rootDir: string
}
) {
@@ -464,6 +451,7 @@ function getLocationFromNodeId<T> (
const parents = scan((prevNodeId, pkgId) => createNodeId(prevNodeId, pkgId), '>', parts)
.slice(2)
.map((nid) => pick(['name', 'version'], dependenciesTree[nid].resolvedPackage as ResolvedPackage))
parents.push({ name: pkg.name, version: pkg.version })
const projectPath = path.relative(lockfileDir, rootDir)
return {
projectPath,

View File

@@ -1,4 +1,5 @@
export * from './misc'
export * from './options'
export * from './package'
export * from './peerDependencyIssues'
export * from './project'

View File

@@ -0,0 +1,18 @@
export interface PeerDependencyIssueLocation {
parents: Array<{ name: string, version: string }>
projectPath: string
}
export interface MissingPeerDependencyIssue {
location: PeerDependencyIssueLocation
wantedRange: string
}
export interface BadPeerDependencyIssue extends MissingPeerDependencyIssue {
foundVersion: string
}
export interface PeerDependencyIssues {
bad: Record<string, BadPeerDependencyIssue[]>
missing: Record<string, MissingPeerDependencyIssue[]>
}

35
pnpm-lock.yaml generated
View File

@@ -40,7 +40,7 @@ importers:
'@commitlint/prompt-cli': ^15.0.0
'@pnpm/eslint-config': workspace:*
'@pnpm/meta-updater': 0.0.6
'@pnpm/registry-mock': ^2.10.0
'@pnpm/registry-mock': ^2.11.0
'@pnpm/tsconfig': workspace:*
'@types/jest': ^26.0.24
'@types/node': ^14.17.32
@@ -71,7 +71,7 @@ importers:
'@commitlint/prompt-cli': 15.0.0
'@pnpm/eslint-config': link:utils/eslint-config
'@pnpm/meta-updater': 0.0.6
'@pnpm/registry-mock': 2.10.0
'@pnpm/registry-mock': 2.11.0
'@pnpm/tsconfig': link:utils/tsconfig
'@types/jest': 26.0.24
'@types/node': 14.17.34
@@ -449,6 +449,7 @@ importers:
resolve-link-target: ^2.0.0
run-groups: ^3.0.1
semver: ^7.3.4
semver-intersect: ^1.4.0
sinon: ^11.1.1
symlink-dir: ^5.0.0
version-selector-type: ^3.0.0
@@ -502,6 +503,7 @@ importers:
ramda: 0.27.1
run-groups: 3.0.1
semver: 7.3.5
semver-intersect: 1.4.0
version-selector-type: 3.0.0
devDependencies:
'@pnpm/assert-project': link:../../privatePackages/assert-project
@@ -556,6 +558,7 @@ importers:
'@pnpm/default-reporter': 'link:'
'@pnpm/error': workspace:2.0.0
'@pnpm/logger': ^4.0.0
'@pnpm/render-peer-issues': workspace:0.0.0
'@pnpm/types': workspace:7.6.0
'@types/normalize-path': ^3.0.0
'@types/ramda': 0.27.39
@@ -580,6 +583,7 @@ importers:
'@pnpm/config': link:../config
'@pnpm/core-loggers': link:../core-loggers
'@pnpm/error': link:../error
'@pnpm/render-peer-issues': link:../render-peer-issues
'@pnpm/types': link:../types
ansi-diff: 1.1.1
boxen: 5.1.2
@@ -3052,6 +3056,23 @@ importers:
'@types/is-windows': 1.0.0
'@types/ramda': 0.27.39
packages/render-peer-issues:
specifiers:
'@pnpm/render-peer-issues': 'link:'
'@pnpm/types': workspace:7.6.0
'@types/archy': 0.0.31
archy: ^1.0.0
chalk: ^4.1.0
strip-ansi: ^6.0.0
dependencies:
'@pnpm/types': link:../types
archy: 1.0.0
chalk: 4.1.2
devDependencies:
'@pnpm/render-peer-issues': 'link:'
'@types/archy': 0.0.31
strip-ansi: 6.0.1
packages/resolve-dependencies:
specifiers:
'@pnpm/constants': workspace:5.0.0
@@ -4940,8 +4961,8 @@ packages:
strip-bom: 4.0.0
dev: true
/@pnpm/registry-mock/2.10.0:
resolution: {integrity: sha512-96frA3W36rjd6BRIjKmkXKTLIlpZFogvkbKnYp68+K2RWBoqDQd789ZZbJvz6Tz3fNWYcKpz6lUI4Bm3S+jtwA==}
/@pnpm/registry-mock/2.11.0:
resolution: {integrity: sha512-260WRwYOBizASI/LMYT0gxYpfRCFOezBxu7gL3UlRSCaWnCMP3COegrCKFWinQBRnFSgkns7BV7kaRryg3V5wg==}
engines: {node: '>=10.13'}
hasBin: true
dependencies:
@@ -13932,6 +13953,12 @@ packages:
xmlchars: 2.2.0
dev: true
/semver-intersect/1.4.0:
resolution: {integrity: sha512-d8fvGg5ycKAq0+I6nfWeCx6ffaWJCsBYU0H2Rq56+/zFePYfT8mXkB3tWBSjR5BerkHNZ5eTPIk1/LBYas35xQ==}
dependencies:
semver: 5.7.1
dev: false
/semver-utils/1.1.4:
resolution: {integrity: sha512-EjnoLE5OGmDAVV/8YDoN5KiajNadjzIp9BAHOhYeQHt7j0UWxjmgsx4YD48wp4Ue1Qogq38F1GNUJNqF1kKKxA==}
dev: false

4
typings/typed.d.ts vendored
View File

@@ -60,3 +60,7 @@ declare module 'bin-links/lib/fix-bin' {
declare namespace NodeJS.Module {
function _nodeModulePaths(from: string): string[]
}
declare module 'semver-intersect' {
export function intersect (...range: string[]): string
}