👮

"闇ルール"を減らすRuboCop CustomCopの作り方

に公開

はじめに

こんにちは。カウンターワークスにて主にワークフローや非機能要件の改善を担当させていただいておりますエンジニアの香下と申します。

さて、AIレビューが全盛になりつつある今日このごろですが、みなさんは RuboCop をどのように設定していますか?

RuboCop-githubrubocop-railsrubocop-performance など、
「入れればとりあえず最適解に近づく」便利なGemはたくさんあります。

しかし、そうした “巨人の肩” に乗っても解決できない、プロジェクト独自のルール が存在することも多々ありますよね。

たとえば…

  • このクラスのこのクラスメソッドは禁止にしたい
  • 代わりに別メソッドを使わせたい
  • 新人さんに毎回レビューで指摘するのがつらい

こういうときに使うのが CustomCop です。

この記事では、実際のコードを使って次の内容を解説します。

  • 特定クラスのスタティックメソッドを禁止する CustomCop を作る
  • 自動修正(Autocorrect)で別メソッドに書き換える
  • RSpec で CustomCop をテストする

今回実装するルールは以下の通りです。

User.build(...) → ❌ 禁止
User.new(...)   → ⭕ 推奨(自動修正)

完成イメージ

>> rubocop

app/models/user.rb:3:5: C: Custom/RestrictedStaticMethods:
Use `User.new` instead of `User.build`.
    User.build(name: 'Alice')
    ^^^^^^^^^^

自動修正(-A)を実行すると…

- User.build(name: 'Alice')
+ User.new(name: 'Alice')

に変わります。

前提

  • Ruby / Bundler がインストールされている
  • RuboCop と RSpec を利用する前提で進めます

Step 0. RuboCop と RSpec を導入する

Gemfile に以下を追加します。

Gemfile
group :development, :test do
  gem 'rubocop', require: false
  gem 'rspec'
end

既存プロジェクトであればすでに入っていることも多いですが、まだなら追加してから bundle install を実行します。

bundle install

Step 1. CustomCop 用のファイルを作る

ディレクトリ構成は一例なので、プロジェクトに合わせて変更してください。

mkdir -p rubocop/custom_cops
touch rubocop/custom_cops/restricted_static_methods.rb

Step 2. Cop を実装する

まずは基本的な CustomCop を実装します。

restricted_static_methods.rb
module RuboCop
  module CustomCops
    # 特定のクラスのスタティックメソッドを禁止するCop
    class RestrictedStaticMethods < RuboCop::Cop::Base
      extend ::RuboCop::Cop::AutoCorrector

      MSG = 'Use `%<replacement>s` instead of `%<original>s`.'

      TARGET_CLASS       = 'User'
      FORBIDDEN_METHOD   = :build
      REPLACEMENT_METHOD = :new

      def on_send(node)
        receiver = node.receiver
        return unless receiver&.const_type?
        return unless receiver.const_name == TARGET_CLASS
        return unless node.method_name == FORBIDDEN_METHOD

        add_offense(
          node.loc.selector,
          message: format(
            MSG,
            replacement: "#{TARGET_CLASS}.#{REPLACEMENT_METHOD}",
            original: "#{TARGET_CLASS}.#{FORBIDDEN_METHOD}"
          )
        ) do |corrector|
          corrector.replace(node.loc.selector, REPLACEMENT_METHOD.to_s)
        end
      end
    end
  end
end

簡単に解説します。

class RestrictedStaticMethods < RuboCop::Cop::Base

Rubocop の Cop の基底クラス(RuboCop::Cop::Base)を継承することで、Rubocop 本体に「チェック対象の Cop」として登録されます。

extend RuboCop::Cop::AutoCorrector

この 1 行があることで…

  • add_offense にブロックを書いて自動修正処理ができる
  • rubocop -A(または --auto-correct)実行時にコードを書き換えられる

というように、Cop に「自動修正機能」を追加できます。

MSG = 'Use `%<replacement>s` instead of `%<original>s`.'

誤りを検出したときに表示するメッセージのテンプレートです。
format メソッドを用いることで柔軟にメッセージを組み立てられるようにしています。

TARGET_CLASS       = 'User'
FORBIDDEN_METHOD   = :build
REPLACEMENT_METHOD = :new

この Cop が禁止する対象(ここでは User.build)と、推奨する置き換え先(User.new)を 固定値として定義しています。

def on_send(node)

Rubocop の AST (構文木) 解析の入り口。
Ruby のメソッド呼び出しが出現するたびに、このメソッドが呼ばれます。

たとえば User.build(name: "Alice") は AST 上では「send ノード」なので、このメソッドが呼ばれます。

receiver = node.receiver

メソッド呼び出しの 左側(レシーバ) を取得します。


User.build(...) → receiver = User(定数ノード)
foo.bar → receiver = foo

return unless receiver&.const_type?

レシーバがクラス定数以外の場合は Cop を発火させたくないため early returnします。

例えば:

  • some_object.buildUser ではないのでスキップ
  • build(name: ...) → レシーバが存在しないのでスキップ
return unless receiver.const_name == TARGET_CLASS

レシーバ名が指定したクラス(ここでは "User")と一致するかを判定します。

return unless node.method_name == FORBIDDEN_METHOD

呼ばれたメソッドが :build のときだけ反応させます。
つまり User.build のときだけ処理する、ということです。

add_offense(
  node.loc.selector,
  message: format(
    MSG,
    replacement: "#{TARGET_CLASS}.#{REPLACEMENT_METHOD}",
    original: "#{TARGET_CLASS}.#{FORBIDDEN_METHOD}")
)

ここで RuboCop に 「ここが違反です」 と伝えます。

  • node.loc.selector → メソッド名の部分(build)だけを選択
  • message: に警告文を渡す

RuboCop が出力するメッセージはこの部分で決まります。

do |corrector|
  corrector.replace(node.loc.selector, REPLACEMENT_METHOD.to_s)
end

このブロック内が 自動修正の処理 です。

ここで何をしているかというと:

  • buildnew に書き換える

だけです。

つまり、

User.build(name: "Alice")

が、自動修正すると

User.new(name: "Alice")

に変わります。

RuboCop がファイルを直接編集してくれる仕組みです。

Step 3. .rubocop.yml に登録する

.rubocop.yml
require:
  - ./rubocop/custom_cops/restricted_static_methods.rb

Custom/RestrictedStaticMethods:
  Enabled: true

Step 4. 動作確認する

では期待通り動くか確認してみましょう。

User.rb
class User
  def self.build_alice
    User.build(name: 'Alice')
  end
end

RuboCop を実行します。
期待通りなら警告が表示されます。

>> rubocop user.rb
Inspecting 1 file
C

Offenses:

user.rb:3:10: C: [Correctable] CustomCops/RestrictedStaticMethods: Use User.new instead of User.build.
    User.build(name: 'Alice')
         ^^^^^
1 file inspected, 1 offense detected, 1 offense autocorrectable

続いて自動修正:

>> rubocop -A user.rb
Inspecting 1 file
C

Offenses:

user.rb:3:10: C: [Corrected] CustomCops/RestrictedStaticMethods: Use User.new instead of User.build.
    User.build(name: 'Alice')
         ^^^^^

1 file inspected, 1 offense detected, 1 offense corrected

User.buildUser.new に置き換われば成功 🎉

 class User
   def self.build_alice
-    User.build(name: 'Alice')
+    User.new(name: 'Alice')
   end
 end

Step 5. RSpec で CustomCop をテストする

Step 4. では手動での動作確認を行いましたが、せっかくなら RSpec で CustomCop の動作をテストしましょう。

5-1. ヘルパーの読み込み

