Ruby: parser gemで未解決の定数を検出する(翻訳)
最近の私たちは、とあるRubyアプリで古くなったスタックをアップグレードする作業に従事しています。このアプリケーションはRuby 2.4で動いていました。使われていないgemを50個も削除し、セキュリティアップデートをかけ、非推奨警告を消し去って、いよいよRuby本体をアップグレードするときが来たと判断しました。
しかし、本当のお話はここから始まるのです。古いRubyの内部に興味のない方も、ぜひこのままお読みください。本記事の最後に、コードベースに隠れている未解決の定数を検出するのに役立つ強力なツールを紹介します。
🔗 トップレベルの定数探索について
Ruby 2.5で導入された大きな変更の1つは、トップレベルの定数探索が削除されたことです(44a2576f79)。つまり、Rubyの定数解決方法でbreaking changesが起きたということです。1つ例を挙げて説明しましょう。
class A
class B
end
end
class C
end
Ruby 2.4以前では、A::B::C
を呼び出したときの出力はC
になります。驚きましたか?私は驚きました。
[1] pry(main)> RUBY_VERSION
=> "2.4.5"
[2] pry(main)> A::B::C
(pry):20: warning: toplevel constant C referenced by A::B::C
=> C
この警告メッセージは、思わぬ結果を引き起こす「何か」が行われていることを示しています。
同じことをRuby 2.5以降で行うと、普通に以下のエラーが表示されます。
[1] pry(main)> RUBY_VERSION
=> "2.5.9"
[2] pry(main)> A::B::C
NameError: uninitialized constant A::B::C
from (pry):8:in `<main>'
私たちのアプリはコードベースが巨大で、しかもテストが不十分だったので、この変更でアプリが壊れる箇所をもれなく検出するスマートな方法を見出さなければならなくなりました。
比較的手軽な方法はコードベースを正規表現でgrep
することですが、その場合、それらの定数が使われている文脈で正しく解決されているかどうかを調べる必要がありました。
🔗 parser gem
Pawełが「perser gemを未解決定数の追跡ツールとして使ってみてはどうか」というアイデアを思いつきました。この強力なツールについては過去記事でも何度か取り上げています。
要するに、parser gemを使ってRubyコードを解析してAST(Abstract Syntax Tree: 構文木)を生成し、それをトラバース(traverse: くまなくスキャンする)できるようになるのです。
🔗 Processor
手始めに、Parser::AST::Processor
クラスを拡張し、on_const
メソッド(コードで定数が見つかるたびにトリガーされる)をオーバーライドしました。
class Collector < Parser::AST::Processor
include AST::Sexp
def initialize
@store = Set.new
@root_path = Rails.root
end
def suspicious_consts
@store.to_a
end
def on_const(node)
return if node.parent.module_definition?
return if node.parent.class_definition?
namespace = node.namespace
while namespace
return if namespace.lvar_type? # local_variable::SOME_CONSTANT
return if namespace.send_type? # obj.method::SomeClass
return if namespace.self_type? # self::SOME_CONSTANT
break if namespace.cbase_type? # トップレベルに到達した場合
namespace = namespace.namespace
end
const_string = Unparser.unparse(node)
if node.namespace&.cbase_type?
return if validate_const(const_string)
else
namespace_const_names =
node
.each_ancestor
.select { |n| n.class_type? || n.module_type? }
.map { |mod| mod.children.first.const_name }
.reverse
(namespace_const_names.size + 1).times do |i|
concated = (namespace_const_names[0...namespace_const_names.size - i] + [node.const_name]).join("::")
return if validate_const(concated)
end
end
store(const_string, node.location)
end
def store(const_string, location)
@store << [
File.join(@root_path, location.name.to_s),
const_string
]
end
def validate_const(namespaced_const_string)
eval(namespaced_const_string)
true
rescue NameError, LoadError
false
end
end
on_const
メソッドの以下のガード文は、定数がクラス定義やモジュール定義の一部である場合はスキップするためのものです(ここでは定数の用法だけを探索したいのです)。
return if node.parent.module_definition?
return if node.parent.class_definition?
次に、動的な用法をすべて削除します。動的な用法は検証が難しく、特殊な取り扱いが必要です。
namespace = node.namespace
while namespace
return if namespace.lvar_type? # local_variable::SOME_CONSTANT
return if namespace.send_type? # obj.method::SomeClass
return if namespace.self_type? # self::SOME_CONSTANT
break if namespace.cbase_type? # トップレベルに到達した場合
namespace = namespace.namespace
end
チェックが終わったら、フィルタで残った定数が正しく解決可能かをチェックします。
定数が明示的にトップレベルから参照されている場合は、単にその定数の評価を試みます。
それ以外の場合は、定数が使われている名前空間を考慮しながら、定数名の冒頭に完全な名前空間を追加して呼び出しを試み、次に1レベル下げて呼び出す...ということをトップレベルのバインディングに到達するまで繰り返し試みる必要があります。
const_string = Unparser.unparse(node)
if node.namespace&.cbase_type?
return if validate_const(const_string)
else
namespace_const_names =
node
.each_ancestor
.select { |n| n.class_type? || n.module_type? }
.map { |mod| mod.children.first.const_name }
.reverse
(namespace_const_names.size + 1).times do |i|
concated = (namespace_const_names[0...namespace_const_names.size - i] + [node.const_name]).join("::")
return if validate_const(concated)
end
end
最後に、解決に失敗した定数と、コードベース内の位置情報を保存します。
store(const_string, node.location)
🔗 Runner
拡張が必要なもうひとつのクラスはParser::Runner
です。Runner
の役割は、ファイルを解析してProcessor
に渡すことです。最後に、保存済みの疑わしい定数をすべて出力します。
runner =
Class.new(Parser::Runner) do
def runner_name
"dudu"
end
def process(buffer)
parser = @parser_class.new(RuboCop::AST::Builder.new)
collector = Collector.new
collector.process(parser.parse(buffer))
show(collector.suspicious_consts)
end
def show(collection)
return if collection.empty?
puts
collection.each { |pair| puts pair.join("\t") }
end
end
runner.go(ARGV)
🔗 結果
eager loadingを有効にしたうえで、Ruby 2.4とRuby 2.5でそれぞれスクリプトを呼び出して結果を比較しました。
bundle exec ruby collector.rb app/ lib/
その結果、Ruby 2.5では正しく解決できない定数が52個もあり、Ruby 2.4ではわずか7個であることが判明しました。つまり、既存のテストでは検出できないランタイムエラーになりうる原因が既にコードベースに45個も潜んでいたということです!🤯
幸い、既に使われなくなっているコードの中に潜んでいたものもあったので、そういうメソッドはそのまま無事削除できました。
🔗 ボーナス
このスクリプトを、GitHub上に置いたサンプルアプリのコンテキストで公開しました。
このconstants-resolverをぜひチェックしてみてください。collector.rbファイルをコピーして自分たちのコードベースに対して実行し、解決できない定数がアプリにあるかどうかをチェックしてみましょう。そうした定数が見つかったら、このソリューションを友だちと共有して問題を回避できるようにしましょう。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。