chenglong

Apr 03, 2023

使用 ChatGPT 定制团队专属 ESLint 规则集

notion image

本文主要面向前端和 Node.js 研发团队,介绍如何将 CR 经验更高效地转换成 ESLint 规则集,以提升团队研发质量下限。以下是探讨的内容:
  1. 如何写:使用 ChatGPT 生成 ESLint 规则,并通过 snapshot 测试用例快照提升规则的鲁棒性
  1. 如何配:搭建 ESLint 工程(主要以 Monorepo),根据不同应用规则集需求,在 precommit 和 CI 阶段对增量代码做检测
本文演示的代码见:

背景

ESLint 是一个广泛使用的 JavaScript 代码检查工具,可帮助团队确保代码质量和一致性。然而,ESLint 默认的规则集可能并不符合各团队的具体需求,因此,定制化 ESLint 规则集对于团队高效协作研发是较为常见的需求。

痛点

在研发过程中,使用过 ESLint 的团队通常会遇到以下几个痛点问题:
  1. 祖传代码改不动:接手项目代码时,大部分没有配备 ESLint,为了符合规则,修改祖传代码的风险极高!
  1. 无效规则集较多:实践中,有很多规则过于严格(例如 no-throw-literaldot-notation 等)
  1. 规则定制困难:众口难调,每个团队的代码风格都不同,完全按照业界/公司的代码规范会让团队非常痛苦,而编写一个 ESLint 规则需要与 AST 斗智斗勇。
notion image
(按代码规范的代码检测错误数)

效果

以上痛点的解法大概是:
  1. 增量检测:只对当前变更的代码进行 ESLint 检测
  1. 取其精华:在公司代码规范基础上,关闭无效的规则,只开启『一定会出错』的规则
  1. 利用 ChatGPT:生成式 AI 最适合做这类事,99% 的规则都可以由 ChatGPT 生成的
    1. React JSX 中不允许 number 数字类型直接和 &&(逻辑和) 使用
    2. 不允许使用"登陆",建议改成"登录"
    3. 不允许使用 new Date()、Date(),建议使用 dayjs()
    4. "帐号" and "账户"
最终,每个项目都可以定制规则,并按增量执行检测。如果 CI 检测未通过,则会给出修复命令,如下图所示:
notion image
不同目录执行不同规则

如何写?

ChatGPT

最初,直接使用 ChatGPT 在对话中生成规则。后来,发现可以通过固定 prompt,提供『规则描述』、『正确代码示例』和『错误代码示例』三个参数,让 AI 自动生成规则。
notion image
notion image
notion image
Prompt 使用的是(抛砖引玉,大家有更好的 Prompt 可以交流):
请帮我写一个 eslint 规则,只需要给出规则代码,规则要求:{description}。规则校验正确代码通过,错误代码不通过,以下是正确和错误代码示例:\n\n// 正确\n``js\n{correct_code}\n```\n\n// 错误\n``js\n{incorrect_code}\n``。

手工

ChatGPT 生成的规则也不是银弹,目前发现没法写 TS 相关的规则,所以这部分只能借助 @typescript-eslint/utils 工具来手写 AST。
新建一个规则文件 packages/eslint/rules/{新规则}.js,借助 AST ExplorerTS ESLint 等工具编写规则:
/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', docs: { description: '', category: 'Best Practices', recommended: true, }, }, create: function (context) { return { // AST 部分编写细节及 API 见:https://eslint.org/docs/latest/extend/custom-rules }; }, };

TS 类型判断

这里举一个很有用的规则:React JSX 中不允许 number 数字类型直接和 &&(逻辑和) 使用,经常会出现页面直接渲染了 0,而不是 hello
const Home = (props: { gender: number; text: string; obj: { gender: number } }) => { const { gender, text } = props; return ( <div> {gender && <p>Hello</p>} {props?.obj?.gender && <p>Hello</p>} {foo() && <p>Hello</p>} {text && <p>Hello</p>} </div> ); }; function foo(): number { return 0; }
这个规则需要借助 typescript-eslint 来写, 解析成 TSEsTree AST 抽象语法树,最终的规则代码如下:

如何测?

快照测试用例

