Class: ActiveMutator::Runner

Inherits:
Object
  • Object
show all
Defined in:
lib/active_mutator/runner.rb

Instance Method Summary collapse

Constructor Details

#initialize(config, reporter: nil) ⇒ Runner

Returns a new instance of Runner.



5
6
7
8
# File 'lib/active_mutator/runner.rb', line 5

def initialize(config, reporter: nil)
  @config = config
  @reporter = reporter || build_reporter
end

Instance Method Details

#callObject



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/active_mutator/runner.rb', line 10

def call
  ENV["ACTIVE_MUTATOR"] = "1"
  load_operators
  ClosureReload.cap = @config.class_level_closure_cap
  preload!
  preload_spec_helper!
  map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
  @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
  subjects = discover_subjects
  analyses = subjects.map { |s| Engine.new.analyze(s) }
  mutations = analyses.flat_map(&:mutations)
  mutations = mutations.first(@config.max_mutants) if @config.max_mutants
  invalid_count = analyses.sum(&:invalid_count)

  fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
  ledger = AcceptedLedger.load(@config.root)
  scanned_files = prune_scope(subjects)
  warn_stale(ledger, fingerprints.values, scanned_files)

  items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
  return debug_plan(items, pre_results) if @config.debug_plan

  pre_results.each { |r| @reporter.on_result(r) }
  calibrators = if @config.adaptive_timeout
                  { parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
                end
  scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
                            calibrators: calibrators)
  results = scheduler.run(items) + pre_results
  # Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
  results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)

  accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors

  @reporter.summary(results, invalid_count: invalid_count)
  exit_code(results)
end

#escalate_class_body_survivors(results, scheduler, map, phase1_ids:) ⇒ Object

Phase 2 of the class-body kill pipeline (public for unit testing). A class-body survivor is only DECLARED after every spec file that references the constant has had its shot: re-enqueue against the referencing files phase 1 didn't run, and take the escalated verdict.

scheduler is injectable for unit tests; in the normal run it is nil and a dedicated escalation scheduler is built lazily (only when there is phase-2 work) with NO on_result — escalation is a refinement pass, and reporting through the live callback would print a second status char for a mutant already streamed in phase 1. The final summary reflects the escalated verdicts regardless.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/active_mutator/runner.rb', line 81

def escalate_class_body_survivors(results, scheduler, map, phase1_ids:)
  candidates = results.select { |r| r.status == :survived && r.mutation.subject.class_body? }
  # Perf gate: skip reading the whole spec suite into memory in the common
  # case of no class-body survivors. (Deleting this line is a behavioral
  # no-op — the later `items.empty?` return still guards correctness — so
  # its mutant is a known equivalent.)
  return results if candidates.empty?

  spec_contents = Dir[File.join(@config.root, "spec/**/*_spec.rb")].to_h { |f| [f, File.read(f)] }
  patterns = {} # subject file => constant-reference pattern (parsed once per file)
  items = {}
  candidates.each do |r|
    file = r.mutation.subject.file
    pattern = patterns.fetch(file) do
      patterns[file] = BaselineDelta.constant_reference_pattern(File.read(file))
    end
    next unless pattern

    ids = escalation_examples(map, spec_contents, phase1_ids.fetch(r.mutation, []), pattern)
    next if ids.empty?

    items[r.mutation] = build_work_item(r.mutation, ids, map)
  end
  return results if items.empty?

  scheduler ||= Scheduler.new(jobs: @config.jobs)
  escalated = scheduler.run(items.values).to_h { |res| [res.mutation, res] }
  results.map do |r|
    # A replacement only ever exists for a survived candidate (items is
    # built solely from those), so no redundant status re-check is needed.
    replacement = escalated[r.mutation]
    next r unless replacement

    case replacement.status
    when :killed
      replacement
    when :survived
      extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
      replacement.with(details: "escalated (+#{extra} spec files)")
    else
      # A timeout/error/skip in phase 2 did NOT prove a kill — the mutant
      # already survived phase 1, so keep that verdict rather than letting
      # an inconclusive escalation inflate the score (a :timeout counts as
      # detected in exit_code/score).
      r
    end
  end
end

#exit_code(results) ⇒ Object



130
131
132
133
134
135
136
137
138
# File 'lib/active_mutator/runner.rb', line 130

def exit_code(results)
  survived = results.count { |r| r.status == :survived }
  return 0 if survived.zero?
  return 1 unless @config.fail_at

  detected = results.count { |r| %i[killed timeout].include?(r.status) }
  score = detected * 100.0 / (detected + survived)
  score >= @config.fail_at ? 0 : 1
end

#plan_work(mutations, map, ledger: nil, fingerprints: {}) ⇒ Object

Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each planned mutation to the example ids it was scheduled against, so phase 2 escalation can subtract what was already run. Public for unit testing.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/active_mutator/runner.rb', line 51

def plan_work(mutations, map, ledger: nil, fingerprints: {})
  items = []
  pre_results = []
  mutations.each do |mutation|
    if ledger&.accepted?(fingerprints[mutation])
      pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
      next
    end
    example_ids = examples_for_mutation(mutation, map)
    if example_ids.empty?
      pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
    else
      items << build_work_item(mutation, example_ids, map)
    end
  end
  phase1_ids = items.to_h { |i| [i.mutation, i.example_ids] }
  [items, pre_results, phase1_ids]
end