rubocopをRBSファイルにも効かせたい

背景

RBSも利用が増えてくると、細かい"こうしたほうがいい"が出てくることがあると予想される。

例えば、

  • インデント
  • initializeの返り値はvoidを使う
  • untyped?ではなくuntypedを使う
  • メソッド引数にvoidを使わない

などなど。

こうしたことを指摘するツールとしては、Rubyではrubocopが覇権をとっており、大体の環境ですでに使われている。

このエコシステムにRBSも乗っかりたい。

問題点

VSCode上で警告を表示できない

大きな問題点として、VSCode上の波線(squigglyというらしい)が表示できないという問題がある。

エディタでインタラクティブに問題点が見える体験はかなり重要だと思っているので対応したい。

しかしながら何が原因なのかはわからない。

試しにrubocop orgが公式にホストしているrubocop-mdを使ってみたが、こちらも特にエディタ上ではなにも表示されなかった(ruby-lsp)。

原因を究明するのに時間を使うべきか、より機能追加に時間を使うべきか、悩みどころである。

名前をrubocop-rbsにできない

今回作っているものはrubocop-rbsと言うしかないものなのだが、

rubocop-rbsというgemはすでに存在している。

これはRBSを見て、Ruby側のメソッド定義の引数の形式と数のマッチングをみるもののようだ。

名前は早い者勝ちなのでしょうがないが、この作っているものの名前に悩んでいる。

今は仮にrubocop-rbs-sigとしている。

機能

とりあえず、

  • シンタックスエラー表示
  • initializeの返り値はvoidを使う
  • untyped?ではなくuntypedを使う

できた。

sig/rubocop/rbs/sig.rbs:5:31: C: [Correctable] RBS/Sig/InitializeReturnType: #initialize method should return void
        def initialize: () -> nil
                              ^^^
sig/rubocop/rbs/sig.rbs:6:19: C: [Correctable] RBS/Sig/Untyped: untyped? should be untyped
        def foo: (untyped?) -> (Integer | untyped)
                  ^^^^^^^^
sig/rubocop/rbs/sig.rbs:6:33: C: [Correctable] RBS/Sig/Untyped: Integer | untyped should be untyped
        def foo: (untyped?) -> (Integer | untyped)
                                ^^^^^^^^^^^^^^^^^
  • autocorrect
  • 機能別にCopを分ける

といったこともできている。

実装方法

rubocopはRubyに特化したツールであり、RBSRubyとは違う。

Ruby以外のファイルにrubocopを実行する参考例としては、rubocop-erbrubocop-mdがみつかった。これを参考にした。

rubocopではターゲットファイルがRubyとしてパースできなかった場合、on_other_fileを呼び出すようになっている。

rubocop/lib/rubocop/cop/commissioner.rb at 673495b70e8b9e42d82d85526292945d47189f10 · rubocop/rubocop · GitHub

このon_other_fileRBSとしてパースし、パースできたらRBSとして扱うようにしてみている。

Rubyとしてパースして、RBSとしてもパースしているので無駄はある気はするが、ここを変えようとするとrubocopにかなり無茶させないといけなくなり、コード量も増える気がするので一旦こうしている。

つまり

やればできそう。

ブロック引数と返り値は似ている

RaaPを開発していて、ブロック引数の扱いについて解像度が上がったのでメモとして書いておく。

これまでの考え方

Array#mapを例に見てみる。

[1, 2, 3].map do |i|
  i.to_s
end

これまではこう考えていた。 メソッドの最終的な返り値のためには、iはぶっちゃけなんでもよく、ブロックの返り値さえ想定された型なら良いだろうと。

実装イメージとしてはこうしていた。

array = Array.new(3) { random_integer }
return_value = random_string
block = Proc.new
  return_value
end

array.times(&block)

しかしながら、これでは動作検証とは言えないケースが出てきた。

例えばSet#divideはブロック引数の数で挙動が変わるという面白いメソッドで、実装も明確に

if func.arity == 2

とブロック引数の数で挙動が分岐している。

set/lib/set.rb at 4e95d55294f0e577529ca8702c4d2c3ae90247e7 · ruby/set · GitHub

この挙動もサポートしたい。

Procを動的に作る

func.arity == 2を達成するにはブロック用のProcの引数部分を動的に生成しなければならない。

Rubyでは現状Procを動的に組み立てるメソッドはない。evalするしかない。