ESLint 官方推荐写规则测试用例通过 RuleTester,但是这无疑增加了规则用例的复杂度,同时还需要处理有效和无效代码的格式。
// test/text-specification.test.js const RuleTester = require('eslint').RuleTester const rule = require('../rules/text-specification') const tester = new RuleTester({ parser: ..., parserOptions: { ecmaVersion: 2015 } ...一堆配置 }) tester.run('text-specification', rule, { valid: [ { filename: 'test.js', code: `const a = "账号";\nconst b = "账户"\n` } ], invalid: [ { filename: 'test.js', code: `const a = "帐号";\nconst b = "帐户"\n` } ], }
这里以使用者视角,用更简单的快照方式,规则开发者只需要补 goodbadbad-stdout 三个快照即完成测试用例:
// test/text-specification/good?.(tsx|jsx|js|jsx) const id = '1234'; const rawText = '账户'; const rawText1 = `账户: ${id}`; console.log(rawText, rawText1);
// test/text-specification/bad // test/text-specification/bad.(tsx|jsx|js|jsx) const id = '1234'; const rawText = '帐户'; const rawText1 = `帐户: ${id}`; console.log(rawText, rawText1);
// test/text-specification/bad-stdout 2:17 error 不允许使用 "帐户" 文案,建议改成 "账户" rulesdir/text-specification 3:18 error 不允许使用 "帐户" 文案,建议改成 "账户" rulesdir/text-specification
执行测试用例:
$ vitest run -t text-specification ❯ test/index.test.ts (12) ↓ eslint react('disable-rules_no-throw-literal') (2) [skipped] ✓ eslint react('talent_text-specification') (2) 1636ms ❯ eslint react('talent_text-specification_2') (2) ✓ 'good' 796ms ⠏ 'bad' · eslint react('talent_text-specification_3') (2) ...
有了快照用例后,可以让规则自身功能更健壮:

如何配?

这里我们以单仓 Monorepo 为例,配置 ESLint 规则集,多仓配置方式和单仓中的单个目录规则一致。

目录结构图

主要在两部分:
  • 全量 ESLint 规则集 npm 包:这里用packages/eslint ,包名为 @infras/eslint-config-local
  • 应用 apps/,对 ESLint 的需求中一般有三类:
    • 使用默认规则app1
    • 规则定制app2:主要是开启一些规则
    • 关闭/不使用 lintapp3:不再迭代的废弃应用,不希望
(其中紫色部分为 ESLint 配置部分)

ESLint 规则集 npm 本地包

eslint 规则集是经常快速变化的,不建议通过发包形式使用,而是以本地包方式使用
packages/eslint npm 包目录结构如下:
// packages/eslint - rules - {定制的规则1}.js - {定制的规则2}.js - react.js - node.js - package.json - test - fixtures // 快照测试用例 - {定制的规则1} - good - bad - bad-stdout - config.js

package.json

