inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

reviewdog x Custom FormatterでRuboCopの自動修正を提案させるようにしました

reviewdog/action-rubocopというOSSプロジェクトをご存知でしょうか。Rubyの静的解析ツールであるRuboCopをGitHub Actionsで実行し、指摘箇所にコメントを付けてくれる便利なCustom Actionです。

本記事では筆者がこのOSSを改良して実装したSuggestion featureという便利機能とその実装についてご紹介します。RuboCopをお使いの方、より良いRuboCop生活をお求めの方、静的解析が好きな方向けの記事となります。

なお、この記事は2023-06-21に行われたGotanda.rb#53@ギフティで"RuboCop Custom Formatter for Reviewdog Diagnostic Format"と題して筆者が行った発表をもとにしています。

前提となる知識

初めにreviewdog/action-rubocopを知らない方に向けてかんたんに前提知識をインプットします。まずはreviewdog、そのあとにreviewdog/action-rubocopについて紹介します。

RuboCopについては十分知られているものとして説明は割愛させていただきます。加えて本記事ではプロジェクト・製品を指す場合はRuboCop、コマンドはrubocopと表記します。また、本記事においてlinterとは「コードと設定を入力としてプログラムの問題を静的に検出するツール」を指すものとします。

reviewdogとは?

reviewdogはGitHubなどのコードホスティングサービスにレビューコメントを投稿するためのツールです。

賢い犬。公式より引用

詳細な説明は公式のドキュメントに譲りますが、ポイントとしてはCLIとして実行可能なツールであるという点です。パイプなどで他のツールの実行結果を受け取って連携できます。

$ reviewdog -h
Usage:  reviewdog [flags]
    reviewdog accepts any compiler or linter results from stdin and filters
    them by diff for review. reviewdog also can posts the results as a comment to
    GitHub if you use reviewdog in CI service.
# 以下、省略

RuboCopのような特定のツールに依存しているわけではなく、任意のlinterと連携できるのが特徴です。実際に数多くのツールとのインテグレーションがGitHubのreviewdog organizationに存在します。

reviewdog · GitHub にプロジェクトがたくさん並んでいるようす

具体的な連携方法については後に深掘りしていきます。

reviewdog/action-rubocopとは?

次にreviewdog/action-rubocopについてです。こちらは冒頭で書いた通りRuboCopとreviewdogを統合するGitHub custom actionです。

github.com

pull requestをトリガーとするworkflowとして設定すれば、GitHub Actions上でRuboCopを実行して指摘内容をコメントしてくれます。実行したジョブのログをいちいち見に行かなくてもよいので便利というわけです。

GitHubのpull request画面にてdiffにコメントがつくようす*1。reviewerには賢い犬以外も設定できる

コメントとして指摘内容を残せるのも地味に嬉しく、指摘事項に関するdiscussionをpull request画面上で行うこともできます*2

導入はとても簡単で、以下のようにworkflowを設定するYAMLを追加するだけで完了です。GitHub Actionsそのものやcustom actionの詳しい設定方法については省きますので公式のREADMEを参照してください。

name: rucobop
on: [pull_request]
permissions:
  contents: read
  pull-requests: write
jobs:
  rubocop:
    name: rubocop
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v3
      - uses: ruby/setup-ruby@v1
      - name: rubocop
        uses: reviewdog/action-rubocop@v2
        with:
          github_token: ${{ secrets.github_token }}
          reporter: github-pr-review
          fail_on_error: true

Suggestion feature

さて、本記事で紹介したい目玉がreviewdog/action-rubocop v2で追加されたSuggestion featureです*3

RuboCopにはautocorrect機能があり、検出した違反に対する修正方法を提案してくれます。その提案がsuggested changeとしてコメントされるようになりました 🎉

"答え"が提案されるようす

細かい変更であればpull request画面から提案を受け入れるcommitをするだけでよいので便利になりました。RubyやRuboCopに不慣れなメンバーにとっても修正すべき内容が一目瞭然となり、好評をいただいています。

実現したアプローチ

ここからはSuggestion featureの実装について解説していきます。かんたんにいえばRuboCopの出力を整形してreviewdogに食わせているだけです。

Reviewdog Diagnostic Format

reviewdog CLIはRuboCop等のメジャーなlinterの出力をparseする機能を備えているのに加え、Reviewdog Diagnostic Format (以下、RDFormat)という独自の構造化された入力形式も用意しています。

RDFormatはProtocol Bufferで定義されており、JSONでの例はこんな感じです。

