reviewdogの謎のエラーを乗り越えてESLintのCIに導入した話

皆さんこんにちは。この記事は[株式会社カオナビ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kaonavi)の1日目の記事です。このアドベントカレンダーでは、筆者が所属する株式会社カオナビの有志が記事を投稿していく予定です。

筆者はフロントエンドの開発環境改善を含むさまざまな改善を行っていますが、今回は[ESLint](https://eslint.org/)のCIに[reviewdog](https://github.com/reviewdog/reviewdog)を導入した話をご紹介します。

また、途中でreviewdogが発する`exit status: 128` という謎のエラーに苦しめられたので、その原因と解決策もお伝えします。

## ESLintのルールに対する継続的な改善とreviewdog

コーディングルールの運用やコード品質の向上に際してESLintのようなリンターは強力なツールです。レビュー等で人間がチェックする手間を省いてくれます。

コーディングルールの改善に合わせて、継続的にESLintのルール設定もアップデートしていくことになりますが、その際に問題となるのが**既存コードに対する対応**です。新たなlintルールを追加する場合、ルールを満たさない既存コードをどうするのかという問題が発生します。何も対応しなければCIが落ちてしまうため、lintルールをアップデートできません。

対応の方針としては、おおよそ3つ考えられます。

1. lintエラーになる既存コードを全部修正する
2. [eslint-interactive](https://github.com/mizdra/eslint-interactive)のようなツールを利用して全ての既存lintエラーに対して`eslint-disable-next-line`コメントを一括で追加する
3. 既存コードのエラーは許容し、マージリクエスト[^note_mr]で変更が加わったところだけCIで落ちるようにする

[^note_mr]: 弊社ではGitLabを使っているので、PR（プルリクエスト）ではなくMR（マージリクエスト）です。

筆者は最初2で進めていましたが（eslint-interactiveにバグ報告した際はすみやかに対応いただきました。ありがとうございました）、あまりに`eslint-disable-next-line`コメントが増えてしまってつらかったので方針を3に変えました。3の方針についてもう少し説明します。

### ESLintのルールを2つに区分する

この方法では、ESLintのルール設定を「**メインのルールセット**」と「**移行期間のルールセット**」に区分し、前者をerror、後者をwarningとします。

そして、errorはコード全体のどこかで発生していたらCIが落ちるようにする一方で、warnはMRのdiff内で発生していたらエラー、それ以外のコードではOKという扱いにします。

これにより、最初はコードベース内にwarningが残った状態になりますが、基本的には新規にwarningが増えることは無くなります。また、既存コードを修正する場合にwarning箇所が修正対象になる場合は、一緒に直してもらうことにします。

筆者の考えでは、問題のあるコードが新規に増えないことが一番重要だと考えており、コード内から違反箇所が1つ残らず無くなることはそこまで重要視していません（万一コピペされても新規コードであればCIが通りませんしね）。そのため、この方法に満足しています。

既存コードでもエディタ上ではwarningを表す黄色い表示がされるので、ちょっとだけ既存コードの修正を促す効果もあります。

そして、この仕組みを実践するために用いた道具がreviewdogです。

### reviewdogをESLintと組み合わせる

reviewdogはいくつかの機能を持っていますが、今回使いたかったのは**diff内に含まれるlintエラーのみを抽出する**機能です。他にも、GitHubやGitLabのAPIを叩いていい感じにlintエラーを報告する機能などもありますが、今回はそれは使いません。既存の運用ではCIが落ちたときはCIのログを見て原因を確認するようになっていたので、それは維持します。

reviewdogは様々なリンターが出力するエラーフォーマットをパースする能力を持っており、ESLintの出力（ESLintのデフォルトの[stylish](https://eslint.org/docs/latest/use/formatters/#stylish)フォーマッター）にも対応しています。

そのため、ESLintの出力をそのままreviewdogに食わせればMRのdiffでフィルタリングしてくれます。

まず、ESLintの出力をテキストファイルに保存します。

```sh
yarn lint > .output-eslint.txt
```

次に、このように`.reviewdog.yml`を設定します。`cat .output-eslint.txt`の結果をeslintの結果として読み込むという意味です。

```yaml
runner:
  eslint:
    # 事前に yarn lint でESLintを実行してある想定
    cmd: 'cat .output-eslint.txt'
    format: 'eslint'
    level: 'warning'
```

そして、reviewdogの実行はこのようにします。

```sh
reviewdog -diff="git diff $CI_MERGE_REQUEST_DIFF_BASE_SHA" -runners=eslint -filter-mode=diff_context -fail-level=warning
```

どのコミットとのdiffを取ればいいかはGitLabが環境変数で与えてくれるので、それをもとに`-diff`でdiffを出力するコマンドを与えています。diff内にワーニングがあった場合、次のように出力されます。

```
time=2024-11-08T10:32:11.672+09:00 level=INFO msg="reviewdog: [start] runner=eslint"
time=2024-11-08T10:32:11.709+09:00 level=INFO msg="reviewdog: [finish] runner=eslint"
reviewdog: found at least one issue with severity greater than or equal to the given level: warning
path/to/xxx.tsx:73:7: [eslint] Do not use any type assertions  @typescript-eslint/consistent-type-assertions
```

### エラーとワーニングの振り分け

実際のCIでは、ESLintを実行してコード全体のerrorを報告するジョブと、reviewdogを実行してdiff内のwarningを報告するジョブが分かれています。前者から後者へ`.output-eslint.txt`をアーティファクトとして転送します。

そうなると、全体のerrorを報告するジョブでは、warningの報告は省略したくなります。前述のとおりCIが落ちた場合は開発者がCIのログを見に行ってlintエラーの内容を確認する運用になっていますので、errorとwarningが入り混じったログの中から本当に修正すべきerrorの内容を探すのは、特にwarningが増えるこの運用では骨が折れます。

そこで、全体のerrorを報告するジョブでは、warningを省略してerrorのみを抽出してCIのログに吐き出したいです。これはいくつかの方法が考えられますが、今回は簡単にすませる方針で、シェルスクリプトで`.output-eslint.txt`の結果を加工することにしました。筆者はシェルスクリプトが得意ではありませんが、弊社では業務にも活用できる[AIチャットボット「ナービィ」](https://zenn.dev/matsukaz/articles/04c3ee46420fbc#ai%E3%83%81%E3%83%A3%E3%83%83%E3%83%88%E3%83%9C%E3%83%83%E3%83%88%E3%80%8C%E3%83%8A%E3%83%BC%E3%83%93%E3%82%A3%E3%80%8D%E5%B0%8E%E5%85%A5)がいますので、「`awk`か`sed`を使ってこういう加工をするスクリプトを書いて」とお願いすれば何往復かの会話で意図通りのものができました。

これが実際のスクリプトです。細かい説明は省きますが、ESLintの出力からwarningを省いて出力するようになっています。

```sh
if ! yarn lint >.output-eslint.txt; then
  cat .output-eslint.txt | awk 'BEGIN {file=""} 
    /^\// {file=$0}
    /^  / && /error/ && !/warning/ {
      if (file != last_file) {
          if (NR > 1) print ""
          print file
          last_file = file
      }
      print $0
    }'
  exit 1
else
  echo "ESLintでエラーはありませんでした。"
  echo "warning一覧は.output-eslint.txtを参照してください。"
fi
```

以上がESLintとreviewdogで差分に対するlintを行う方法の全容です。同じことをやりたい場合は参考にしてください。

ポイントは、ESLintの実行は1回だけで、errorの報告とwarningの報告のジョブで`.output-eslint.txt`を共有していることです。これは、ESLintの実行が遅い（[typed linting](https://typescript-eslint.io/blog/typed-linting)を活用しているため）ので複数回実行したくないからです。

## reviewdogの謎のエラー `exit status: 128`

この仕組みを導入した当初は、うまく動きませんでした。正確には、動くときもあったのですが、**変な落ち方**をする場合がありました。実際には問題ないのに、reviewdogのジョブが失敗してしまうのです。

変な落ち方をする場合、reviewdogは次のようなログを出力していました。

```
time=2024-10-22T16:08:59.287+09:00 level=INFO msg="reviewdog: [start] runner=eslint"
time=2024-10-22T16:08:59.319+09:00 level=INFO msg="reviewdog: [finish] runner=eslint"
reviewdog: exit status 128
```

`reviewdog: exit status 128` というログしか出ていません。この原因究明に少し苦労したので、この記事の後半ではこのエラーの原因と対処法を説明します。


### エラー発生源の考察

このエラー文からは「終了コードが128だ」ということは分かりますが、主語がなくてよく分かりません。

ちなみに、reviewdogに`-log-level=debug`を渡して実行したら何か情報が増えないかと思って最初に試してみましたが、何も増えませんでした。

ただ、推察できることとして、reviewdogが内部的に呼び出した何らかのプロセスが終了コード128で終わったのだろうということは分かります。reviewdog自体が終了コード128で終了したとしてもこのログの出方にはならないからです。

また、「reviewdog exit status 128」で検索するとこのようなディスカッションも出てきます。

https://github.com/reviewdog/reviewdog/discussions/884

ただ、エラーメッセージが`reviewdog: failed to get merge-base commit: exit status 128`となっておりこの記事のものとは違います。

他にも以下のような記事が出てきますが、全てエラーメッセージが違うので、この記事とは微妙に状況が違うようです。

- [PullRequest needs 'git' command: failed to run 'git rev-parse --show-prefix': exit status 128](https://github.com/reviewdog/reviewdog/issues/920)
- [actions/checkoutのアップデートで、gitのsafe.directoryの件が対策されていました](https://ikesyo.hatenablog.com/entry/2022/04/22/114840)
- [reviewdogのmisspellとalexでエラーが発生したのを解決した件](https://katutoki.hatenablog.com/entry/2022/04/13/190232)

「gitのsafe.directoryの件」は結局根本原因がこれではないかと疑って調べもしましたが、違うようでした。

ただ、上記の記事等から察せられることとして、「`git`コマンドを実行したがエラーが発生して終了コード128で終了した」点が共通しているように思われます。`git`は終了コード128で終了する癖があるようです。

ということで、今回もreviewdogが`git`コマンドを呼び出して失敗したのではないかと推測できます。ここでもう一度今回reviewdogを走らせているスクリプトを見てみましょう。

:eyes:

```sh
reviewdog -diff="git diff $CI_MERGE_REQUEST_DIFF_BASE_SHA" -runners=eslint -filter-mode=diff_context -fail-level=warning
```

:eye: :eye:

```sh
-diff="git diff $CI_MERGE_REQUEST_DIFF_BASE_SHA"
```

ここが怪しいですね。これは、reviewdogに対して、diffを取得するために実行すべきコマンドを与えるところです。reviewdogがこれを実行して失敗したという仮説が考えられます。

なぜ`git diff`コマンドが終了したのかですが、`$CI_MERGE_REQUEST_DIFF_BASE_SHA`が指すコミットが存在しないことです（この環境変数はGitLabが提供してくれるもので、コミットSHAらしき文字列はちゃんと入っていました）。

ということで、試しに存在しないコミットに対して`git diff`してみましょう。

```sh
$ git diff aaaaaaaaaaaaaa
fatal: ambiguous argument 'aaaaaaaaaaaaaa': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
$ echo $?
128
```

確かに終了コード128になります。ということで、存在しないコミットと`git diff`している可能性が高いですね。

### 結論と対応

ではなぜ`$CI_MERGE_REQUEST_DIFF_BASE_SHA`に存在しないコミットが入っているのでしょうか。筆者は少し頭を悩ませなければなりませんでしたが、結論は単純でした。ジョブの定義を改めて見直してみるとこう書かれている箇所がありました。

```yaml
  variables:
    GIT_DEPTH: 10
```

もうお分かりですね。CI環境では[シャロークローン](https://docs.gitlab.com/ee/ci/runners/configure_runners.html#shallow-cloning)が行われていたため、`$CI_MERGE_REQUEST_DIFF_BASE_SHA`が指すコミットがフェッチされていない可能性があったのです[^note_shallow_clone_discussion]。

[^note_shallow_clone_discussion]: 実は[最初に紹介したディスカッション](https://github.com/reviewdog/reviewdog/discussions/884)をよく見ると「シャロークローンを消したら直った」というコメントがあるのですが、エラーメッセージが違うことに気を取られて気づいていませんでした。 :innocent: 

これであれば、動くときもあるし動かないときもあるという挙動が説明できます。`$CI_MERGE_REQUEST_DIFF_BASE_SHA`がシャロークローンの範囲内であればうまく動いていたのです。また、[前のジョブの`.git`を再利用する可能性もある](https://docs.gitlab.com/ee/ci/runners/configure_runners.html#git-strategy)ためそれで動く場合もあるでしょう。

実際にはこの時点で原因が確定したわけではなく、以上が原因ではないかと当たりをつけてCIジョブを修正してみました。そうすると問題が起きなくなったのでこれが問題だと判断できたわけです。

修正は簡単で、コミットが無かったら`git fetch`するだけです。

```sh
if ! git cat-file -e $CI_MERGE_REQUEST_DIFF_BASE_SHA^{commit} 2>/dev/null; then
  echo git fetch origin $CI_MERGE_REQUEST_DIFF_BASE_SHA
  git fetch origin $CI_MERGE_REQUEST_DIFF_BASE_SHA
fi
```

こうするとシャロークローンの利点が消えるので完璧ではないのですが、動くようにはなりました。

そもそも今時はシャロークローンで頑張るよりも[パーシャルクローン](https://github.blog/jp/2021-01-13-get-up-to-speed-with-partial-clone-and-shallow-clone/)（ブロブレスツリーなど）にする方が筋が良さそうなので、どうせ直すのであればこの方針で直したいところです。

ちなみに、今回`reviewdog: exit status 128`というエラーメッセージに情報が少なくて調査に手間取ったので、エラーメッセージの改善を要望するissueも建てておきました。

https://github.com/reviewdog/reviewdog/issues/1969

## まとめ

この記事では、ESLintのルールを途中から厳しくするためにreviewdogを活用する方法を紹介しました。

また、シャロークローンが原因でreviewdogが`reviewdog: exit status 128`という変なエラーで落ちてしまう問題も紹介しました。