Class: ActiveMutator::Runner
- Inherits:
-
Object
- Object
- ActiveMutator::Runner
- Defined in:
- lib/active_mutator/runner.rb
Instance Method Summary collapse
- #call ⇒ Object
-
#escalate_class_body_survivors(results, scheduler, map, phase1_ids:) ⇒ Object
Phase 2 of the class-body kill pipeline (public for unit testing).
- #exit_code(results) ⇒ Object
-
#initialize(config, reporter: nil) ⇒ Runner
constructor
A new instance of Runner.
-
#plan_work(mutations, map, ledger: nil, fingerprints: {}) ⇒ Object
Returns [work_items, pre_results, phase1_ids].
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
#call ⇒ Object
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 |