【一網打尽】Specが書かれていないメソッドを検知する構文解析CLIツール"omochi"の紹介

## 課題

わたしは、Ruby on Rails を使ってアプリケーション開発を本業としているエンジニアです。
Railsで機能を開発すれば、当然Specも書く必要がありますよね。Specを書かないと動作確認ができていない状態でリリースしてしまう危険性があります。
しかし、開発中に多くのクラスやメソッドを書いていると、どのクラスに対して/どのメソッドに対してSpecを書いたのか忘れてしまいます。そうすると、十分に動作を検証していない状態で、PRがマージされてしまうことがあります。
Railsのアプリケーションにおいて、どのメソッドにSpecが書かれていて、どのメソッドには書かれていないのかをSpecファイルを見なくても明らかにしたい!そんなふうに考えたことはないでしょうか。
わたしは、実装を見た瞬間に対応するSpecが存在することが保証されている（または、実装側にSpecが不要であることが明示されている）ようにしたいと考え、静的解析の技術を使ったCLIツール "omochi" を作成しました。

## 作成したCLI "omochi"

>Ruby on Railsの開発を支援するCLIツールです。
未着手のRSpecファイルとメソッドを出力し、それに付随するサンプルコードを生成します。

https://github.com/mikik0/omochi

こちらのツールの概要としては、大きく分けて２つの機能があります。
1つ目が、「対応するSpecが存在しない全てのメソッドを列挙する機能」です。

```shell
$ omochi verify
"Verify File List: [\"lib/omochi/cli.rb\", \"lib/omochi/util.rb\"]"
"specファイルあり"
======= RESULT: lib/omochi/cli.rb =======
- exit_on_failure?
"specファイルなし"
======= RESULT: lib/omochi/util.rb =======
- local_diff_path
- github_diff_path
- remote_diff_path
- get_ast
- dfs
- find_spec_file
- get_pure_function_name
- dfs_describe
- print_result
- get_ignore_methods
- create_spec_by_bedrock
===================================================================
```

このように、Specが実装されていないメソッドが全て列挙されます。
これによって、大きな機能を開発している最中にもSpecがないメソッドが一覧で見れるので、Specの書き忘れを防止できます。また、ひとつひとつ丁寧に対応するSpecがあるか手作業で確認する手間が省けます。

CI上で動かすことも想定されていて、github であれば、'--github'オプションを付けることで、Specの書いていないメソッドがあれば、CIを落とすことができるようになっています。
「全てのメソッドにSpecを書きたいわけじゃないよ」って思うかもしれませんが、不要なメソッドには以下のようなコメントアウトをつけることで、omochi から ignore することができるようになっています。

```ruby
#omochi:ignore:
```

もしも、CIでomochiを実行しているプロジェクトの場合、Specの書いてないメソッドにはすべてこのコメントアウトがついていることになります。裏を返せば、コメントアウトがなければどこかに対応したSpecが存在することになるので、冒頭で述べたような「実装を見た瞬間に対応するSpecが存在することが保証されている」と言えるようになっています。PRのレビューの際にも、テストすべきメソッドにこのコメントがついていれば、テスト書いてねって言えますし、逆にコメントがついていなければ何かしらのテストが書かれているので差分を追っていれば必ずどこかにSpecがあるわけです。そういったコードレビューの際の認知負荷を軽減させることもできるようになります。

２つ目の機能が、RSpecのコードの雛形を自動生成する機能です。
こちらは、Amazon Bedrockの機能を活用しているので、各自でAWSのセットアップが必要になりますが、
`-c`オプションを付与することで有効になります。
Spec未作成のメソッドに対応するRSpecのコードの雛形を自動生成してくれます。
こちらの機能を利用することによって、テストケース作成の手間が省けます。実際に動かしてみると以下のように動作します。

