ESLint でプロジェクト独自のルールを設定する

# はじめに
今となっては Node.js での開発などでは必須となったツール、 [ESLint](https://eslint.org/)。
インポートの順番を特定の順番にしたり未使用の変数に Warning を出したり、プロジェクトに必要なルールを設定することでコードの品質を一定に保つことができるスグれたツールです。
プラグインの機構も備えており、多くの開発者が様々なプラグインを組み合わせてルールを設定していることと思います。

そんな ESLint ですがあらゆるユースケースに対応できるかというと必ずしもそうではなく、中には ESLint でカバーできていないプロジェクト独自のルールがあったりするのではないでしょうか。
この手の独自のルールというのは機械的にチェックすることができないと、レビューの時に余計なやり取りが生じたりプロジェクト新規参画者にとってもどういうルールがあるのかをいちいち読みに行かないといけなかったりとあまりいい状態とは言えません。
そこで今回は、そんな ESLint でカバーできないルールをプラグインを自作することでカバーできるようにしてみようと思います。

# 必要なもの
ESLint や Typescript など基本的なものを除くと必要なものは [@typescript-eslint/utils](https://www.npmjs.com/package/@typescript-eslint/utils) 一つだけです。
モノレポ前提で話を進めます。

# プラグインを作っていく

今回は、 `import { foo as bar } from 'baz'` の `as bar` のように、名前付きモジュールをリネームしてインポートさせないようなルールを含むプラグインを作ってみます。
仕様としては以下の感じです。

- OK `import { foo } from 'bar'`
- NG `import { foo as bar } from 'baz'`
*Do not rename imported identifier. (no-renamed-import)*
- NG `import { foo as foo } from 'bar'`
*Do not use unnecessary local specifier. (no-unnecessary-local-specifier)*

NG パターンでは OK パターンに修正可能にもしてみます。

## 1. 自作プラグイン用のワークスペースを作る

ここでは `local-rules` というディレクトリに `@project/eslint-plugin-local-rules` というワークスペースを作成します。

```package.json
{
  "workspaces": [
    "local-rules"
  ]
}
```

エントリポイントは `dist/index.js` とします。

```local-rules/package.json
{
  "name": "@project/eslint-plugin-local-rules",
  "main": "dist/index.js",
  "scripts": {
    "build": "rm -rf dist && tsc src/index.ts --target es2015 --module commonjs --outDir dist"
  }
}
```

`typescript-eslint/utils` をインストールします。

```sh
$ yarn workspace @project/eslint-plugin-local-rules add -D @typescript-eslint/utils
```

このあたりの設定は適宜書き換えてください。

## 2. ルールを書く

ESLint はコードを AST にして解釈するので、ルールもそれに従った形で書く必要があります。
AST がよくわからなくても大丈夫です。 [AST explorer](https://astexplorer.net/) というサイトでコードを貼り付けるとどういうものに変換されるのかを確認することができます。

例えば `import { foo as bar  } from 'baz'` というコードは次のように変換されます。

<details><summary>変換結果</summary>

```json
{
  "type": "Program",
  "start": 0,
  "end": 34,
  "body": [
    {
      "type": "ImportDeclaration",
      "start": 0,
      "end": 33,
      "specifiers": [
        {
          "type": "ImportSpecifier",
          "start": 9,
          "end": 19,
          "imported": {
            "type": "Identifier",
            "start": 9,
            "end": 12,
            "name": "foo"
          },
          "local": {
            "type": "Identifier",
            "start": 16,
            "end": 19,
            "name": "bar"
          }
        }
      ],
      "source": {
        "type": "Literal",
        "start": 28,
        "end": 33,
        "value": "baz",
        "raw": "'baz'"
      }
    }
  ],
  "sourceType": "module"
}
```
</details>

今回の例だと `ImportSpecifier` のあたりをごにょごにょすれば良さそうです。
型でいうと `TSESLint.RuleModule` を使って書いていくことになります。詳細は[ドキュメント](https://eslint.org/docs/developer-guide/working-with-rules#rule-basics)に！

```local-rules/src/rules/no-renamed-import.ts
import { TSESLint } from '@typescript-eslint/utils'

export const NoRenamedImportRule: TSESLint.RuleModule<
  'no-renamed-import' | 'no-unnecessary-local-specifier'
> = {
  meta: {
    type: 'suggestion',
    schema: [],
    messages: {
      'no-renamed-import': 'Do not rename imported identifier.',
      'no-unnecessary-local-specifier': 'Do not use unnecessary local specifier.',
    },
    hasSuggestions: true,
    fixable: 'code',
  },
  create: context => ({
    ImportSpecifier: node => {
      if (node.imported.name !== node.local.name)
        context.report({
          node,
          messageId: 'no-renamed-import',
          fix: fixer => fixer.replaceText(node, node.imported.name),
        })
      else if (node.imported.range.some((v, i) => v !== node.local.range[i]))
        context.report({
          node,
          messageId: 'no-unnecessary-local-specifier',
          fix: fixer => fixer.replaceText(node, node.imported.name),
        })
    },
  }),
}
```

キモは `context.report()` です。これにより ESLint にエラーを伝えることができます。

## 3. ルールをテストする

これも `TSESLint.RuleTester` のおかげで簡単に書くことができます。

```ts
import { TSESLint } from '@typescript-eslint/utils'
import { NoRenamedImportRule } from './no-renamed-import'

const tester = new TSESLint.RuleTester({
  parser: require.resolve('espree'),
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module',
  },
})

test('no-renamed-import のテスト', () => {
  tester.run('no-renamed-import', NoRenamedImportRule, {
    valid: [
      {
        code: `import { foo } from 'baz'`,
      },
    ],
    invalid: [
      {
        code: `import { foo as bar } from 'baz'`,
        errors: [
          {
            messageId: 'no-renamed-import',
          },
        ],
        output: `import { foo } from 'baz'`,
      },
      {
        code: `import { foo as foo } from 'baz'`,
        errors: [
          {
            messageId: 'no-unnecessary-local-specifier',
          },
        ],
        output: `import { foo } from 'baz'`,
      },
    ],
  })
})
```

`valid` には OK パターンを、 `invalid` には NG パターン（修正可能なものには `output` を添えて）を書いておけば OK です。
注意点としては複数のエラーが期待される NG パターンの場合、それら全ての `messageId` を `errors` に書く必要があることですかね。

## 4. ESLint に適用する

あとはビルドして ESLint の設定ファイルに書き込むだけです。
一般的なプラグイン同様に `eslint-plugin-` は省略することができます。

```sh
$ yarn workspace @project/eslint-plugin-local-rules build
```

```.eslintrc.json
{
  "plugins": ["@project/local-rules"],
  "rules": {
    "@project/local-rules/no-renamed-import": "error"
  }
}
```

![スクリーンショット 2022-05-18 15.51.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217275/44168338-98ea-99c1-1023-7060569a8092.png)
![スクリーンショット 2022-05-18 15.52.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217275/a8feefb4-0392-1035-1ad0-17a26241151a.png)

どちらのルールも正常に動いてますね！
画像は VSCode のものですが、読み込まれてない？ってときは ESLint Server を再起動したり Output タブから確認するといいかもです。

# 参考にさせていただいた記事

[TypeScript で eslint-plugin を作成する](https://zenn.dev/kyoncy/articles/aa24cf54441215)
[プロジェクト内で完結するESLintのローカルルールを作りたい](https://zenn.dev/nus3/articles/b2bc110efd0887442c11)