inSmartBank

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

RuboCopのルールセットを大きく変えるときに気をつけること(Standard導入編)

こんにちは osyoyu です。RuboCop は好きですか?

B/43の開発の現場でもRuboCopは活用しており、ご多分に漏れず .rubocop.yml がすくすくと育てられていました。コードの一貫性はよく保たれていた…… のですが、 # rubocop:disable がそれなりの頻度で出現したことからも、適用されているルールが最良のものかは少しばかり疑問があった、という具合です。

そこで、今回は “unconfigurable” をウリにしているRuboCopルールセットであるStandard (standardrb/standard) を採用し、全コードをこのルールに適合するよう修正しました。Standardは少々 “強い” 名前ですが、コミュニティの中でコレ一強・一択というものでもなく、あくまでひとつのオプションという立ち位置です。

しかしそれなりに大きなコードベースではこの修正も当然大きく(+25,252 -20,230 行)、一筋縄ではゆかないものでしたので、この記事では移行にあたっての工夫を紹介します。RuboCopを導入していないところから、rubocop-rails-omakase等を導入する場合も参考になるかもしれません。

コードスタイルについて、チームの合意を得る

RuboCopのルールを変えるならば、なにはともあれチームの合意を取るべきでしょう。ルールの変更について Issueを立てて気持ちを述べてみたところ、”良い” と感じるスタイルは想像以上に異なることが分かりました。

新年にかこつけてぶち上げてみた様子。このとき入社2ヶ月強だが、だいぶ思い切ってissueを立てている

議論のなかで、既存のルールセット(Standard, cookpad/styleguide, thoughtbot/guides)に乗っかる案を示してみたところ、思いのほか良い反応を得られました。同僚の三谷さんの「綺麗な形が人それぞれなので、好みは持ち出さずルールと言われたものに従う方が感情の揺れがなくなって良い」という意見には納得ですね。みなbikeshedは避けたい。

ということで、あとはどのルールセットに乗っかるか、ですが、最終的には先述のStandardに落ち着きました。

理由をご説明する前に、イメージしやすいよう、差分をひとつ紹介します。B/43を支えるcore-apiから少しコードをもってきて、適当に変数名や値を変えたものです。登場する単語からなんとなくクレジットカードシステムの風を感じるかもしれませんが、本物とはさほど関係ないので読み解かなくても大丈夫です。気になる方はぜひご入社ください。

 class BalanceTransaction
   has_one :authorization_request_history,
-          through: :authorization_request_history_balance_transaction_mapping
+    through: :authorization_request_history_balance_transaction_mapping

-  validates :amount,      numericality: { only_integer: true }
-  validates :currency,    inclusion: { in: BalanceTransaction.currencies.keys }
-  validates :source_type, inclusion: { in: BalanceTransaction.source_types.keys }
+  validates :amount, numericality: { only_integer: true }
+  validates :currency, inclusion: { in: BalanceTransaction.currencies.keys }
+  validates :source_type, inclusion: { in: [
+    "authorization_hold",
+    "authorization_release",
+    "authorization_revert",
+    "deposit",
+    "refund",
+    "withdrawal"
+  ] }

    def capture!
-     klass = case clearing.transaction_code.to_sym
-             when :presentment
-               Presentment
-             when :presentment_reversal, :return
-               Reverse
-             when :return_reversal
-               ReverseCancel
-             else
-               raise ArgumentError
-             end
+     klass =
+       case clearing.transaction_code.to_sym
+       when :presentment
+         Presentment
+       when :presentment_reversal, :return
+         Reverse
+       when :return_reversal
+         ReverseCancel
+       else
+        raise ArgumentError
+       end
    end
 end

このdiffを見たとき、それぞれ思うところがあったのではないでしょうか。 case (や if) の桁数が減って読みやすくなった、いや validates の第2引数が不揃いになって読みづらくなった、そもそもRuboCopなくてもいいでしょ……。などなど。かくいう私も末尾カンマ愛好家なので、つけさせてほしいなと思っています(Standardでは許容されません)。

