diff --git a/src/lib/getPeerDependencies.ts b/src/lib/getPeerDependencies.ts deleted file mode 100644 index 8c42b0ac..00000000 --- a/src/lib/getPeerDependencies.ts +++ /dev/null @@ -1,30 +0,0 @@ -import fs from 'fs/promises' -import jph from 'json-parse-helpfulerror' -import { print } from '../lib/logging' -import { Index } from '../types/IndexType' -import { Options } from '../types/Options' -import { VersionSpec } from '../types/VersionSpec' -import getCurrentDependencies from './getCurrentDependencies' - -/** Get peer dependencies from installed packages */ -async function getPeerDependencies(current: Index, options: Options): Promise>> { - const basePath = options.cwd || './' - const accum: Index> = {} - - for (const dep in current) { - const path = basePath + `node_modules/${dep}/package.json` - let peers: Index = {} - try { - const pkgData = await fs.readFile(path, 'utf-8') - const pkg = jph.parse(pkgData) - peers = getCurrentDependencies(pkg, { ...options, dep: 'peer' }) - } catch (e) { - print(options, `Could not read peer dependencies for package ${dep}. Is this package installed?`, 'warn') - } - accum[dep] = peers - } - - return accum -} - -export default getPeerDependencies diff --git a/src/lib/getPeerDependenciesFromRegistry.ts b/src/lib/getPeerDependenciesFromRegistry.ts index 9ba112df..23bdd071 100644 --- a/src/lib/getPeerDependenciesFromRegistry.ts +++ b/src/lib/getPeerDependenciesFromRegistry.ts @@ -1,9 +1,46 @@ import ProgressBar from 'progress' import { Index } from '../types/IndexType' import { Options } from '../types/Options' -import { VersionSpec } from '../types/VersionSpec' +import { Version } from '../types/Version' import getPackageManager from './getPackageManager' +type CircularData = + | { + isCircular: true + offendingPackage: string + } + | { + isCircular: false + } + +/** + * Checks if the specified package will create a loop of peer dependencies by traversing all paths to find a cycle + * + * If a cycle was found, the offending peer dependency of the specified package is returned + */ +function isCircularPeer(peerDependencies: Index>, packageName: string): CircularData { + let queue = [[packageName]] + while (queue.length > 0) { + const nextQueue: string[][] = [] + for (const path of queue) { + const parents = Object.keys(peerDependencies[path[0]] ?? {}) + for (const name of parents) { + if (name === path.at(-1)) { + return { + isCircular: true, + offendingPackage: path[0], + } + } + nextQueue.push([name, ...path]) + } + } + queue = nextQueue + } + return { + isCircular: false, + } +} + /** * Get the latest or greatest versions from the NPM repository based on the version target. * @@ -11,7 +48,7 @@ import getPackageManager from './getPackageManager' * @param [options={}] Options. * @returns Promised {packageName: peer dependencies} collection */ -async function getPeerDependenciesFromRegistry(packageMap: Index, options: Options) { +async function getPeerDependenciesFromRegistry(packageMap: Index, options: Options) { const packageManager = getPackageManager(options, options.packageManager) if (!packageManager.getPeerDependencies) return {} @@ -22,14 +59,23 @@ async function getPeerDependenciesFromRegistry(packageMap: Index, o bar.render() } - return Object.entries(packageMap).reduce(async (accumPromise, [pkg, version]) => { - const dep = await packageManager.getPeerDependencies!(pkg, version) - if (bar) { - bar.tick() - } - const accum = await accumPromise - return { ...accum, [pkg]: dep } - }, {}) + const peerDependencies: Index> = Object.entries(packageMap).reduce( + async (accumPromise, [pkg, version]) => { + const dep = await packageManager.getPeerDependencies!(pkg, version) + if (bar) { + bar.tick() + } + const accum = await accumPromise + const newAcc: Index> = { ...accum, [pkg]: dep } + const circularData = isCircularPeer(newAcc, pkg) + if (circularData.isCircular) { + delete newAcc[pkg][circularData.offendingPackage] + } + return newAcc + }, + {}, + ) + return peerDependencies } export default getPeerDependenciesFromRegistry diff --git a/src/lib/runLocal.ts b/src/lib/runLocal.ts index e6d25616..d1ea876f 100644 --- a/src/lib/runLocal.ts +++ b/src/lib/runLocal.ts @@ -4,7 +4,7 @@ import get from 'lodash/get' import isEmpty from 'lodash/isEmpty' import pick from 'lodash/pick' import prompts from 'prompts-ncu' -import { satisfies } from 'semver' +import nodeSemver from 'semver' import { Index } from '../types/IndexType' import { Maybe } from '../types/Maybe' import { Options } from '../types/Options' @@ -15,7 +15,7 @@ import chalk from './chalk' import getCurrentDependencies from './getCurrentDependencies' import getIgnoredUpgrades from './getIgnoredUpgrades' import getPackageManager from './getPackageManager' -import getPeerDependencies from './getPeerDependencies' +import getPeerDependenciesFromRegistry from './getPeerDependenciesFromRegistry' import keyValueBy from './keyValueBy' import { print, printIgnoredUpdates, printJson, printSorted, printUpgrades, toDependencyTable } from './logging' import programError from './programError' @@ -183,7 +183,14 @@ async function runLocal( } if (options.peer) { - options.peerDependencies = await getPeerDependencies(current, options) + options.peerDependencies = await getPeerDependenciesFromRegistry( + Object.fromEntries( + Object.entries(current).map(([packageName, versionSpec]) => { + return [packageName, nodeSemver.minVersion(versionSpec)?.version ?? versionSpec] + }), + ), + options, + ) } const [upgraded, latestResults, upgradedPeerDependencies] = await upgradePackageDefinitions(current, options) @@ -210,7 +217,9 @@ async function runLocal( // filter out satisfied deps when using --minimal const filteredUpgraded = options.minimal - ? keyValueBy(upgraded, (dep, version) => (!satisfies(latest[dep], current[dep]) ? { [dep]: version } : null)) + ? keyValueBy(upgraded, (dep, version) => + !nodeSemver.satisfies(latest[dep], current[dep]) ? { [dep]: version } : null, + ) : upgraded const ownersChangedDeps = (options.format || []).includes('ownerChanged') diff --git a/test/peer.test.ts b/test/peer.test.ts index 6fe3cfd6..bd654336 100644 --- a/test/peer.test.ts +++ b/test/peer.test.ts @@ -1,52 +1,38 @@ -import fs from 'fs/promises' import path from 'path' import ncu from '../src/' -import spawnNpm from '../src/package-managers/npm' import chaiSetup from './helpers/chaiSetup' chaiSetup() describe('peer dependencies', function () { - it('peer dependencies of installed packages are ignored by default', async () => { + it('peer dependencies are ignored by default', async () => { const cwd = path.join(__dirname, 'test-data/peer/') - try { - await spawnNpm('install', {}, {}, { cwd }) - const upgrades = await ncu({ cwd }) - upgrades!.should.deep.equal({ - 'ncu-test-return-version': '2.0.0', - }) - } finally { - await fs.rm(path.join(cwd, 'node_modules'), { recursive: true, force: true }) - await fs.rm(path.join(cwd, 'package-lock.json'), { recursive: true, force: true }) - } + const upgrades = await ncu({ cwd }) + upgrades!.should.deep.equal({ + 'ncu-test-return-version': '2.0.0', + }) }) - it('peer dependencies of installed packages are checked when using option peer', async () => { + it('peer dependencies are checked when using option peer', async () => { const cwd = path.join(__dirname, 'test-data/peer/') - try { - await spawnNpm('install', {}, {}, { cwd }) - const upgrades = await ncu({ cwd, peer: true }) - upgrades!.should.deep.equal({ - 'ncu-test-return-version': '1.1.0', - }) - } finally { - await fs.rm(path.join(cwd, 'node_modules'), { recursive: true, force: true }) - await fs.rm(path.join(cwd, 'package-lock.json'), { recursive: true, force: true }) - } + const upgrades = await ncu({ cwd, peer: true }) + upgrades!.should.deep.equal({ + 'ncu-test-return-version': '1.1.0', + }) }) - it('peer dependencies of installed packages are checked iteratively when using option peer', async () => { + it('peer dependencies are checked iteratively when using option peer', async () => { const cwd = path.join(__dirname, 'test-data/peer-update/') - try { - await spawnNpm('install', {}, {}, { cwd }) - const upgrades = await ncu({ cwd, peer: true }) - upgrades!.should.deep.equal({ - 'ncu-test-return-version': '1.1.0', - 'ncu-test-peer-update': '1.1.0', - }) - } finally { - await fs.rm(path.join(cwd, 'node_modules'), { recursive: true, force: true }) - await fs.rm(path.join(cwd, 'package-lock.json'), { recursive: true, force: true }) - } + const upgrades = await ncu({ cwd, peer: true }) + upgrades!.should.deep.equal({ + 'ncu-test-return-version': '1.1.0', + 'ncu-test-peer-update': '1.1.0', + }) + }) + + it('circular peer dependencies are ignored', async () => { + const cwd = path.join(__dirname, 'test-data/peer-lock/') + const upgrades = await ncu({ cwd, peer: true }) + upgrades!.should.contain.keys('@vitest/ui', 'vitest') }) }) diff --git a/test/test-data/peer-lock/package.json b/test/test-data/peer-lock/package.json new file mode 100644 index 00000000..f3bb0bed --- /dev/null +++ b/test/test-data/peer-lock/package.json @@ -0,0 +1,7 @@ +{ + "license": "MIT", + "dependencies": { + "@vitest/ui": "^1.3.1", + "vitest": "^1.3.1" + } +}