Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: parser gemで未解決の定数を検出する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

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を未解決定数の追跡ツールとして使ってみてはどうか」というアイデアを思いつきました。この強力なツールについては過去記事でも何度か取り上げています。

whitequark/parser - GitHub

要するに、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上に置いたサンプルアプリのコンテキストで公開しました。

arkency/constants-resolver - GitHub

このconstants-resolverをぜひチェックしてみてください。collector.rbファイルをコピーして自分たちのコードベースに対して実行し、解決できない定数がアプリにあるかどうかをチェックしてみましょう。そうした定数が見つかったら、このソリューションを友だちと共有して問題を回避できるようにしましょう。

関連記事

Rails: Active Recordのfindで怖い思いをした話(翻訳)

Rails: アプリケーションを静的解析で”防弾”する3つの便利ワザ(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。