hitode909の日記

以前はプログラミング日記でしたが、今は子育て日記です

リポジトリ内のソースコードを機械的にリファクタリングし続けるスクリプトを作る

こんにちは、はてなのマンガチームでPerlを書いているid:hitode909です。
先日書いた、Perlのソースコードをリファクタリングし続けるスクリプトについて紹介します。

@EXPORTを撲滅したい

普段Perlを書くときに苦労していたのがテストを書くことで、さまざまなテスト用のヘルパ関数がどこからexportされているかわからない、という困りごとがありました。
私たちのプロダクトではマンガビューワを作っているので、テスト用の作品をcreate_seriesで作り、作品に紐づくエピソードをcreate_episodeで作り、エピソードに対してテストを書く、というような流れでテストを書くことが多いです。
以下の例ではGiga::Test::Core::Seriesからcreate_seriesがexportされていますが、込み入ったテストでは、このようなuseが数十行に渡って続き、どこからどの関数がexportされているかわからない!というのが困っていたことです。
@EXPORTを撲滅して、@EXPORTをすべて@EXPORT_OKに書き換え、名前付きimportに揃えることで、どのファイルから何がimportされているかが自明なものにできれば、テストを書くときに迷う時間を短縮できそうです。

# このcreate_seriesがどこから来たのかわからなくて困る
use Giga::Test::Core::Series;
ok create_series;

# こうしたい
use Giga::Test::Core::Series qw(create_series);
ok create_series;

上記のような課題に対して、同僚のid:papixが1ファイルを対象にした書き換えスクリプトを書いてくれていました。
papix.hatenablog.com


このスクリプトはPPIを使ってPerlのソースコードを書き換えてくれるもので、人間がエディタでリファクタリングするのと比べると、機械的かつ短時間で実行できて便利、結果がいつも同じなので間違いが起きづらい、という利点があるのですが、以下のように対象ファイルを人間が指定して起動する必要がありました。

$ docker-compose exec app \
  carton exec -- perl script/tools/code/kill-export.pl lib/Example.pm

1ファイルずつ実行して動作確認して…というのを繰り返すのは手間が大きく、途中で飽きてしまいそうで、できることなら機械的に対象のファイルを探して書き換えていってほしい!ということで、もうちょっと自動化をしてみます。

対象ファイルを探す→書き換え→テスト→コミット を繰り返すスクリプト

人間が置き換えをするとき、以下のような工程をたどることになります。

  • リファクタリング対象のファイルを探す
  • リファクタリング対象のファイルをスクリプトに渡し、書き換える
  • 影響のありそうなテストを実行する
  • テストが通ったら結果をコミット

どの工程も、機械的に判定できることに気づきます。

  • リファクタリング対象のファイルを探す
    • @EXPORTを使っている箇所をgit grepして探せる
  • リファクタリング対象のファイルをスクリプトに渡し、書き換える
    • 探したファイルを上記スクリプトに渡して実行すればよい
  • 影響のありそうなテストを実行する
    • git diffして、差分のあるファイルだけを探してテストを実行すればよい
  • テストが通ったら結果をコミット
    • 人間が操作していなければ、スクリプトの書き換え結果をgit commit -a -m を使ってまとめてコミットできる
  • 全ファイルの書き換えが終わるまで繰り返し

ということで書いてみたのが以下のスクリプトです。なんとなくRubyで書きました。こちらのgistにぺたっと貼っています。
普段メンテナンスしているプロダクト用のスクリプトをそのまま貼っているので、動かしてみたいという方は少々手直しが必要です。

利用回数の少ない順に書き換えていく

今回の書き換えは以下のような操作になります。

  • t/lib/以下には@EXPORTを使ったpackageがあり
    • こちらは@EXPORT_OKに書き換えたい
  • t/以下に、packageを利用しているテストファイルがある
    • こちらは名前付きimportに書き換えたい

まずは簡単なところからやっていくため、利用回数の少ないところから順にリファクタリングしていくことにしました。
簡単な場所なら、1箇所からのみ参照されているファイルを書き換えることになりますし、一番多く参照されているpackageをリファクタリングするときには、数百ファイルをまとめて書き換えることになります。
ソースコードはgitを使って管理しているので、git grep --name-onlyを使って、参照回数を数えています。雑にfindコマンドを使うとtmp/以下などに書き散らしたファイルも対象となって思ってもみない変更が起きがちだったり、gitで管理していないファイルをいきなり触ると、もとに戻せない可能性もあって危険が伴います。

