Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New tool to check the usage of deprecated and unknown ESLint rules #187

Merged
merged 1 commit into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add `lint-eslint-config-rules` to check deprecated and unknown rule config",
"packageName": "@rightcapital/lint-eslint-config-rules",
"email": "[email protected]",
"dependentChangeType": "patch"
}
5 changes: 5 additions & 0 deletions packages/lint-eslint-config-rules/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @rightcapital/lint-eslint-config-rules

```sh
npx @rightcapital/lint-eslint-config-rules
```
53 changes: 53 additions & 0 deletions packages/lint-eslint-config-rules/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@rightcapital/lint-eslint-config-rules",
"version": "1.0.0",
"description": "Check rule config issues in ESLint configuration",
"keywords": [
"eslint",
"eslint rule",
"eslint config"
],
"repository": {
"type": "git",
"url": "https://github.com/RightCapitalHQ/frontend-style-guide.git",
"directory": "packages/lint-eslint-config-rules"
},
"license": "MIT",
"type": "module",
"exports": {
".": "./lib/index.js",
"./package.json": "./package.json"
},
"main": "./lib/index.js",
"bin": {
"lint-eslint-config-rules": "./lib/cli.js"
},
"scripts": {
"prebuild": "pnpm run clean",
"build": "tsc --build",
"clean": "tsc --build --clean",
"prepack": "pnpm run build"
},
"dependencies": {
"@eslint/eslintrc": "3.1.0",
"@nodelib/fs.walk": "npm:@frantic1048/[email protected]",
"core-js": "3.38.0"
},
"devDependencies": {
"@rightcapital/tsconfig": "workspace:*",
"@types/eslint": "8.56.10",
"@types/eslint__eslintrc": "2.1.2",
"@types/node": "20.14.11",
"typescript": "5.5.3"
},
"peerDependencies": {
"eslint": "^8.0.0"
},
"packageManager": "[email protected]",
"engines": {
"node": ">=20.0.0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org"
}
}
143 changes: 143 additions & 0 deletions packages/lint-eslint-config-rules/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';

import { lintESLintConfigRules, sortedRuleIds } from './index.js';

export interface IESLintConfigRulesLintResultJSON {
knownRuleIds: string[];
usedRuleIds: string[];
usedPluginSpecifiers: string[];
usedDeprecatedRuleIds: string[];
usedUnknownRuleIds: string[];
}

const main = async () => {
const args = parseArgs({
options: {
help: {
type: 'boolean',
short: 'h',
default: false,
},
version: {
type: 'boolean',
default: false,
},
cwd: {
type: 'string',
default: process.cwd(),
},
json: {
type: 'boolean',
default: false,
},
},
});

const { help, version, cwd, json } = args.values as {
[key in keyof typeof args.values]: NonNullable<(typeof args.values)[key]>;
};

if (help || version) {
// MEMO: use JSON modules when it's stable to simplify this
// https://nodejs.org/api/esm.html#json-modules
const packageJson = JSON.parse(
await readFile(fileURLToPath(import.meta.resolve('../package.json')), {
encoding: 'utf8',
}),
) as { name: string; version: string; description: string };

if (version) {
console.log(packageJson.version);
return;
}

// print help
console.log(
`
${packageJson.name} v${packageJson.version}
${packageJson.description}

Usage: lint-eslint-config-rules [options]

Options:
-h, --help\tDisplay this help message
--cwd <path>\tThe directory to lint (default: process.cwd())
--json\tOutput all information in JSON format
--version\tDisplay version number

Note:
"used" means the rule is specified in the config (including all extended configs), whether it's "off" or "warn" or "error".
`.trim(),
);
return;
}

if (!json) {
console.log(`Checking ESLint rules in ${cwd}`);
}

const {
ruleMap: rulesMap,
usedRuleIds,
usedDeprecatedRuleIds,
usedUnknownRuleIds,
usedPluginSpecifiers,
} = await lintESLintConfigRules(cwd);

if (json) {
console.log(
JSON.stringify(
{
knownRuleIds: sortedRuleIds(rulesMap.keys()),
usedRuleIds: sortedRuleIds(usedRuleIds),
usedPluginSpecifiers: Array.from(usedPluginSpecifiers),
usedDeprecatedRuleIds: sortedRuleIds(usedDeprecatedRuleIds),
usedUnknownRuleIds: sortedRuleIds(usedUnknownRuleIds),
} satisfies IESLintConfigRulesLintResultJSON,
null,
2,
),
);
} else {
// default output
console.log(
`Discovered ${usedRuleIds.size}/${rulesMap.size} used/available rules`,
);

if (usedDeprecatedRuleIds.size > 0) {
console.log(
`Found used deprecated rules:\n\t${sortedRuleIds(usedDeprecatedRuleIds)
.map((ruleId) => {
const replacedByMeta = rulesMap.get(ruleId)?.meta?.replacedBy;
const replacedByInfo =
Array.isArray(replacedByMeta) && replacedByMeta.length > 0
? ` (replaced by ${replacedByMeta.join(', ')})`
: '';
return `${ruleId}${replacedByInfo}`;
})
.join('\n\t')}`,
);
} else {
console.log('No used deprecated rules found');
}

if (usedUnknownRuleIds.size > 0) {
console.log(
`Found used unknown rules:\n\t${sortedRuleIds(usedUnknownRuleIds).join(
'\n\t',
)}`,
);
} else {
console.log('No used unknown rules found');
}
}

if (usedDeprecatedRuleIds.size > 0 || usedUnknownRuleIds.size > 0) {
process.exitCode = 1;
}
};

