-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #187 from RightCapitalHQ/feature/add-eslint-rule-l…
…inter New tool to check the usage of deprecated and unknown ESLint rules
- Loading branch information
Showing
24 changed files
with
560 additions
and
3 deletions.
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@rightcapital-lint-eslint-config-rules-57aa6ccf-9b80-4ea7-b013-6d372d1de8b9.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"extends": "@rightcapital/tsconfig", | ||
"compilerOptions": { | ||
"rootDir": "./src", | ||
"outDir": "./lib" | ||
} | ||
} |
Oops, something went wrong.