先看下 package.json,主要是在公司前端代码规范基础上进行定制:
{ "name": "@infras/eslint-config-local", "private":true, "dependencies": { "@rushstack/eslint-patch": "^1.2.0", "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/utils": "^5.58.0", "eslint-plugin-rulesdir": "^0.2.2", // 可以在本地目录中写 eslint 规则 "@typescript-eslint/parser": "^5.47.0", "typescript": "^4.9.5" }, "devDependencies": { "eslint": "^7.32.0", "@types/node": "^14.0.14", "glob": "^10.0.0", "vitest": "^0.29.7", "tsutils": "^3.21.0" }, "scripts": { "dev": "vitest", "test": "vitest run" }, "peerDependencies": { "eslint": "*" } }
name 包名一定要以
eslint-config-*

规则集 react.js

再看下 React 应用的全量规则集 react.js(若有其它类型补充对应规则集文件即可,例如vue.jsnode.jselectron.js
// 解决找不到依赖问题 require('@rushstack/eslint-patch/modern-module-resolution') const path = require('path'); const rulesDirPlugin =require('eslint-plugin-rulesdir');rulesDirPlugin.RULES_DIR = path.join(__dirname,'rules'); /** @type {import('eslint').Linter.Config} */ module.exports = { root: true, extends: ['公司代码规范'], plugins: ['rulesdir'], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 11, sourceType: 'module', ecmaFeatures: { jsx: true, legacyDecorators: true }, project: './tsconfig.json' }, rules: { // 自定义规则 'rulesdir/text-specification': 'error', 'rulesdir/jsx-no-numeric-and': 'error', 'rulesdir/lodash-import': 'error' ... }, };
其中:
  • 关闭 prettier 代码风格规则,让 eslint 只专注做错误代码校验
  • rulesdir/* 开头的规则属于团队定制(如何写见后文),使用原生 JS 文件(不用编译规则立即生效):
    • rules/text-specification.js:文案校验
    • rules/jsx-no-numeric-and:避免出现 0 && <div /> 展示 0 的问题
    • rules/lodash-import:前端项目优先使用 lodash-es 而非 lodash
这里关掉了不少『公司级代码规范』规则,原则是只使用『能拦截有效错误的规则』

应用中使用

都是在 apps/*/package.json 中添加 eslint 规则集 npm 依赖
"devDependencies": { + "@infra/eslint-config-local": "workspace:*", }

使用默认规则

apps/{子应用}里新建 .eslintrc.js
/* eslint-disable */ /** @type {import('eslint').Linter.Config} */ module.exports = { extends: ['@infra/eslint-config-local/react'] };

规则开关

apps/{子应用}里新建 .eslintrc.js ,在 rules 里对具体规则进行 开/关、配置:
/* eslint-disable */ /** @type {import('eslint').Linter.Config} */ module.exports = { extends: ['@infra/eslint-config-local/react'], rules: { "rulesdir/text-specification": ["error", { checkItems: ['帐'], // 根据需要 }] }, };
所有 ESLint 规则都放到 packages/eslint 里面,应用使用时只做规则开/关

完全关闭/不使用 lint

不新建 apps/{子应用}/.eslintrc.js ,这时候会使用根目录下的 .eslintrc.js 规则(即ignorePatterns 忽略所有文件检测):
/* eslint-disable */ /** @type {import('eslint').Linter.Config} */ module.exports = { ignorePatterns: ['**/*'] };

工程化

为了系统性严格执行 ESLint 规则,这里在两个阶段对增量代码进行 eslint 检测:
  1. Git 提交前:在本地对变更的代码执行 eslint 检测
  1. CI MR 环节:对当前 MR 中的代码做检测(主要防止通过 git commit -n 方式绕过本地检测)

Git 提交(precommit)

这里使用较成熟的 husky + lint-staged,主要改动:
  1. 根目录 package.json
{ "name": "name", "version": "0.0.1", "devDependencies": { "eslint": "^7.32.0", + "husky": "^8.0.3", + "lint-staged": "^13.2.0" } }
  1. 执行 husky 安装
$ npx husky install $ npx husky add .husky/pre-commit "npx lint-staged"
  1. 配置根目录的 .lintstagedrc.json 需执行的校验命令
{ "**/*.{js,ts,jsx,tsx}": [ "./node_modules/.bin/eslint --no-error-on-unmatched-pattern", "./node_modules/.bin/prettier --write" ] }
这样每次 git commit 时就会执行规则检测:

CI

CI 为了提高 CR 效率,CI 不过 不 CR,其中 CI 配置有一些优化点:
  1. 使用 git diff 将 MR 中变更文件筛选出来做增量检测
  1. Lint CI 单独配置,这样可以更快执行 lint,给开发者更快地反馈(一次 push 大概 1min 内就可以知道校验结果)
完整配置如下:
# .github/workflows/lint.yml name: Lint staged on: [push] jobs: eslint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: install pnpm shell: bash run: | PNPM_VER=$(jq -r '.packageManager | if .[0:5] == "pnpm@" then .[5:] else "packageManager in package.json does not start with pnpm@\n" | halt_error(1) end' package.json) echo installing pnpm version $PNPM_VER npm i -g pnpm@$PNPM_VER - uses: technote-space/get-diff-action@v6 with: PATTERNS: | apps/**/*.+(ts|tsx|jsx|js) .github/workflows/lint.yml - uses: actions/setup-node@v3 if: env.GIT_DIFF with: node-version: '18' cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - run: echo ${{ env.GIT_DIFF }} - run: pnpm install --ignore-scripts if: env.GIT_DIFF - name: Eslint Checker if: env.GIT_DIFF run: | echo "修复命令 npx eslint --no-error-on-unmatched-pattern --fix --quiet ${{ env.GIT_DIFF_FILTERED }}" pnpm eslint --no-error-on-unmatched-pattern --quiet ${{ env.GIT_DIFF_FILTERED }}

一些感受

  1. 只有团队定制的 eslint 规则才有较高价值
  1. 99% 的规则应该由 AIGC(人工智能生成内容) 来完成

Copyright © 2024 chenglong

logo