Skip to content

Commit

Permalink
feat: add a fix option for autofixing (#134)
Browse files Browse the repository at this point in the history
  • Loading branch information
voxpelli authored Apr 5, 2024
1 parent ff17641 commit 5f0ab6c
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 35 deletions.
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -165,12 +165,14 @@ installedCheck(checks, [lookupOptions], [options]) => Promise<InstalledCheckResu
```ts
type LookupOptions = {
cwd?: string | undefined;
ignorePaths?: string[] | undefined;
includeWorkspaceRoot?: boolean | undefined;
skipWorkspaces?: boolean | undefined;
workspace?: string[] | undefined;
};
type InstalledChecks = 'engine' | 'peer' | 'version'
type InstalledCheckOptions = {
fix?: boolean | undefined;
ignore?: string[] | undefined;
noDev?: boolean | undefined;
prefix?: string | undefined;
Expand All @@ -180,7 +182,7 @@ type InstalledCheckResult = {
errors: string[],
warnings: string[],
suggestions: string[],
}
}
```
#### Checks
Expand All @@ -189,9 +191,13 @@ type InstalledCheckResult = {
* `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`.
#### Lookup options
The same as from [`read-workspaces`](https://github.com/voxpelli/read-workspaces?tab=readme-ov-file#readworkspacesoptions) / [`list-installed`](https://github.com/voxpelli/list-installed?tab=readme-ov-file#workspacelookupoptions)
#### Options
* `cwd = '.'`specifies the path to the package to be checked, with its `package.json` expected to be there and its installed `node_modules` as well.
* `fix = false`when set it will modify the `package.json` files to apply fixes whenever possible
* `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 `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
Expand All @@ -215,7 +221,7 @@ Similar to [`installedCheck()`](#installedcheck) but expects to be given package
#### Syntax
```ts
performInstalledCheck(checks, pkg, installed, options) => Promise<InstalledCheckResult>
performInstalledCheck(checks, pkg, installed, options) => Promise<PerformInstalledCheckResult>
```
#### Arguments
Expand Down
4 changes: 2 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 2 additions & 2 deletions lib/check-package-versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[]} */
Expand Down Expand Up @@ -54,5 +54,5 @@ export function checkPackageVersions (pkg, installedDependencies) {
}
}

return { errors, warnings, suggestions: [] };
return { errors, warnings, suggestions: [], fixes: [] };
}
11 changes: 8 additions & 3 deletions lib/check-version-range.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand All @@ -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;
Expand Down
26 changes: 22 additions & 4 deletions lib/check-versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[]} */
Expand All @@ -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);

Expand All @@ -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,
};
}
65 changes: 65 additions & 0 deletions lib/fix.js
Original file line number Diff line number Diff line change
@@ -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<string[]>}
*/
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;
}
16 changes: 13 additions & 3 deletions lib/installed-check.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { workspaceLookup } from 'list-installed';

import { performInstalledCheck } from './perform-installed-check.js';
import { fixPkg } from './fix.js';

/** @typedef {Omit<import('list-installed').WorkspaceLookupOptions, 'path'> & { cwd?: string|undefined }} LookupOptions */

export const ROOT = Symbol('workspace root');

/** @typedef {{ [workspace: string]: boolean; [ROOT]?: boolean; }} WorkspaceSuccess */

/** @typedef {Omit<import('./perform-installed-check.js').PerformInstalledCheckResult, 'fixes'> & { 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<import('./perform-installed-check.js').InstalledCheckResult & { workspaceSuccess: WorkspaceSuccess }>}
* @param {import('./perform-installed-check.js').InstalledCheckOptions & { fix?: boolean }} [options]
* @returns {Promise<InstalledCheckResult>}
*/
export async function installedCheck (checks, lookupOptions, options) {
export async function installedCheck (checks, lookupOptions, { fix, ...options } = {}) {
const { cwd = '.', ...lookupOptionsRest } = lookupOptions || {};

/** @type {Map<string|ROOT, string[]>} */
Expand All @@ -23,6 +26,8 @@ export async function installedCheck (checks, lookupOptions, options) {
const warnings = new Map();
/** @type {Map<string|ROOT, string[]>} */
const suggestions = new Map();
/** @type {Map<string|ROOT, string[]>} */
const fixFailures = new Map();
/** @type {WorkspaceSuccess} */
const workspaceSuccess = {};

Expand All @@ -36,13 +41,18 @@ 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;
}

return {
errors: prefixNotes(errors, lookupOptions),
warnings: prefixNotes(warnings, lookupOptions),
suggestions: prefixNotes(suggestions, lookupOptions),
...fixFailures.size && { fixFailures: prefixNotes(fixFailures, lookupOptions) },
workspaceSuccess,
};
}
Expand Down
10 changes: 7 additions & 3 deletions lib/perform-installed-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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<InstalledCheckResult>}
* @returns {Promise<PerformInstalledCheckResult>}
*/
export async function performInstalledCheck (checks, pkg, installed, options) {
if (!checks || !Array.isArray(checks)) {
Expand Down Expand Up @@ -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),
Expand All @@ -85,12 +88,13 @@ 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];
}
}

if (!hasCheck) {
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 };
}
Loading

0 comments on commit 5f0ab6c

Please sign in to comment.