というわけで、型からブロック用のProcをevalで生成するコードを書いた。もっといい方法があったら知りたい……。

与えるか、与えられるか

さらにInteger#timesの例を考えてみる。

10.times do |i|
  # ...
end

このiは型から生成しているわけではなく、メソッドの挙動として勝手に生成されている。

これまでの考え方としては、返り値以外は全て自動的に値を生成するもので、メソッドの返り値だけが唯一の型チェック対象だとしていた。

ランダムな食材をメソッドクックに与えると、できあがった料理となって出てくる。こう考えていた。

しかしながら、ブロック引数も実はメソッドが生成している。返り値のように。ならば、その値が本当に型通りなのか確かめたい。なぜならRaaPはテスティングツールだから。

ブロック引数は、実は返り値に似ていて、型チェック対象なのだ。

まとめると、

  • メソッド引数 -> 型から値を生成する
  • メソッド返り値 -> 値を型チェックする
  • ブロック引数 -> 値を型チェックする
  • ブロック返り値 -> 型から値を生成する

メソッドとブロックは反対の関係にあるように見えておもしろい。

実装する

Integer#timesの型が() { (String) -> void } -> selfだったら、間違っていると教えてあげるべきなのだ。

わかってしまえば、なんてことのない当たり前のことだ。

というわけで、ブロック引数の型チェック機能も実装した。

試しにわざとInteger#timesのブロック引数をStringにしてみると、TypeErrorが発生してテストは失敗するようになった。

[RaaP] INFO: # Integer
[RaaP] INFO: ## def times: () { (::String) -> void } -> self
....F
[RaaP] INFO: Failure: [TypeError] block argument type mismatch: expected `(::String)`, got [0]

(::String)をブロック引数に期待していたが、実際は[0]が来たのでおかしいよとRaaPが教えてくれている。

さらに、わざとブロック引数を空にしてみる。

[RaaP] INFO: # Integer
[RaaP] INFO: ## def times: () { () -> void } -> self
......E....EE....EE.E.EE.EE..E...E..E.E.E.....E.EE...E...E....E..EEE..EE....E.E.E.EEE.E.EEE..EE..E.E.
() { () -> void } -> self

[RaaP] INFO: success: 61, skip: 0, exception: 40, time: 17ms

Integer#timesはレシーバーがマイナスのときはブロックを全く実行しないので、マイナスの場合は成功する。 プラスの時はdebug出力するとわかるがArgumentErrorを出しているので、exceptionとして計上されている。

あながち間違いとも言いにくいのだが、これは失敗でもいい気がするなあ。。。

ともかく検知はできたのでいい感じ。

RubyKaigiで発表しないけどスライド書いた

speakerdeck.com

RBSをテストコードにする - スペクトラム で紹介したRaaPがいい感じに育ってきた。

RubyKaigiごっことして紹介スライドを作った。とくにしゃべる予定はない。

Coverage

大きなアップデートとしては、Coverage機能を追加し、デフォルトで有効にしたことだ。

CoverageはRubyの方ではなく、RBSのほうのCoverageだ。

引数

RaaPではランダムな挙動をベースにしているので、1回の動作につき引数生成に様々な選択がなされる。

  • オプション引数はランダムにあったりなかったりする。
  • rest引数は0個〜3個をランダムに生成する。
  • Union型(Integer | Stringみたいなやつ)は1つだけをランダムに選択する。
  • Optional型(Integer?みたいなやつ)はInteger?(つまりnil)かをランダムに選択する。

これらの条件が複数重なったとき、本当に記述したRBSが使われているのか、書く意味があったのか、不安になってくる。

そこで、どの型を使用したのかCoverageを表示することによって安心感を得ることができる。

もしなかなか使用されないなら、試行回数を増やして確率を上げることもできる。

返り値

また、返り値はランダムではないにせよ、以下のような判定になる。

  • Union型ならどれか一つにあてはまっていればよい
  • Optional型なら、とある型かnilかのどちらかであればよい

RaaPでは型が間違っていたら失敗として教えてくれるが、範囲が広すぎて緩すぎる型になっていても失敗はしない。

返り値に書いた型が本当に意味のあるものだったか、全く使われていないなら削除も検討できる。

RBSのIntersection(&)について

自分向けのまとめです。

Intersection(&)

RBSにはIntersection型があります。Object & _Each[Integer]のように&で繋げている型のことです。