{
  "source": {
    "name": "rubocop",
    "url": "<https://rubocop.org/>"
  },
  "diagnostics": [
    {
      "message": "Missing frozen string literal comment.",
      "location": {
        "path": "test/rdjson_formatter/testdata/correctable_offenses.rb",
        "range": {
          "start": {
            "line": 1,
            "column": 1
          },
          "end": {
            "line": 1,
            "column": 2
          }
        }
      },
      "severity": "INFO",
      "code": {
        "value": "Style/FrozenStringLiteralComment"
      },
      "original_output": "test/rdjson_formatter/testdata/correctable_offenses.rb:1:1: C: Style/FrozenStringLiteralComment: Missing frozen string literal comment.",
      "suggestions": [
        {
          "range": {
            "start": {
              "line": 1,
              "column": 1
            },
            "end": {
              "line": 1,
              "column": 1
            }
          },
          "text": "# frozen_string_literal: true\\n"
        }
      ]
    },
  ]
}

エラーとなっている箇所 (location) や修正提案 (suggestions) が表現されていることが、詳細な仕様を読み込まなくてもなんとなく伝わるかと思います。どんなlinterでも出力をこの形式にformatすればreviewdogに対応できるというわけです。


(余談)linterの出力が構造化されていなかったり、統一的な形式がないことでツール間の統合が難しくなっているという現状があるようです。この課題についてもreviewdogのドキュメントで触れられているので興味があれば一読してみると面白いかと思います。

RuboCop Custom Formatter

一方、RuboCopにはformatterを選択する機構があります。用途に合わせてrubocop--format / -fオプションを変更することで出力をコントロールできます。

デフォルトのProgress FormatterはRuboCopを使ったことがある方なら必ず一度は見たことがあるかと思いますし、その他にもJUnit Style Formatter、Clang Style Formatter、Pacman Style Formatter等々が存在します。以下は公式ドキュメントに記載されている例です。

$ rubocop
Inspecting 26 files
..W.C....C..CWCW.C...WC.CC

Offenses:

lib/foo.rb:6:5: C: Style/Documentation: Missing top-level class documentation comment.
    class Foo
    ^^^^^

...

26 files inspected, 46 offenses detected
$ rubocop --format junit
<?xml version='1.0'?>
<testsuites>
  <testsuite name='rubocop' tests='2' failures='2'>
    <testcase classname='example' name='Style/FrozenStringLiteralComment'>
      <failure type='Style/FrozenStringLiteralComment' message='Style/FrozenStringLiteralComment: Missing frozen string literal comment.'>
        /tmp/src/example.rb:1:1
      </failure>
    </testcase>
    <testcase classname='example' name='Naming/MethodName'>
      <failure type='Naming/MethodName' message='Naming/MethodName: Use snake_case for method names.'>
        /tmp/src/example.rb:1:5
      </failure>
    </testcase>
    <testcase classname='example' name='Lint/DeprecatedClassMethods'>
      <failure type='Lint/DeprecatedClassMethods' message='Lint/DeprecatedClassMethods: `File.exists?` is deprecated in favor of `File.exist?`.'>
        /tmp/src/example.rb:2:8
      </failure>
    </testcase>
  </testsuite>
</testsuites>
$ rubocop --format pacman
Eating 31 files
src/foo.rb:1:1: C: Style/FrozenStringLiteralComment: Missing magic comment # frozen_string_literal: true.
src/bar.rb:14:15: C: Style/MutableConstant: Freeze mutable objects assigned to constants.
      GHOST = 'ᗣ'
              ^^^
....ᗣ...ᗣ...ᗧ••••••••••••••••••
31 examples, 2 failures

これらに加えてcustom formatterを自作することもできます。今回の目的ではRDFormatに沿うように構造化された出力をさせたいので、RuboCopのcustom formatterを自作するアプローチを取ってみました。

custom formatter実装について

custom formatter実装はさほど難しくありません。RuboCop::Formatter::BaseFormatterを継承したクラスを作り、フックメソッドを実装するのみです。公式ドキュメントを一読すればイメージが掴めるかと思います。

今回はRdjsonFormatterというクラス名で実装しました。使っているフックメソッドは3つのみ。それぞれ以下の役割です。

  1. 開始時のstartedHashを生成
  2. ファイル単位のインスペクト終了時のfile_finishedで、offensesdiagnosticsの形式に変換して詰めていく
  3. 終了時のfinishedで標準出力にJSONを出力

以下に実際のコードを抜粋します。

class RdjsonFormatter < RuboCop::Formatter::BaseFormatter
  def started(_target_files)
    @rdjson = {
      source: {
        name: 'rubocop',
        url: '<https://rubocop.org/>'
      },
      diagnostics: []
    }
    super
  end

  def file_finished(file, offenses)
    offenses.each do |offense|
      @rdjson[:diagnostics] << build_diagnostic(file, offense)
    end
  end

  def finished(_inspected_files)
    puts @rdjson.to_json
  end

