Code Climate Test ReporterとSimpleCov 0.18で起きるエラーを回避する

昨日あたりから RuboCop の master で CI が落ちていて、見てみたら Code Climate の cc-test-reporter でエラーが起きていることが原因だった。

$ #!/bin/bash -eo pipefail
./tmp/cc-test-reporter before-build
COVERAGE=true bundle exec rake spec
./tmp/cc-test-reporter format-coverage --output tmp/codeclimate.$CIRCLE_JOB.json
Starting test-queue master (/tmp/test_queue_157_5140.sock)

==> Summary (2 workers in 36.1115s)

    [ 1]                         8753 examples, 0 failures, 9 pending       254 suites in 36.1034s      (pid 160 exit 0 )
    [ 2]                         6772 examples, 0 failures, 2 pending       239 suites in 36.1054s      (pid 161 exit 0 )

Coverage report generated for RSpec, rspec-1, rspec-2, rspec-fork-163, rspec-fork-164, rspec-fork-165 to /home/circleci/project/coverage. 21266 / 21459 LOC (99.1%) covered.
Error: json: cannot unmarshal object into Go struct field input.coverage of type []formatters.NullInt
Usage:
  cc-test-reporter format-coverage [coverage file] [flags]

Flags:
      --add-prefix string   add this prefix to file paths
  -t, --input-type string   type of input source to use [clover, cobertura, coverage.py, excoveralls, gcov, gocov, jacoco, lcov, simplecov, xccov]
  -o, --output string       output path (default "coverage/codeclimate.json")
  -p, --prefix string       the root directory where the coverage analysis was performed (default "/home/circleci/project")

Global Flags:
  -d, --debug   run in debug mode


Exited with code exit status 255

https://circleci.com/gh/rubocop-hq/rubocop/83619

調べたところ SimpleCov 0.18 のリリースがリリースされており、cc-test-reporter がうまく組み合っていないというイシューが開かれていた。

github.com

PR がパスしているかどうかが分からないのは不便なので、対応までのワークアラウンドとして Gemfile に SimpleCov 0.18 未満を指定して乗り切ることにした。

-gem 'simplecov', '~> 0.10'
+# Workaround for cc-test-reporter with SimpleCov 0.18.
+# Stop upgrading SimpleCov until the following issue will be resolved.
+# https://github.com/codeclimate/test-reporter/issues/418
+gem 'simplecov', '~> 0.10', '< 0.18'

github.com

キーワード引数の分離への対応にRuby 2.8.0-devを使う

先日のパッチ会で kamipo さんにもらったアドバイスを書き残しておく。

TL;DR としては表題そのまま。キーワード引数の分離への対応にRuby 2.8.0-devを使うというもの。

Ruby 2.7.0 を使ってキーワード引数の分離への警告のみでそれを抑制しようとする場合は、スーパーハードモードルビーとパッチ会で呼ばれた変更箇所の特定が難しいケースになる場合がある。

スーパーハードモード (Ruby 2.7.0)

Ruby 3.0 に向けてキーワード引数の分離が必要になる場合は、Ruby 2.7.0 を使うと以下のような警告が表示される。

% ruby -v
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-linux]

% bundle exec rake
(略)

/home/vagrant/.rvm/gems/ruby-2.7.0/bundler/gems/rails-80e72c5eb7d4/activerecord/lib/active_record/migration.rb:907: warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call
/home/vagrant/src/oracle-enhanced/lib/active_record/connection_adapters/oracle_enhanced/schema_statements.rb:199: warning: The called method `create_table' is defined here

まず warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call となっている Active Record のコードの場所を示す。

    def method_missing(method, *arguments, &block)
      arg_list = arguments.map(&:inspect) * ", "

      say_with_time "#{method}(#{arg_list})" do
        unless connection.respond_to? :revert
          unless arguments.empty? || [:execute, :enable_extension, :disable_extension].include?(method)
            arguments[0] = proper_table_name(arguments.first, table_name_options)
            if [:rename_table, :add_foreign_key].include?(method) ||
              (method == :remove_foreign_key && !arguments.second.is_a?(Hash))
              arguments[1] = proper_table_name(arguments.second, table_name_options)
            end
          end
        end
        return super unless connection.respond_to?(method)
->      connection.send(method, *arguments, &block) # ここが `activerecord/lib/active_record/migration.rb:907`
      end
    end
    ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)

次に warning: The called methodcreate_table' is defined here` となっている Oracle enhanced adapter のコードの場所を示す。

->      def create_table(table_name, id: :primary_key, primary_key: nil, force: nil, **options) # ここが `oracle_enhanced/schema_statements.rb:199`
          create_sequence = id != false
          td = create_table_definition(
            table_name, **options.extract!(:temporary, :options, :as, :comment, :tablespace, :organization)
          )