RuboCopは RSpec 用のサポートモジュールを提供してくれています。
spec/cop_helper.rb を作成し、RuboCop の RSpec サポートを読み込めるようにします。

spec/cop_helper.rb
require 'rubocop'
require 'rubocop/rspec/support'

RSpec.configure do |config|
  config.include RuboCop::RSpec::ExpectOffense
  Dir[File.dirname(__FILE__) + '/../rubocop/custom_cops/*.rb'].each { |file| require file }
end

5-2. テストファイルを作成する

では RSpec ファイルを作成しましょう。

mkdir -p spec/rubocop/custom_cops
touch spec/rubocop/custom_cops/restricted_static_methods_spec.rb

内容:

spec/rubocop/custom_cops/restricted_static_methods_spec.rb
require 'cop_helper'

RSpec.describe RuboCop::CustomCops::RestrictedStaticMethods, :config, type: :cop do
  let(:config) { RuboCop::Config.new({}) }

  it 'registers an offense for User.build' do
    expect_offense(<<~RUBY)
      User.build(name: 'Alice')
           ^^^^^ Use `User.new` instead of `User.build`.
    RUBY
  end

  it 'autocorrects User.build to User.new' do
    expect_offense(<<~RUBY)
      User.build(name: 'Alice')
           ^^^^^ Use `User.new` instead of `User.build`.
    RUBY

    expect_correction(<<~RUBY)
      User.new(name: 'Alice')
    RUBY
  end

  it 'does not register an offense for User.new' do
    expect_no_offenses(<<~RUBY)
      User.new(name: 'Alice')
    RUBY
  end
end

この RSpec が 具体的に何をしているのか を解説します。

RSpec.describe RuboCop::CustomCops::RestrictedStaticMethods, :config, type: :cop do

RuboCop の RSpec では、type: :cop を指定することで
Cop 用の特殊ヘルパー(expect_offense など)を使えるようになります

:config は RuboCop の RSpec が内部的に読むメタデータです。

let(:config) { RuboCop::Config.new({}) }

RuboCop の Cop は .rubocop.yml を参照して動きますが、
テストではあえて空の RuboCop::Config を渡します。

理由としては:

  • 小さく、再現しやすい環境を意図的に作るため
  • .rubocop.yml の影響を受けず、Cop 単体でテストできるようにするため

になります。テストケースごとに異なる設定を与えたい場合もありますよね。

  it 'registers an offense for User.build' do
    expect_offense(<<~RUBY)
      User.build(name: 'Alice')
           ^^^^^ Use `User.new` instead of `User.build`.
    RUBY
  end

RuboCop には 「このコードにどんな警告が出るか」を丸ごと期待値にできる DSL が用意されています。

  • テスト対象コードをそのまま書く
  • 指摘してほしい箇所に ^ を置く(複数文字でもOK)
  • 下に「期待するメッセージ」を書く

この 3 点で Cop が正しく警告できているかを検証できます。

例:

User.build(name: 'Alice')
     ^^^^^

^^^^^ が示すのは build の部分が警告対象であるべきという意味です。

  it 'autocorrects User.build to User.new' do
    expect_offense(<<~RUBY)
      User.build(name: 'Alice')
           ^^^^^ Use `User.new` instead of `User.build`.
    RUBY

    expect_correction(<<~RUBY)
      User.new(name: 'Alice')
    RUBY
  end

expect_correction自動修正した後のコードがどうなるか を検証します。

RuboCop RSpec の仕組みでは:

  1. expect_offense が呼ばれる
  2. 内部的に AutoCorrector が実行される
  3. その結果を expect_correction が検証する

という流れになっています。

このテストは:

  • User.build に警告が出ること
  • User.new に書き換わること

2つをまとめて確かめるテスト になっています。

it 'does not register an offense for User.new' do
  expect_no_offenses(<<~RUBY)
    User.new(name: 'Alice')
  RUBY