target_files = `git grep --name-only -z -w '@EXPORT' t/lib/`.split(/\0/)

# 利用回数の少ない順に処理していく
used_counts = {}
target_files.each{|target_file|
    package = IO.read(target_file).scan(/package ([^; ]+)/).first.first
    used_count = `git grep --name-only -z -w 'use #{package}'`.split(/\0/).length
    used_counts[target_file] = used_count
}
target_files_order_by_used_count_asc = target_files.sort_by{|target_file|
    used_counts[target_file]
}

実装から対応するテストファイルを探す

リファクタリングが成功しているかテストするのも自動化しているのですが、全テストを手元で実行すると時間がかかるので、今回変更のありそうなファイルだけを対象にして実行しています。
このあたりのテストを実行する部分も頑張っているポイントで、普段メンテナンスしているプロダクトでは、lib/Giga/Example.pmのテストをt/Example.tに配置しているので、実装とテストファイルの対応を機械的に変換して対応づけています。
Kernel.#systemの結果が失敗していたらそこでraiseして処理を打ち切っているので、目視で壊れた部分を手直ししてコミットし、再度スクリプトを実行すると、続きからリファクタリングを再開できる作りとなっています。
最終的に本当にリファクタリングが成功しているかは、CI上でテストが通るかどうかで確認します。

def run_changed_tests
    test_target = `echo "$(git diff --name-only t lib/)\n$(git diff --cached --name-only t lib)" | perl -pe 's{^lib/Giga/}{t/}g; s{\.pm$}{.t}g;' | sort | uniq | perl -nlE 'say if -e'`.gsub(/\n/, ' ')
    return if test_target.empty?
    puts "Running tests #{test_target}"
    result = system %Q(docker-compose exec app carton exec -- prove -v #{test_target})
    unless result
        raise "test failed"
    end
end

コミットメッセージにpowered byを入れておく

機械的に変更されたコミットは、blameしたときなどに、あとからみて意図を読み取りづらい場合があります。
結果をgit commitするところは以下なのですが、powered by #{$0}をコミットメッセージに入れておくことで、このコミットがスクリプトによって機械的におこなわれたことがわかるようになっています。
結果が間違っていたときには、スクリプトを見に行くと、どこがおかしかったかわかるように手がかりを残しています。

def commit target_file
    system %Q(git commit -a -m "#{target_file} の @export 撲滅\n\npowered by #{$0}")
end

結果

上記のようなスクリプトを用意することで、ほぼ自動的に63コミット、649ファイルの書き換えに成功しました。
この63コミットにかかった時間は数時間ほどで、定時後にご飯を食べたり風呂に入ったりしている間にPCをほったらかしにしていたら、コミットがどんどん進んで便利でした。
もし仕事しているふりをしたい人がいたら、リファクタリングのスクリプトはリポジトリにコミットせず、1ファイルずつ丁寧に書き換えているという報告をすれば、しばらく遊んで暮らせそうです。

f:id:hitode909:20211222193222p:plain
63コミット、649ファイルの書き換えに成功
f:id:hitode909:20211222193030p:plain
機械的にコミットしている様子

その後も手分けして何個かPull Requestを出していくことで、プロジェクト内のテスト用パッケージ(t/lib/)からは@EXPORTを撲滅することができました。
残るはlib/以下にいくつか残っていますが、こちらはテストを書くときにはあまり参照しないので、そんなに困っている箇所ではなく、難しいポイントは乗り越えられて、ほっとしています。

まとめ

今回、以下のような知見を得られました。

  • ソースコードが大きく、機械的に書き換えられる場面では、スクリプトを使ったリファクタリングが有用
  • リファクタリング方針に沿って自力でリファクタリングを進めるスクリプトを書くことで、リファクタリングを自動化できる

GitHub上で自動的にパッチを送ってくれるbotは、こういうことをソースコードのクロールとセットでやっているわけですね。GItHubなどのコードリポジトリを対象にして、スク数のリポジトリから対象のソースコードを探す機能を入れられれば、人類滅亡後もしばらくはリファクタリングを続けることができて良さそうです。


この記事は、Perl Advent Calendar 2021の23日目でした。前日はid:papix、明日はid:karupaneruraです。おたのしみに!
qiita.com