その前にUnion(|)とは

String | Symbolのように、|で繋いだ型をUnion型と言います。こちらは理解は簡単です。

「String もしくは Symbol のどちらか一方」という解釈になります。使用頻度は多めです。

Intersectionの使い方

それをふまえてObject & _Each[Integer]は何を意味しているのでしょうか?

Objectかつ_Eachインターフェースを持つってどういうこと?Objecteachメソッドはないよ???」

というのが、私が最初にIntersection型に抱いた感想でした。

型について学んでいくうちに、何となく言語化できたパターンがあったので書き残しておきます。

最低限classを保証したい

一番多い利用ケースは、BasicObjectの排除だと思います。 例えばeachメソッドを使うから_Eachを指定したいんだけど、nil?is_a?等もじつは使ってた場合。 この場合、_Each_Nil_Isaなんていう別のInterfaceを作ってもいいのですが、このnil?is_a?を使用しているのは、インターフェースというよりは単にObject class以上のオブジェクト(ややこしい)を想定しているだけである場合が多いです。こんな場合に使えるのがObject &という指定方法です。これで

「最低限Object classのインスタンスであることを保証しつつ、eachメソッドが使えること」

という型を表現できます。「最低限Object classのインスタンスである」ということは、言い換えれば「BasicObjectは除く」とも考えられます。

型同士を足し算したい

では本当にインターフェースとして複数のメソッドを使えることを表したい場合はどのような方法があるでしょうか?

1) 新しいinterfaceを作る

新しく以下のようなinterfaceを作るパターンです。

interface _Each_Nil_Isa
  def each: 略
  def nil?: 略
  def is_a?: 略
end

これでもいいのですが、もし_Eachが修正されたらそれに追従したい場合もありますよね。

2) 新しいinterfaceを作りつつ、元からあるものはincludeする

それならば

interface _Each_Nil_Isa
  include _Each
  def nil?: ...
  def is_a?: ...
end

のように元からあるインターフェースはincludeで合成して使用するのもアリだと思います。

3) Intersectionを使う

さらに別の方法がIntersectionを使用する方法です。

2)の方法は実は間違っていて、実際は型変数を使用します。

interface _Each_Nil_Isa[T]
  include _Each[T]
  def nil?: ...
  def is_a?: ...
end

しかしながらnil?is_a?は型変数を使用していません。_Eachにしか型変数は必要無い点がモヤりますよね。

そこで、新しいinterfaceを作るけど_Eachは除外してみます。

interface _Nil_Isa
  def nil?: ...
  def is_a?: ...
end

さらにこれを_Eachとくっつければいいわけです。

_Each[Integer] & _Nil_Isa

こんな感じで、型同士を足し算したいときに&を使えると覚えておくとよさそうです。

要はフィルター

2つの手法はどちらもif文の&&のように、単に条件を追加してフィルターしているとも考えられます。

Foo & _ToF

という型だったら、Foo型でフィルターしつつ、かつ_ToFでもフィルターしています。こう考えると、Intersection型とも友達になれるんじゃないでしょうか。

Intersection型の制約

なんでもかんでもIntersectionで繋げれるでしょうか?

例えばString & Integerとすると、「Stringのインスタンスであり、かつIntegerのインスタンスである」という意味になります。しかし、このインスタンスは存在し得ません。Rubyは1つのclassのみ継承できるので、Intersectionに書けるclassは1つまでに必然的になってしまいます。

moduleはいくらでもIntersectionで繋げられます。moduleはいくらでもincludeできるので、すべてのmoduleがincludeされていればいいわけです。

interfaceもいくらでもIntersectionで繋げられます。class/moduleはis_a?で調べてtrueが返ってくるインスタンスであればよいですが、interfaceは指定のメソッドを持っていればいいだけです。_ToF & _ToI & _ToSとつなげても全く不自然ではありません。

つまり

「Intersection型は、繋げれるのはclassは1まで、あとはいくらでもOK」

というルールが見えてきます。

moduleのself-typeもIntersection

moduleの定義にmoduleのselfが何かを設定できます。例えばEnumerable moduleは _Each をself-typeにしており、eachメソッドが使えることを保証しています。

self-typeの説明は最小限にして、このself-type、実は何個でも書けます。

module Foo : _ToI, _ToF, _ToA
end

