Skip to content

Commit

Permalink
feat: add lint-eslint-config-rules to check deprecated and unknown …
Browse files Browse the repository at this point in the history
…rule config

resolves #185
  • Loading branch information
frantic1048 committed Aug 15, 2024
1 parent d1fe33f commit 0c5aaf6
Show file tree
Hide file tree
Showing 24 changed files with 534 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": "[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
```
50 changes: 50 additions & 0 deletions packages/lint-eslint-config-rules/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"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",
"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": "2.0.0",
"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"
}
}
130 changes: 130 additions & 0 deletions packages/lint-eslint-config-rules/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
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,
},
cwd: {
type: 'string',
default: process.cwd(),
},
json: {
type: 'boolean',
default: false,
},
},
});

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

if (help) {
// 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(import.meta.resolve('../package.json'), {
encoding: 'utf8',
}),
) as { name: string; version: string; description: string };
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
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();
155 changes: 155 additions & 0 deletions packages/lint-eslint-config-rules/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// eslint-disable-next-line import/extensions
import 'core-js/es/set/index.js'; // for Node.js <22.0.0

import { basename } from 'node:path';

import { FlatCompat } from '@eslint/eslintrc';
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 rulesMap: 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
*/
for (const entry of fsWalk.walkSync(
cwd,
new fsWalk.Settings({
deepFilter: (_entry) =>
!['.git', 'node_modules'].includes(basename(_entry.path)),
entryFilter: (_entry) => _entry.dirent.isFile(),
}),
)) {
// eslint-disable-next-line no-await-in-loop
const config = (await eslint.calculateConfigForFile(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
*/
}

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

for (const [pluginName, plugin] of pluginsMap.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') {
rulesMap.set(`${pluginName}/${ruleId}`, {
id: `${pluginName}/${ruleId}`,
meta: rule.meta,
});
}
}
}

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

return {
eslint,
compat,

usedRuleIds,
usedPluginSpecifiers,
pluginMap: pluginsMap,
ruleMap: rulesMap,

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 0c5aaf6

Please sign in to comment.