From 2846815b60c26b3ed75aeb6fa4dea6cfc1abd157 Mon Sep 17 00:00:00 2001 From: Pelle Wessman Date: Fri, 15 Mar 2024 22:20:42 +0100 Subject: [PATCH] feat: validate peer dependency ranges Fixes #115 --- README.md | 27 ++++++++-- index.js | 6 ++- lib/check-engine-versions.js | 54 ------------------- lib/check-version-range.js | 42 +++++++++++++-- lib/check-versions.js | 40 ++++++++++++++ lib/perform-installed-check.js | 12 +++-- lib/utils.js | 21 ++++++++ .../peer/node_modules/bar/package.json | 5 ++ .../peer/node_modules/foo/package.json | 11 ++++ test/fixtures/peer/package.json | 9 ++++ test/installed-check.spec.js | 14 +++++ 11 files changed, 177 insertions(+), 64 deletions(-) delete mode 100644 lib/check-engine-versions.js create mode 100644 lib/check-versions.js create mode 100644 test/fixtures/peer/node_modules/bar/package.json create mode 100644 test/fixtures/peer/node_modules/foo/package.json create mode 100644 test/fixtures/peer/package.json diff --git a/README.md b/README.md index 4fd5740..6377b2b 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,25 @@ if (result.valid === true) { } ``` +### checkVersionRangeCollection() + + +Wrapper around as [`checkVersionRange()`](#checkversionrange) that differs from it in three ways: + +* `key` is for a collection of range, eg `engines` rather than `engines.node` +* The results for every individual version range is returned in an `object` keyed with the full key for that range, eg: `{ 'engines.node': ... }` +* Accepts an additional optional `defaultKeys` option that's used if the collection for `key` is empty. Eg: `{ defaultKeys: ['node'] }` + +#### Syntax + +```ts +checkVersionRangeCollection(mainPackage, key, installedDependencies, [options]) => VersionRangesResult +``` + +#### Arguments + +See main description of [`checkVersionRangeCollection()`](#checkversionrangecollection) and full docs for [`checkVersionRange()`](#checkversionrange). + ### getInstalledData() Companion method to eg. `checkVersionRange()` that which makes it easy to get the correct data required. Not meant for any other use. @@ -165,25 +184,27 @@ installedCheck(checks, options) => Promise #### Arguments -* `checks`: Type `('engine' | 'version')[]` – the checks to run +* `checks`: Type `InstalledChecks[]` – the checks to run, an array of one or more of: `'engine'`, `'peer'`, `'version'` * `options`: Type `InstalledCheckOptions` #### Types ```ts +type InstalledChecks = 'engine' | 'peer' | 'version' type InstalledCheckResult = { errors: string[], warnings: string[] } ``` #### Checks * `engine` – will check that the installed modules comply with the [engines requirements](https://docs.npmjs.com/files/package.json#engines) of the `package.json` and suggest an alternative requirement if the installed modules don't comply. +* `peer` – like `engine` but for `peerDependencies` instead. Will check that the promised `peerDependencies` are not wider than those of ones required dependencies. * `version` – will check that the installed modules comply with the version requirements set for them the `package.json`. #### Options * `path = '.'` – specifies the path to the package to be checked, with its `package.json` expected to be there and its installed `node_modules` as well. * `ignores = string[]` – names of modules to exclude from checks. Supports [`picomatch`](https://www.npmjs.com/package/picomatch) globbing syntax, eg. `@types/*`. (Not supported by `version` checks) -* `noDev = false` – exclude dev dependencies from checks (Not supported by `version` checks) +* `noDev = false` – exclude `devDependencies` from checks. `devDependencies` that are also in `peerDependencies` will not be ignored. (Not supported by `version` checks) * `strict = false` – converts most warnings into failures #### Example @@ -210,7 +231,7 @@ performInstalledCheck(checks, mainPackage, installedDependencies, options) => Pr #### Arguments -* `checks`: Type `('engine' | 'version')[]` – same as for [`installedCheck()`](#installedcheck) +* `checks`: Type `InstalledChecks[]` – same as for [`installedCheck()`](#installedcheck) * `mainPackage`: Type `PackageJsonLike` – the content of the `package.json` file to check, see [`getInstalledData()`](#getinstalleddata) * `installedDependencies`: Type `InstalledDependencies` – the installed dependencies to use when checking, see [`getInstalledData()`](#getinstalleddata) * `options`: Type `InstalledCheckOptions` – same as for [`installedCheck()`](#installedcheck), but without the `path` option diff --git a/index.js b/index.js index 2e1b3bd..261416e 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,16 @@ /** @typedef {import('./lib/check-version-range.js').VersionRangeItem} VersionRangeItem */ /** @typedef {import('./lib/check-version-range.js').VersionRangeOptions} VersionRangeOptions */ /** @typedef {import('./lib/check-version-range.js').VersionRangeResult} VersionRangeResult */ +/** @typedef {import('./lib/check-version-range.js').VersionRangeOptions} VersionRangesOptions */ + /** @typedef {import('./lib/get-installed-data.js').PackageJsonLike} PackageJsonLike */ /** @typedef {import('./lib/get-installed-data.js').InstalledDependencies} InstalledDependencies */ + +/** @typedef {import('./lib/perform-installed-check.js').InstalledChecks} InstalledChecks */ /** @typedef {import('./lib/perform-installed-check.js').InstalledCheckOptions} InstalledCheckOptions */ /** @typedef {import('./lib/perform-installed-check.js').InstalledCheckResult} InstalledCheckResult */ -export { checkVersionRange } from './lib/check-version-range.js'; +export { checkVersionRange, checkVersionRangeCollection } from './lib/check-version-range.js'; export { getInstalledData } from './lib/get-installed-data.js'; export { installedCheck } from './lib/installed-check.js'; export { performInstalledCheck } from './lib/perform-installed-check.js'; diff --git a/lib/check-engine-versions.js b/lib/check-engine-versions.js deleted file mode 100644 index 09fbd1a..0000000 --- a/lib/check-engine-versions.js +++ /dev/null @@ -1,54 +0,0 @@ -import { checkVersionRange } from './check-version-range.js'; - -/** - * @param {import('./get-installed-data.js').PackageJsonLike} mainPackage - * @param {import('./get-installed-data.js').InstalledDependencies} installedDependencies - * @param {Omit} options - * @returns {{ errors: string[], warnings: string[] }} - */ -export function checkEngineVersions (mainPackage, installedDependencies, options) { - /** @type {string[]} */ - const errors = []; - /** @type {string[]} */ - const warnings = []; - /** @type {string[]} */ - const summaries = []; - - let engines = typeof mainPackage.engines === 'object' ? Object.keys(mainPackage.engines) : []; - - if (engines.length === 0) { - engines = ['node']; - } - - for (const engine of engines) { - const key = `engines.${engine}`; - const engineResult = checkVersionRange( - mainPackage, - key, - installedDependencies, - { - ...options, - expectedInDependencies: true, - } - ); - - for (const result of [engineResult, ...engineResult.packageNotes]) { - if (result.note) { - (result.valid === false ? errors : warnings).push(('name' in result ? `${result.name}: ` : '') + result.note); - } - } - - if (!engineResult.valid) { - summaries.push( - engineResult.suggested - ? `Combined "${key}" needs to be narrower: ${engineResult.suggested}` - : `Incompatible combined "${key}" requirements.` - ); - } - } - - return { - errors: [...new Set(errors), ...summaries], - warnings: [...new Set(warnings)], - }; -} diff --git a/lib/check-version-range.js b/lib/check-version-range.js index 89d561c..f9e73c0 100644 --- a/lib/check-version-range.js +++ b/lib/check-version-range.js @@ -1,8 +1,8 @@ import { semverIntersect } from '@voxpelli/semver-set'; -import { omit } from '@voxpelli/typed-utils'; +import { omit, pick } from '@voxpelli/typed-utils'; import { validRange } from 'semver'; -import { getStringValueByPath } from './utils.js'; +import { getObjectValueByPath, getStringValueByPath } from './utils.js'; /** @typedef {{ valid: boolean|undefined, suggested?: string|undefined, note: string|undefined }} VersionRangeItem */ @@ -40,8 +40,14 @@ export function checkVersionRange (mainPackage, key, installedDependencies, opti const requiredDependencies = omit({ ...mainPackage.dependencies, - ...(noDev ? mainPackage.devDependencies : {}), + ...( + noDev + // Always include peer dependency dev deps + ? pick(mainPackage.devDependencies || {}, Object.keys(mainPackage.peerDependencies || {})) + : mainPackage.devDependencies + ), }, Array.isArray(ignore) ? ignore : []); + const optionalDependencies = { ...mainPackage.optionalDependencies }; /** @type {string | false} */ @@ -84,6 +90,36 @@ export function checkVersionRange (mainPackage, key, installedDependencies, opti }; } +/** @typedef {VersionRangeOptions & { defaultKeys?: string[] }} VersionRangesOptions */ + +/** + * @param {import('./get-installed-data.js').PackageJsonLike} mainPackage + * @param {string} topKey + * @param {import('./get-installed-data.js').InstalledDependencies} installedDependencies + * @param {VersionRangesOptions} [options] + * @returns {{ [key: string]: VersionRangeResult }} + */ +export function checkVersionRangeCollection (mainPackage, topKey, installedDependencies, options) { + const { defaultKeys, ...restOptions } = options || {}; + + let foundKeys = Object.keys(getObjectValueByPath(mainPackage, topKey) || {}); + + if (foundKeys.length === 0 && defaultKeys) { + foundKeys = defaultKeys; + } + + /** @type {{ [key: string]: VersionRangeResult }} */ + const result = {}; + + for (const childKey of foundKeys) { + const key = `${topKey}.${childKey}`; + + result[key] = checkVersionRange(mainPackage, key, installedDependencies, restOptions); + } + + return result; +} + /** * @param {string} referenceRange * @param {string} key diff --git a/lib/check-versions.js b/lib/check-versions.js new file mode 100644 index 0000000..50e762a --- /dev/null +++ b/lib/check-versions.js @@ -0,0 +1,40 @@ +import { checkVersionRangeCollection } from './check-version-range.js'; + +/** + * @param {import('./get-installed-data.js').PackageJsonLike} mainPackage + * @param {string} key + * @param {import('./get-installed-data.js').InstalledDependencies} installedDependencies + * @param {import('./check-version-range.js').VersionRangesOptions} options + * @returns {{ errors: string[], warnings: string[] }} + */ +export function checkVersions (mainPackage, key, installedDependencies, options) { + /** @type {string[]} */ + const errors = []; + /** @type {string[]} */ + const warnings = []; + /** @type {string[]} */ + const summaries = []; + + const rangesResult = checkVersionRangeCollection(mainPackage, key, installedDependencies, options); + + for (const [name, rangeResult] of Object.entries(rangesResult)) { + for (const result of [rangeResult, ...rangeResult.packageNotes]) { + if (result.note) { + (result.valid === false ? errors : warnings).push(('name' in result ? `${result.name}: ` : '') + result.note); + } + } + + if (!rangeResult.valid) { + summaries.push(( + rangeResult.suggested + ? `Combined "${name}" needs to be narrower: ${rangeResult.suggested}` + : `Incompatible combined "${name}" requirements.` + )); + } + } + + return { + errors: [...new Set(errors), ...summaries], + warnings: [...new Set(warnings)], + }; +} diff --git a/lib/perform-installed-check.js b/lib/perform-installed-check.js index 3d4ddc0..0055992 100644 --- a/lib/perform-installed-check.js +++ b/lib/perform-installed-check.js @@ -1,8 +1,8 @@ import { explainVariable, typedObjectKeys } from '@voxpelli/typed-utils'; import isGlob from 'is-glob'; -import { checkEngineVersions } from './check-engine-versions.js'; import { checkPackageVersions } from './check-package-versions.js'; +import { checkVersions } from './check-versions.js'; /** * @typedef InstalledCheckResult @@ -10,11 +10,12 @@ import { checkPackageVersions } from './check-package-versions.js'; * @property {string[]} warnings */ -/** @typedef {'engine' | 'version'} InstalledChecks */ +/** @typedef {'engine' | 'peer' | 'version'} InstalledChecks */ /** @type {Record} */ const checkTypeMap = { 'engine': true, + 'peer': true, 'version': true, }; @@ -68,7 +69,12 @@ export async function performInstalledCheck (checks, mainPackage, installedDepen const results = [ checks.includes('version') && checkPackageVersions(mainPackage, installedDependencies), - checks.includes('engine') && checkEngineVersions(mainPackage, installedDependencies, checkOptions), + checks.includes('engine') && checkVersions(mainPackage, 'engines', installedDependencies, { + ...checkOptions, + defaultKeys: ['node'], + expectedInDependencies: true, + }), + checks.includes('peer') && checkVersions(mainPackage, 'peerDependencies', installedDependencies, checkOptions), ]; for (const result of results) { diff --git a/lib/utils.js b/lib/utils.js index 77cbfb7..828929a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -18,3 +18,24 @@ export function getStringValueByPath (obj, path) { ? undefined : (typeof currentValue === 'string' ? currentValue : false); } + +/** + * @param {unknown} obj + * @param {string} path + * @returns {Record|undefined|false} + */ +export function getObjectValueByPath (obj, path) { + let currentValue = obj; + + for (const key of path.split('.')) { + if (!currentValue || typeof currentValue !== 'object') { + return; + } + + currentValue = currentValue[/** @type {keyof typeof currentValue} */ (key)]; + } + + return currentValue === undefined + ? undefined + : (currentValue && typeof currentValue === 'object' ? { ...currentValue } : false); +} diff --git a/test/fixtures/peer/node_modules/bar/package.json b/test/fixtures/peer/node_modules/bar/package.json new file mode 100644 index 0000000..b5a6ea2 --- /dev/null +++ b/test/fixtures/peer/node_modules/bar/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "bar", + "version": "5.0.0" +} diff --git a/test/fixtures/peer/node_modules/foo/package.json b/test/fixtures/peer/node_modules/foo/package.json new file mode 100644 index 0000000..78022ea --- /dev/null +++ b/test/fixtures/peer/node_modules/foo/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "name": "foo", + "version": "1.0.0", + "peerDependencies": { + "bar": ">=4.6.8" + }, + "devDependencies": { + "bar": "^5.0.0" + } +} diff --git a/test/fixtures/peer/package.json b/test/fixtures/peer/package.json new file mode 100644 index 0000000..d9d170e --- /dev/null +++ b/test/fixtures/peer/package.json @@ -0,0 +1,9 @@ +{ + "peerDependencies": { + "bar": ">=4.0.0" + }, + "devDependencies": { + "bar": "^5.0.0", + "foo": "^1.0.0" + } +} diff --git a/test/installed-check.spec.js b/test/installed-check.spec.js index bf8f347..81fe3fa 100644 --- a/test/installed-check.spec.js +++ b/test/installed-check.spec.js @@ -152,5 +152,19 @@ describe('installedCheck()', () => { ], }); }); + + it('should check peer dependencies', async () => { + await installedCheck(['peer'], { + path: join(import.meta.url, 'fixtures/peer'), + }) + .should.eventually.deep.equal({ + 'errors': [ + 'foo: Narrower "peerDependencies.bar" is needed: >=4.6.8', + 'Combined "peerDependencies.bar" needs to be narrower: >=4.6.8', + ], + warnings: [ + ], + }); + }); }); });