結論としてはどちらもキーワード引数の分離への変更対象ではない。メソッドの定義側も呼び出し側いずれのコードも期待から外れたものではなかった。

これが kamipo さんの "warningみてもどこ直せばいいかまったくといっていいほどわからんからな" の一例である。

次に Ruby 2.8.0-dev を使うと警告ではなくエラーになることでバックトレースが見えるイージーモードに切り替えてみよう。

イージーモード (Ruby 2.8.0-dev)

Ruby 2.8.0-dev を使った結果は以下となる。

Failures:
  1) OracleEnhancedAdapter schema dump tables should not include ignored
  table names in schema dump
     Failure/Error:
               def create_table(table_name, id: :primary_key, primary_key: nil, force: nil, **options)
                 create_sequence = id != false
                 td = create_table_definition(
                   table_name, **options.extract!(:temporary, :options, :as, :comment, :tablespace, :organization)
                 )

                 if id && !td.as
                   pk = primary_key || Base.get_primary_key(table_name.to_s.singularize)

                   if pk.is_a?(Array)

     ArgumentError:
            wrong number of arguments (given 2, expected 1)
     # ./lib/active_record/connection_adapters/oracle_enhanced/schema_statements.rb:199:in `create_table'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/migration.rb:907:in `block in method_missing'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/migration.rb:875:in `block in say_with_time'
     # /home/travis/.rvm/rubies/ruby-head/lib/ruby/2.8.0/benchmark.rb:293:in `measure'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/migration.rb:875:in `say_with_time'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/migration.rb:896:in `method_mi
     # ./spec/active_record/connection_adapters/oracle_enhanced/schema_dumper_spec.rb:24:in `block in create_test_posts_table'
     # ./spec/spec_helper.rb:121:in `instance_eval'
     # ./spec/spec_helper.rb:121:in `block (2 levels) in schema_define'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/migration.rb:884:in `suppress_messages'
     # ./spec/spec_helper.rb:120:in `block in schema_define'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/schema.rb:50:in `instance_eval'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/schema.rb:50:in `define'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/schema.rb:46:in `define'
     # ./spec/spec_helper.rb:119:in `schema_define'
     (以下略)

Ruby 2.7.0 で表示されている警告は ArgumentError からのバックトレースの以下2行となる。そして、Ruby 2.8.0-dev では警告ではなくエラーになることでその先もバックトレースとして表示されている。

     ArgumentError:
            wrong number of arguments (given 2, expected 1)
     # ./lib/active_record/connection_adapters/oracle_enhanced/schema_statements.rb:199:in `create_table'
     # /home/travis/.rvm/gems/ruby-head/bundler/gems/rails-d80714e0834f/activerecord/lib/active_record/migration.rb:907:in `block in method_missing'

これが示すことはバックトレースで問題の箇所を探索することができるようになるということ。この例だとバックトレース中に示されている spec/active_record/connection_adapters/oracle_enhanced/schema_dumper_spec.rb:24 が修正対象となるものだった。

これはテストコード中の以下を示している。

  def create_test_posts_table(options = {})
    options[:force] = true
    schema_define do
->    create_table :test_posts, options do |t| # ここが `oracle_enhanced/schema_dumper_spec.rb:24`
        t.string :title
        t.timestamps null: true
      end
      add_index :test_posts, :title
    end
  end

変更するべきはこのテストコードで次のようになる。

diff --git a/spec/active_record/connection_adapters/oracle_enhanced/schema_dumper_spec.rb b/spec/active_record/connec
index f3b1df0..8330a95 100644
--- a/spec/active_record/connection_adapters/oracle_enhanced/schema_dumper_spec.rb
+++ b/spec/active_record/connection_adapters/oracle_enhanced/schema_dumper_spec.rb
@@ -21,7 +21,7 @@ describe "OracleEnhancedAdapter schema dump" do
   def create_test_posts_table(options = {})
     options[:force] = true
     schema_define do
-      create_table :test_posts, options do |t|
+      create_table :test_posts, **options do |t|
         t.string :title
         t.timestamps null: true
       end

これによって解決している PR が以下です。

github.com

このように、キーワード引数の分離への対応には Ruby 2.8.0-dev を使った方がよりヒントを得られるケースがあるので、困った時は rbenv install 2.8.0-dev などで master の Ruby を使ってみることを考慮してみると良いでしょう。

この問題に2ヶ月間取り組んだ kamipo さんからの知見に感謝します。


次回のパッチ会は 2020年2月27日(木)です。

blog.agile.esm.co.jp

Ginza.rb 第79回

Ginza.rb 第79回 Rails6の新しい定数自動読み込みZeitwerkのコードを読もう!に参加した。会場はメドピアさん。

ginzarb.doorkeeper.jp