```shell
$ omochi verify -c
"Verify File List: [\"lib/omochi/cli.rb\", \"lib/omochi/util.rb\"]"
"specファイルあり"
===================================================================
verifyのテストを以下に表示します。
require 'rspec'

describe 'exit_on_failure?' do
  it 'returns true' do
    expect(exit_on_failure?).to eq(true)
  end
end
======= RESULT: lib/omochi/cli.rb =======
- exit_on_failure?
"specファイルなし"
======= RESULT: lib/omochi/util.rb =======
- local_diff_path
- github_diff_path
- remote_diff_path
- get_ast
- dfs
- find_spec_file
- get_pure_function_name
- dfs_describe
- print_result
- get_ignore_methods
- create_spec_by_bedrock
===================================================================
lib/omochi/util.rbのテストを以下に表示します。
require "spec_helper"

describe "local_diff_path" do
  it "returns array of diff paths from git" do
    allow(Open3).to receive(:capture3).with("git diff --name-only", any_args).and_return(["path1", "path2"], "", double(success?: true))
    expect(local_diff_path).to eq(["path1", "path2"])
  end

  it "returns empty array if git command fails" do
    allow(Open3).to receive(:capture3).with("git diff --name-only", any_args).and_return("", "error", double(success?: false))
    expect(local_diff_path).to eq([])
  end
end

describe "github_diff_path" do
  it "returns array of diff paths from gh" do
    allow(Open3).to receive(:capture3).with("gh pr diff --name-only", any_args).and_return(["path1", "path2"], "", double(success?: true))
    expect(github_diff_path).to eq(["path1", "path2"])
  end

  it "returns empty array if gh command fails" do
    allow(Open3).to receive(:capture3).with("gh pr diff --name-only", any_args).and_return("", "error", double(success?: false))
    expect(github_diff_path).to eq([])
  end
end

describe "remote_diff_path" do
  it "returns array of diff paths from remote" do
    allow(Open3).to receive(:capture3).with(/git diff --name-only .*${{ github\.sha }}/, any_args).and_return(["path1", "path2"], "", double(success?: true))
    expect(remote_diff_path).to eq(["path1", "path2"])
  end

  it "returns empty array if git command fails" do
    allow(Open3).to receive(:capture3).with(/git diff --name-only .*${{ github\.sha }}/, any_args).and_return("", "error", double(success?: false))
    expect(remote_diff_path).to eq([])
  end
end

describe "get_ast" do
  it "returns AST for given file" do
    allow(File).to receive(:read).with("file.rb").and_return("code")
    allow(Parser::CurrentRuby).to receive(:parse_with_comments).with("code").and_return(["ast"], ["comments"])
    expect(get_ast("file.rb")).to eq([{ast: "ast", filename: "file.rb"}])
  end
end

describe "dfs" do
  let(:node) { double(:node, type: :def, children: [double(:child, children: ["name"])]) }
  let(:result) { {} }

  it "traverses node and captures def names" do
    dfs(node, "file.rb", result)
    expect(result).to eq({"name" => "def name\nend"})
  end
end

describe "find_spec_file" do
  before do
    allow(File).to receive(:exist?).with("spec/app/file_spec.rb").and_return(true)
  end

  it "returns spec file path if exists" do
    expect(find_spec_file("app/file.rb")).to eq("spec/app/file_spec.rb")
  end

  it "returns nil if spec file does not exist" do
    allow(File).to receive(:exist?).with("spec/app/file_spec.rb").and_return(false)
    expect(find_spec_file("app/file.rb")).to be_nil
  end
end

# similarly test other functions
```

雛形自体は標準出力に出されるだけなのでコピペする形で利用しますが、0からテストコードを書くよりも楽になります。

## 実装

続いて、上記で説明したような機能をどのように設計したかについて説明します。

1. `git diff` を取得する。--github オプションでは、`gh pr diff` を取得する
2. 差分のあったファイルの中から `.rb` だけを全て取得する
3. `.rb` ファイルを parser gem を用いて、抽象構文木(AST) にパースする
4. 取得したASTに対して、深さ優先探索(DFS)を用いて、全てのメソッドを取得する
5. 取得した全てのメソッドに対応するSpecがあるかどうかを確認するため、対応するSpecファイルを取得する
6. 取得したSpecファイルでも同様に、parser gem を用いて、抽象構文木(AST) にパースする
7. 取得したSpecファイルのASTにおいても同様に、深さ優先探索(DFS)を用いる。　describe に対応するメソッドが記述されているかを確認する。ここでは、 describe に、テストしたいメソッドのメソッド名が入っていることを前提としている。
8. Specが実装されていないメソッドが存在すれば出力し、異常終了する。Specが実装されていないメソッドが存在しなければ正常終了する。
9. --create オプションでは、Specが実装されていないメソッドに対するSpecの雛形を生成する。この際には、Amazon Bedrockを用いる。




## 苦労したこと

次に、Omochi の開発過程で直面した課題と、それらをどのように克服したかについて紹介します。


### プライベートメソッドを検知の対象外にしたかった

全てのメソッドを検知対象にするのではなく、プライベートメソッドは対象外にしたいと考えていました。
しかし、その機能を実装することはできませんでした。

