"闇ルール"を減らすRuboCop CustomCopの作り方
はじめに
こんにちは。カウンターワークスにて主にワークフローや非機能要件の改善を担当させていただいておりますエンジニアの香下と申します。
さて、AIレビューが全盛になりつつある今日このごろですが、みなさんは RuboCop をどのように設定していますか?
RuboCop-github や rubocop-rails、rubocop-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 に以下を追加します。
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 を実装します。
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.build→Userではないのでスキップ -
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
このブロック内が 自動修正の処理 です。
ここで何をしているかというと:
-
build→newに書き換える
だけです。
つまり、
User.build(name: "Alice")
が、自動修正すると
User.new(name: "Alice")
に変わります。
RuboCop がファイルを直接編集してくれる仕組みです。
Step 3. .rubocop.yml に登録する
require:
- ./rubocop/custom_cops/restricted_static_methods.rb
Custom/RestrictedStaticMethods:
Enabled: true
Step 4. 動作確認する
では期待通り動くか確認してみましょう。
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.build が User.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 サポートを読み込めるようにします。
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
内容:
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 の仕組みでは:
-
expect_offenseが呼ばれる - 内部的に AutoCorrector が実行される
- その結果を
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を修正してみましょう。
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 側を修正していきましょう。
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 も修正する必要があります。
Custom/RestrictedStaticMethods:
Enabled: true
+ RestrictedMethods:
+ - class: "User"
+ forbidden: "build"
+ preferred: "new"
+ - class: "Order"
+ forbidden: "create!"
+ preferred: "create"
Step 7. 動作確認
必要な設定が整いましたので、実際に動作確認してみましょう。
7-1. 未整形コードの準備
整形を行う対象のコードを用意します。
User クラスに加えて Order クラスも使うようにします。
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化して他プロジェクトで再利用することも可能です。
ぜひ自分のプロジェクトでも試してみてください 🙌
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion