#!/usr/bin/env ruby ################################ # Requires ################################ require 'erb' require 'fileutils' require 'json' require 'open3' require 'optparse' require 'stringio' ################################ # Options ################################ @options = { branch: 'HEAD', iterations: 5, skip_clean: false, verbose: false, only_repos: [] } OptionParser.new do |opts| opts.on('--branch BRANCH', "Compares the performance of BRANCH against `main`") do |branch| @options[:branch] = branch end opts.on('--iterations N', Integer, 'Runs linting N times on each repository') do |iterations| @options[:iterations] = iterations end opts.on('--skip-clean', 'Skip cleaning upon completion') do |skip_clean| @options[:skip_clean] = skip_clean end opts.on('--force', 'Run oss-check even if binaries are equal') do |force| @options[:force] = force end opts.on('-v', '--[no-]verbose', 'Run verbosely') do |v| @options[:verbose] = v end opts.on('--only-repos REPO1,REPO2', Array, 'Run oss-check only on the specified repositories') do |only_repos| @options[:only_repos] = only_repos end end.parse! ################################ # Classes ################################ class Repo attr_accessor :name attr_accessor :github_location attr_accessor :keep_config attr_accessor :config attr_accessor :commit_hash attr_accessor :branch_exit_value attr_accessor :branch_duration attr_accessor :main_exit_value attr_accessor :main_duration def initialize(name, github_location, keep_config=false, config=nil) @name = name @github_location = github_location @keep_config = keep_config @config = config end def git_url "https://github.com/#{github_location}" end def to_s @name end def duration_report percent_change = 100 * (@main_duration - @branch_duration) / @main_duration faster_slower = nil if @branch_duration < @main_duration faster_slower = 'faster' else faster_slower = 'slower' percent_change *= -1 end "Linting #{self} with this PR took #{@branch_duration} s " \ "vs #{@main_duration} s on `main` (#{percent_change.to_i}\% #{faster_slower})." end end class ReportEntry < Struct.new(:file, :line, :column, :severity, :message, :rule_id) def self.from_xcode(line) # /path/to/file.swift:line:column: (warning|error): message (rule_id) match = line.match(/^(.*):(\d+):(\d+): (warning|error): (.+) \((\w+)\)$/) if match.nil? error "Could not parse line '#{line}'" return nil end ReportEntry.new(*match.captures) end def self.from_json(json) ReportEntry.new(json['file'], json['line'], json['character'], json['severity'], json['reason'], json['rule_id']) end def self.html_escape(str) "/#{ERB::Util.url_encode(str.gsub(%r{^/}, ''))}" end def to_full_message_with_linked_path(repo) "#{to_linked_relative_path(repo)}: #{severity}: #{message} (#{rule_id})" end def to_link(repo) "#{repo.git_url}/blob/#{repo.commit_hash}#{ReportEntry.html_escape(file_as_relative_path(repo))}#L#{line}" end def to_linked_relative_path(repo) "[#{file_as_relative_path(repo)}:#{line}:#{column}](#{to_link(repo)})" end def file_as_relative_path(repo) file.sub("#{Dir.pwd}/#{$working_dir}/#{repo.name}", '') end def equal_to?(other, except = []) (members - except).all? { |member| send(member) == other.send(member) } end end class Change < Struct.new(:category, :new, :old) def print_as_diff(repo, into) into.puts "- #{new.to_linked_relative_path(repo)} " into.puts " ```diff" into.puts " - #{old.send(category)}" into.puts " + #{new.send(category)}" into.puts " ```" into.puts end end ################################ # Methods ################################ def message(str) $stderr.puts('Message: ' + str) end def warn(str) $stderr.puts('Warning: ' + str) end def fail(str) $stderr.puts('Error: ' + str) exit end def perform(command, dir: nil) puts command if @options[:verbose] if dir Dir.chdir(dir) { perform(command) } else system(command) end end def validate_state_to_run if `git symbolic-ref HEAD --short || true`.strip == 'main' && @options[:branch] == 'HEAD' fail "can't run osscheck without '--branch' option from 'main' as the script compares " \ "the performance of this branch against 'main'" end end def make_directory_structure ['branch_reports', 'main_reports'].each do |dir| FileUtils.mkdir_p("#{$working_dir}/#{dir}") end end def setup_repos @repos.each do |repo| dir = "#{$working_dir}/#{repo.name}" puts "Cloning #{repo}" perform("git clone #{repo.git_url} --depth 1 #{dir} 2> /dev/null") swiftlint_config = "#{dir}/.swiftlint.yml" if !repo.keep_config FileUtils.rm_rf(swiftlint_config) end if repo.config File.open(swiftlint_config, 'a') do |file| file.puts(repo.config) end end if @only_rules_changed && @rules_changed File.open(swiftlint_config, 'a') do |file| file.puts('only_rules:') file.puts(@rules_changed.map { |rule| " - #{rule}" }) end end Dir.chdir(dir) do repo.commit_hash = `git rev-parse HEAD`.strip end end end def generate_reports(branch) @repos.each do |repo| Dir.chdir("#{$working_dir}/#{repo.name}") do perform("git checkout #{repo.commit_hash}") iterations = @options[:iterations] print "Linting #{iterations} iterations of #{repo} with #{branch}: 1" durations = [] start = Time.now command = "../builds/swiftlint-#{branch} lint --no-cache #{'--enable-all-rules' unless @only_rules_changed} --reporter json" File.open("../#{branch}_reports/#{repo}.json", 'w') do |file| puts "\n#{command}" if @options[:verbose] Open3.popen2(command) do |_, stdout, wait_thr| while line = stdout.gets file.puts line end if branch == 'branch' repo.branch_exit_value = wait_thr.value else repo.main_exit_value = wait_thr.value end end end durations << Time.now - start for i in 2..iterations print "..#{i}" start = Time.now puts command if @options[:verbose] Open3.popen2(command) { |_, stdout, _| stdout.read } durations << Time.now - start end puts '' average_duration = (durations.reduce(:+) / iterations).round(2) if branch == 'branch' repo.branch_duration = average_duration else repo.main_duration = average_duration end end end end def build(branch) puts "Building #{branch}" dir = "#{$working_dir}/builds" target = branch == 'main' ? @effective_main_commitish : @options[:branch] if File.directory?(dir) perform("git checkout #{target}", dir: dir) else perform("git worktree add --detach #{dir} #{target}") end build_command = "bazel build --config=release @SwiftLint//:swiftlint" return_value = nil puts build_command if @options[:verbose] Open3.popen3(build_command, :chdir=>"#{dir}") do |_, stdout, stderr, wait_thr| puts stdout.read.chomp puts stderr.read.chomp return_value = wait_thr.value end fail "Could not build #{branch}" unless return_value.success? perform("mv bazel-bin/swiftlint swiftlint-#{branch}", dir: dir) end def diff_and_report_changes_to_danger @repos.each { |repo| message repo.duration_report } summaries = @repos.map do |repo| if repo.main_exit_value != repo.branch_exit_value warn "This PR changed the exit value from #{repo.main_exit_value} to #{repo.branch_exit_value} when " \ "running in #{repo.name}." # If the exit value changed, don't show the fixes or regressions for this # repo because it's likely due to a crash, and all violations would be noisy next end branch = JSON.parse(File.read("#{$working_dir}/branch_reports/#{repo.name}.json")).map { |json| ReportEntry.from_json(json) } main = JSON.parse(File.read("#{$working_dir}/main_reports/#{repo.name}.json")).map { |json| ReportEntry.from_json(json) } new_violations = branch - main fixed_violations = main - branch message_changed = [] severity_changed = [] rule_id_changed = [] column_changed = [] remaining_violations = [] new_violations.each do |line| fixed = fixed_violations.find { |other| line.equal_to?(other, [:message]) } if fixed next message_changed << Change.new(:message, line, fixed) end fixed = fixed_violations.find { |other| line.equal_to?(other, [:severity]) } if fixed next severity_changed << Change.new(:severity, line, fixed) end fixed = fixed_violations.find { |other| line.equal_to?(other, [:rule_id]) } if fixed next rule_id_changed << Change.new(:rule_id, line, fixed) end fixed = fixed_violations.find { |other| line.equal_to?(other, [:column]) } if fixed next column_changed << Change.new(:column, line, fixed) end remaining_violations << line end remaining_fixed = fixed_violations - (message_changed + severity_changed + rule_id_changed + column_changed).map(&:old) # Print new and fixed violations to be processed by Danger. new_violations.each { |line| warn "This PR introduced a violation in #{repo.name}: #{line.to_full_message_with_linked_path(repo)}" } fixed_violations.each { |line| message "This PR fixed a violation in #{repo.name}: #{line.to_full_message_with_linked_path(repo)}" } # Print report in Markdown format that lists all changes by category. summary = StringIO.new summary.puts "## #{repo.name}" summary.puts summary.puts "### Message changed (#{message_changed.count})" summary.puts message_changed.each { |change| change.print_as_diff(repo, summary) } summary.puts summary.puts "### Severity changed (#{severity_changed.count})" summary.puts severity_changed.each { |change| change.print_as_diff(repo, summary) } summary.puts summary.puts "### Rule ID changed (#{rule_id_changed.count})" summary.puts rule_id_changed.each { |change| change.print_as_diff(repo, summary) } summary.puts summary.puts "### Column changed (#{column_changed.count})" summary.puts column_changed.each { |change| change.print_as_diff(repo, summary) } summary.puts summary.puts "### Other fixed violations (#{remaining_fixed.count})" summary.puts remaining_fixed.each { |violation| summary.puts "- #{violation.to_full_message_with_linked_path(repo)}" } summary.puts summary.puts "### Other new violations (#{remaining_violations.count})" summary.puts remaining_violations.each { |violation| summary.puts "- #{violation.to_full_message_with_linked_path(repo)}" } summary.puts summary.string end File.open("oss-check-summary.md", 'w') do |file| file.puts "# Summary" file.puts file.puts summaries.compact.join("\n") end end def fetch_origin perform('git fetch origin') end def clean_up FileUtils.rm_rf($working_dir) perform('git worktree prune') end def set_globals @effective_main_commitish = `git merge-base origin/main #{@options[:branch]}`.chomp @changed_swift_files = `git diff --diff-filter=AMRCU #{@effective_main_commitish} --name-only | grep "\.swift$" || true`.split("\n") @changed_rule_files = @changed_swift_files.select do |file| file.start_with? 'Source/SwiftLintBuiltInRules/Rules/' end @rules_changed = @changed_rule_files.map do |path| if File.read(path) =~ /^\s+identifier: "(\w+)",$/ $1 else nil end end.compact.sort # True iff the only Swift files that were changed are SwiftLint rules, and that number is one or greater @only_rules_changed = !@rules_changed.empty? && @changed_swift_files.count == @rules_changed.count end def print_rules_changed if @only_rules_changed puts "Only #{@rules_changed.count} rules changed: #{@rules_changed.join(', ')}" end end def report_binary_size size_branch = File.size("#{$working_dir}/builds/swiftlint-branch") size_main = File.size("#{$working_dir}/builds/swiftlint-main") if size_branch == size_main message "Building this branch resulted in the same binary size as when built on `main`." else percent_change = 100 * (size_branch - size_main) / size_main faster_slower = size_branch < size_main ? 'smaller' : 'larger' in_kilo_bytes = ->(size) { (size / 1024.0).round(2) } msg = "Building this branch resulted in a binary size of #{in_kilo_bytes.call(size_branch)} KiB " \ "vs #{in_kilo_bytes.call(size_main)} KiB when built on `main` (#{percent_change.to_i}\% #{faster_slower})." if percent_change.abs < 2 message msg else warn msg end end end def warmup %w[branch main].each do |branch| perform("../builds/swiftlint-#{branch} lint --no-cache --enable-all-rules", dir: "#{$working_dir}/Aerial") end end ################################ # Script ################################ # Constants $working_dir = 'osscheck' @repos = [ Repo.new('Aerial', 'JohnCoates/Aerial'), Repo.new('Alamofire', 'Alamofire/Alamofire'), Repo.new('Brave', 'brave/brave-core', false, 'included: ios/brave-ios'), Repo.new('DuckDuckGo', 'duckduckgo/apple-browsers'), Repo.new('Firefox', 'mozilla-mobile/firefox-ios'), Repo.new('Kickstarter', 'kickstarter/ios-oss'), Repo.new('Moya', 'Moya/Moya'), Repo.new('NetNewsWire', 'Ranchero-Software/NetNewsWire'), Repo.new('Nimble', 'Quick/Nimble'), Repo.new('PocketCasts', 'Automattic/pocket-casts-ios'), Repo.new('Quick', 'Quick/Quick'), Repo.new('Realm', 'realm/realm-swift'), Repo.new('Sourcery', 'krzysztofzablocki/Sourcery'), Repo.new('Swift', 'apple/swift', false, 'included: stdlib'), Repo.new('VLC', 'videolan/vlc-ios'), Repo.new('Wire', 'wireapp/wire-ios', false, 'excluded: wire-ios/Templates/Viper'), Repo.new('WordPress', 'wordpress-mobile/WordPress-iOS') ].select { |repo| @options[:only_repos].empty? || @options[:only_repos].include?(repo.name) } # Clean up clean_up unless @options[:skip_clean] # Prep $stdout.sync = true validate_state_to_run fetch_origin set_globals print_rules_changed make_directory_structure # Build & generate reports for branch & main %w[branch main].each do |branch| build(branch) end # Compare binary size of both builds. report_binary_size unless @options[:force] full_version_branch = `#{$working_dir}/builds/swiftlint-branch version --verbose` full_version_main = `#{$working_dir}/builds/swiftlint-main version --verbose` if full_version_branch == full_version_main message "Skipping OSS check because SwiftLint hasn't changed compared to `main`." # Clean up clean_up unless @options[:skip_clean] exit end end setup_repos warmup %w[branch main].each do |branch| generate_reports(branch) end # Diff and report changes to Danger diff_and_report_changes_to_danger # Clean up clean_up unless @options[:skip_clean]