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/action-rubocopとは?
次にreviewdog/action-rubocopについてです。こちらは冒頭で書いた通りRuboCopとreviewdogを統合するGitHub custom actionです。
pull requestをトリガーとするworkflowとして設定すれば、GitHub Actions上でRuboCopを実行して指摘内容をコメントしてくれます。実行したジョブのログをいちいち見に行かなくてもよいので便利というわけです。
コメントとして指摘内容を残せるのも地味に嬉しく、指摘事項に関する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つのみ。それぞれ以下の役割です。
- 開始時の
started
でHash
を生成 - ファイル単位のインスペクト終了時の
file_finished
で、offenses
をdiagnostics
の形式に変換して詰めていく - 終了時の
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のoffenses
をdiagnostics
に変換するところは、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との統合は完了です。
(余談)今回、大した作業も必要なく実装できた背景には両者のツールが持つインタフェース上の工夫があります。
- reviewdogはRDFormatという入力インタフェースのおかげであらゆるlinterと統合可能になっている
- 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さんより以下の指摘をもらいました。
- 変更頻度の高いリポジトリではAPIのRate Limitに引っ掛かる可能性が高い
- 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が執筆しました。
SmartBankでは日常で使うツールやOSSを改良しながら開発したいエンジニアを募集しています!