Skip to content

Commit d187924

Browse files
authored
Merge pull request #187 from RightCapitalHQ/feature/add-eslint-rule-linter
New tool to check the usage of deprecated and unknown ESLint rules
2 parents 7e36f82 + 92a97a6 commit d187924

24 files changed

+560
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: add `lint-eslint-config-rules` to check deprecated and unknown rule config",
4+
"packageName": "@rightcapital/lint-eslint-config-rules",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# @rightcapital/lint-eslint-config-rules
2+
3+
```sh
4+
npx @rightcapital/lint-eslint-config-rules
5+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"name": "@rightcapital/lint-eslint-config-rules",
3+
"version": "1.0.0",
4+
"description": "Check rule config issues in ESLint configuration",
5+
"keywords": [
6+
"eslint",
7+
"eslint rule",
8+
"eslint config"
9+
],
10+
"repository": {
11+
"type": "git",
12+
"url": "https://github.com/RightCapitalHQ/frontend-style-guide.git",
13+
"directory": "packages/lint-eslint-config-rules"
14+
},
15+
"license": "MIT",
16+
"type": "module",
17+
"exports": {
18+
".": "./lib/index.js",
19+
"./package.json": "./package.json"
20+
},
21+
"main": "./lib/index.js",
22+
"bin": {
23+
"lint-eslint-config-rules": "./lib/cli.js"
24+
},
25+
"scripts": {
26+
"prebuild": "pnpm run clean",
27+
"build": "tsc --build",
28+
"clean": "tsc --build --clean",
29+
"prepack": "pnpm run build"
30+
},
31+
"dependencies": {
32+
"@eslint/eslintrc": "3.1.0",
33+
"@nodelib/fs.walk": "npm:@frantic1048/[email protected]",
34+
"core-js": "3.38.0"
35+
},
36+
"devDependencies": {
37+
"@rightcapital/tsconfig": "workspace:*",
38+
"@types/eslint": "8.56.10",
39+
"@types/eslint__eslintrc": "2.1.2",
40+
"@types/node": "20.14.11",
41+
"typescript": "5.5.3"
42+
},
43+
"peerDependencies": {
44+
"eslint": "^8.0.0"
45+
},
46+
"packageManager": "[email protected]",
47+
"engines": {
48+
"node": ">=20.0.0"
49+
},
50+
"publishConfig": {
51+
"registry": "https://registry.npmjs.org"
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env node
2+
import { readFile } from 'node:fs/promises';
3+
import { fileURLToPath } from 'node:url';
4+
import { parseArgs } from 'node:util';
5+
6+
import { lintESLintConfigRules, sortedRuleIds } from './index.js';
7+
8+
export interface IESLintConfigRulesLintResultJSON {
9+
knownRuleIds: string[];
10+
usedRuleIds: string[];
11+
usedPluginSpecifiers: string[];
12+
usedDeprecatedRuleIds: string[];
13+
usedUnknownRuleIds: string[];
14+
}
15+
16+
const main = async () => {
17+
const args = parseArgs({
18+
options: {
19+
help: {
20+
type: 'boolean',
21+
short: 'h',
22+
default: false,
23+
},
24+
version: {
25+
type: 'boolean',
26+
default: false,
27+
},
28+
cwd: {
29+
type: 'string',
30+
default: process.cwd(),
31+
},
32+
json: {
33+
type: 'boolean',
34+
default: false,
35+
},
36+
},
37+
});
38+
39+
const { help, version, cwd, json } = args.values as {
40+
[key in keyof typeof args.values]: NonNullable<(typeof args.values)[key]>;
41+
};
42+
43+
if (help || version) {
44+
// MEMO: use JSON modules when it's stable to simplify this
45+
// https://nodejs.org/api/esm.html#json-modules
46+
const packageJson = JSON.parse(
47+
await readFile(fileURLToPath(import.meta.resolve('../package.json')), {
48+
encoding: 'utf8',
49+
}),
50+
) as { name: string; version: string; description: string };
51+
52+
if (version) {
53+
console.log(packageJson.version);
54+
return;
55+
}
56+
57+
// print help
58+
console.log(
59+
`
60+
${packageJson.name} v${packageJson.version}
61+
${packageJson.description}
62+
63+
Usage: lint-eslint-config-rules [options]
64+
65+
Options:
66+
-h, --help\tDisplay this help message
67+
--cwd <path>\tThe directory to lint (default: process.cwd())
68+
--json\tOutput all information in JSON format
69+
--version\tDisplay version number
70+
71+
Note:
72+
"used" means the rule is specified in the config (including all extended configs), whether it's "off" or "warn" or "error".
73+
`.trim(),
74+
);
75+
return;
76+
}
77+
78+
if (!json) {
79+
console.log(`Checking ESLint rules in ${cwd}`);
80+
}
81+
82+
const {
83+
ruleMap: rulesMap,
84+
usedRuleIds,
85+
usedDeprecatedRuleIds,
86+
usedUnknownRuleIds,
87+
usedPluginSpecifiers,
88+
} = await lintESLintConfigRules(cwd);
89+
90+
if (json) {
91+
console.log(
92+
JSON.stringify(
93+
{
94+
knownRuleIds: sortedRuleIds(rulesMap.keys()),
95+
usedRuleIds: sortedRuleIds(usedRuleIds),
96+
usedPluginSpecifiers: Array.from(usedPluginSpecifiers),
97+
usedDeprecatedRuleIds: sortedRuleIds(usedDeprecatedRuleIds),
98+
usedUnknownRuleIds: sortedRuleIds(usedUnknownRuleIds),
99+
} satisfies IESLintConfigRulesLintResultJSON,
100+
null,
101+
2,
102+
),
103+
);
104+
} else {
105+
// default output
106+
console.log(
107+
`Discovered ${usedRuleIds.size}/${rulesMap.size} used/available rules`,
108+
);
109+
110+
if (usedDeprecatedRuleIds.size > 0) {
111+
console.log(
112+
`Found used deprecated rules:\n\t${sortedRuleIds(usedDeprecatedRuleIds)
113+
.map((ruleId) => {
114+
const replacedByMeta = rulesMap.get(ruleId)?.meta?.replacedBy;
115+
const replacedByInfo =
116+
Array.isArray(replacedByMeta) && replacedByMeta.length > 0
117+
? ` (replaced by ${replacedByMeta.join(', ')})`
118+
: '';
119+
return `${ruleId}${replacedByInfo}`;
120+
})
121+
.join('\n\t')}`,
122+
);
123+
} else {
124+
console.log('No used deprecated rules found');
125+
}
126+
127+
if (usedUnknownRuleIds.size > 0) {
128+
console.log(
129+
`Found used unknown rules:\n\t${sortedRuleIds(usedUnknownRuleIds).join(
130+
'\n\t',
131+
)}`,
132+
);
133+
} else {
134+
console.log('No used unknown rules found');
135+
}
136+
}
137+
138+
if (usedDeprecatedRuleIds.size > 0 || usedUnknownRuleIds.size > 0) {
139+
process.exitCode = 1;
140+
}
141+
};
142+
143+
await main();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// eslint-disable-next-line import/extensions
2+
import 'core-js/es/set/index.js'; // for Node.js <22.0.0
3+
4+
import { cpus } from 'node:os';
5+
import { basename } from 'node:path';
6+
7+
import { FlatCompat } from '@eslint/eslintrc';
8+
import type { Entry } from '@nodelib/fs.walk';
9+
import * as fsWalk from '@nodelib/fs.walk';
10+
import { ESLint, type Rule } from 'eslint';
11+
import { builtinRules } from 'eslint/use-at-your-own-risk';
12+
13+
const defaultCwd = process.cwd();
14+
15+
/** `${ruleId}` (from ESLint core) or `${pluginName}/${ruleName}` (from ESLint plugin) */
16+
export type ESLintRuleId = string;
17+
export interface IESLintRule {
18+
id: ESLintRuleId;
19+
meta: Rule.RuleMetaData | undefined;
20+
}
21+
export type ESLintRuleMap = Map<ESLintRuleId, IESLintRule>;
22+
23+
export interface IESLintConfigRulesLintResult {
24+
// other context
25+
readonly eslint: ESLint;
26+
readonly compat: FlatCompat;
27+
28+
readonly pluginMap: Map<string, ESLint.Plugin>;
29+
/** all rules that can be used */
30+
readonly ruleMap: ESLintRuleMap;
31+
32+
// calculated result
33+
/**
34+
* Rules that specified in the config file
35+
*
36+
* Whether the rule is 'off' or 'warn' or 'error' is not considered.
37+
*/
38+
readonly usedRuleIds: Set<string>;
39+
readonly usedPluginSpecifiers: Set<string>;
40+
41+
readonly usedUnknownRuleIds: Set<string>;
42+
readonly usedKnownRuleIds: Set<string>;
43+
readonly usedDeprecatedRuleIds: Set<string>;
44+
}
45+
46+
const collator = new Intl.Collator('en');
47+
/**
48+
* Sort ruleIds in a way that
49+
* core rules come first, then plugin rules
50+
* for human readability
51+
*/
52+
export const sortedRuleIds = (ruleIds: Iterable<string>): string[] =>
53+
Array.from(ruleIds).sort((a, b) => {
54+
if (a.includes('/') && !b.includes('/')) {
55+
return 1;
56+
}
57+
if (!a.includes('/') && b.includes('/')) {
58+
return -1;
59+
}
60+
return collator.compare(a, b);
61+
});
62+
63+
export const lintESLintConfigRules = async (
64+
/**
65+
* The directory to lint, default to `process.cwd()`
66+
*/
67+
cwd = defaultCwd,
68+
): Promise<IESLintConfigRulesLintResult> => {
69+
const eslint = new ESLint({ cwd });
70+
const compat = new FlatCompat({
71+
baseDirectory: cwd,
72+
resolvePluginsRelativeTo: cwd,
73+
});
74+
75+
let usedRuleIds: Set<string> = new Set();
76+
let usedPluginSpecifiers: Set<string> = new Set();
77+
const ruleMap: ESLintRuleMap = new Map(
78+
Array.from(builtinRules.entries(), ([ruleId, rule]) => [
79+
ruleId,
80+
{ id: ruleId, meta: rule.meta } satisfies IESLintRule,
81+
]),
82+
);
83+
84+
/**
85+
* Iterate over all files in the project to
86+
* collect used rules and plugin specifiers
87+
*/
88+
await fsWalk
89+
.walkStream(
90+
cwd,
91+
new fsWalk.Settings({
92+
deepFilter: (_entry) =>
93+
!['.git', 'node_modules'].includes(basename(_entry.path)),
94+
entryFilter: (_entry) => _entry.dirent.isFile(),
95+
}),
96+
)
97+
.forEach(
98+
async (entry: Promise<Entry>) => {
99+
const config = (await eslint.calculateConfigForFile(
100+
(await entry).path,
101+
)) as
102+
| undefined
103+
| {
104+
rules: Record<string, unknown>;
105+
plugins: string[];
106+
};
107+
108+
if (config !== undefined) {
109+
usedRuleIds = usedRuleIds.union(new Set(Object.keys(config.rules)));
110+
usedPluginSpecifiers = usedPluginSpecifiers.union(
111+
new Set(config.plugins),
112+
);
113+
}
114+
115+
/**
116+
* config === undefined, means the file is ignored by ESLint
117+
* @see https://github.com/eslint/eslint/blob/63881dc11299aba1d0960747c199a4cf48d6b9c8/lib/eslint/eslint.js#L1208-L1212
118+
*/
119+
},
120+
{
121+
concurrency: cpus().length * 2,
122+
},
123+
);
124+
125+
// resolve all plugins
126+
const pluginMap: Map<string, ESLint.Plugin> = new Map(
127+
Object.entries(compat.plugins(...usedPluginSpecifiers)[0].plugins ?? {}),
128+
);
129+
130+
for (const [pluginName, plugin] of pluginMap.entries()) {
131+
/**
132+
* MEMO: do not use plugin.meta.name, it is not always available
133+
*/
134+
for (const [ruleId, rule] of Object.entries(plugin.rules ?? {})) {
135+
if (typeof rule === 'object') {
136+
ruleMap.set(`${pluginName}/${ruleId}`, {
137+
id: `${pluginName}/${ruleId}`,
138+
meta: rule.meta,
139+
});
140+
}
141+
}
142+
}
143+
144+
const usedUnknownRules = usedRuleIds.difference(ruleMap);
145+
const usedKnownRules = usedRuleIds.intersection(ruleMap);
146+
const usedDeprecatedRules = new Set(
147+
Array.from(usedKnownRules).filter((ruleId) => {
148+
return ruleMap.get(ruleId)?.meta?.deprecated;
149+
}),
150+
);
151+
152+
return {
153+
eslint,
154+
compat,
155+
156+
usedRuleIds,
157+
usedPluginSpecifiers,
158+
pluginMap,
159+
ruleMap,
160+
161+
usedUnknownRuleIds: usedUnknownRules,
162+
usedKnownRuleIds: usedKnownRules,
163+
usedDeprecatedRuleIds: usedDeprecatedRules,
164+
};
165+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "@rightcapital/tsconfig",
3+
"compilerOptions": {
4+
"rootDir": "./src",
5+
"outDir": "./lib"
6+
}
7+
}

0 commit comments

Comments
 (0)