🛠️

ESLint(v9, Flat config) でカスタムルールを追加する(JavaScript)

2024/04/14に公開

最初に

先日ESlintのv9がリリースされました。
ESLint v9.0.0 released - ESLint - Pluggable JavaScript Linter

v9からはデフォルトが Flat config になっています。
NodeのバージョンもNode.js < v18.18.0, v19 no longer supportedとなりました。

ただしtypescript-eslintは現在(2024/04/14)v9対応中のようです。
ESLint v9 Support · Issue #8211 · typescript-eslint/typescript-eslint

なのでTypeScriptでやりたいところですが、とりあえずJavaScriptでv9の Flat config におけるカスタムルールを追加するやり方のメモです。

https://l.pg1x.com/SjpuSEN1A7r1LCWG6

全体のファイル構成

eslint-custom-rules-tutorial
 ┣ eslint-rules-plugin
 ┃ ┣ enforce-foo-bar.js
 ┃ ┣ enforce-foo-bar.test.js
 ┃ ┣ index.js
 ┃ ┗ vitest.config.js
 ┣ src
 ┃ ┗ example.js
 ┣ .prettierrc.json
 ┣ eslint.config.js
 ┣ package.json
 ┗ pnpm-lock.yaml

package.json

ESM形式なので"type":"module"を指定しています。

package.json
{
  "name": "eslint-custom-rules-tutorial",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "lint": "eslint",
    "lint:test": "vitest --config eslint-rules-plugin/vitest.config.js run",
    "lint:fix": "eslint --fix",
    "lint:i": "eslint --inspect-config",
    "format": "prettier {src,eslint-rules-plugin}/**/*.js --write"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "eslint": "^9.0.0",
    "prettier": "3.2.5",
    "vitest": "^1.4.0"
  },
  "packageManager": "pnpm@8.15.6+sha256.01c01eeb990e379b31ef19c03e9d06a14afa5250b82e81303f88721c99ff2e6f"
}

リント対象のコード

src/example.js
function correctFooBar() {
  const foo = 'bar'
}

function incorrectFoo() {
  const foo = 'baz' // Problem!
}

ルールの作成部分

プラグインにして読み込むためのルールを作成しています。

eslint-rules-plugin/enforce-foo-bar.js
export const enforceFooBarRule = {
  meta: {
    type: 'problem',
    docs: {
      description: "Enforce that a variable named `foo` can only be assigned a value of 'bar'.",
    },
    fixable: 'code',
    schema: [],
    messages: {
      fooBarMessageId: 'Value other than "bar" assigned to `const foo`. Unexpected value: {{ notBar }}.',
    },
  },
  create(context) {
    return {
      VariableDeclarator(node) {
        if (node.parent.kind === 'const') {
          if (node.id.type === 'Identifier' && node.id.name === 'foo') {
            if (node.init && node.init.type === 'Literal' && node.init.value !== 'bar') {
              context.report({
                node,
                messageId: 'fooBarMessageId',
                data: {
                  notBar: node.init.value,
                },
                fix(fixer) {
                  return fixer.replaceText(node.init, '"bar"')
                },
              })
            }
          }
        }
      },
    }
  },
}

ESM形式で書いている点以外はほぼ公式のサンプルのままです。
Rule Structureを参考に定義していきます。

ルールオブジェクトにmetaプロパティでメタ情報(ルールのタイプ、説明、自動修正、エラーメッセージなど)を追加します。

それからルールを記述するためのcreate関数を定義します。
custom-rules#rule-structureに書いてあるcreate関数の説明を抜粋すると以下のように書いてあります。

create():JavaScriptコードの抽象構文木(ESTreeで定義されたAST)を走査する際に、ESLintがノードを "訪問 "するために呼び出すメソッドを持つオブジェクトを返します:

create関数で返すオブジェクトのキーにはESTreeのノードタイプセレクターを指定することができ、それに対して定義したビジター関数は対象のノードを拾って返してくれます。