まず、最初に考えたことは、RubyプログラムをAST（抽象構文木）に変換した際に、privateをシンボルとして検知し、メソッドの除外をおこなうということをしたかったのですが、できませんでした。
実は、Rubyでは `private`は特別なキーワードではなく、単なるメソッドであるからです。私はこの課題に直面するまでこの事実を知りませんでした。
他のプログラミング言語では、Private関数は構文として提供されていて、Parserの機能だけでその関数がPublicかPrivateかがわかることが多いです。Rubyでは、`private`は単にメソッドなので実行しないとわからないんです。そのため、今回のように静的解析をする(すなわちプログラムを実行しない)場合には、その関数がPublicかPrivateかを取得することはできません。
[ruby-jp のSlackで質問させていただいた](https://ruby-jp.slack.com/archives/CLWSHA76V/p1705845705018939)ところ、以下のようなケースもあるということもご指摘いただきました。([ぺんさん](https://twitter.com/tompng)ご回答ありがとうございます。)

```ruby
class A
  private
  class ::A # class A自身
    def a;end # public
    private
    def b;end # private
    public
    def c;end # public
  end
  def d;end # private
end
```

このような場合では、privateというキーワードの直下にある関数でさえ、 public であり、public と　private が入れ子になってしまいます。そのため、静的解析で厳密に管理するには実装が非常に複雑になってしまいます。なので、privateメソッドを検知の対象外にするという機能は現時点では諦めました。

将来的にはprivateメソッドを検知するために、privateが出現した場合にis_private=trueとするような対処法を考えています。

### Rubyのコメントアウトの取り扱いに苦戦
Omochi では、Spec 実装が不要なメソッドの前にコメントアウトを挿入することで、警告を抑制できます。

当初は、`[parse_with_comments](https://docs.ruby-lang.org/en/master/Prism/Translation/Parser.html#method-i-parse_with_comments)` を使用してコメントアウトを取得しました。
しかし、コメントは AST に組み込まれることはなく、単なる一覧として出力されるため、コメント前後の コードの情報を取得することができず、実装に難航しました。

そのため、Rubyの構文としてコメントを捉えることは諦め、ソースコードを1行ずつ文字列としてチェックすることにしました。
ロジックとしては、ソースコードをテキストファイルとして読み取り、1行ずつ上から順番に見ていきます。

```ruby
line.match(/omochi:ignore:*/) && line.strip.start_with?('#')
```

によって、`omochi:ignore:`のコメントアウトを探します。
このコメントアウトの次にくる def が ignore の対象です。

```ruby
line.match(/\s*def\s+(\w+)/)
```

この関数は、ignoreするものとして omochi は記録しておくので、対応するSpecがなかったとしても、異常終了しないように動きます。
これによって、コメントアウトによる回避を実現しました。

### 1回の深さ優先探索(DFS)で必要な値を全て取ってくるべきかどうか

コードをプロンプトに入力するには、いくつかの課題がありました。

* コードをどのように渡すのか？
* 同じコードを何度も解析したくない。
* ファイル名も渡したい。

これらの課題を解決するために、以下の方法を採用しました。

1. **`get_ast(diff_path)`メソッドの活用**: 元々、このメソッドはファイル名とASTを詰めたハッシュを返す機能を持っていました。
2. **createオプション**: このオプションが設定された場合、`get_ast(diff_path)`メソッドの結果をアンパースすることで、プロンプトに出力したいspecに該当するメソッドとそのファイル名を渡すことができます。

上記の工夫により、1回の深さ優先探索(DFS)で必要な値を全て取得することができました。具体的には、以下の情報を取得しています。

* プロンプトに出力したいspecに該当するメソッド
* メソッドのファイル名

これにより、コードを何度も解析することなく、必要な情報を効率的に取得することが可能になりました。

### CLI フレームワークの選定
Omochi は、CLIツールなので、コマンドラインインターフェイス作成に適したフレームワークがあると便利だと思いました。

Ruby では、CLIの定番のフレームワークをなかなか見つけられませんでした。当初は Ruby 標準ライブラリの [OptionParser](https://docs.ruby-lang.org/ja/latest/class/OptionParser.html) を検討しましたが、最終的に [Thor](https://github.com/rails/thor) を採用しました。
Thor はフル機能の CLI フレームワークであり、コマンドラインツールの作成に特化しています。
機能が豊富で実装が容易という点もあるのですが、CLIツールとして配布することを考えた時に、Option Parserの場合は、リッチなエントリーポイントを作るのが難しそうだったのですが、Thorの場合は、code generator が用意されているので、簡単にエントリーポイントを作ることができたためこちらを採用しました。実際に、以下のコマンドからインストールすると、`omochi`コマンドとして利用できます。

```
gem specific_install -l https://github.com/mikik0/omochi.git
```

## 今後の展望
Omochi は、開発初期段階であり、以下の機能強化を予定しています。

* コード構造の改善による可読性向上
* Request spec 対応
* Omochi自体のSpecを増やす
* GitLab 対応

Omochi は、今後も Ruby on Rails 開発を支援するCLI ツールとして、進化し続けていく予定です。