表題のとおり今回は Zeitwerk がテーマ。余談だが自分は発音できないのでカタカナ読みでツァイトヴェルクと読んでいたが、それでも読みづらいので Rails コミュニティで略称として使われている zloader (ゼットローダー) の方で読んでいたりする。

オートローダーとは Railsrequire を書かなくてもクラスが使える機構を支えているものだが、Ruby 標準の autoload を Rails が拡張した autoload が Rails 5.2 以前に択一だったもので、Rails 6 で追加されたオートローダーが Zeitwerk となる。従来のオートローダーは classic というもので Active Support が提供している。なお、Rails では Active Support が Zeitwerk に依存してる

autoload はクラスが見つからなかったときにファイルをロードするが、Zeitwerk はその逆でファイル名をもとにクラスをロードする。なのでファイル名とクラス名のマッピングが不確かだと NameError が起きるのでそのあたりはひとつの注意点としましょうとなっている。例としては html_parser.rb は HtmlParserマッピングされるため HTMLParser としたい場合は infrection を使ったマッピングを行う。

github.com

また Zeitwerk はスレッドセーフな実装になっているので、autoload を使っていたときにレースコンディションでエラーになるようなケースが解消されるなどのメリットがあるとのこと。

実装として面白かったのはクラスの reload だが、自分だったら愚直に load メソッド (require と異なり同じファイルを読み直す) を使うのが第一感となるが、require 済みのファイルパスを保持する $LOADED_FEATURES から対象のパスを reject! で破壊的に除いて 再び setup を実行するとかマジかと思って面白かった。このコードは面白かった。印象的なことだったので2度言った。

あと TracePoint が実装として使われていたりもするライブラリなので、TracePoint ファンの人は読んでみると面白いと思う。

今回 willnet さんの主導で README を読んで実装のコード解説に入っていたけれど、時間内にしゅっと本筋のコードを眺めてどういったものかを解説する様はまさに匠の技だった。Zeitwerk への理解が深まりました。ありがとうございました。

Gem 開発における gemspec と Gemfile への開発 Gem の指定について

昨日の Asakusa.rb 第547回で、hsbt さんに聞いて amatsuda さんたちを交えて話していた表題について書き残しておく。先に記しておくと決定的な結論はない。

Gem を開発する際に、開発時のみに使う Gem を指定する先として gemspec を使った spec.add_development_dependency での指定と Gemfile を使った gem での指定がある。補足しておくとそれぞれ前者は RubyGem での指定で、後者は Bundler での指定となる。このあたりどちらが推奨されているとかあれば聞いてみようというのが質問の発端だった。

RubyGems 3.1.2 と Bundler 2.1.4 時点で、それぞれでできることは以下となる。

RubyGems

メタデータとして rubygems.org に公開できる。ただしそれを使ったアナリティクスなどをとっているわけではないみたい。

Gemfile

Gemfile に gem 指定する際にできることとして path オプションや github オプションに加え、group 指定などできる。加えて gemfiles に複数バージョンそれぞれ用の Gemfile を配置することで、例えば複数バージョンの Rails についてバージョンごとに Gemfile を切り替えたテストができる。Appraisals を使った複数の Gemfile 切り替えなんかもこれがベースになる。これらは gemspec ではできないこと。

github.com

結局のところ?

基本的にできることが多い Gemfile に書けば良いというスタイルもある一方で amatsuda さんのスタイルは面白くて、複数環境の Gemfile に共通するものは gemspec に指定して、環境差分がある分についてはそれぞれの Gemfile に指定することで開発依存 gem の指定を DRY にするというもの。好きな言葉を地で行っていて流石だと思った。

結論という結論は特になく、現状だと Gemfile でしか指定できないことは Gemfile をもちいて行うしかなく、あとは考え方次第のスタイルという感じではないだろうかと理解している。

ちなみに GitHubUsed by はそれぞれが参照されていそうという感じだった。

Asakusa.rb 第547回

Asakusa.rb 第547回だった。

ちょうど RubyKaigi 2020 のプロポーザルの提出期限日に Asakusa.rb というタイミングだったので、yahonda さんや joker1007 さんにフィードバックをもらいつつ、締め切り3時間ちょっと前くらいに提出していた。提出が終わったらだいたい燃え尽きていた (レビューありがとうございました!) 。

あとは RubyGems と Bundler のメンテナーでもある hsbt さんに、gem 開発リポジトリにおける gemspec への add_development_dependency 指定と Gemfile への指定の違いについて疑問に思っていたことを聞いたりしていた。有用な話を聞けたので、別エントリとして書くと思う。

Asakusa.rb べんり。

Ruby 2.7.0 で導入された Arguments Forwarding

Ruby 2.7.0 で導入された Arguments Forwarding について、RuboCop でスタイル部署の新たな Cop として開発中なので、Cop 開発の過程で得ている知見やらを進捗を記しておきます。

Arguments Forwarding とは