サンプル中でいうとVariableDeclaratorをキーに指定しているので、その関数はVariableDeclaratorのノードを拾って返してくれます。

ちなみにconsole.log({node})を書いて中身を見るとこんな感じです。

console.log({node})の表示結果
{
  node: <ref *1> Node {
    type: 'VariableDeclarator',
    start: 35,
    end: 46,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 35, 46 ],
    id: Node {
      type: 'Identifier',
      start: 35,
      end: 38,
      loc: [SourceLocation],
      range: [Array],
      name: 'foo',
      parent: [Circular *1]
    },
    init: Node {
      type: 'Literal',
      start: 41,
      end: 46,
      loc: [SourceLocation],
      range: [Array],
      value: 'bar',
      raw: "'bar'",
      parent: [Circular *1]
    },
    parent: Node {
      type: 'VariableDeclaration',
      start: 29,
      end: 46,
      loc: [SourceLocation],
      range: [Array],
      declarations: [Array],
      kind: 'const',
      parent: [Node]
    }
  }
}

ASTの解析結果はAST explorerのサイトでも確認できます。

サンプルコードのAST全体

foo = 'baz'VariableDeclaratorを選択した場合

あとはVariableDeclaratorで取得したノードの中を調べていきます。
constで宣言したfoo変数にbarという値が代入された場合、ルールは何もしません。逆にbarが代入されていない場合、context.report()でESLintにエラーを報告します。messageId: 'fooBarMessageId',でメタ情報のエラーメッセージと紐付けます。data属性で指定した値はエラーメッセージ内で参照することできます。 fix()関数では--fixをつけて実行したときの修正内容を定義します。

ルールをプラグインとして追加できる形にしてエクスポートする

eslint-rules-plugin/index.js
import { enforceFooBarRule } from './enforce-foo-bar.js'
const plugin = {
  meta: {
    name: 'eslint-rules-plugin',
    version: '1.0.0',
  },
  rules: { 'enforce-foo-bar': enforceFooBarRule },
}
export default plugin

※参考

eslint.config.js

フラットコンフィグなのでeslint.config.jsに配列形式で設定を書いてエクスポートします。

eslint.config.js
'use strict'
import eslintRulesPlugin from './eslint-rules-plugin/index.js'

export default [
  {
    files: ['src/**/*.js'],
    plugins: { localRules: eslintRulesPlugin },
    rules: {
      'no-unused-vars': 'error',
      'localRules/enforce-foo-bar': 'error',
    },
  },
]

作成したプラグインをインポートして、pluginsプロパティにオブジェクトで追加しています。
plugins: { 任意のキー名: 作成したプラグイン }の形式で記述します。
これでプラグインに追加されます。

plugins: { localRules: eslintRulesPlugin }

追加したプラグインのルールを有効化します。
rulesプロパティに{rules: 'プラグインのキー名/ルール名': '深刻度'}の形式で記述します。

rules: {
      ...,
      'localRules/enforce-foo-bar': 'error',
    },

※参考:

ルールのテスト部分

RuleTesterを使ってルールのテストを書くことができます。
validにルールに当てはまる内容を書きます。invalidにルールに当てはまらない内容を書きます。
invalidoutputには自動修正されたときの期待値を書きます。

eslint-rules-plugin/enforce-foo-bar.test.js
import { RuleTester } from 'eslint'
import { enforceFooBarRule } from './enforce-foo-bar.js'

const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 'latest' } })

// Throws error if the tests in ruleTester.run() do not pass
ruleTester.run(
  'enforce-foo-bar', // rule name
  enforceFooBarRule, // rule code
  {
    valid: [
      {
        name: '変数名がfooで、内容がbarの場合',
        code: "const foo = 'bar';",
      },
      {
        name: '変数名がfoo以外で、内容がbar以外の場合',
        code: "const huga = 'bar';",
      },
    ],
    invalid: [
      {
        name: '変数名がfooで、内容がbar以外の場合はbarに修正される',
        code: "const foo = 'baz';",
        output: 'const foo = "bar";',
        errors: [
          {
            messageId: 'fooBarMessageId',
          },
        ],
      },
    ],
  },
)

