Skip to content

Commit

Permalink
Merge pull request #187 from RightCapitalHQ/feature/add-eslint-rule-l…
Browse files Browse the repository at this point in the history
…inter

New tool to check the usage of deprecated and unknown ESLint rules
  • Loading branch information
frantic1048 authored Aug 20, 2024
2 parents 7e36f82 + 92a97a6 commit d187924
Show file tree
Hide file tree
Showing 24 changed files with 560 additions and 3 deletions.
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": "im@pyonpyon.today",
"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/fs.walk@2.0.1-pr103-async-iterator-fix",
"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": "pnpm@9.6.0",
"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

0 comments on commit d187924

Please sign in to comment.