Standardの “良さ” はまさにここにあります。Standardではカスタマイズ不能なルールを天下りに与えられます。個人の感情に左右されず、bikeshedな議論を許さない、これがStandardの大きな特長と言えるでしょう。その上で…… 私の主観ではありますが、コミュニティで主流なコードスタイルから大きく離れていないように感じるのも +1 です。

また、autocorrect可能なルールのみが定義されているのもすぐれたポイントです。この方針がエディタのformat on save機能と非常に相性がよく、ruby-lspユーザーである私はファイルを保存するだけでRuboCopに怒られることがまったくなくなりました。

Standard導入 as a RuboCopのいちルール

さて、ゴールが決まったところで、実際に移行作業をはじめてゆきます。

Standardはカスタマイズ不能なのが良さと語ったばかりですが、理想ばかりでは食えないのが人間というもの。

通常、StandardはRuboCopを完全に置き換える形で導入します。コマンドも rubocop の代わりに standard が提供され、設定ファイルも .standard.yml になります。RuboCopを完全に隠蔽することで、カスタマイズの禁止を実現しているのです。

が、今回はスムーズな移行が優先事項でした。そのため、カスタマイズを可能にするべく standard コマンドを使うのではなく、あえてRuboCopのいちルールとしてStandardのルールセットを読み込む方式を採りました。Standard的には推奨されないやり方ですので、プロジェクトの立ち上げ当初であれば素直に standard のツールセットを使うのが良いでしょう。

また、この方式を採用することで、社内の開発者のRuboCopを使ったワークフローを壊さずに済みました。「困難は分割せよ」ではないですが、スタイルの変更とツールセットの変更を分けるのは良い戦略でした。

- gem 'rubocop'
+ gem 'standard' # 依存で rubocop を連れてくる
require:
  - standard
  - standard-custom
  # - standard-performance # 移行当初は使わない

inherit_gem:
  standard: config/base.yml
  standard-custom: config/base.yml
  # standard-performance: config/base.yml # 移行当初は使わない

この状態で rubocop を実行すると、大量のoffenseが検出されるはずです。が、autocorrectするのはグッとこらえて、まずは地盤を整えていきます。

既存のrubocop:disableコメントの除去

それなりに開発を続けてきたコードベースであれば、rubocop:disable コメントもそれなりにあるものと思います。まずはこれらを整理するのが大事です。Standardのルールセットでそもそも有効になっていないcopのdisableコメントは単なるノイズですし、対になる rubocop:enable コメントで無効であるべきcopが意図せず有効化されてしまい、offenseを引き起こすこともあります。

B/43のコードベースの rubocop:disable コメントを観察したところ、Metrics 配下のルールが無効化されているケースが特に多いことが分かりました。 Metrics/MethodLength などのデフォルトの閾値が少し厳しく、偽陽性と判断されて無効化されるケースが多かったようです。

これはsedでザクっと撤去してしまいました。

# 範囲にかかる disable/enable の除去
rg --files-with-matches '^\s*# rubocop:(disable|enable) Metrics' | xargs -- sed -i '' -E '/^ *# rubocop:(disable|enable) Metrics/d'
# 1行にかかる disable の除去
rg --files-with-matches '\s+# rubocop:disable Metrics' | xargs -- sed -i '' -E 's/( +)# rubocop:disable Metrics\/.*$//'

コードスタイルの変更にあたってはコードベースの観察が何よりも大事です。

いざ --autocorrect & スクリプト化

地盤が整ったところで、いよいよ rubocop --autocorrect の時間です。

--autocorrect も一発でいけるとは限りません。元のsyntaxによっては無限ループに陥ってしまい完了しないこともあるので、コードベース全体を一発でautocorrectせず、ディレクトリ単位などで作業を進めつつgit commitするのがよいでしょう。