def foo(...) といった形で引数への構文拡張がされています。以下のコードを動かしてみるとイメージがつくかもしれません。

% cat arguments_forwarding.rb
def foo(...)
  bar(...)
end

def bar(str)
  puts "hello, #{str}"
end

foo('world') # => hello, world

... でイメージがつくかもしれませんが、サンプルではひとつの引数のみですが、実際は複数の引数をそのままフォワードします。

効能

pocke さんによる RuboCop への Feature Request に詳しいです。

github.com

キーワード引数の分離への警告 (Ruby 3.0 ではエラー) の回避の側面を提案に含めているのが興味深かったです。実際に検知できるケースがアプリケーションレイヤーでどの程度あるかはわかりませんが、Ruby 3.0 へのマイグレーターのひとつになるかと思い着手することにしました。

構文上の注意

Arguments Forwarding を使ったメソッド定義では引数の括弧は省略できません。

def foo ...
end

以下のような構文エラーになります。

% ruby -vc foo.rb
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-darwin17]
foo.rb:1: warning: ... at EOL, should be parenthesized?
foo.rb:1: syntax error, unexpected ..., expecting ';' or '\n'
def foo ...

つまり以下のようなコードを Arguments Forwarding に置き換える場合は括弧が必須になります。

# Before
def foo *args, **kwargs, &block
end

# After
def foo(...)
end

一方でフォワーディング先のメソッド呼び出しは括弧を省略できます。

def foo(...)
  bar ...
end

ただし以下のような警告が発生するため括弧をつけることが好ましいでしょう。 ただし以下のような警告が発生します。追記した内容を踏まえて括弧をつけることを一考するとよいでしょう。

% ruby foo.rb
foo.rb:2: warning: ... at EOL, should be parenthesized?

2020年1月15日追記

こちらについて pocke さんから括弧の有無で意味合いが変わるというフィードバックをいただく。

Parser gem でも試して見たところ以下のように結果が変わりました。pocke さん、いつもありがとうございます!

% ruby-parse -e 'def foo(...); bar ...; end'
(def :foo
  (forward-args)
  (erange
    (send nil :bar) nil))

% ruby-parse -e 'def foo(...); bar(...); end'
(def :foo
  (forward-args)
  (send nil :bar
    (forwarded-args)))

(追記ここまで)

また想像にかたくないですが、メソッド定義が Arguments Forwarding でない場合に、フォワード先のみ ... にしていると構文エラーです。

def foo(*args, **kwargs, &block)
  bar(...)
end

def bar(*args, **kwargs, &block)
end

foo('world', num: 42)

実行した内容は以下です。

% ruby foo.rb
foo.rb:2: unexpected ...

Style/ArgumentsForwarding cop

以下で開発中の Ruby 2.7 以上を対象とした現在開発中の新しい Cop です。

github.com

pocke さんのイシューにあるとおり以下のケースを Arguments Forwarding にするとブロックまで引き渡されるという振る舞いの破壊が入ります。

# We can replace the parameter with `...`, but it will change the behavior.
# Because `...` forwards block also.
def foo(*args)
  bar(*args)
end

そのためこのケースは Strict オプションとして設定できるようにして、デフォルトでは Safe な振る舞いにするため無効にしています。

オプションを含めてここまでの説明について概ね実装ができています。何が不足してるかというとメソッド定義の (Arguments Forwarding ではない) 通常の引数がメソッド内で使われているケースで誤検知がおきます。

例えば以下のようなケースです。

def foo(*args, **kwargs, &block)
  bar(*args, **kwargs, &block)
  args.first.do_something
end

式展開で使われているケースや、以下のようなコーナーケースもアウトです。

# Array リテラルでもちいたケース
def foo(*args, **kwargs, &block)
  bar(*args, **kwargs, &block)
  var = [args]
  do_something(var)
end

# Hash リテラルでもちいたケース
def foo(*args, **kwargs, &block)
  bar(*args, **kwargs, &block)
  h = {key: args} # key: value 両方とも
  do_something(h)
end

以下のようにフォワーディングだけしているケースを検出するのであれば容易ですが、フォワーディングの前後でデコレーションあるいはフィルタリング処理をいれているような実践ケースが検知できなくなります。

def foo(*args)
  bar(*args)
end

このあたりをどのように実装するかというのが現状で、variable_table あたりを使ったなにか良い方法があるのか、地道に例外ケースを潰すのかといった調査と設計をしているが現状のステータスです。


おわりに - OSS の開発、メンテナンスについて

このような OSS 開発への活動を支援していただけるスポンサーを募集しています。

github.com

実際のところ GitHub Sponsors は想像していた以上に OSS 活動のモチベーション継続の一要因になっています。これを通じて RailsConf 2019 (at ミネアポリス) にて DHH が講演した OSS の社会構図が変わるきっかけになるといいなと思っています。