PathScannerのバンドルパス判定とコード統合の改善
PathScannerのnative_callにおけるバンドルパス判定のバグを修正し、ruby_callとnative_callの共通処理をprepare_scanヘルパーメソッドに抽出することで、両実装間の動作差異を最小化しました。
背景
#526のレビュー中に、PathScannerのruby_callとnative_callの間に複数の動作差異が発見されました。特にnative_callは、ネストしたディレクトリスキャンにおいて相対パスと絶対パスのBUNDLE_PATHを比較するバグを含んでいました。
バンドルパスのチェックは、ロードパスに.が含まれ、バンドルパスが.bundleである場合など、スキャン対象パスがバンドルパスの祖先ディレクトリになるケースで重要です。この状況では、バンドルパス内への再帰を防ぐ必要があります。バンドルパス内のコードは他のロードパス項目に存在するため、スキャンする必要がないためです。
技術的な変更
バンドルパス判定のバグ修正
native_callのdirs.map!処理後、dir変数は相対パスになりますが、BUNDLE_PATHは絶対パスのままでした。このため、パス比較が常に失敗していました。
変更前:
dirs.map! do |dir|
next if ignored_dir_names&.include?(dir)
next if contains_bundle_path && File.join(root_path, dir).start_with?(BUNDLE_PATH)
absolute_dir = File.join(current_dir, dir)
next if ignored_abs_paths&.any? { |p| absolute_dir.start_with?(p) }
dir
end
変更後:
dirs.map! do |dir|
next if ignored_dir_names&.include?(dir)
absolute_dir = File.join(current_dir, dir)
next if contains_bundle_path && absolute_dir.start_with?(BUNDLE_PATH)
next if ignored_abs_paths&.any? { |p| absolute_dir.start_with?(p) }
dir
end
absolute_dirの計算を前に移動し、絶対パス同士での比較に変更しました。.bundleのようなドット始まりのディレクトリは別の条件でスキップされるため、このバグは多くのケースで顕在化しませんでしたが、BUNDLE_PATHが/path/to/vendor/bundleのようなドットプレフィックスを持たないパスに設定されている場合は失敗していました。
共通処理の抽出
ruby_callとnative_callの初期化処理をprepare_scanヘルパーメソッドに統合しました。
def prepare_scan(root_path)
root_path = File.expand_path(root_path.to_s).freeze
contains_bundle_path = BUNDLE_PATH.start_with?(root_path)
ignored_abs_paths, ignored_dir_names = ignored_directories.partition { |p| File.absolute_path?(p) }
ignored_abs_paths = nil if ignored_abs_paths.empty?
ignored_dir_names = nil if ignored_dir_names.empty?
[root_path, contains_bundle_path, ignored_abs_paths, ignored_dir_names]
end
このメソッドは以下の処理を一元化します:
-
パスの正規化:
File.expand_pathによる絶対パスへの変換とfreeze -
バンドルパス判定:
BUNDLE_PATHがスキャン対象の子孫かどうかの確認 - 無視ディレクトリの分類: 絶対パスと相対パス(ディレクトリ名)への分割
walkメソッドの最適化
ruby_callから呼ばれるwalkメソッドが、再帰呼び出しごとに無視ディレクトリリストを再計算していた処理を改善しました。
変更前:
def walk(absolute_dir_path, relative_dir_path, &block)
# ...
ignored_abs_paths, ignored_dir_names = ignored_directories.partition { |p| File.absolute_path?(p) }
# ...
end
変更後:
def walk(absolute_dir_path, relative_dir_path, ignored_abs_paths, ignored_dir_names, &block)
# ...
end
prepare_scanで事前に分割したignored_abs_pathsとignored_dir_namesを引数として受け取ることで、再帰呼び出しごとのpartition処理を排除しました。
テストの改善
test/load_path_cache/path_scanner_test.rbに、バンドルパスがドットプレフィックスを持たない場合のテストケースを追加しました。また、stub_constヘルパーをtest/test_helper.rbに移動し、テスト全体で再利用可能にしています。
def test_ignores_bundle_path_without_dot_prefix
Dir.mktmpdir do |dir|
bundle_dir = "#{dir}/vendor/bundle"
stub_const(Bootsnap::LoadPathCache::PathScanner, :BUNDLE_PATH, bundle_dir) do
with_ignored_directories([]) do
FileUtils.mkdir_p(bundle_dir)
FileUtils.mkdir_p("#{dir}/lib")
FileUtils.touch("#{bundle_dir}/a.rb")
FileUtils.touch("#{dir}/lib/b.rb")
entries = PathScanner.call(dir)
assert_equal ["lib/b.rb"], entries.sort
end
end
end
end
このテストは、元のコードでは失敗するケースを捕捉します。
設計判断
共通処理の抽出により、ruby_callとnative_callの実装差異を最小化する方針が採られました。両メソッドは異なる実装戦略(Pure Rubyとネイティブ拡張)を取りますが、スキャン前の準備処理は同一であるべきです。prepare_scanへの統合により、一方にのみ適用される修正や、変数名の不一致による混乱を防ぎます。
変数名の統一も重要な改善点です。ruby_callのpathパラメータをroot_pathにリネームし、両メソッドで一貫した命名規則を使用することで、実装の対比が容易になりました。
walkメソッドへの引数追加は、パフォーマンスと設計の明確さのトレードオフです。引数が増えることでシグネチャは複雑になりますが、再帰呼び出しごとの無駄な計算を排除し、無視ディレクトリリストが呼び出し全体で不変であることを明示しています。
まとめ
本PRは、PathScannerのnative_callにおけるバンドルパス判定のバグを修正し、ruby_callとnative_callの共通処理を統合することで、両実装の保守性と一貫性を向上させています。prepare_scanの導入により、今後の機能追加や修正が両実装に確実に反映される基盤が整いました。