From 9e813c0c71a4f8d175d462af52e2c3a377121450 Mon Sep 17 00:00:00 2001 From: Regev Brody Date: Sun, 2 Jun 2024 11:21:09 +0300 Subject: [PATCH] fix: Is there a way to not upgrade a package if it's peer depndencies are not currently met #1418 --- src/lib/getIgnoredUpgradesDueToPeerDeps.ts | 51 +++++--- src/lib/upgradePackageDefinitions.ts | 116 ++++++++++++++++-- test/getIgnoredUpgradesDueToPeerDeps.test.ts | 107 ++++++++++++++++ test/peer.test.ts | 115 +++++++++++++++++ .../package.json | 8 ++ test/test-data/peer-post-upgrade/package.json | 10 ++ 6 files changed, 379 insertions(+), 28 deletions(-) create mode 100644 test/test-data/peer-post-upgrade-no-upgrades/package.json create mode 100644 test/test-data/peer-post-upgrade/package.json diff --git a/src/lib/getIgnoredUpgradesDueToPeerDeps.ts b/src/lib/getIgnoredUpgradesDueToPeerDeps.ts index 571ffbe4..d77fa954 100644 --- a/src/lib/getIgnoredUpgradesDueToPeerDeps.ts +++ b/src/lib/getIgnoredUpgradesDueToPeerDeps.ts @@ -1,9 +1,10 @@ -import { satisfies } from 'semver' +import { intersects, satisfies } from 'semver' import { IgnoredUpgradeDueToPeerDeps } from '../types/IgnoredUpgradeDueToPeerDeps' import { Index } from '../types/IndexType' import { Options } from '../types/Options' import { Version } from '../types/Version' import { VersionSpec } from '../types/VersionSpec' +import getPeerDependenciesFromRegistry from './getPeerDependenciesFromRegistry' import upgradePackageDefinitions from './upgradePackageDefinitions' /** Get all upgrades that are ignored due to incompatible peer dependencies. */ @@ -13,36 +14,50 @@ export async function getIgnoredUpgradesDueToPeerDeps( upgradedPeerDependencies: Index>, options: Options = {}, ) { + const upgradedPackagesWithPeerRestriction = { + ...current, + ...upgraded, + } const [upgradedLatestVersions, latestVersionResults] = await upgradePackageDefinitions(current, { ...options, peer: false, peerDependencies: undefined, loglevel: 'silent', }) - + const upgradedPeerDependenciesLatest = await getPeerDependenciesFromRegistry(upgradedLatestVersions, options) return Object.entries(upgradedLatestVersions) .filter(([pkgName, newVersion]) => upgraded[pkgName] !== newVersion) - .reduce( - (accum, [pkgName, newVersion]) => ({ + .reduce((accum, [pkgName, newVersion]) => { + let reason = Object.entries(upgradedPeerDependencies) + .filter( + ([, peers]) => + peers[pkgName] !== undefined && + latestVersionResults[pkgName]?.version && + !satisfies(latestVersionResults[pkgName].version!, peers[pkgName]), + ) + .reduce((accumReason, [peerPkg, peers]) => ({ ...accumReason, [peerPkg]: peers[pkgName] }), {} as Index) + if (Object.keys(reason).length === 0) { + const peersOfPkg = upgradedPeerDependenciesLatest?.[pkgName] || {} + reason = Object.entries(peersOfPkg) + .filter( + ([peer, peerSpec]) => + upgradedPackagesWithPeerRestriction[peer] && + !intersects(upgradedPackagesWithPeerRestriction[peer], peerSpec), + ) + .reduce( + (accumReason, [peerPkg, peerSpec]) => ({ ...accumReason, [pkgName]: `${peerPkg} ${peerSpec}` }), + {} as Index, + ) + } + return { ...accum, [pkgName]: { from: current[pkgName], to: newVersion, - reason: Object.entries(upgradedPeerDependencies) - .filter( - ([, peers]) => - peers[pkgName] !== undefined && - latestVersionResults[pkgName]?.version && - !satisfies(latestVersionResults[pkgName].version!, peers[pkgName]), - ) - .reduce( - (accumReason, [peerPkg, peers]) => ({ ...accumReason, [peerPkg]: peers[pkgName] }), - {} as Index, - ), + reason, }, - }), - {} as Index, - ) + } + }, {} as Index) } export default getIgnoredUpgradesDueToPeerDeps diff --git a/src/lib/upgradePackageDefinitions.ts b/src/lib/upgradePackageDefinitions.ts index 5d2c3651..51189195 100644 --- a/src/lib/upgradePackageDefinitions.ts +++ b/src/lib/upgradePackageDefinitions.ts @@ -1,5 +1,5 @@ import { dequal } from 'dequal' -import { satisfies } from 'semver' +import { intersects, satisfies } from 'semver' import { parse, parseRange } from 'semver-utils' import { Index } from '../types/IndexType' import { Options } from '../types/Options' @@ -11,6 +11,56 @@ import { pickBy } from './pick' import queryVersions from './queryVersions' import upgradeDependencies from './upgradeDependencies' +type CheckIfInPeerViolationResult = { + violated: boolean + filteredUpgradedDependencies: Index + upgradedPeerDependencies: Index> +} + +/** + * Check if the peer dependencies constraints of each upgraded package, are in violation, + * thus rendering the upgrade to be invalid + * + * @returns Whether there was any violation, and the upgrades that are not in violation + */ +const checkIfInPeerViolation = ( + currentDependencies: Index, + filteredUpgradedDependencies: Index, + upgradedPeerDependencies: Index>, +): CheckIfInPeerViolationResult => { + const upgradedDependencies = { ...currentDependencies, ...filteredUpgradedDependencies } + const filteredUpgradedDependenciesAfterPeers = pickBy(filteredUpgradedDependencies, (spec, dep) => { + const peerDeps = upgradedPeerDependencies[dep] + if (!peerDeps) { + return true + } + return Object.entries(peerDeps).every( + ([peer, peerSpec]) => + upgradedDependencies[peer] === undefined || intersects(upgradedDependencies[peer], peerSpec), + ) + }) + const violated = + Object.keys(filteredUpgradedDependencies).length > Object.keys(filteredUpgradedDependenciesAfterPeers).length + let filteredUpgradedPeerDependencies = upgradedPeerDependencies + if (violated) { + filteredUpgradedPeerDependencies = pickBy( + upgradedPeerDependencies, + (spec, dep) => filteredUpgradedDependenciesAfterPeers[dep] || !filteredUpgradedDependencies[dep], + ) + } + return { + violated, + filteredUpgradedDependencies: filteredUpgradedDependenciesAfterPeers, + upgradedPeerDependencies: filteredUpgradedPeerDependencies, + } +} + +export type UpgradePackageDefinitionsResult = [ + upgradedDependencies: Index, + latestVersionResults: Index, + newPeerDependencies?: Index>, +] + /** * Returns a 3-tuple of upgradedDependencies, their latest versions and the resulting peer dependencies. * @@ -21,7 +71,7 @@ import upgradeDependencies from './upgradeDependencies' export async function upgradePackageDefinitions( currentDependencies: Index, options: Options, -): Promise<[Index, Index, Index>?]> { +): Promise { const latestVersionResults = await queryVersions(currentDependencies, options) const latestVersions = keyValueBy(latestVersionResults, (dep, result) => @@ -47,22 +97,68 @@ export async function upgradePackageDefinitions( const filteredLatestDependencies = pickBy(latestVersions, (spec, dep) => filteredUpgradedDependencies[dep]) + let result: UpgradePackageDefinitionsResult = [ + filteredUpgradedDependencies, + latestVersionResults, + options.peerDependencies, + ] + + if (!options.peer || Object.keys(filteredLatestDependencies).length === 0) { + return result + } + if (options.peer && Object.keys(filteredLatestDependencies).length > 0) { const upgradedPeerDependencies = await getPeerDependenciesFromRegistry(filteredLatestDependencies, options) - const peerDependencies = { ...options.peerDependencies, ...upgradedPeerDependencies } - if (!dequal(options.peerDependencies, peerDependencies)) { + + let checkPeerViolationResult: CheckIfInPeerViolationResult + + if ( + dequal(options.peerDependencies, { + ...options.peerDependencies, + ...upgradedPeerDependencies, + }) + ) { + checkPeerViolationResult = checkIfInPeerViolation( + currentDependencies, + filteredUpgradedDependencies, + options.peerDependencies!, + ) + if (!checkPeerViolationResult.violated) { + return result + } + } else { + checkPeerViolationResult = { + violated: false, + filteredUpgradedDependencies, + upgradedPeerDependencies, + } + } + let runCount = 0 + do { + if (runCount++ > 6) { + throw new Error(`Stuck in a while loop. Please report an issue`) + } + const peerDependenciesAfterUpgrade = { + ...options.peerDependencies, + ...checkPeerViolationResult.upgradedPeerDependencies, + } + if (dequal(options.peerDependencies, peerDependenciesAfterUpgrade)) { + // We can't find anything to do, will not upgrade anything + return [{}, latestVersionResults, options.peerDependencies] + } const [newUpgradedDependencies, newLatestVersions, newPeerDependencies] = await upgradePackageDefinitions( - { ...currentDependencies, ...filteredUpgradedDependencies }, - { ...options, peerDependencies, loglevel: 'silent' }, + { ...currentDependencies, ...checkPeerViolationResult.filteredUpgradedDependencies }, + { ...options, peerDependencies: peerDependenciesAfterUpgrade, loglevel: 'silent' }, ) - return [ - { ...filteredUpgradedDependencies, ...newUpgradedDependencies }, + result = [ + { ...checkPeerViolationResult.filteredUpgradedDependencies, ...newUpgradedDependencies }, { ...latestVersionResults, ...newLatestVersions }, newPeerDependencies, ] - } + checkPeerViolationResult = checkIfInPeerViolation(currentDependencies, result[0], result[2]!) + } while (checkPeerViolationResult.violated) } - return [filteredUpgradedDependencies, latestVersionResults, options.peerDependencies] + return result } export default upgradePackageDefinitions diff --git a/test/getIgnoredUpgradesDueToPeerDeps.test.ts b/test/getIgnoredUpgradesDueToPeerDeps.test.ts index ee05e3e2..758a46c0 100644 --- a/test/getIgnoredUpgradesDueToPeerDeps.test.ts +++ b/test/getIgnoredUpgradesDueToPeerDeps.test.ts @@ -1,5 +1,7 @@ import getIgnoredUpgradesDueToPeerDeps from '../src/lib/getIgnoredUpgradesDueToPeerDeps' +import { Packument } from '../src/types/Packument' import chaiSetup from './helpers/chaiSetup' +import stubVersions from './helpers/stubVersions' chaiSetup() @@ -31,4 +33,109 @@ describe('getIgnoredUpgradesDueToPeerDeps', function () { }, }) }) + it('ignored peer after upgrade', async () => { + const stub = stubVersions({ + '@vitest/ui': { + version: '1.6.0', + versions: { + '1.3.1': { + version: '1.3.1', + } as Packument, + '1.6.0': { + version: '1.6.0', + } as Packument, + }, + }, + vitest: { + version: '1.6.0', + versions: { + '1.3.1': { + version: '1.3.1', + } as Packument, + '1.6.0': { + version: '1.6.0', + } as Packument, + }, + }, + eslint: { + version: '9.0.0', + versions: { + '8.57.0': { + version: '8.57.0', + } as Packument, + '9.0.0': { + version: '9.0.0', + } as Packument, + }, + }, + 'eslint-plugin-import': { + version: '2.29.1', + versions: { + '2.29.1': { + version: '2.29.1', + } as Packument, + }, + }, + 'eslint-plugin-unused-imports': { + version: '4.0.0', + versions: { + '4.0.0': { + version: '4.0.0', + } as Packument, + '3.0.0': { + version: '3.0.0', + } as Packument, + }, + }, + }) + const data = await getIgnoredUpgradesDueToPeerDeps( + { + '@vitest/ui': '1.3.1', + vitest: '1.3.1', + eslint: '8.57.0', + 'eslint-plugin-import': '2.29.1', + 'eslint-plugin-unused-imports': '3.0.0', + }, + { + '@vitest/ui': '1.6.0', + vitest: '1.6.0', + }, + { + '@vitest/ui': { + vitest: '1.6.0', + }, + vitest: { + jsdom: '*', + 'happy-dom': '*', + '@vitest/ui': '1.6.0', + '@types/node': '^18.0.0 || >=20.0.0', + '@vitest/browser': '1.6.0', + '@edge-runtime/vm': '*', + }, + eslint: {}, + 'eslint-plugin-import': { + eslint: '^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8', + }, + 'eslint-plugin-unused-imports': { + '@typescript-eslint/eslint-plugin': '^6.0.0', + eslint: '^8.0.0', + }, + }, + { + target: packageName => { + return packageName === 'eslint-plugin-unused-imports' ? 'greatest' : 'minor' + }, + }, + ) + data.should.deep.equal({ + 'eslint-plugin-unused-imports': { + from: '3.0.0', + reason: { + 'eslint-plugin-unused-imports': 'eslint 9', + }, + to: '4.0.0', + }, + }) + stub.restore() + }) }) diff --git a/test/peer.test.ts b/test/peer.test.ts index 96fe5d70..6070af94 100644 --- a/test/peer.test.ts +++ b/test/peer.test.ts @@ -1,5 +1,8 @@ +import path from 'path' import ncu from '../src/' +import { Packument } from '../src/types/Packument' import chaiSetup from './helpers/chaiSetup' +import stubVersions from './helpers/stubVersions' chaiSetup() @@ -74,4 +77,116 @@ describe('peer dependencies', function () { }) upgrades!.should.deep.equal({}) }) + + it('ignores if post upgrade peers are unmet', async () => { + const stub = stubVersions({ + '@vitest/ui': { + version: '1.6.0', + versions: { + '1.3.1': { + version: '1.3.1', + } as Packument, + '1.6.0': { + version: '1.6.0', + } as Packument, + }, + }, + vitest: { + version: '1.6.0', + versions: { + '1.3.1': { + version: '1.3.1', + } as Packument, + '1.6.0': { + version: '1.6.0', + } as Packument, + }, + }, + eslint: { + version: '9.0.0', + versions: { + '8.57.0': { + version: '8.57.0', + } as Packument, + '9.0.0': { + version: '9.0.0', + } as Packument, + }, + }, + 'eslint-plugin-import': { + version: '2.29.1', + versions: { + '2.29.1': { + version: '2.29.1', + } as Packument, + }, + }, + 'eslint-plugin-unused-imports': { + version: '4.0.0', + versions: { + '4.0.0': { + version: '4.0.0', + } as Packument, + '3.0.0': { + version: '3.0.0', + } as Packument, + }, + }, + }) + const cwd = path.join(__dirname, 'test-data/peer-post-upgrade/') + const upgrades = await ncu({ + cwd, + peer: true, + target: packageName => { + return packageName === 'eslint-plugin-unused-imports' ? 'greatest' : 'minor' + }, + }) + upgrades!.should.have.all.keys('@vitest/ui', 'vitest') + stub.restore() + }) + + it('ignores if post upgrade peers are unmet - no upgrades', async () => { + const stub = stubVersions({ + eslint: { + version: '9.0.0', + versions: { + '8.57.0': { + version: '8.57.0', + } as Packument, + '9.0.0': { + version: '9.0.0', + } as Packument, + }, + }, + 'eslint-plugin-import': { + version: '2.29.1', + versions: { + '2.29.1': { + version: '2.29.1', + } as Packument, + }, + }, + 'eslint-plugin-unused-imports': { + version: '4.0.0', + versions: { + '4.0.0': { + version: '4.0.0', + } as Packument, + '3.0.0': { + version: '3.0.0', + } as Packument, + }, + }, + }) + const cwd = path.join(__dirname, 'test-data/peer-post-upgrade-no-upgrades/') + const upgrades = await ncu({ + cwd, + peer: true, + target: packageName => { + return packageName === 'eslint-plugin-unused-imports' ? 'greatest' : 'minor' + }, + }) + upgrades!.should.deep.equal({}) + stub.restore() + }) }) diff --git a/test/test-data/peer-post-upgrade-no-upgrades/package.json b/test/test-data/peer-post-upgrade-no-upgrades/package.json new file mode 100644 index 00000000..334cdde9 --- /dev/null +++ b/test/test-data/peer-post-upgrade-no-upgrades/package.json @@ -0,0 +1,8 @@ +{ + "license": "MIT", + "dependencies": { + "eslint": "8.57.0", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-unused-imports": "3.0.0" + } +} diff --git a/test/test-data/peer-post-upgrade/package.json b/test/test-data/peer-post-upgrade/package.json new file mode 100644 index 00000000..2ee53fb1 --- /dev/null +++ b/test/test-data/peer-post-upgrade/package.json @@ -0,0 +1,10 @@ +{ + "license": "MIT", + "dependencies": { + "@vitest/ui": "1.3.1", + "vitest": "1.3.1", + "eslint": "8.57.0", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-unused-imports": "3.0.0" + } +}