実際に作業していると、autocorrect一発では理想の状態にはたどり着かず、本当にいろいろな理由でやり直しを迫られます。その最大の理由が、コードスタイルの変更作業中にも他の変更が次々と main ブランチにmergeされていくことです。autocorrectをかけられる段階まで来ていたら、実行すべきコマンドはある程度見えているはずなので、git commitまで含めてスクリプトにしてしまうのがおすすめです。コードレビューも変更手順(スクリプト)に対して行うほうが、巨大になる実差分よりもラク・有意義というものです。

autocorrectを超えたスタイル修正

RuboCop (Standard) のautocorrectは強力ですが、ときどき微妙な修正結果に出会うことがあります。

# Before
method_call(:long_long_long_long_argument_1,
            :long_long_long_long_argument_2)

# After 1: rubocop -a
method_call(:long_long_long_long_argument_1,
  :long_long_long_long_argument_2)

# After 2: 手動修正後
method_call(
  :long_long_long_long_argument_1,
  :long_long_long_long_argument_2
)

After 1もAfter 2もRuboCopに許容されるスタイルですが、After 2のほうが視線移動が少なく、読みやすいと判断しました(bikeshedポイントではありますね)。量が非常に多かったため、awkを用いて修正しました。

rg --files-with-matches '^[^#]+\([^)#]+,$' | xargs -- sed -i '' -E 's/\(([^)#]+)$/(\n\1/'

ところでawkを用いた修正は当然safeではなく、意図しない破壊が起きていないかを確認すべきです。Rubyでは文を ; で明示的に区切らない都合、改行の挿入は特にパース結果の変化を引き起こす可能性も高いです。

今回はPrismを用いてawkの実行前後でASTを比較し、問題のない変換だけをしていることを確認する方法がとれます。Prism 0.27.0以上ではASTのNodeに #=== メソッドが定義されており、これを使うことでlocationの変化を無視して、あくまでsyntacticに等価なプログラムであるかを検証できます。

require 'prism'

# awk 適用前のファイルをパース
before = Prism.parse_file("before/app/controllers/application_controller.rb")
# awk 適用後のファイルをパース
after = Prism.parse_file("after/app/controllers/application_controller.rb")

# パース結果が不一致であれば意図せぬ破壊をしている
raise unless before === after

rubocop --autocorrectをgit blameから除外する

GitHubのBlameビューを開いたとき、ファイル全体を覆う rubocop -a コミットに遭遇したことのない歴史探検家はいないものと思います。

ここで役に立つのが .git-blame-ignore-revs ファイルです。このファイルに記述されたコミットハッシュはBlame時に無視され、関心が低いであろう rubocop -a コミットを非表示にできます。

# Migrate to Standardrb
2b0a7dd627c85ece795b65efb188b97438c144d1

コミットの整理 & デプロイ

この記事では一直線に書いていますが、実際にはいくらかの試行錯誤がありました。先述したとおり、そうしている間にも main ブランチ側は進んでゆき、それらにもautocorrectをかけねばならなくなります。

そこで、スタイルの変更の適用日を宣言し、最終的な作業が完了するまで Pull Request のマージを含む main ブランチへのコミットをしないようお願いしました。

あとは事前に作成したスクリプトをmergeの直前に main から切ったばかりのブランチに対して実行するだけです。わくわく。

この過程で生まれた差分はすべてコード的に等価なはずですが、何が起こるか分からないのが +25,252 -20,230 の規模の変更。他の差分が混ざらないよう注意し、デプロイ前後でメトリクスの変更がないことを見届けてggでした。


この記事ではある程度規模の大きいコードベースに対して、RuboCopのルールを大幅に変更したときの手順をご紹介しました。面倒な部分もありましたが、とても気持ちよくコーディングに集中できるルールに落ち着いたと感じております。

スマートバンクでは .rubocop.yml が大好きなエンジニアを募集しています。

smartbank.co.jp smartbank.co.jp

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.