class Fast::ExperimentFile

Combines an {Fast::Experiment} with a specific file. It coordinates and regulate multiple replacements in the same file. Everytime it {#run} a file, it uses {#partial_replace} and generate a new file with the new content. It executes the {Fast::Experiment#policy} block yielding the new file. Depending on the policy result, it adds the occurrence to {#fail_experiments} or {#ok_experiments}. When all possible occurrences are replaced in isolated experiments, it #{build_combinations} with the winner experiments going to a next round of experiments with multiple partial replacements until find all possible combinations. @note it can easily spend days handling multiple one to one combinations,

because of that, after the first round of replacements the algorithm goes
replacing all winner solutions in the same shot. If it fails, it goes
combining one to one.

@see Fast::Experiment @example Temporary spec to analyze

tempfile = Tempfile.new('some_spec.rb')
tempfile.write <<~RUBY
  let(:user) { create(:user) }
  let(:address) { create(:address) }
  let(:phone_number) { create(:phone_number) }
  let(:country) { create(:country) }
  let(:language) { create(:language) }
RUBY
tempfile.close

@example Temporary experiment to replace create with build stubbed

experiment = Fast.experiment('RSpec/ReplaceCreateWithBuildStubbed') do
  lookup 'some_spec.rb'
  search '(send nil create)'
  edit { |node| replace(node.loc.selector, 'build_stubbed') }
  policy { |new_file| system("rspec --fail-fast #{new_file}") }
end

@example ExperimentFile exploring combinations and failures

experiment_file = Fast::ExperimentFile.new(tempfile.path, experiment)
experiment_file.build_combinations # => [1, 2, 3, 4, 5]
experiment_file.ok_with(1)
experiment_file.failed_with(2)
experiment_file.ok_with(3)
experiment_file.ok_with(4)
experiment_file.ok_with(5)
# Try a combination of all OK individual replacements.
experiment_file.build_combinations # => [[1, 3, 4, 5]]
experiment_file.failed_with([1, 3, 4, 5])
# If the above failed, divide and conquer.
experiment_file.build_combinations # => [[1, 3], [1, 4], [1, 5], [3, 4], [3, 5], [4, 5]]
experiment_file.ok_with([1, 3])
experiment_file.failed_with([1, 4])
experiment_file.build_combinations # => [[4, 5], [1, 3, 4], [1, 3, 5]]
experiment_file.failed_with([1, 3, 4])
experiment_file.build_combinations # => [[4, 5], [1, 3, 5]]
experiment_file.failed_with([4, 5])
experiment_file.build_combinations # => [[1, 3, 5]]
experiment_file.ok_with([1, 3, 5])
experiment_file.build_combinations # => []

Attributes

experiment[R]
fail_experiments[R]
ok_experiments[R]

Public Class Methods

new(file, experiment) click to toggle source
# File lib/fast/experiment.rb, line 247
def initialize(file, experiment)
  @file = file
  @ast = Fast.ast_from_file(file) if file
  @experiment = experiment
  @ok_experiments = []
  @fail_experiments = []
  @round = 0
end

Public Instance Methods

build_combinations() click to toggle source

Increase the `@round` by 1 to {ExperimentCombinations#generate_combinations}.

# File lib/fast/experiment.rb, line 338
def build_combinations
  @round += 1
  ExperimentCombinations.new(
    round: @round,
    occurrences_count: search_cases.size,
    ok_experiments: @ok_experiments,
    fail_experiments: @fail_experiments
  ).generate_combinations
end
done!() click to toggle source
# File lib/fast/experiment.rb, line 326
def done!
  count_executed_combinations = @fail_experiments.size + @ok_experiments.size
  puts "Done with #{@file} after #{count_executed_combinations} combinations"
  return unless perfect_combination = @ok_experiments.last # rubocop:disable Lint/AssignmentInCondition

  puts 'The following changes were applied to the file:'
  `diff #{experimental_filename(perfect_combination)} #{@file}`
  puts "mv #{experimental_filename(perfect_combination)} #{@file}"
  `mv #{experimental_filename(perfect_combination)} #{@file}`
end
experimental_filename(combination) click to toggle source

@return [String] with a derived name with the combination number.

# File lib/fast/experiment.rb, line 262
def experimental_filename(combination)
  parts = @file.split('/')
  dir = parts[0..-2]
  filename = "experiment_#{[*combination].join('_')}_#{parts[-1]}"
  File.join(*dir, filename)
end
failed_with(combination) click to toggle source

Track failed experiments to avoid run them again. @return [void]

# File lib/fast/experiment.rb, line 284
def failed_with(combination)
  @fail_experiments << combination
end
ok_with(combination) click to toggle source

Keep track of ok experiments depending on the current combination. It keep the combinations unique removing single replacements after the first round. @return void

# File lib/fast/experiment.rb, line 273
def ok_with(combination)
  @ok_experiments << combination
  return unless combination.is_a?(Array)

  combination.each do |element|
    @ok_experiments.delete(element)
  end
end
partial_replace(*indices) click to toggle source

rubocop:disable Metrics/MethodLength

Execute partial replacements generating new file with the content replaced. @return [void]

# File lib/fast/experiment.rb, line 298
def partial_replace(*indices)
  replacement = experiment.replacement
  new_content = Fast.replace_file experiment.expression, @file do |node, *captures|
    if indices.nil? || indices.empty? || indices.include?(match_index)
      if replacement.parameters.length == 1
        instance_exec node, &replacement
      else
        instance_exec node, *captures, &replacement
      end
    end
  end
  return unless new_content

  write_experiment_file(indices, new_content)
  new_content
end
run() click to toggle source
# File lib/fast/experiment.rb, line 348
def run
  while (combinations = build_combinations).any?
    if combinations.size > 1000
      puts "Ignoring #{@file} because it has #{combinations.size} possible combinations"
      break
    end
    puts "#{@file} - Round #{@round} - Possible combinations: #{combinations.inspect}"
    while combination = combinations.shift # rubocop:disable Lint/AssignmentInCondition
      run_partial_replacement_with(combination)
    end
  end
  done!
end
run_partial_replacement_with(combination) click to toggle source

Writes a new file with partial replacements based on the current combination. Raise error if no changes was made with the given combination indices. @param [Array<Integer>] combination to be replaced.

# File lib/fast/experiment.rb, line 365
def run_partial_replacement_with(combination)
  content = partial_replace(*combination)
  experimental_file = experimental_filename(combination)

  File.open(experimental_file, 'w+') { |f| f.puts content }

  raise 'No changes were made to the file.' if FileUtils.compare_file(@file, experimental_file)

  result = experiment.ok_if.call(experimental_file)

  if result
    ok_with(combination)
    puts "✅ #{experimental_file} - Combination: #{combination}"
  else
    failed_with(combination)
    puts "🔴 #{experimental_file} - Combination: #{combination}"
  end
end
search_cases() click to toggle source

@return [Array<Astrolabe::Node>]

# File lib/fast/experiment.rb, line 289
def search_cases
  Fast.search(experiment.expression, @ast) || []
end
write_experiment_file(combination, new_content) click to toggle source

Write new file name depending on the combination @param [Array<Integer>] combination @param [String] new_content to be persisted

# File lib/fast/experiment.rb, line 320
def write_experiment_file(combination, new_content)
  filename = experimental_filename(combination)
  File.open(filename, 'w+') { |f| f.puts new_content }
  filename
end