From 5f0ab6c8ade1f77f7784e7fc5b13b50e609cbfbf Mon Sep 17 00:00:00 2001 From: Pelle Wessman Date: Fri, 5 Apr 2024 18:15:41 +0200 Subject: [PATCH] feat: add a `fix` option for autofixing (#134) --- README.md | 14 +++++-- index.d.ts | 4 +- lib/check-package-versions.js | 4 +- lib/check-version-range.js | 11 +++-- lib/check-versions.js | 26 ++++++++++-- lib/fix.js | 65 ++++++++++++++++++++++++++++ lib/installed-check.js | 16 +++++-- lib/perform-installed-check.js | 10 +++-- lib/utils.js | 72 +++++++++++++++++++++++++------- package.json | 1 + test/installed-check-fix.spec.js | 53 +++++++++++++++++++++++ 11 files changed, 241 insertions(+), 35 deletions(-) create mode 100644 lib/fix.js create mode 100644 test/installed-check-fix.spec.js diff --git a/README.md b/README.md index f92b9bd..0fc6d0b 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Wrapper around as [`checkVersionRange()`](#checkversionrange) that differs from #### Syntax ```ts -checkVersionRangeCollection(pkg, key, installed, [options]) => VersionRangesResult +checkVersionRangeCollection(pkg, key, installed, [options]) => VersionRangeCollectionResult ``` #### Arguments @@ -165,12 +165,14 @@ installedCheck(checks, [lookupOptions], [options]) => Promise Promise +performInstalledCheck(checks, pkg, installed, options) => Promise ``` #### Arguments diff --git a/index.d.ts b/index.d.ts index e82116a..c7d452c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,8 +1,8 @@ export type * from './lib/lookup-types.d.ts'; export type { VersionRangeItem, VersionRangeOptions, VersionRangeResult, VersionRangesOptions } from './lib/check-version-range.js'; -export type { LookupOptions, WorkspaceSuccess } from './lib/installed-check.js'; -export type { InstalledChecks, InstalledCheckOptions, InstalledCheckResult } from './lib/perform-installed-check.js'; +export type { LookupOptions, WorkspaceSuccess, InstalledCheckResult } from './lib/installed-check.js'; +export type { InstalledChecks, InstalledCheckOptions, PerformInstalledCheckResult } from './lib/perform-installed-check.js'; export { checkVersionRange, checkVersionRangeCollection } from './lib/check-version-range.js'; export { installedCheck, ROOT } from './lib/installed-check.js'; diff --git a/lib/check-package-versions.js b/lib/check-package-versions.js index 4330db2..2911f2b 100644 --- a/lib/check-package-versions.js +++ b/lib/check-package-versions.js @@ -3,7 +3,7 @@ import semver from 'semver'; /** * @param {import('./lookup-types.d.ts').PackageJsonLike} pkg * @param {import('./lookup-types.d.ts').InstalledDependencies} installedDependencies - * @returns {import('./perform-installed-check.js').InstalledCheckResult} + * @returns {import('./perform-installed-check.js').PerformInstalledCheckResult} */ export function checkPackageVersions (pkg, installedDependencies) { /** @type {string[]} */ @@ -54,5 +54,5 @@ export function checkPackageVersions (pkg, installedDependencies) { } } - return { errors, warnings, suggestions: [] }; + return { errors, warnings, suggestions: [], fixes: [] }; } diff --git a/lib/check-version-range.js b/lib/check-version-range.js index 349fcbe..f88c220 100644 --- a/lib/check-version-range.js +++ b/lib/check-version-range.js @@ -109,13 +109,14 @@ export function checkVersionRange (pkg, key, installed, options) { } /** @typedef {VersionRangeOptions & { defaultKeys?: string[]|undefined }} VersionRangesOptions */ +/** @typedef {VersionRangeResult & { topKey: string, childKey: string }} VersionRangeCollectionResult */ /** * @param {import('./lookup-types.d.ts').PackageJsonLike} pkg * @param {string} topKey * @param {import('./lookup-types.d.ts').InstalledDependencies} installed * @param {VersionRangesOptions} [options] - * @returns {{ [key: string]: VersionRangeResult }} + * @returns {{ [key: string]: VersionRangeCollectionResult }} */ export function checkVersionRangeCollection (pkg, topKey, installed, options) { const { defaultKeys, ...restOptions } = options || {}; @@ -126,13 +127,17 @@ export function checkVersionRangeCollection (pkg, topKey, installed, options) { foundKeys = defaultKeys; } - /** @type {{ [key: string]: VersionRangeResult }} */ + /** @type {{ [key: string]: VersionRangeCollectionResult }} */ const result = {}; for (const childKey of foundKeys) { const key = `${topKey}.${childKey}`; - result[key] = checkVersionRange(pkg, key, installed, restOptions); + result[key] = { + ...checkVersionRange(pkg, key, installed, restOptions), + topKey, + childKey, + }; } return result; diff --git a/lib/check-versions.js b/lib/check-versions.js index 1ddf973..6801d62 100644 --- a/lib/check-versions.js +++ b/lib/check-versions.js @@ -5,7 +5,7 @@ import { checkVersionRangeCollection } from './check-version-range.js'; * @param {string} key * @param {import('./lookup-types.d.ts').InstalledDependencies} installed * @param {import('./check-version-range.js').VersionRangesOptions} options - * @returns {import('./perform-installed-check.js').InstalledCheckResult} + * @returns {import('./perform-installed-check.js').PerformInstalledCheckResult} */ export function checkVersions (pkg, key, installed, options) { /** @type {string[]} */ @@ -14,6 +14,8 @@ export function checkVersions (pkg, key, installed, options) { const warnings = []; /** @type {string[]} */ const suggestions = []; + /** @type {import('./fix.js').Fix[]} */ + const fixes = []; const rangesResult = checkVersionRangeCollection(pkg, key, installed, options); @@ -24,18 +26,34 @@ export function checkVersions (pkg, key, installed, options) { } } - if (!rangeResult.valid) { + const { + childKey, + suggested, + topKey, + valid, + } = rangeResult; + + if (!valid) { suggestions.push(( - rangeResult.suggested - ? `Combined "${name}" needs to be narrower: ${rangeResult.suggested}` + suggested + ? `Combined "${name}" needs to be narrower: ${suggested}` : `Incompatible combined "${name}" requirements.` )); } + + if (suggested) { + fixes.push({ + childKey, + suggested, + topKey, + }); + } } return { errors: [...new Set(errors)], warnings: [...new Set(warnings)], suggestions, + fixes, }; } diff --git a/lib/fix.js b/lib/fix.js new file mode 100644 index 0000000..5dec9d0 --- /dev/null +++ b/lib/fix.js @@ -0,0 +1,65 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { getObjectValueByPath } from './utils.js'; + +/** + * @param {string} content + * @returns {string} + */ +function getIndent (content) { + const indentMatch = content.match(/^[\t ]+/m); + + return indentMatch ? indentMatch[0] : ' '; +} + +/** + * @typedef Fix + * @property {string} childKey + * @property {string} suggested + * @property {string} topKey + */ + +/** + * @param {string} cwd + * @param {Fix[]} fixes + * @returns {Promise} + */ +export async function fixPkg (cwd, fixes) { + /** @type {string[]} */ + const failures = []; + + if (fixes.length === 0) { + return failures; + } + + const pkgPath = path.join(cwd, 'package.json'); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + const raw = await readFile(pkgPath, { encoding: 'utf8' }); + + const indent = getIndent(raw); + + const data = JSON.parse(raw); + + for (const { childKey, suggested, topKey } of fixes) { + const collection = getObjectValueByPath(data, topKey, true); + + if (!collection) { + failures.push(`Failed to fix "${topKey}.${childKey}". Not an object at "${topKey}".`); + continue; + } else if (childKey === '__proto__' || childKey === 'constructor' || childKey === 'prototype') { + failures.push(`Do not include "${childKey}" in your path`); + continue; + } + + collection[childKey] = suggested; + } + + const newRaw = JSON.stringify(data, undefined, indent) + '\n'; + + // eslint-disable-next-line security/detect-non-literal-fs-filename + await writeFile(pkgPath, newRaw); + + return failures; +} diff --git a/lib/installed-check.js b/lib/installed-check.js index e681693..fa1089b 100644 --- a/lib/installed-check.js +++ b/lib/installed-check.js @@ -1,6 +1,7 @@ import { workspaceLookup } from 'list-installed'; import { performInstalledCheck } from './perform-installed-check.js'; +import { fixPkg } from './fix.js'; /** @typedef {Omit & { cwd?: string|undefined }} LookupOptions */ @@ -8,13 +9,15 @@ export const ROOT = Symbol('workspace root'); /** @typedef {{ [workspace: string]: boolean; [ROOT]?: boolean; }} WorkspaceSuccess */ +/** @typedef {Omit & { fixFailures?: string[], workspaceSuccess: WorkspaceSuccess }} InstalledCheckResult */ + /** * @param {import('./perform-installed-check.js').InstalledChecks[]} checks * @param {LookupOptions} [lookupOptions] - * @param {import('./perform-installed-check.js').InstalledCheckOptions} [options] - * @returns {Promise} + * @param {import('./perform-installed-check.js').InstalledCheckOptions & { fix?: boolean }} [options] + * @returns {Promise} */ -export async function installedCheck (checks, lookupOptions, options) { +export async function installedCheck (checks, lookupOptions, { fix, ...options } = {}) { const { cwd = '.', ...lookupOptionsRest } = lookupOptions || {}; /** @type {Map} */ @@ -23,6 +26,8 @@ export async function installedCheck (checks, lookupOptions, options) { const warnings = new Map(); /** @type {Map} */ const suggestions = new Map(); + /** @type {Map} */ + const fixFailures = new Map(); /** @type {WorkspaceSuccess} */ const workspaceSuccess = {}; @@ -36,6 +41,10 @@ export async function installedCheck (checks, lookupOptions, options) { warnings.set(key, result.warnings); suggestions.set(key, result.suggestions); + if (fix) { + fixFailures.set(key, await fixPkg(item.cwd, result.fixes)); + } + workspaceSuccess[key] = result.errors.length === 0; } @@ -43,6 +52,7 @@ export async function installedCheck (checks, lookupOptions, options) { errors: prefixNotes(errors, lookupOptions), warnings: prefixNotes(warnings, lookupOptions), suggestions: prefixNotes(suggestions, lookupOptions), + ...fixFailures.size && { fixFailures: prefixNotes(fixFailures, lookupOptions) }, workspaceSuccess, }; } diff --git a/lib/perform-installed-check.js b/lib/perform-installed-check.js index 69f77b1..de423e2 100644 --- a/lib/perform-installed-check.js +++ b/lib/perform-installed-check.js @@ -5,10 +5,11 @@ import { checkPackageVersions } from './check-package-versions.js'; import { checkVersions } from './check-versions.js'; /** - * @typedef InstalledCheckResult + * @typedef PerformInstalledCheckResult * @property {string[]} errors * @property {string[]} warnings * @property {string[]} suggestions + * @property {import('./fix.js').Fix[]} fixes */ /** @typedef {'engine' | 'peer' | 'version'} InstalledChecks */ @@ -35,7 +36,7 @@ const checkTypes = typedObjectKeys(checkTypeMap); * @param {import('./lookup-types.d.ts').PackageJsonLike} pkg * @param {import('./lookup-types.d.ts').InstalledDependencies} installed * @param {InstalledCheckOptions} [options] - * @returns {Promise} + * @returns {Promise} */ export async function performInstalledCheck (checks, pkg, installed, options) { if (!checks || !Array.isArray(checks)) { @@ -67,6 +68,8 @@ export async function performInstalledCheck (checks, pkg, installed, options) { let warnings = []; /** @type {string[]} */ let suggestions = []; + /** @type {import('./fix.js').Fix[]} */ + let fixes = []; const results = [ checks.includes('version') && checkPackageVersions(pkg, installed), @@ -85,6 +88,7 @@ export async function performInstalledCheck (checks, pkg, installed, options) { errors = [...errors, ...(prefix ? result.errors.map(note => prefix + ': ' + note) : result.errors)]; warnings = [...warnings, ...(prefix ? result.warnings.map(note => prefix + ': ' + note) : result.warnings)]; suggestions = [...suggestions, ...(prefix ? result.suggestions.map(note => prefix + ': ' + note) : result.suggestions)]; + fixes = [...fixes, ...result.fixes]; } } @@ -92,5 +96,5 @@ export async function performInstalledCheck (checks, pkg, installed, options) { throw new Error('Expected to run at least one check. "checks" should include at least one of: ' + checkTypes.join(', ')); } - return { errors, warnings, suggestions }; + return { errors, warnings, suggestions, fixes }; } diff --git a/lib/utils.js b/lib/utils.js index 828929a..0b96f0a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,38 +4,82 @@ * @returns {string|undefined|false} */ export function getStringValueByPath (obj, path) { - let currentValue = obj; + if (!obj || typeof obj !== 'object') { + return false; + } - for (const key of path.split('.')) { - if (!currentValue || typeof currentValue !== 'object') { - return; - } + const pathKeys = path.split('.'); + const stringKey = pathKeys.pop(); - currentValue = currentValue[/** @type {keyof typeof currentValue} */ (key)]; + if (!stringKey) { + return; } - return currentValue === undefined + const objectValue = pathKeys.length + ? getObjectValueByPath(obj, pathKeys.join('.')) + : /** @type {Record} */ (obj); + + if (!objectValue) { + return objectValue; + } + + const value = objectValue[stringKey]; + + return value === undefined ? undefined - : (typeof currentValue === 'string' ? currentValue : false); + : (typeof value === 'string' ? value : false); } /** + * @overload * @param {unknown} obj * @param {string} path + * @param {false} [createIfMissing] * @returns {Record|undefined|false} */ -export function getObjectValueByPath (obj, path) { - let currentValue = obj; +/** + * @overload + * @param {unknown} obj + * @param {string} path + * @param {true} createIfMissing + * @returns {Record|false} + */ +/** + * @param {unknown} obj + * @param {string} path + * @param {boolean} [createIfMissing] + * @returns {Record|undefined|false} + */ +export function getObjectValueByPath (obj, path, createIfMissing) { + if (!obj || typeof obj !== 'object') { + return false; + } + + let currentValue = /** @type {Record} */ (obj); for (const key of path.split('.')) { - if (!currentValue || typeof currentValue !== 'object') { - return; + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + throw new Error(`Do not include "${key}" in your path`); } - currentValue = currentValue[/** @type {keyof typeof currentValue} */ (key)]; + const nextValue = currentValue[key]; + + if (nextValue === undefined) { + if (!createIfMissing) { + return; + } + /** @type {Record} */ + const newValue = {}; + currentValue[key] = newValue; + currentValue = newValue; + } else if (nextValue && typeof nextValue === 'object') { + currentValue = /** @type {Record} */ (nextValue); + } else { + return false; + } } return currentValue === undefined ? undefined - : (currentValue && typeof currentValue === 'object' ? { ...currentValue } : false); + : (currentValue && typeof currentValue === 'object' ? currentValue : false); } diff --git a/package.json b/package.json index 77673d3..0f0c7ba 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "mocha": "^10.4.0", "npm-run-all2": "^6.1.2", "read-pkg": "^9.0.1", + "tempy": "^3.1.0", "type-coverage": "^2.28.1", "typescript": "~5.4.4", "validate-conventional-commit": "^1.0.3" diff --git a/test/installed-check-fix.spec.js b/test/installed-check-fix.spec.js new file mode 100644 index 0000000..4d4ee65 --- /dev/null +++ b/test/installed-check-fix.spec.js @@ -0,0 +1,53 @@ +import { cp } from 'node:fs/promises'; + +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { join } from 'desm'; +import { temporaryDirectoryTask } from 'tempy'; + +import { ROOT, installedCheck } from '../lib/installed-check.js'; + +chai.use(chaiAsPromised); +chai.should(); + +describe('installedCheck() fix', () => { + it('should be able to automatically fix a project', async () => { + await temporaryDirectoryTask(async (tmpDir) => { + await cp(join(import.meta.url, 'fixtures/workspace'), tmpDir, { + recursive: true, + }); + + await installedCheck(['engine'], { cwd: tmpDir }, { fix: true }) + .should.eventually.deep.equal({ + 'errors': [ + 'root: foo: Narrower "engines.node" is needed: >=10.4.0', + 'root: bar: Narrower "engines.node" is needed: >=12.0.0', + '@voxpelli/workspace-a: foo: Narrower "engines.node" is needed: >=10.4.0', + '@voxpelli/workspace-a: bar: Narrower "engines.node" is needed: >=10.5.0', + '@voxpelli/workspace-a: abc: Narrower "engines.node" is needed: >=10.8.0', + ], + suggestions: [ + 'root: Combined "engines.node" needs to be narrower: >=12.0.0', + '@voxpelli/workspace-a: Combined "engines.node" needs to be narrower: >=10.8.0', + ], + warnings: [], + fixFailures: [], + workspaceSuccess: { + [ROOT]: false, + '@voxpelli/workspace-a': false, + }, + }); + + return installedCheck(['engine'], { cwd: tmpDir }); + }) + .should.eventually.deep.equal({ + 'errors': [], + suggestions: [], + warnings: [], + workspaceSuccess: { + [ROOT]: true, + '@voxpelli/workspace-a': true, + }, + }); + }); +});