blob: 799c3510d9d653a04068f7e54d815c9cae65d1bb [file] [log] [blame]
# frozen_string_literal: true
require 'json'
require 'set'
# Dirs that affect all bindings - changes here trigger "run all tests"
HIGH_IMPACT_DIRS = %w[common rust/src javascript/atoms javascript/webdriver/atoms].freeze
HIGH_IMPACT_PATTERN = %r{\A(?:#{HIGH_IMPACT_DIRS.map { |d| Regexp.escape(d) }.join('|')})(?:/|$)}
# ./go bazel:affected_targets --> HEAD^..HEAD with default index
# ./go bazel:affected_targets abc123..def456 --> explicit range
# ./go bazel:affected_targets abc123..def456 my-index --> explicit range with custom index
# ./go bazel:affected_targets my-index --> HEAD^..HEAD with custom index
desc 'Find test targets affected by changes between revisions'
task :affected_targets do |_task, args|
values = args.to_a
index_file = values.find { |value| File.exist?(value) }
range = (values - [index_file]).first || 'HEAD'
index_file ||= 'build/bazel-test-file-index'
base_rev, head_rev = if range.include?('..')
range.split('..', 2)
else
["#{range}^", range]
end
puts "Commit range: #{base_rev}..#{head_rev}"
changed_files = `git diff --name-only #{base_rev} #{head_rev}`.split("\n").map(&:strip).reject(&:empty?)
puts "Changed files: #{changed_files.size}"
targets = if changed_files.any? { |f| f.match?(HIGH_IMPACT_PATTERN) }
BINDING_TARGETS.values
elsif File.exist?(index_file)
affected_targets_with_index(changed_files, index_file)
else
puts 'No index found, using directory-based fallback'
affected_targets_by_directory(changed_files)
end
if targets.empty?
puts 'No test targets affected'
File.write('bazel-targets.txt', '')
else
puts "Found #{targets.size} affected test targets"
File.write('bazel-targets.txt', targets.sort.join(' '))
targets.sort.each { |t| puts t }
end
end
# ./go bazel:build_test_index --> 'build/bazel-test-file-index'
# ./go bazel:build_test_index my-index --> 'my-index'
desc 'Build test target index for faster affected target lookup'
task :build_test_index, [:index_file] do |_task, args|
output = args[:index_file] || 'build/bazel-test-file-index'
# Flat index: file path → [test targets]
index = Hash.new { |h, k| h[k] = [] }
tests = []
exclude_tags = %w[manual spotbugs ie]
all_bindings = BINDING_TARGETS.values.join(' + ')
tag_exclusions = exclude_tags.map { |tag| "except attr('tags', '#{tag}', #{all_bindings})" }.join(' ')
kind = '_test' # do not match test_suite or pytest_runner
puts "Finding all test targets for #{all_bindings}, excluding: #{exclude_tags}"
Bazel.execute('query', ['--output=label'], "kind(#{kind}, #{all_bindings}) #{tag_exclusions}") do |out|
tests = out.lines.map(&:strip).select { |l| l.start_with?('//') }
end
puts "Found #{tests.size} test targets"
puts 'Building file → tests mapping...'
srcs_cache = {}
tests.each_with_index do |test, i|
puts "Processing #{i + 1}/#{tests.size}: #{test}" if (i % 100).zero?
query_test_deps(test).each do |dep|
srcs_cache[dep] ||= query_dep_srcs(dep)
add_test_to_index(index, test, srcs_cache[dep])
end
end
puts "Cached #{srcs_cache.size} dep → srcs lookups"
sorted_index = index.keys.sort.each_with_object({}) do |filepath, h|
h[filepath] = index[filepath].uniq.sort
end
FileUtils.mkdir_p(File.dirname(output))
File.write(output, JSON.pretty_generate(sorted_index))
puts "Wrote index with #{sorted_index.size} files to #{output}"
end
def query_test_deps(test)
deps = []
Bazel.execute('query', ['--output=label'], "deps(#{test}) intersect //... except attr(testonly, 1, //...)") do |out|
deps = out.lines.map(&:strip).select { |l| l.start_with?('//') }
end
deps.reject do |d|
# Skip high-impact dirs and root package targets (generated files, LICENSE, etc)
HIGH_IMPACT_DIRS.any? { |dir| d.start_with?("//#{dir}") } || d.start_with?('//:')
end
rescue StandardError => e
puts " Warning: Failed to query deps for #{test}: #{e.message}"
[]
end
def add_test_to_index(index, test, srcs)
srcs.each do |src|
# Convert //pkg:file to pkg/file
filepath = src.sub(%r{^//}, '').tr(':', '/')
# Skip dotnet tests for java sources (dotnet depends on java server but has no remote tests)
next if filepath.start_with?('java/') && test.start_with?('//dotnet/')
index[filepath] << test
end
end
def query_dep_srcs(dep)
srcs = []
Bazel.execute('query', ['--output=label'], "labels(srcs, #{dep})") do |out|
srcs = out.lines.map(&:strip).select { |l| l.start_with?('//') && !l.start_with?('//:') }
end
srcs
rescue StandardError => e
puts " Warning: Failed to query srcs for #{dep}: #{e.message}"
[]
end
def find_bazel_package(filepath)
path = File.dirname(filepath)
until path.empty?
return path if File.exist?(File.join(path, 'BUILD.bazel')) || File.exist?(File.join(path, 'BUILD'))
return nil if path == '.'
path = File.dirname(path)
end
nil
end
def affected_targets_with_index(changed_files, index_file)
puts "Using index: #{index_file}"
begin
index = JSON.parse(File.read(index_file))
rescue JSON::ParserError => e
puts "Invalid JSON in index file: #{e.message}"
puts 'Using directory-based fallback'
return affected_targets_by_directory(changed_files)
end
test_files, lib_files = changed_files.partition { |f| f.match?(%r{[_-]test\.rb$|_tests?\.py$|Test\.java$|\.test\.[jt]s$|_spec\.rb$|^dotnet/test/}) }
affected = Set.new
# Just test the tests
affected.merge(targets_from_tests(test_files))
lib_files.each do |filepath|
tests = index[filepath]
if tests
puts " #{filepath} → #{tests.size} tests"
affected.merge(tests)
else
puts " #{filepath} not in index, querying for affected tests"
affected.merge(query_unindexed_file(filepath))
end
end
affected.to_a
end
def query_unindexed_file(filepath)
pkg = find_bazel_package(filepath)
return [] unless pkg
rel = pkg == '.' ? filepath : filepath.sub(%r{^#{Regexp.escape(pkg)}/}, '')
pkg = '' if pkg == '.'
# Find targets that contain this file in their srcs
containing = []
Bazel.execute('query', ['--output=label'], "attr(srcs, '#{rel}', //#{pkg}:*)") do |out|
containing = out.lines.map(&:strip).select { |l| l.start_with?('//') }
end
return [] if containing.empty?
# Find tests that depend on those targets
targets = []
Bazel.execute('query', ['--output=label'], "kind(_test, rdeps(//..., #{containing.join(' + ')}))") do |out|
targets = out.lines.map(&:strip).select { |l| l.start_with?('//') }
end
# dotnet tests depend on java server, but there are no remote tests, so safe to ignore
filepath.start_with?('java/') ? targets.reject { |t| t.start_with?('//dotnet/') } : targets
rescue StandardError => e
puts " Warning: Failed to query unindexed file #{filepath}: #{e.message}"
[]
end
def targets_from_tests(test_files)
test_files.select! { |f| File.exist?(f) }
return [] if test_files.empty?
query = test_files.filter_map { |f|
pkg = find_bazel_package(f)
next unless pkg
# Bazel srcs often use paths relative to the package, not basenames.
rel = f.sub(%r{^#{Regexp.escape(pkg)}/}, '')
"attr(srcs, '#{rel}', //#{pkg}:*)"
}.join(' + ')
return [] if query.empty?
targets = []
Bazel.execute('query', ['--output=label'], "kind(_test, #{query})") do |out|
targets = out.lines.map(&:strip).select { |l| l.start_with?('//') }
end
targets
end
def affected_targets_by_directory(changed_files)
targets = Set.new
top_level_dirs = changed_files.map { |f| f.split('/').first }.uniq
return BINDING_TARGETS.values if top_level_dirs.intersect?(%w[common rust])
top_level_dirs.each do |dir|
targets << BINDING_TARGETS[dir] if BINDING_TARGETS[dir]
end
targets.to_a
end