await main();
165 changes: 165 additions & 0 deletions packages/lint-eslint-config-rules/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// eslint-disable-next-line import/extensions
import 'core-js/es/set/index.js'; // for Node.js <22.0.0

import { cpus } from 'node:os';
import { basename } from 'node:path';

import { FlatCompat } from '@eslint/eslintrc';
import type { Entry } from '@nodelib/fs.walk';
import * as fsWalk from '@nodelib/fs.walk';
import { ESLint, type Rule } from 'eslint';
import { builtinRules } from 'eslint/use-at-your-own-risk';

const defaultCwd = process.cwd();

/** `${ruleId}` (from ESLint core) or `${pluginName}/${ruleName}` (from ESLint plugin) */
export type ESLintRuleId = string;
export interface IESLintRule {
id: ESLintRuleId;
meta: Rule.RuleMetaData | undefined;
}
export type ESLintRuleMap = Map<ESLintRuleId, IESLintRule>;

export interface IESLintConfigRulesLintResult {
// other context
readonly eslint: ESLint;
readonly compat: FlatCompat;

readonly pluginMap: Map<string, ESLint.Plugin>;
/** all rules that can be used */
readonly ruleMap: ESLintRuleMap;

// calculated result
/**
* Rules that specified in the config file
*
* Whether the rule is 'off' or 'warn' or 'error' is not considered.
*/
readonly usedRuleIds: Set<string>;
readonly usedPluginSpecifiers: Set<string>;

readonly usedUnknownRuleIds: Set<string>;
readonly usedKnownRuleIds: Set<string>;
readonly usedDeprecatedRuleIds: Set<string>;
}

const collator = new Intl.Collator('en');
/**
* Sort ruleIds in a way that
* core rules come first, then plugin rules
* for human readability
*/
export const sortedRuleIds = (ruleIds: Iterable<string>): string[] =>
Array.from(ruleIds).sort((a, b) => {
if (a.includes('/') && !b.includes('/')) {
return 1;
}
if (!a.includes('/') && b.includes('/')) {
return -1;
}
return collator.compare(a, b);
});

export const lintESLintConfigRules = async (
/**
* The directory to lint, default to `process.cwd()`
*/
cwd = defaultCwd,
): Promise<IESLintConfigRulesLintResult> => {
const eslint = new ESLint({ cwd });
const compat = new FlatCompat({
baseDirectory: cwd,
resolvePluginsRelativeTo: cwd,
});

let usedRuleIds: Set<string> = new Set();
let usedPluginSpecifiers: Set<string> = new Set();
const ruleMap: ESLintRuleMap = new Map(
Array.from(builtinRules.entries(), ([ruleId, rule]) => [
ruleId,
{ id: ruleId, meta: rule.meta } satisfies IESLintRule,
]),
);

/**
* Iterate over all files in the project to
* collect used rules and plugin specifiers
*/
await fsWalk
.walkStream(
cwd,
new fsWalk.Settings({
deepFilter: (_entry) =>
!['.git', 'node_modules'].includes(basename(_entry.path)),
entryFilter: (_entry) => _entry.dirent.isFile(),
}),
)
.forEach(
async (entry: Promise<Entry>) => {
const config = (await eslint.calculateConfigForFile(
(await entry).path,
)) as
| undefined
| {
rules: Record<string, unknown>;
plugins: string[];
};

if (config !== undefined) {
usedRuleIds = usedRuleIds.union(new Set(Object.keys(config.rules)));
usedPluginSpecifiers = usedPluginSpecifiers.union(
new Set(config.plugins),
);
}

/**
* config === undefined, means the file is ignored by ESLint
* @see https://github.com/eslint/eslint/blob/63881dc11299aba1d0960747c199a4cf48d6b9c8/lib/eslint/eslint.js#L1208-L1212
*/
},
{
concurrency: cpus().length * 2,
},
);

// resolve all plugins
const pluginMap: Map<string, ESLint.Plugin> = new Map(
Object.entries(compat.plugins(...usedPluginSpecifiers)[0].plugins ?? {}),
);

for (const [pluginName, plugin] of pluginMap.entries()) {
/**
* MEMO: do not use plugin.meta.name, it is not always available
*/
for (const [ruleId, rule] of Object.entries(plugin.rules ?? {})) {
if (typeof rule === 'object') {
ruleMap.set(`${pluginName}/${ruleId}`, {
id: `${pluginName}/${ruleId}`,
meta: rule.meta,
});
}
}
}

const usedUnknownRules = usedRuleIds.difference(ruleMap);
const usedKnownRules = usedRuleIds.intersection(ruleMap);
const usedDeprecatedRules = new Set(
Array.from(usedKnownRules).filter((ruleId) => {
return ruleMap.get(ruleId)?.meta?.deprecated;
}),
);

return {
eslint,
compat,

usedRuleIds,
usedPluginSpecifiers,
pluginMap,
ruleMap,

usedUnknownRuleIds: usedUnknownRules,
usedKnownRuleIds: usedKnownRules,
usedDeprecatedRuleIds: usedDeprecatedRules,
};
};
7 changes: 7 additions & 0 deletions packages/lint-eslint-config-rules/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "@rightcapital/tsconfig",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib"
}
}
Loading