end

この DSL はその名の通りこのコードには一切警告が出ないでほしい! というときに使います。

誤検知が起きていないことを保証するテストは非常に重要です。

静的解析ツールは誤検知が多いとすぐに嫌われるので、
このテストがあるだけで Cop の品質が大きく上がります。

テスト内容 何を保証しているか?
expect_offense(User.build) 禁止メソッドの検知ロジック
expect_correction Autocorrect の上書き処理
expect_no_offenses 許可されたメソッドの正常性(誤検知なし)

この 3 本が揃っていると、
Cop の基本動作がすべて担保されたテストセットになります。

5-3. テストを実行する

では実際にテストを実行してみましょう。

>> rspec spec/rubocop/custom_cops/restricted_static_methods_spec.rb
...

Finished in 0.03495 seconds (files took 0.38722 seconds to load)
3 examples, 0 failures

すべて通れば成功 🎉

Step 6. 複数ルールに対応させる

さてここまでで当初の目的は達成できましたが、いまのままだと User.build のみを禁止しています。
実際には複数クラス・メソッドに対応させたいことも多いでしょう。

6-1. RSpec の修正

先程のステップでRSpecを書きましたので、まずはRSpecを修正してみましょう。

spec/rubocop/custom_cops/restricted_static_methods_spec.rb
require 'cop_helper'

RSpec.describe RuboCop::CustomCops::RestrictedStaticMethods, :config, type: :cop do
  let(:config) do
    RuboCop::Config.new(
      {
        'CustomCops/RestrictedStaticMethods' => {
          'RestrictedMethods' => [
            {
              'class' => 'User',
              'forbidden' => 'build',
              'preferred' => 'new'
            },
            {
              'class' => 'Order',
              'forbidden' => 'create!',
              'preferred' => 'create'
            }
          ]
        }
      }
    )
  end

  it 'registers an offense for User.build' do
    expect_offense(<<~RUBY)
      User.build(name: 'Alice')
           ^^^^^ Use `User.new` instead of `User.build`.
    RUBY
  end

  it 'autocorrects User.build to User.new' do
    expect_offense(<<~RUBY)
      User.build(name: 'Alice')
           ^^^^^ Use `User.new` instead of `User.build`.
    RUBY

    expect_correction(<<~RUBY)
      User.new(name: 'Alice')
    RUBY
  end

  it 'does not register an offense for User.new' do
    expect_no_offenses(<<~RUBY)
      User.new(name: 'Alice')
    RUBY
  end

  it 'registers an offense for Order.create! and autocorrects to Order.create' do
    expect_offense(<<~RUBY)
      Order.create!(name: 'Bob')
            ^^^^^^^ Use `Order.create` instead of `Order.create!`.
    RUBY

    expect_correction(<<~RUBY)
      Order.create(name: 'Bob')
    RUBY
  end
end

では新たに追加した箇所を解説します。

-  let(:config) { RuboCop::Config.new({}) }
+  let(:config) do
+    RuboCop::Config.new(
+      {
+        'CustomCops/RestrictedStaticMethods' => {
+          'RestrictedMethods' => [
+            {
+              'class' => 'User',
+              'forbidden' => 'build',
+              'preferred' => 'new'
+            },
+            {
+              'class' => 'Order',
+              'forbidden' => 'create!',
+              'preferred' => 'create'
+            }
+          ]
+        }
+      }
+    )
+  end

✨ ここが今回の RSpec の一番重要な部分です。

ここで 擬似的な .rubocop.yml を Ruby のハッシュで作り、それを RuboCop::Config.new に渡しています。
.rubocop.yml としては以下のようなイメージです。

CustomCops/RestrictedStaticMethods:
  RestrictedMethods:
    - class: "User"
      forbidden: "build"
      preferred: "new"
    - class: "Order"
      forbidden: "create!"
      preferred: "create"