Vitestの追加

RuleTesterのデフォルトのままだとテストの実行結果が表示されずわかりにくいのでVitestを追加しています。

pnpm add -D vitest

eslint-rules-pluginディレクトリにvitest.config.jsも追加しています。
globalstrueにしてやると自動でRuleTesterのアサーションにvitestが組み込まれます。

eslint-rules-plugin/vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    root: './',
    include: ['**.test.js'],
  },
})

もしくはglobals:trueを設定せずにテストファイル内でRuleTesterにvitestを設定するやり方でも実行できます。

eslint-rules-plugin/vitest.config.js
import { RuleTester } from 'eslint'
import { enforceFooBarRule } from './enforce-foo-bar.js'
import * as vitest from 'vitest'

// vitestの設定 -------------------------
RuleTester.afterAll = vitest.afterAll
RuleTester.it = vitest.it
RuleTester.only = vitest.it.only
RuleTester.describe = vitest.describe
// -------------------------------------

const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 'latest' } })

...

※参考

テストの実行

package.jsonにvitestを実行するスクリプトを追加しています。

package.json
{
  "scripts": {
    "lint:test": "vitest --config eslint-rules-plugin/vitest.config.js run",
  },
}

テストを実行します。

eslint-custom-rules-tutorial $ pnpm lint:test
...中略

 RUN  v1.4.0

 ...中略

 ✓ eslint-rules-plugin/enforce-foo-bar.test.js (3)
   ✓ enforce-foo-bar (3)
     ✓ valid (2)
       ✓ 変数名がfooで、内容がbarの場合
       ✓ 変数名がfoo以外で、内容がbar以外の場合
     ✓ invalid (1)
       ✓ 変数名がfooで、内容がbar以外の場合はbarに修正される

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  19:17:02
   Duration  1.61s (transform 39ms, setup 0ms, collect 457ms, tests 32ms, environment 0ms, prepare 526ms)

リントの実行

ESlintを実行してみます。
rulesに指定したビルトインのルールno-unused-varsとオリジナルで作成したlocalRules/enforce-foo-barが実行されています。

eslint-custom-rules-tutorial $ pnpm lint

...中略
eslint-custom-rules-tutorial/src/example.js
  1:10  error  'correctFooBar' is defined but never used                              no-unused-vars
  2:9   error  'foo' is assigned a value but never used                               no-unused-vars
  5:10  error  'incorrectFoo' is defined but never used                               no-unused-vars
  6:9   error  Value other than "bar" assigned to `const foo`. Unexpected value: baz  localRules/enforce-foo-bar
  6:9   error  'foo' is assigned a value but never used                               no-unused-vars

✖ 5 problems (5 errors, 0 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.

 ELIFECYCLE  Command failed with exit code 1.
eslint-custom-rules-tutorial $ 

自動修正の実行

--fixをつけて実行すると自動で修正されます。

eslint-custom-rules-tutorial $ pnpm lint:fix

...中略

  1:10  error  'correctFooBar' is defined but never used  no-unused-vars
  2:9   error  'foo' is assigned a value but never used   no-unused-vars
  5:10  error  'incorrectFoo' is defined but never used   no-unused-vars
  6:9   error  'foo' is assigned a value but never used   no-unused-vars

✖ 4 problems (4 errors, 0 warnings)

 ELIFECYCLE  Command failed with exit code 1.
src/example.js
function incorrectFoo() {
-  const foo = 'baz' // Problem!
+  const foo = "bar" // Problem!
}

インスペクターの起動

v9でコンフィグのインスペクターツールが導入されました。
Introducing ESLint Config Inspector - ESLint - Pluggable JavaScript Linter
eslint --inspect-configで起動できます。

スクリプトで起動コマンドを実行します。

pnpm lint:i

127.0.0.1:7777で立ち上がります。

参考

Discussion