この複数のself-typeは全ての条件を満たしていることを表しているので、Intersectionの関係にあります。 実際にsteepで存在しないメソッドを書いてみるとself-typeをのぞけるので見てみてください。

まとめ

Intersectionはともだち

RBSがテストになるとおもしろいんじゃないか日記6

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記2 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記3 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記4 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記5 - スペクトラム

追加コードなしにこだわる理由

RaaPでは基本的な姿勢として、テストコードを書かなくても動くことを目指しています。(もちろんRBSは書く) その理由を言語化してみます。

そもそもここで言うコードとは

プロパティベーステストはプロパティというルールをコードで記述して、このルールがどんな入力でも成立するかを検証するテスト手法です。 QuickCheckやproper・propcheckを読んだなら、当然以下のようなコードを思いつくでしょう。

it "2回reverseしたら元に戻るはず" do
  forall(list(integer())) do |ary|
    expect(ary.reverse.reverse).to eq(ary)
  end
end

RaaPではこういうことをできなくはない(内部のテストでは使用している)のですが、あえてファーストサポートとしないように意識しており、あくまでCLIツールとしての利用をファーストサポートとして考えています。

利用しやすさ

利用に関して、障壁は少なければ少ないほど良いです。コード書かなくていいなら最も利用しやすく、lsコマンドやtopコマンドのようにカジュアルに使うことができます。

利用される数が多ければ多いほど、良いフィードバックも増えてプロダクトの成長にもつながります。

互換性の問題からの解放

rspecではv2からv3で大きな非互換性があったことが記憶に新しいと思いますが、、、10年以上前(2013年)のことでした……。まじか。

ともかく、コードあるところに互換性の問題は常にあります。一度決めたAPIはなかなか変えれません。

しかしながらコードがなければ互換性に気を揉まなくても良くなります。よりより設計を思いついたらすぐに取り入れることができます。これはプロダクトの成長スピードに関わってくると思います。

CLIの挙動の非互換はもちろんありえますが、コードの非互換よりは小さな問題でしょう。

縛り

縛りを設けることで呪力の総量を上げるのはプログラマーの基本中の基本です。

目的を絞りコンセプトを一貫させることによって、より深いプロダクトになるはずです。

ほんとに何とかなるの?

当然全てのパターンでは成立しません。例えばArray.newはマイナスの値を入れるとエラーになるし、RBSとしてパースできる文字列しか::RBS::Parser.parse_typeは受け付けません。このような場合ではカスタムコードを書かざるをえないでしょう。なんならRaaP本体ではRubyの基本的なclassに対して結構カスタムコードを書いてしまっています。

しかしながら、書いてしまうとこれまで否定してきた問題に直面することになります。

この辺の問題とどうバランスを取っていくのかが、今後の見どころです。

RBSがテストになるとおもしろいんじゃないか日記5

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記2 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記3 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記4 - スペクトラム

続シンボリックコール

シンボリックコールは、前回紹介したようにメソッドの呼び出しを遅延評価するデータ構造である。

ザックリとしか紹介できていなかったので、ここで深掘りしておく。

シンボリックコールは次のようなシンプルなデータ構造だ。

[:call, Integer, :sqrt, [4], {}, nil]

この構造は次のRubyコードをシンボリックコールで表現したものだ。

Integer.sqrt(4)

最初に:callがあることで、ありえそうなArrayデータと区別しやすくしている。

次のIntegerはレシーバーだ。

次の:sqrtはレシーバーから呼び出すメソッド名。

次の[4]は位置引数。

次の{}はキーワード引数。

次のnilはブロック引数を表した実際の値となっている。

Rubyにおけるオブジェクト指向的にはclassにしたほうがいいのだろうが、シンボリックコールの発祥であろうQuickCheckをリスペクトした形だ。また、単純なタプルだとinspectで単純に表示できるし、パターンマッチでマッチするように書いても自然だ。

余談だが、raapを作るにあたってerlangとelixirとhaskellを少し学んだので、raapではこれらをリスペクトしてパターンマッチやメソッドの再起呼び出しを多用している。

ネスト

このシンボリックコールはネストできる。例えば前の例を拡張してみよう。

i = "4".to_i
Integer.sqrt(i)

上のコードをシンボリックコードで表現するとこうなる。

[:call,
  Integer, :sqrt, [
    [:call, "4", :to_i, [], {}, nil]
  ],
{}, nil]

位置引数部分でネストしていることがわかる。