Cop が内部で cop_config['RestrictedMethods'] を読むことで複数の禁止ルールを取得できるようになります。

+it 'registers an offense for Order.create! and autocorrects to Order.create' do
+  expect_offense(<<~RUBY)
+    Order.create!(name: 'Bob')
+          ^^^^^^^ Use `Order.create` instead of `Order.create!`.
+  RUBY
+
+  expect_correction(<<~RUBY)
+    Order.create(name: 'Bob')
+  RUBY
+end

今回追加した期待する動作は:

Order.create!(...) → Order.create(...)

になっていることです。
当然ながら現時点ではこのテストは失敗するはずです。

>> rspec spec/rubocop/custom_cops/restricted_static_methods_spec.rb
...F

Failures:

  1) RuboCop::CustomCops::RestrictedStaticMethods registers an offense for Order.create! and autocorrects to Order.create
     Failure/Error:
           expect_offense(<<~RUBY)
             Order.create!(name: 'Bob')
                   ^^^^^^^ Use `Order.create` instead of `Order.create!`.
           RUBY

       Diff:
       @@ -1,2 +1 @@
        Order.create!(name: 'Bob')
       -      ^^^^^^^ Use `Order.create` instead of `Order.create!`.

     # ./spec/rubocop/custom_cops/restricted_static_methods_spec.rb:50:in 'block (2 levels) in <top (required)>'

Finished in 0.04298 seconds (files took 0.63543 seconds to load)
4 examples, 1 failure

Failed examples:

rspec ./spec/rubocop/custom_cops/restricted_static_methods_spec.rb:49 # RuboCop::CustomCops::RestrictedStaticMethods registers an offense for Order.create! and autocorrects to Order.create

6-2. Cop の修正

それでは Cop 側を修正していきましょう。

rubocop/custom_cops/restricted_static_methods.rb
module RuboCop
  module CustomCops
    class RestrictedStaticMethods < RuboCop::Cop::Base
      extend RuboCop::Cop::AutoCorrector

      MSG = 'Use `%<replacement>s` instead of `%<original>s`.'

      def rules
        Array(cop_config['RestrictedMethods']).map do |rule|
          {
            class_name: rule['class'],
            forbidden: rule['forbidden'].to_sym,
            preferred: rule['preferred']
          }
        end
      end

      def on_send(node)
        receiver = node.receiver
        return unless receiver&.const_type?

        rules.each do |rule|
          next unless receiver.const_name == rule[:class_name]
          next unless node.method_name == rule[:forbidden]

          add_offense(
            node.loc.selector,
            message: format(
              MSG,
              replacement: "#{rule[:class_name]}.#{rule[:preferred]}",
              original: "#{rule[:class_name]}.#{rule[:forbidden]}"
            )
          ) do |corrector|
            corrector.replace(node.loc.selector, rule[:preferred])
          end
        end
      end
    end
  end
end

こちらも順を追って解説します。

MSG = 'Use `%<replacement>s` instead of `%<original>s`.'

- TARGET_CLASS       = 'User'
- FORBIDDEN_METHOD   = :build
- REPLACEMENT_METHOD = :new

.rubocop.yml(RSpecではRuboCop::Config)で設定した値を使うため、これらの定数は削除します。

+  def rules
+    Array(cop_config['RestrictedMethods']).map do |rule|
+      {
+        class_name:  rule['class'],
+        forbidden:   rule['forbidden'].to_sym,
+        preferred:   rule['preferred']
+      }
+    end
+  end

削除した定数の代わりに、禁止ルールを配列で取得するメソッドを追加します。
cop_config は RuboCop の Cop クラスに組み込まれているメソッドで、.rubocop.yml の当該 Cop セクションをハッシュ形式で取得できます。

  def on_send(node)
    receiver = node.receiver
    return unless receiver&.const_type?
-   return unless receiver.const_name == TARGET_CLASS
-   return unless node.method_name == FORBIDDEN_METHOD

