danger-suggesterの話
r7kamura/danger-suggester をつくったときの話。


Danger

Danger というツールがあって、こいつは CircleCI や TravisCI などの CI で RuboCop や ESLint やその他プロジェクト独自の設定と共に動かすと、GitHub の Pull Request に自動でコメントを付けてくれたりして、要はコードレビューに役立つ機能をいろいろ持っています。自分は仕事でよく Ruby を書くので、RuboCop のためのプラグイン、danger-rubocop をよく利用しています。

しかし、Pull Request を出す → CI を待つ → 怒られる → 手元で自動修正する → Push する → CI を待つ、という手順が面倒で、最近β版としてリリースされた GitHub の Suggested Changes を利用することでボタンを押すだけにできないものかなと考えていました。


Suggested changes

Suggested Changes は2018年10月16日にリリースされた機能で、要は Pull Request のインラインコメントを利用して特定の行に対する変更案を出せる、というものです。具体的には、suggestion という言語名のコードブロックをインラインコメントに埋め込むと Apply ボタン付きのコメントになって、ボタンを押すとその変更案を新たな commit として取り込めます。

2018年11月15日現在もフィードバックを集めている状態のようで、自分も過去に「複数行に対して変更案を出したい (※現在は1行に対する変更案しか出せない)」というリクエストを送っていたんですが、やはりこの手のリクエストが多く集まっているみたいです。


git-diff の利用

RuboCop にはルールに違反したコードを自動修正する機能があり、danger-rubycop にはこの違反をインラインコメントとして投稿する機能があるので、当初はこのインラインコメントが suggested changes に則った形式になるように書き換えるだけで上手くいく可能性を模索していました。しかし、RuboCop の自動修正では「この違反に対する修正結果がこれです」という情報を上手く取りにくいため、別の方法を考える必要がありそうだと考えました。

そこで「(何らかのの変換ツールを動かした結果) この行はこう書き換えられることが分かりました」というコメントを付けるツールにすることを考えました。この方法であれば、RuboCop に限らず任意の変換ツールを利用できるし、danger-rubocop などを動かしていれば違反箇所に別途コメントが付けられるはずなので、変更提案の原因もそこからある程度推測できるはずです。書き換えられるかどうかは、git-diff から得られる情報を元に判断することにしました。


Unified format

git-diff の出力は unified format (a.k.a. unidiff) という形式で表されるので、この出力を解析することで「この行はこう書き換えられることが分かりました」という情報を用意できそうです。

Diff - Wikipedia

unified format では、変更は hunk という単位にまとめられます。1つのファイルは1度に複数箇所書き換えられることもあるので、1つのファイルに対して複数の hunk が存在することもあり得ます。また、近い行にある変更は1つの hunk にまとめられることがあります。

今回の「この行はこう書き換えられることが分かりました」という用途で使うならば、1つの hunk の中での細かい変更もそれぞれ検知したいはずです。そこで、この細かい変更を最小単位として、git-diff の結果からこの変更単位を収集してくるようなプログラムをつくることにしました。説明のため、この変更単位を以下 change と呼ぶことにします。

この変更単位が複数の hunk 間にまたがって存在することは仕組み的にあり得ないだろうと思うので、a file has many hunks、a hunk has many changes という1対多の木構造の関係を考えれば良いということになります。

hunk は変更前後のファイルパスや行番号などのメタ情報と行ごとの変更情報から構成されます。行ごとの変更情報には、非変更箇所行、削除行、追加行が含まれます。そこで、hunk から前述した変更単位を切り出すためのルールとして「削除行に続く追加行のまとまりが1つの変更単位である」というルールを考えました (片方が0個の場合もある)。

いきなり unified format のデータを解析しようとすると難しいので、まずは unified format に見立てた簡単なテキストデータを、求める出力に変換するプログラム をつくりました。

これは Enumerabl#chunk_while を使う好機なのではないかと書く前から少し楽しみにしていて、実際そこそこうまく使えたような気がしています。効率はちょっと悪いですが... 仕様変更には強い気がします。このロジックは、実際のライブラリ内では この箇所 で利用しています。


Suggested changes の仕様に合わせた調整

suggested changes では、「この1行をこういう複数行にするのはどう?」という変更は提案できますが、「この複数行をこういう内容にするのはどう?」というように、複数行に対する変更は提案できません。1行ごとに変更を提案することも考えたのですが、煩わしい結果になりそうので、対象が1行になるような変更だけ提案するようにしました。

やや込み入った箇所なので興味がある方だけ見ていただければと思いますが、コードで言うと #suggestible? メソッドの箇所です。change には必ず先に deletion の列が並ぶはずなので、削除行だけ取り出すなら Enumerable#take_while が、追加行だけ取り出すなら Enumerable#drop_while が利用できます。

また、suggested changes では Pull Request で変更されていない行に対する変更は提案できないため、上述の方法で求めた提案候補の中で、提案可能なものだけを絞り込むような仕組みを用意しました。これは Pull Request の git-diff から hunk を辿って追加行を抽出することで判定できます。


danger-suggester

以上の試行錯誤によって suggested changes と git-diff でやりたいことが実現できそうだと分かったところで、danger-suggester というプラグインをつくりました。

r7kamura/danger-suggester

このリポジトリのコード自体も danger-suggester で指摘されるようになっていて、CircleCI の設定ファイルと Dangerfile でその仕組みが設定されています。

最初は danger-suggestion という名前でつくっていましたが、どうやら Danger では、利用者が Dangerfile で呼び出すことになるプラグイン参照用のメソッド名がその名前から取られる仕組みになっているようだったので、 操作自体の概念より、操作の担い手の概念を名前に冠した方がこの場合自然だろうと思い、danger-suggester という名前にしました。

下図は rubocop の自動修正結果が danger-suggester によって提案されている様子です。



余談: -able 形容詞

余談ですが、今回のライブラリのコードでは #suggestible? というメソッド名を利用しました。このようにレシーバの性質、傾向、能力、適合などを返すような述語的なメソッドを用意する場合、変換規則が分かりやすいことから動詞に -able という接尾辞を付けた形容詞をその名前に利用しがちなのですが、ほんとうにその命名規則でスケールするのか気になるところがあって、少し調べてみることにしました。

というのも、-able でつくられた単語は一般的に「...され得る」などのように受動態としての用法で使われることが多いのですが、中には accountable や changeable のように、「...する権利がある」「...しやすい」という能動態としての用法で使われる単語が存在します。そのため、動詞に -able を付けた形容詞をみだりに述語メソッドの名前に利用していては、語によっては何を表すのか分かりづらいパターンが発生してしまうのではないか、と前々から気になっていました。

調べた結果だけ述べると、それぞれの語の発生時期の分布を元に「-able の能動態としての機能が -ive の持つ機能と競合したために、それ以降の時代では機能分担が進み、-able の能動態としての役割が弱まっていったのではないか」とする説が考えられていいることが分かりました。自分の中での結論としては、能動態と受動態の両方の意味を持ってしまう -able を利用した語の利用には注意しつつ、可能なら利用を避け、もし -ableで造語をつくる場合は受動態に限定しよう、というように考えを改めました。