さらに複雑な例をraapのテストコードから引用する。

test_a = Test::A.new()
test_b = Test::B.new()
test_c = Test::C.new(a: test_a, b: test_b)
test_c.run()

を表現すると次のようになる。

[:call,
  [:call, Test::C, :new, [], {
    a: [:call, Test::A, :new, [], {}, nil],
    b: [:call, Test::B, :new, [], {}, nil],
  }, nil],
:run, [], {}, nil]

このように、メソッド呼び出しの結果を利用しなければならないという制限はあるものの、raapでイメージしている型からの値生成では十分に柔軟なものだと思われる。

なににつかうの

さて、このシンボリックコールが何の役に立つかというと、"ランダムな値"の生成時にこの値を生成してしまうのではなく、シンボリックコールで生成するのだ。

単純な例えだと、あるFloatの値が欲しいとしたら、

Random.rand

とするのではなく、

[:call, Random, :rand, [], {}, nil]

としておくのだ。

"ランダムな値"を取得すると、このシンボリックコールが手に入り、このシンボリックコールを実際に実行することで"ランダムな値"を取得する。

なんでこんな回りくどいことを?

シンボリックコールはinspectするだけでも、なんとなく何がしたいのかわかるが、そこまでフレンドリーでもない。 そこで、このシンボリックコールからRubyのコードに戻すことを考えた。これはQuickCheckでもproperでもやっていない。

つまり、これを

[:call, Random, :rand, [], {}, nil]

こうする。

Random.rand

これを、

[:call,
  [:call, Test::C, :new, [], {
    a: [:call, Test::A, :new, [], {}, nil],
    b: [:call, Test::B, :new, [], {}, nil],
  }, nil],
:run, [], {}, nil]

こうするのだ。

test_a = Test::A.new()
test_b = Test::B.new()
test_c = Test::C.new(a: test_a, b: test_b)
test_c.run()

こうすることによって、もしテストが失敗した場合に、かなり実際に近い"再現コード"を手に入れることができる。

"ランダムな値"を手に入れるために、さまざまな選択肢が存在する。

例えば Integer? なら Integernil かをランダムに選択している。

Integer | Stringだったら、 IntegerString かでランダムに選択している。

選択肢があると、引数に使われた42はどうやって作られた42なのかわからなくなる。

再現コードがあれば、問題の再現はもちろん、raapがどういう選択をして"ランダムな値"を選択したのかもわかる。

これはさすがに便利なんじゃない?

どうやってRubyコードにするの?

"実装を見てくれ"の一言だが、軽く説明する。

まずこのシンボリックコールを再起的に渡り歩いていく。

トップレベルがシンボリックコールでないならその値が最終的な値。

シンボリックコールなら、レシーバー、位置引数、キーワード引数の順に見ていき、これがシンボリックコールなら、またレシーバー、位置引数、キーワード引数と見ていく。

再起的に見ていくと、もうこれ以上深ぼれないシンボリックコールにたどり着くので、これから文字列化していく。 文字列化したら最終結果にためておき、"変数名"を戻す。

変数

この変数は現状かなりザックリつくっており、module/class名をdowncaseしただけというものになっている。名前が被ってしまうことがあるが、あんまりいい解決策は見つかっていない(なんとかしたい)。

変数が返ってきたらこの変数をRubyコードに文字列として組み込んでやる。再起が戻っていけば、自然と上からRubyコードを実行したかのように文字列が組み上がるという寸法だ。

つなみに"ランダムな値"は、変数を返すのではなく、シンボリックコードを実行した結果を返す。

[:call, Random, :rand, [], {}, nil]

だったら、

Random.rand

にして、

0.2585089800916408

を得るのだ。これを再起的に戻していって、レシーバーなり引数に利用して、コードを実行していく。

つまり?

シンボリックコールを使えば使うほど、再現コードが手に入る確率が上がり、便利になっていくというわけだ。

まとめ

シンボリックコールはいいぞ。

あんまり読み返してなくて眠いので読みづらいと思うが、書いて出しが長続きするのでここで一旦publishする。

RBSをテストコードにする

DALL-Eより: Imagine a scene where the abstract concepts of Ruby programming and property-based testing blend harmoniously. Picture a large, glowing ruby crystal

まいどお馴染み、作ってみたシリーズです。

今回は、RaaP(ラープ)というツールを作りました。RBS as a PropertyでRaaPです。

github.com