-   add_offense(
-     node.loc.selector,
-     message: format(
-       MSG,
-       replacement: "#{TARGET_CLASS}.#{REPLACEMENT_METHOD}",
-       original: "#{TARGET_CLASS}.#{FORBIDDEN_METHOD}"
-     )
-   ) do |corrector|
-     corrector.replace(node.loc.selector, REPLACEMENT_METHOD.to_s)
+   rules.each do |rule|
+     next unless receiver.const_name == rule[:class_name]
+     next unless node.method_name == rule[:forbidden]
+
+     add_offense(
+       node.loc.selector,
+       message: format(
+         MSG,
+         replacement: "#{rule[:class_name]}.#{rule[:preferred]}",
+         original: "#{rule[:class_name]}.#{rule[:forbidden]}"
+       )
+     ) do |corrector|
+       corrector.replace(node.loc.selector, rule[:preferred])
+     end
    end
  end

rules.each を使うことで .rubocop.yml の定義に応じて動的に判定できます。
自動修正の部分も同様に rules ハッシュから値を取り出すように変更しています。

6-3. RSpec の再実行

では再度 RSpec を実行してみましょう。

>> rspec spec/rubocop/custom_cops/restricted_static_methods_spec.rb
....

Finished in 0.03519 seconds (files took 0.63748 seconds to load)
4 examples, 0 failures

すべて通れば成功 🎉

6-4. .rubocop.yml の修正

ここまでで動作確認は RSpec で行いましたが、実際のプロジェクトで使うには .rubocop.yml も修正する必要があります。

.rubocop.yml
Custom/RestrictedStaticMethods:
  Enabled: true
+  RestrictedMethods:
+    - class: "User"
+      forbidden: "build"
+      preferred: "new"
+    - class: "Order"
+      forbidden: "create!"
+      preferred: "create"

Step 7. 動作確認

必要な設定が整いましたので、実際に動作確認してみましょう。

7-1. 未整形コードの準備

整形を行う対象のコードを用意します。
User クラスに加えて Order クラスも使うようにします。

user.rb
class User
  def self.build_alice
    User.build(name: 'Alice')
    Order.create!(name: 'Wonderland')
  end
end

7-2. 実行

では実行してみましょう。

>> rubocop user.rb
Inspecting 1 file
C

Offenses:

user.rb:3:10: C: [Correctable] CustomCops/RestrictedStaticMethods: Use User.new instead of User.build.
    User.build(name: 'Alice')
         ^^^^^
user.rb:4:11: C: [Correctable] CustomCops/RestrictedStaticMethods: Use Order.create instead of Order.create!.
    Order.create!(name: 'Wonderland')
          ^^^^^^^

1 file inspected, 2 offenses detected, 2 offenses autocorrectable

警告が 2 つ出れば成功です 🎉

自動修正も試してみましょう。

>> rubocop -A user.rb
Inspecting 1 file
C

Offenses:

user.rb:3:10: C: [Corrected] CustomCops/RestrictedStaticMethods: Use User.new instead of User.build.
    User.build(name: 'Alice')
         ^^^^^
user.rb:4:11: C: [Corrected] CustomCops/RestrictedStaticMethods: Use Order.create instead of Order.create!.
    Order.create!(name: 'Wonderland')
          ^^^^^^^

1 file inspected, 2 offenses detected, 2 offenses corrected

自動修正も成功しました。
これで複数ルールに対応できました 🎉

まとめ

CustomCop を活用すると、レビュー作業を効率化し、チームルールの標準化・自動化 が進みます。
また今回のように RSpec でしっかりテストを書くことで、誤検知の防止や将来のメンテナンス性向上 にもつながります。
もちろんGem化して他プロジェクトで再利用することも可能です。

ぜひ自分のプロジェクトでも試してみてください 🙌

COUNTERWORKS テックブログ

Discussion