diff --git a/.changeset/empty-chairs-push.md b/.changeset/empty-chairs-push.md new file mode 100644 index 0000000000..a8b815565b --- /dev/null +++ b/.changeset/empty-chairs-push.md @@ -0,0 +1,6 @@ +--- +"@pnpm/resolve-dependencies": major +"pnpm": patch +--- + +Refuse to install when `peerDependencies` has specifications that don't make sense. diff --git a/pkg-manager/resolve-dependencies/src/toResolveImporter.ts b/pkg-manager/resolve-dependencies/src/toResolveImporter.ts index 0fe648d037..6aeeba7ba1 100644 --- a/pkg-manager/resolve-dependencies/src/toResolveImporter.ts +++ b/pkg-manager/resolve-dependencies/src/toResolveImporter.ts @@ -10,6 +10,7 @@ import { type ImporterToResolve } from '.' import { getWantedDependencies, type WantedDependency } from './getWantedDependencies' import { type ImporterToResolveGeneric } from './resolveDependencyTree' import { safeIsInnerLink } from './safeIsInnerLink' +import { validatePeerDependencies } from './validatePeerDependencies' export interface ResolveImporter extends ImporterToResolve, ImporterToResolveGeneric<{ isNew?: boolean }> { wantedDependencies: Array { + validatePeerDependencies(project) const allDeps = getWantedDependencies(project.manifest) const nonLinkedDependencies = await partitionLinkedPackages(allDeps, { lockfileOnly: opts.lockfileOnly, diff --git a/pkg-manager/resolve-dependencies/src/validatePeerDependencies.ts b/pkg-manager/resolve-dependencies/src/validatePeerDependencies.ts new file mode 100644 index 0000000000..60acd81d71 --- /dev/null +++ b/pkg-manager/resolve-dependencies/src/validatePeerDependencies.ts @@ -0,0 +1,29 @@ +import { PnpmError } from '@pnpm/error' +import { type ProjectManifest } from '@pnpm/types' +import { validRange } from 'semver' + +export interface ProjectToValidate { + rootDir: string + manifest: Pick +} + +export function validatePeerDependencies (project: ProjectToValidate): void { + const { name, peerDependencies } = project.manifest + const projectId = name ?? project.rootDir + for (const depName in peerDependencies) { + const version = peerDependencies[depName] + if (!isValidPeerVersion(version)) { + throw new PnpmError( + 'INVALID_PEER_DEPENDENCY_SPECIFICATION', + `The peerDependencies field named '${depName}' of package '${projectId}' has an invalid value: '${version}'`, + { + hint: 'The values in peerDependencies should be either a valid semver range, a `workspace:` spec, or a `catalog:` spec', + } + ) + } + } +} + +function isValidPeerVersion (version: string): boolean { + return typeof validRange(version) === 'string' || version.startsWith('workspace:') || version.startsWith('catalog:') +} diff --git a/pkg-manager/resolve-dependencies/test/validatePeerDependencies.test.ts b/pkg-manager/resolve-dependencies/test/validatePeerDependencies.test.ts new file mode 100644 index 0000000000..e07c0725c2 --- /dev/null +++ b/pkg-manager/resolve-dependencies/test/validatePeerDependencies.test.ts @@ -0,0 +1,74 @@ +import { validatePeerDependencies } from '../src/validatePeerDependencies' + +test('accepts valid specifications that make sense for peerDependencies', () => { + validatePeerDependencies({ + rootDir: '/repo/packages/pkg', + manifest: { + peerDependencies: { + 'semver-range': '>=1.2.3 || ^3.2.1', + 'workspace-scheme': 'workspace:^', + 'catalog-scheme': 'catalog:', + }, + }, + }) +}) + +test('forbids aliases', () => { + expect(validatePeerDependencies.bind(null, { + rootDir: '/repo/packages/pkg', + manifest: { + peerDependencies: { + foo: 'bar@1.2.3', + }, + }, + })).toThrow('The peerDependencies field named \'foo\' of package \'/repo/packages/pkg\' has an invalid value: \'bar@1.2.3\'') + expect(validatePeerDependencies.bind(null, { + rootDir: '/repo/packages/pkg', + manifest: { + name: 'my-pkg', + peerDependencies: { + foo: 'bar@1.2.3', + }, + }, + })).toThrow('The peerDependencies field named \'foo\' of package \'my-pkg\' has an invalid value: \'bar@1.2.3\'') +}) + +test('forbids `file:` scheme', () => { + expect(validatePeerDependencies.bind(null, { + rootDir: '/repo/packages/pkg', + manifest: { + peerDependencies: { + foo: 'file:../foo', + }, + }, + })).toThrow('The peerDependencies field named \'foo\' of package \'/repo/packages/pkg\' has an invalid value: \'file:../foo\'') + expect(validatePeerDependencies.bind(null, { + rootDir: '/repo/packages/pkg', + manifest: { + name: 'my-pkg', + peerDependencies: { + foo: 'file:../foo', + }, + }, + })).toThrow('The peerDependencies field named \'foo\' of package \'my-pkg\' has an invalid value: \'file:../foo\'') +}) + +test('forbids `link:` scheme', () => { + expect(validatePeerDependencies.bind(null, { + rootDir: '/repo/packages/pkg', + manifest: { + peerDependencies: { + foo: 'link:../foo', + }, + }, + })).toThrow('The peerDependencies field named \'foo\' of package \'/repo/packages/pkg\' has an invalid value: \'link:../foo\'') + expect(validatePeerDependencies.bind(null, { + rootDir: '/repo/packages/pkg', + manifest: { + name: 'my-pkg', + peerDependencies: { + foo: 'link:../foo', + }, + }, + })).toThrow('The peerDependencies field named \'foo\' of package \'my-pkg\' has an invalid value: \'link:../foo\'') +})