RaaPはテスティングツールの一種で、RBSをそのままテストコードにみたてて実行してくれるツールです。

次のようなRBSがあったとして

class Foo
end

class Bar
  def initialize: (foo: Foo) -> void
  def f2s: (Float) -> String
end

つぎのようなテストコードを自動的に作って実行してくれるイメージです。

describe Bar do
  let(:foo) { Foo.new }
  let(:bar) { Bar.new(foo: foo) }

  it "#f2s" do
    100.times do |size|
      float = Random.rand * size
      expect(bar.f2s(float)).to be_a(String)
    end
  end
end

プロパティベーステストにおけるプロパティとは、どんなランダムな値が来ても共通で成功するテストケースのことです。

RBSをプロパティとして考える、"RBS as a Property"というわけです。

実際は追加のテストコードの記述は一切必要ありません。RBSを書くだけでテストコードとしても使えるし、書いたRBSはもちろんSteep等で利用できます。これが基本的なコンセプトです。

着想

最初の着想は、golangからでした。

私はrgotという誰も使っていないテスティングツールを愛用しているのですが、これはgolangの標準テスティングライブラリーであるtesting PackageRubyでそのまま書いてみたものです。RubyKaigiのLTでも発表したことがあるので、このときにテストの考え方については自分なりのモデルを作れたと思います。

久しぶりにrgotのメンテをしようとgolangのtesting Packageをのぞいてみると、rgot実装時にはなかったFuzzingという機能が追加されていました。なんの機能かもわからずとりあえずポートしてみると、なるほど、型を指定するとランダムな値を生成してテストを実行する機能だったようで、そういう考え方もあるのかと感心しました。

実践プロパティベーステスト

そこからさらに「実践プロパティベーステスト」を読んだことで新しいテストパラダイムがあることが非常に面白く感じました。

『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』www.lambdanote.com

この本を読んでいる時に、いろいろな考えが浮かびます。

「これをRubyで表現したらどんな感じかなあ」

「QuickCheckは型が重要なHaskellでうまれてる。RBSを使って何かできないかなあ」

「このinteger()っていうジェネレーター、RBSから作れないかなあ」

RBSを書いたらテストコードを自動生成するのはどうだろう?」

「っていうかそのまま実行したらいいんじゃない?」

「そうすればテストコードの互換性とかで悩まなくても済むし、コードを書かなくてもいきなり使える手軽さもあるし、コードのテストもRBSのテストもできるし、最悪ツールを使わなくなってもRBSは残るんじゃない?」

「これやばくない?」

とそんな感じで開発してみたものになります。

実績からみる使用例

このツールは開発中でも多くの成果を出しています。

Symbol#=~

簡単な例から紹介しましょう。

https://github.com/ruby/rbs/pull/1704

ここではSymboldef =~: (untyped obj) -> Integer?を例に見てみます。

untypedはいわゆるany型で、全ての型がありえることを表します。

しかしながら、実際はなんでもいいわけではありません。

:sym =~ 1
# => undefined method `=~' for an instance of Integer (NoMethodError)

Integerを引数に渡しても絶対に成功しません。このままではSteepでチェックするとIntegerを渡しても型エラーになりません。このRBSは不十分であることがわかります。

これまでruby/rbsリポジトリーでは、RBSの記述の確かさはどのように確認されていたのでしょうか?

もちろん、テストが全く無かったわけではありません。

Symbol#=~のためには、以下のテストが用意されていました。

    assert_send_type "(Regexp) -> Integer",
                     :a, :=~, /a/
    assert_send_type "(nil) -> nil",
                     :a, :=~, nil

https://github.com/ruby/rbs/blob/03e1ad0d9925c4e107bed8859f26739cfd848ce8/test/stdlib/Symbol_test.rb#L40-L45

おそらくRBS明瞭期、大量にあるcoreメソッド一つ一つ確認していくことは難しく、メソッド名だけでもと型が用意されたのでしょう。それくらい正確なRBSを考えるのは人間には大変な作業なのです。

しかしながらこれでは、RBSの表現に対してテストパターンが足りなかったわけです。

RaaPで試してみましょう。

$ bundle exec raap 'Symbol#=~'
# Symbol

