Skip to content
Go back

Using ChatGPT to Customize a Team-Specific ESLint Ruleset

Published:  at  16:00

Preface

This article is mainly intended for frontend and Node.js engineering teams. It introduces how to convert CR experience into ESLint rule sets more efficiently to raise the baseline of team engineering quality. The topics covered are:

  1. How to write them: Use ChatGPT to generate ESLint rules, and improve rule robustness through snapshot test cases
  2. How to configure them: Set up an ESLint project (mainly in a Monorepo), and run checks on incremental code during precommit and CI stages according to the rule-set requirements of different applications

The code demonstrated in this article can be found at: ycjcl868/monorepo#15, #16

Background

ESLint is a widely used JavaScript code linting tool that helps teams ensure code quality and consistency. However, ESLint’s default rule set may not meet the specific needs of every team. Therefore, customizing ESLint rule sets is a fairly common requirement for efficient team collaboration and development.

Pain Points

During development, teams that have used ESLint usually encounter the following pain points:

  1. Legacy code is hard to change: When taking over an existing project, most codebases do not come with ESLint configured. Modifying legacy code just to comply with rules is extremely risky!
  2. Many ineffective rules: In practice, many rules are too strict (for example, no-throw-literal, dot-notation, etc.)
  3. Rule customization is difficult: It is impossible to satisfy everyone. Each team has a different coding style, and strictly following industry/company coding standards can be very painful for the team. Meanwhile, writing an ESLint rule requires wrestling with the AST. (Number of code linting errors according to the coding standards)

Results

The solutions to the above pain points are roughly:

  1. Incremental checks: Run ESLint checks only on the currently changed code
  2. Keep the good parts: Based on the company coding standards, disable ineffective rules and only enable rules that “will definitely catch bugs”
  3. Use ChatGPT: Generative AI is best suited for this kind of task. 99% of rules can be generated by ChatGPT
    1. React JSX 中不允许 number 数字类型直接和 &&(逻辑和) 使用
    2. 不允许使用”登陆”,建议改成”登录”
    3. 不允许使用 new Date()、Date(),建议使用 dayjs()
    4. “帐号” and “账户” Ultimately, each project can customize its rules and run checks incrementally. If CI checks fail, a fix command will be provided, as shown below: Run different rules in different directories

How to write them?

ChatGPT

At first, rules were generated directly in conversations with ChatGPT. Later, I found that by using a fixed prompt and providing three parameters—“rule description,” “correct code examples,” and “incorrect code examples”—AI could automatically generate rules. The prompt used is as follows (just to get the conversation started—feel free to share better prompts):

请帮我写一个 eslint 规则,只需要给出规则代码,规则要求:{description}。规则校验正确代码通过,错误代码不通过,以下是正确和错误代码示例:\n\n// 正确\n``js\n{correct_code}\n```\n\n// 错误\n``js\n{incorrect_code}\n``。

Manual

Rules generated by ChatGPT are not a silver bullet. Currently, I have found that it cannot write TS-related rules, so this part can only be handled by manually writing the AST with the help of the @typescript-eslint/utils tool. Create a new rule file packages/eslint/rules/{新规则}.js, and use tools such as AST Explorer and TS ESLint to write the rule:

/** @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 Type Checking

Here is a very useful rule: in React JSX, number numeric types are not allowed to be used directly with && (logical AND). Otherwise, pages often end up rendering 0 directly instead of 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;
}

This rule needs to be written with the help of typescript-eslint. It parses the code into a TSEsTree AST. The final rule code is as follows:

How to test?

Snapshot Test Cases

ESLint officially recommends writing rule test cases with RuleTester, but this undoubtedly increases the complexity of rule test cases, and you also need to handle the format of valid and invalid code.

// 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`
    }
  ],
}

From the user’s perspective, a simpler snapshot approach is used here. Rule developers only need to add three snapshots: good, bad, and bad-stdout to complete the test case:

// 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

Run the test cases:

$ 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)
   ...

With snapshot test cases, the rule’s own functionality can become more robust:

How to configure them?

Here we use a single-repo Monorepo as an example to configure ESLint rule sets. The configuration approach for multiple repositories is the same as configuring rules for a single directory in a monorepo.

Directory Structure Diagram

There are mainly two parts:

ESLint Rule-Set Local npm Package

ESLint rule sets often change quickly, so it is not recommended to use them by publishing packages. Instead, use them as local packages. The directory structure of the packages/eslint npm package is as follows:

// packages/eslint
- rules
    - {定制的规则1}.js
    - {定制的规则2}.js
- react.js
- node.js
- package.json
- test
    - fixtures // 快照测试用例
        - {定制的规则1}
            - good
            - bad
            - bad-stdout
            - config.js

package.json

First, take a look at package.json. It is mainly customized based on the company’s frontend coding standards:

{
  "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": "*"
  }
}

The package name must end with eslint-config-*; otherwise, it cannot be extended when used. #creating-a-shareable-config

Rule Set react.js

Next, take a look at the full rule set for React applications, react.js (if there are other types, just add the corresponding rule-set files, such as vue.js, node.js, electron.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'
		...
  },
};

Where:

Quite a few “company-level coding standards” rules are disabled here. The principle is to use only rules that can catch real bugs.

Using It in Applications

Add the ESLint rule-set npm dependency in apps/*/package.json.

"devDependencies": {
+   "@infra/eslint-config-local": "workspace:*",
}

Use the Default Rules

Create a new .eslintrc.js in apps/{子应用}:

/* eslint-disable */
/** @type {import('eslint').Linter.Config} */
module.exports = {
  extends: ['@infra/eslint-config-local/react']
};

Rule Toggles

Create a new .eslintrc.js in apps/{子应用}, and enable/disable or configure specific rules in rules:

/* eslint-disable */
/** @type {import('eslint').Linter.Config} */
module.exports = {
  extends: ['@infra/eslint-config-local/react'],
  rules: {
     "rulesdir/text-specification": ["error", {
       checkItems: [''], // 根据需要
    }]
  },
};

All ESLint rules are placed under packages/eslint. Applications only enable/disable rules when using them.

Completely Disable/Do Not Use lint

Do not create apps/{子应用}/.eslintrc.js. In this case, the rules in the root .eslintrc.js will be used (that is, ignorePatterns ignores all files from checks):

/* eslint-disable */
/** @type {import('eslint').Linter.Config} */
module.exports = {
  ignorePatterns: ['**/*']
};

Engineering

To systematically and strictly enforce ESLint rules, eslint checks are run on incremental code in two stages:

  1. Before Git commit: Run eslint checks locally on changed code
  2. During CI MR stage: Check the code in the current MR (mainly to prevent bypassing local checks with git commit -n)

Git Commit (precommit)

Here we use the mature combination of husky + lint-staged. The main changes are:

  1. Root directory package.json
{
  "name": "name",
  "version": "0.0.1",
  "devDependencies": {
    "eslint": "^7.32.0",
+   "husky": "^8.0.3",
+   "lint-staged": "^13.2.0"
  }
}

  1. Run husky installation
$ npx husky install
$ npx husky add .husky/pre-commit "npx lint-staged"
  1. Configure the validation commands to run in the root .lintstagedrc.json
{
  "**/*.{js,ts,jsx,tsx}": [
    "./node_modules/.bin/eslint --no-error-on-unmatched-pattern",
    "./node_modules/.bin/prettier --write"
  ]
}

This way, rule checks will be executed every time you run git commit:

CI

To improve CR efficiency in CI, no CI pass, no CR. The CI configuration includes several optimizations:

  1. Use git diff to filter out changed files in the MR for incremental checks
  2. Configure Lint CI separately, so lint can run faster and give developers quicker feedback (after a push, you can usually know the validation result within about 1 minute) The complete configuration is as follows:
# .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 }}

Some Thoughts

  1. Only team-customized eslint rules have high value
  2. 99% of rules should be completed by AIGC (AI-generated content)

Share this post on:

Previous Post
Code Review Practice Based on Large Models + Knowledge Bases
Next Post
📝 New MacBook Pro Setup Notes