ステップ2のoffensesdiagnosticsに変換するところは、RDFormatの仕様書を眺めつつRuboCop::Cop::Offenseから必要な情報を取り出して詰めていくだけです。

  # @param [String] file
  # @param [RuboCop::Cop::Offense] offense
  # @return [Hash]
  def build_diagnostic(file, offense)
    code, message = offense.message.split(':', 2).map(&:strip)

    diagnostic = {
      message: message,
      location: {
        path: convert_path(file),
        range: {
          start: {
            line: offense.location.begin.line,
            column: offense.location.begin.column + 1
          },
          end: {
            line: offense.location.end.line,
            column: offense.location.end.column + 1
          }
        }
      },
      severity: convert_severity(offense.severity),
      code: {
        value: code
      },
      original_output: offense.to_s
    }

    diagnostic[:suggestions] = build_suggestions(offense) if offense.correctable? && offense.corrector

    diagnostic
  end

  # @param [RuboCop::Cop::Offense] offense
  # @return [Array{Hash}]
  def build_suggestions(offense)
    range, text = offense.corrector.as_replacements[0]

    [
      {
        range: {
          start: {
            line: range.begin.line,
            column: range.begin.column + 1 # rubocop is 0-origin, reviewdog is 1-origin
          },
          end: {
            line: range.end.line,
            column: range.end.column + 1
          }
        },
        text: text
      }
    ]
  end

autocorrect可能であればsuggestionsに値を入れるようにしているあたりで、Suggestion featureを実装している感じが出ているのではないでしょうか。

最後にshell scriptを少々修正

あとはreviewdog/action-rubocopのshell scriptを少々修正して、実装したRdJsonFormatterを使うようにするだけです。

-rubocop \\
-  | reviewdog -f=rubocop
+rubocop \\
+  --require ./rdjson_formatter.rb \\
+  --format RdjsonFormatter \\
+  | reviewdog -f=rdjson

簡単のため実際のdiffから一部を省略していますが、骨子としてはこのような変更をするだけでcustom formatterによる出力とreviewdogとの統合は完了です。


(余談)今回、大した作業も必要なく実装できた背景には両者のツールが持つインタフェース上の工夫があります。

  1. reviewdogはRDFormatという入力インタフェースのおかげであらゆるlinterと統合可能になっている
  2. RuboCopはcustom formatterのおかげで出力をmachine-readableにできる

UNIX哲学と通ずるものが感じられて大変良いですね。今後なにかツールを自作するときにも意識したい視点です。

まとめ

というわけで、RuboCopのcustom formatterを書くことでreviewdog/action-rubocopにSuggestion featureを実装することができました。v2以降を利用されていれば自動的に恩恵を受けられるようになっていますのでぜひ試してみてください。


(余談*4)「reviewdog/action-brakemanもあるが、型検査ツール (sorbet, steep) のcustom actionは存在しないぽいのでコントリビューションチャンスかも」とLTで呟いたら、同イベントに参加された@tk0miyaさんが早速action-steepを実装していました。手が速くてすごい!見習いたいものです。


(2023-06-29 22:25 追記)本記事をruby-jp Slack workspaceに貼ったところ、Annotationと比較してコメントを利用するデメリットについて@r7kamuraさんより以下の指摘をもらいました。

  1. 変更頻度の高いリポジトリではAPIのRate Limitに引っ掛かる可能性が高い
  2. Botのコメントにより通知が飛んでくるのが鬱陶しい

1については@takesatoさんからより詳しい背景を教えてもらいました。

  • Annotationはworkflow commandで付けられる
  • ::error file={name},line={line},endLine={endLine},title={title}::{message} と標準出力に出すだけであとはGitHub Actionsが勝手にAnnotationを付けてくれる
  • API自体使わないのでRate Limitに引っかかることがない

知らなかった...! 今のチームではRate Limitに引っかかったことがないのですが壁にぶつかった際にはreviewdog設定の切り替えを検討してみたく思います。また、2についてはその通りでそういうものだと思って弊チームで利用していました。

加えて@ybiquitousさんからは「RuboCop内蔵のGitHub Actionsフォーマッタもworkflow commandを利用している」と教えていただきました*5。ちょうどCustom Formatterに関連する箇所ですね、面白い。

皆さんありがとうございます!


本記事は@ohbaryeが執筆しました。

株式会社スマートバンクでは日常で使うツールやOSSを改良しながら開発したいエンジニアを募集しています!

smartbank.co.jp

smartbank.co.jp

*1:rubocopコマンドは全体に対して実行され、結果をreviewdog側でfilterすることになります

*2:RuboCopのルールに関する議論の是非は割愛します

*3:目新しいことのように書いていますが2021年のことです

*4:余談が多いとよく言われます

*5:それがわかる箇所 https://github.com/rubocop/rubocop/blob/aaf1c3f6285e5e9e84d43c37944a7faaacb3bf60/spec/rubocop/formatter/git_hub_actions_formatter_spec.rb#L33

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.