## def =~: (untyped obj) -> ::Integer?
SE, [2024-03-22T22:45:57.257849 #92819] ERROR -- : [TypeError] type mismatch: String given
F
Failed in case of `:v.=~(:r) -> nil[NilClass]`

### call stack:

```
:v.=~(:r)
```

success: 0, skip: 1, exception: 0

Fail:
def =~: (untyped obj) -> ::Integer?

:v.=~(:r)というケースで、TypeErrorが起きたと報告しています。

untypedという表現から、様々なオブジェクトで呼び出しを試み、TypeErrorが起きるパターンを、再現コード付きで教えてくれました。

機械的にテストケースを生成して試すことで、人間には大変だったテストケースの生成を大量に行うことができ、RBSの不十分な点がCLIコマンド一発で見つかりました。

Integer#pow

もう少し大きい例を紹介します。

https://github.com/ruby/rbs/pull/1706

これは以前の開発日記でも紹介したように、型の専門家であっても気付けない難しい型です。

RaaPなら簡単に再現コード付きで型の間違いを教えてくれます。

$ bundle exec raap 'Integer#pow'
# Integer

## def pow: (::Integer other, ?::Integer modulo) -> ::Integer
EI, [2024-03-22T22:53:31.836090 #93160]  INFO -- : Exception: [ZeroDivisionError] divided by 0
..EI, [2024-03-22T22:53:31.836288 #93160]  INFO -- : Exception: [ZeroDivisionError] divided by 0
..F
Failed in case of `2.pow(-1) -> (1/2)[Rational]`

### call stack:

```
2.pow(-1)
```

success: 4, skip: 0, exception: 2

## def pow: (::Float) -> ::Float
...F
Failed in case of `-2.pow(-4.25) -> (0.037162722343835025-0.037162722343835025i)[Complex]`

### call stack:

```
-2.pow(-4.25)
```

success: 3, skip: 0, exception: 0

## def pow: (::Rational) -> ::Rational
..F
Failed in case of `3.pow((3/11)) -> 1.3493480275940617[Float]`

### call stack:

```
rational = Rational(-3, -11)
3.pow(rational)
```

success: 2, skip: 0, exception: 0

## def pow: (::Complex) -> ::Complex
....................................................................................................
success: 100, skip: 0, exception: 0

Fail:
def pow: (::Integer other, ?::Integer modulo) -> ::Integer
Fail:
def pow: (::Float) -> ::Float
Fail:
def pow: (::Rational) -> ::Rational

3つのケースで間違いがあることを、再現コード付きで教えてくれました。 Rationalを使用したケースではcall stackという再現コードが2行に分かれています。 これをそのまま実行すれば、型がおかしかったパターンを再現できるわけです。

String#initialize

https://bugs.ruby-lang.org/issues/20292

開発途中でRubyのバグも見つけ報告しています。こんなコード人間はまず書かないと思いますが、ランダムな値によるテストならみつけ出せたので可能性を感じます。現在のRaaPでは、publicメソッドのみ確認するように変更したので再現コードは作り出せません。privateメソッドはオプショナルで実行できてもいいのかな……?

どうやって見つけたの?

RaaPではルートとなるmodule/class名を指定すれば、その名前で定義されているメソッド全てでテストできるようになっています。

出力が小さそうな既知のclassで試してみます。

$ bundle exec raap 'TrueClass'
# ::TrueClass#!

## def !: () -> false
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#===

## def ===: (true) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

## def ===: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#inspect

## def inspect: () -> "true"
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#to_s

## def to_s: () -> "true"
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#to_json

## def to_json: (?::JSON::State state) -> ::String
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#&

## def &: (false | nil) -> false
....................................................................................................
success: 100, skip: 0, exception: 0

## def &: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#^

## def ^: (false | nil) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

## def ^: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#|

## def |: (untyped obj) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

複数のメソッドを横断的に確認することができました。

他にもいくつかruby/rbsRBSでの間違いをみつけています。

これから

このツールには可能性を感じているので、さらなる有用性を探究していきたいと思います。

RBSからのランダム生成だと、どうしてもカスタムなコードがないと無理なケースも多く存在します。 そのため、あくまで第一優先はCLIからの利用と考えつつも、既存のテストコードに組み込める表現も考えています。

これまではCRubyのcoreなclassに対して実験してきたので、実装はかなり信頼におけるケースが多かったのですが、 実際のアプリケーションでどのような有用性を発揮できるか確認できていないので、その辺も探っていきたいと思います。

無理矢理いいところを探すんじゃなくて課題ベースで考えたらって?

はは。