active_mutator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +244 -0
  4. data/exe/active_mutator +4 -0
  5. data/lib/active_mutator/accepted_ledger.rb +45 -0
  6. data/lib/active_mutator/analysis.rb +3 -0
  7. data/lib/active_mutator/atomic_file.rb +15 -0
  8. data/lib/active_mutator/baseline.rb +125 -0
  9. data/lib/active_mutator/baseline_delta.rb +57 -0
  10. data/lib/active_mutator/baseline_hooks.rb +68 -0
  11. data/lib/active_mutator/cli.rb +53 -0
  12. data/lib/active_mutator/config.rb +8 -0
  13. data/lib/active_mutator/coverage_map.rb +55 -0
  14. data/lib/active_mutator/edit.rb +5 -0
  15. data/lib/active_mutator/engine.rb +75 -0
  16. data/lib/active_mutator/fingerprint.rb +21 -0
  17. data/lib/active_mutator/inserter.rb +18 -0
  18. data/lib/active_mutator/mutation.rb +11 -0
  19. data/lib/active_mutator/operators/base.rb +25 -0
  20. data/lib/active_mutator/operators/call_swap.rb +28 -0
  21. data/lib/active_mutator/operators/condition_forcing.rb +17 -0
  22. data/lib/active_mutator/operators/conditional_boundary.rb +16 -0
  23. data/lib/active_mutator/operators/early_return.rb +16 -0
  24. data/lib/active_mutator/operators/literal.rb +37 -0
  25. data/lib/active_mutator/operators/logical_operator.rb +24 -0
  26. data/lib/active_mutator/operators/negation_removal.rb +11 -0
  27. data/lib/active_mutator/operators/statement_deletion.rb +15 -0
  28. data/lib/active_mutator/reporter/json.rb +40 -0
  29. data/lib/active_mutator/reporter/terminal.rb +50 -0
  30. data/lib/active_mutator/result.rb +6 -0
  31. data/lib/active_mutator/runner.rb +143 -0
  32. data/lib/active_mutator/scheduler.rb +125 -0
  33. data/lib/active_mutator/since_filter.rb +48 -0
  34. data/lib/active_mutator/splicer.rb +13 -0
  35. data/lib/active_mutator/subject.rb +7 -0
  36. data/lib/active_mutator/subject_finder.rb +56 -0
  37. data/lib/active_mutator/version.rb +3 -0
  38. data/lib/active_mutator/work_item.rb +4 -0
  39. data/lib/active_mutator/worker.rb +64 -0
  40. data/lib/active_mutator.rb +42 -0
  41. metadata +132 -0
@@ -0,0 +1,55 @@
1
+ require "json"
2
+
3
+ module ActiveMutator
4
+ # Cache format v2: primary data is per-example `records`
5
+ # ({example_id => [[abs_path, line], ...]}); the inverted index is derived
6
+ # in memory at load. A missing/old version is simply stale — the cache is
7
+ # disposable, so there is no migration path, only regeneration.
8
+ class CoverageMap
9
+ def self.load(path) = new(JSON.parse(File.read(path)))
10
+
11
+ attr_reader :version, :records
12
+
13
+ def initialize(data)
14
+ @version = data["version"]
15
+ @records = data.fetch("records", {})
16
+ @times = data.fetch("times", {})
17
+ @digests = data.fetch("digests", {})
18
+ @map = build_map
19
+ end
20
+
21
+ def examples_for(file, lines)
22
+ lines.flat_map { |line| @map.fetch("#{file}:#{line}", []) }.uniq.sort
23
+ end
24
+
25
+ def time_for(example_ids)
26
+ # `|| 0.0`, not fetch-with-default: a key present with nil value must
27
+ # also coerce to zero, or Runner#plan_work explodes with TypeError.
28
+ example_ids.sum { |id| @times[id] || 0.0 }
29
+ end
30
+
31
+ def fresh?(digests) = @version == 2 && @digests == digests
32
+
33
+ def examples_covering_file(abs_path)
34
+ @records.filter_map do |example_id, hits|
35
+ example_id if hits.any? { |(path, _line)| path == abs_path }
36
+ end
37
+ end
38
+
39
+ def examples_for_spec_file(rel_spec_path)
40
+ @records.keys.select do |example_id|
41
+ example_id.sub(%r{\A\./}, "").start_with?("#{rel_spec_path}[")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def build_map
48
+ map = Hash.new { |h, k| h[k] = [] }
49
+ @records.each do |example_id, hits|
50
+ hits.each { |(path, line)| map["#{path}:#{line}"] << example_id }
51
+ end
52
+ map
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveMutator
2
+ # A single mutation as a text edit: replace `range` (exclusive byte Range)
3
+ # in the original source with `replacement`.
4
+ Edit = Data.define(:range, :replacement, :description)
5
+ end
@@ -0,0 +1,75 @@
1
+ module ActiveMutator
2
+ class Engine
3
+ def initialize(operators: Operators::Base.all)
4
+ @operators = operators
5
+ end
6
+
7
+ def analyze(subject, source: File.read(subject.file))
8
+ result = Prism.parse(source)
9
+ raise Error, "#{subject.file} no longer parses" unless result.success?
10
+
11
+ def_node = find_def(result.value, subject.byte_range.begin)
12
+ raise Error, "subject not found: #{subject.name}" unless def_node
13
+
14
+ invalid = 0
15
+ mutations = collect_edits(def_node).filter_map do |edit|
16
+ mutation, valid = build_mutation(subject, source, edit)
17
+ invalid += 1 unless valid
18
+ mutation
19
+ end
20
+ Analysis.new(mutations: mutations, invalid_count: invalid)
21
+ end
22
+
23
+ private
24
+
25
+ def find_def(node, start_offset)
26
+ return node if node.is_a?(Prism::DefNode) && node.location.start_offset == start_offset
27
+
28
+ node.compact_child_nodes.each do |child|
29
+ found = find_def(child, start_offset)
30
+ return found if found
31
+ end
32
+ nil
33
+ end
34
+
35
+ def collect_edits(def_node)
36
+ edits = []
37
+ walk(def_node.body) do |node|
38
+ @operators.each { |op| edits.concat(op.edits(node)) }
39
+ end
40
+ edits
41
+ end
42
+
43
+ def walk(node, &blk)
44
+ return if node.nil?
45
+ return if node.is_a?(Prism::DefNode) # nested defs are separate subjects
46
+
47
+ yield node
48
+ node.compact_child_nodes.each { |child| walk(child, &blk) }
49
+ end
50
+
51
+ # Returns [mutation_or_nil, valid_boolean].
52
+ # valid=true with nil mutation means "skipped no-op", which is not an error.
53
+ def build_mutation(subject, source, edit)
54
+ original = source.byteslice(edit.range)
55
+ return [nil, true] if edit.replacement == original # no-op guard
56
+
57
+ mutated = Splicer.apply(source, [edit])
58
+ parsed = Prism.parse(mutated)
59
+ return [nil, false] unless parsed.success?
60
+
61
+ new_def = find_def(parsed.value, subject.byte_range.begin)
62
+ return [nil, false] unless new_def
63
+
64
+ [Mutation.new(
65
+ subject: subject,
66
+ edit: edit,
67
+ original_snippet: original,
68
+ line: source.byteslice(0, edit.range.begin).count("\n") + 1,
69
+ mutated_file_source: mutated,
70
+ mutated_def_source: new_def.slice,
71
+ mutated_def_line: new_def.location.start_line
72
+ ), true]
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ module ActiveMutator
2
+ # Line-number-independent identity for a mutant, used by the acceptance
3
+ # ledger. `ordinal` disambiguates byte-identical mutants within one subject
4
+ # (e.g. the two `>` in `a > 0 && b > 0`) by source order — without it,
5
+ # accepting one would silently accept both.
6
+ Fingerprint = Data.define(:file, :subject, :description, :original_snippet, :ordinal) do
7
+ def self.for_mutations(mutations, root:)
8
+ counters = Hash.new(0)
9
+ mutations.sort_by { |m| [m.subject.name, m.edit.range.begin] }.to_h do |m|
10
+ key = [m.subject.name, m.description, m.original_snippet]
11
+ ordinal = counters[key]
12
+ counters[key] += 1
13
+ [m, new(file: m.subject.file.delete_prefix("#{root}/"),
14
+ subject: m.subject.name,
15
+ description: m.description,
16
+ original_snippet: m.original_snippet,
17
+ ordinal: ordinal)]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module ActiveMutator
2
+ # Redefines the subject's method with its mutated source. `class_eval` of a
3
+ # `def` handles instance methods; a `def self.x` source string defines the
4
+ # singleton method the same way. Top-level subjects eval at main scope.
5
+ class Inserter
6
+ def insert(mutation)
7
+ subject = mutation.subject
8
+ if subject.constant_scope
9
+ Object.const_get(subject.constant_scope)
10
+ .class_eval(mutation.mutated_def_source, subject.file, mutation.mutated_def_line)
11
+ else
12
+ eval(mutation.mutated_def_source, TOPLEVEL_BINDING, # rubocop:disable Security/Eval
13
+ subject.file, mutation.mutated_def_line)
14
+ end
15
+ nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,11 @@
1
+ module ActiveMutator
2
+ # One concrete mutant. `line` is the 1-based line of the edit in the
3
+ # ORIGINAL file (used for coverage lookup and reporting).
4
+ Mutation = Data.define(:subject, :edit, :original_snippet, :line,
5
+ :mutated_file_source, :mutated_def_source, :mutated_def_line) do
6
+ def description = edit.description
7
+
8
+ # Original-file lines the edit touches (edit may span lines).
9
+ def lines = line..(line + original_snippet.count("\n"))
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class Base
4
+ REGISTRY = []
5
+
6
+ def self.inherited(klass)
7
+ super
8
+ REGISTRY << klass
9
+ end
10
+
11
+ def self.all = REGISTRY.map(&:new)
12
+
13
+ # Returns [Edit] for this node, or [] when the operator does not apply.
14
+ def edits(node) = []
15
+
16
+ private
17
+
18
+ def loc_range(loc) = loc.start_offset...loc.end_offset
19
+
20
+ def edit(range, replacement, description)
21
+ Edit.new(range: range, replacement: replacement, description: description)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class CallSwap < Base
4
+ # One-directional where the reverse is usually an equivalent mutant
5
+ # (e.g. each→map when return value is unused).
6
+ MAP = {
7
+ map: "each",
8
+ select: "reject", reject: "select",
9
+ min: "max", max: "min",
10
+ first: "last", last: "first",
11
+ any?: "none?", none?: "any?",
12
+ # Rails-aware pack:
13
+ present?: "blank?", blank?: "present?",
14
+ save: "save!", save!: "save"
15
+ }.freeze
16
+
17
+ def edits(node)
18
+ return [] unless node.is_a?(Prism::CallNode) && node.receiver && node.message_loc
19
+
20
+ replacement = MAP[node.name]
21
+ return [] unless replacement
22
+
23
+ [edit(loc_range(node.message_loc), replacement,
24
+ "replace `.#{node.name}` with `.#{replacement}`")]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class ConditionForcing < Base
4
+ def edits(node)
5
+ predicate =
6
+ case node
7
+ when Prism::IfNode, Prism::UnlessNode then node.predicate
8
+ end
9
+ return [] unless predicate
10
+
11
+ %w[true false].reject { |lit| predicate.slice == lit }.map do |lit|
12
+ edit(loc_range(predicate.location), lit, "force condition to `#{lit}`")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class ConditionalBoundary < Base
4
+ MAP = { :> => ">=", :>= => ">", :< => "<=", :<= => "<" }.freeze
5
+
6
+ def edits(node)
7
+ return [] unless node.is_a?(Prism::CallNode) && MAP.key?(node.name)
8
+ return [] unless node.receiver && node.arguments&.arguments&.size == 1
9
+
10
+ replacement = MAP.fetch(node.name)
11
+ [edit(loc_range(node.message_loc), replacement,
12
+ "replace `#{node.name}` with `#{replacement}`")]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class EarlyReturn < Base
4
+ def edits(node)
5
+ return [] unless node.is_a?(Prism::ReturnNode) && node.arguments
6
+
7
+ value = node.arguments.slice
8
+ out = [edit(loc_range(node.location), value, "unwrap `return`")]
9
+ unless value == "nil"
10
+ out << edit(loc_range(node.location), "return nil", "return nil instead")
11
+ end
12
+ out
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class Literal < Base
4
+ def edits(node)
5
+ case node
6
+ when Prism::IntegerNode then integer_edits(node)
7
+ when Prism::StringNode then string_edits(node)
8
+ when Prism::TrueNode
9
+ [edit(loc_range(node.location), "false", "replace `true` with `false`")]
10
+ when Prism::FalseNode
11
+ [edit(loc_range(node.location), "true", "replace `false` with `true`")]
12
+ else []
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def integer_edits(node)
19
+ [0, node.value + 1].uniq.reject { |v| v == node.value }.map do |v|
20
+ edit(loc_range(node.location), v.to_s, "replace `#{node.value}` with `#{v}`")
21
+ end
22
+ end
23
+
24
+ def string_edits(node)
25
+ opening = node.opening_loc&.slice
26
+ return [] unless opening # quote-less parts (interpolation)
27
+ return [] if opening.start_with?("<<") # heredocs: v1 limit
28
+
29
+ if node.unescaped.empty?
30
+ [edit(loc_range(node.location), %("active_mutator"), %(replace "" with "active_mutator"))]
31
+ else
32
+ [edit(loc_range(node.location), %(""), %(replace string with ""))]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class LogicalOperator < Base
4
+ def edits(node)
5
+ case node
6
+ when Prism::AndNode then variants(node, "||")
7
+ when Prism::OrNode then variants(node, "&&")
8
+ else []
9
+ end
10
+ end
11
+
12
+ private
13
+
14
+ def variants(node, swapped)
15
+ [
16
+ edit(loc_range(node.operator_loc), swapped,
17
+ "replace `#{node.operator_loc.slice}` with `#{swapped}`"),
18
+ edit(loc_range(node.location), node.left.slice, "keep only left operand"),
19
+ edit(loc_range(node.location), node.right.slice, "keep only right operand")
20
+ ]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class NegationRemoval < Base
4
+ def edits(node)
5
+ return [] unless node.is_a?(Prism::CallNode) && node.name == :! && node.receiver
6
+
7
+ [edit(loc_range(node.location), node.receiver.slice, "remove negation")]
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ module ActiveMutator
2
+ module Operators
3
+ class StatementDeletion < Base
4
+ def edits(node)
5
+ return [] unless node.is_a?(Prism::StatementsNode)
6
+ return [] if node.body.size < 2
7
+
8
+ node.body.map do |stmt|
9
+ edit(loc_range(stmt.location), "",
10
+ "delete `#{stmt.slice.lines.first.strip}`")
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,40 @@
1
+ require "json"
2
+
3
+ module ActiveMutator
4
+ module Reporter
5
+ class Json
6
+ def initialize(out: $stdout)
7
+ @out = out
8
+ end
9
+
10
+ def on_result(result); end
11
+
12
+ def summary(results, invalid_count:)
13
+ counts = results.group_by(&:status).transform_values(&:size)
14
+ @out.puts JSON.pretty_generate(
15
+ "score" => Terminal.score(counts),
16
+ "counts" => counts.transform_keys(&:to_s),
17
+ "invalid" => invalid_count,
18
+ "results" => results.map { |r| serialize(r) },
19
+ "exit_reason" => counts.fetch(:survived, 0).positive? ? "unaccepted_survivors" : "clean"
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def serialize(result)
26
+ m = result.mutation
27
+ {
28
+ "subject" => m.subject.name,
29
+ "status" => result.status.to_s,
30
+ "description" => m.description,
31
+ "file" => m.subject.file,
32
+ "line" => m.line,
33
+ "original" => m.original_snippet,
34
+ "replacement" => m.edit.replacement,
35
+ "details" => result.details
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ module ActiveMutator
2
+ module Reporter
3
+ class Terminal
4
+ CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A" }.freeze
5
+
6
+ def initialize(out: $stdout)
7
+ @out = out
8
+ end
9
+
10
+ def on_result(result)
11
+ @out.print(CHARS.fetch(result.status))
12
+ end
13
+
14
+ def summary(results, invalid_count:)
15
+ counts = results.group_by(&:status).transform_values(&:size)
16
+ @out.puts "", ""
17
+ CHARS.each_key do |status|
18
+ @out.puts "#{status}: #{counts.fetch(status, 0)}"
19
+ end
20
+ @out.puts "invalid (discarded): #{invalid_count}"
21
+ @out.puts format("Mutation score: %.1f%%", score(counts) * 100)
22
+ survivors = results.select { |r| r.status == :survived }
23
+ print_survivors(survivors) unless survivors.empty?
24
+ end
25
+
26
+ def self.score(counts)
27
+ detected = counts.fetch(:killed, 0) + counts.fetch(:timeout, 0)
28
+ denominator = detected + counts.fetch(:survived, 0)
29
+ return 1.0 if denominator.zero?
30
+
31
+ detected.to_f / denominator
32
+ end
33
+
34
+ private
35
+
36
+ def score(counts) = self.class.score(counts)
37
+
38
+ def print_survivors(survivors)
39
+ @out.puts "", "Surviving mutants:"
40
+ survivors.each do |result|
41
+ m = result.mutation
42
+ @out.puts "", " #{m.subject.name} (#{m.subject.file}:#{m.line})"
43
+ @out.puts " #{m.description}"
44
+ @out.puts " - #{m.original_snippet}"
45
+ @out.puts " + #{m.edit.replacement}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,6 @@
1
+ module ActiveMutator
2
+ # status: :killed | :survived | :timeout | :error | :uncovered | :accepted
3
+ Result = Data.define(:mutation, :status, :details) do
4
+ def detected? = %i[killed timeout].include?(status)
5
+ end
6
+ end
@@ -0,0 +1,143 @@
1
+ module ActiveMutator
2
+ class Runner
3
+ def initialize(config, reporter: nil)
4
+ @config = config
5
+ @reporter = reporter || build_reporter
6
+ end
7
+
8
+ def call
9
+ ENV["ACTIVE_MUTATOR"] = "1"
10
+ preload!
11
+ preload_spec_helper!
12
+ map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
13
+ subjects = discover_subjects
14
+ analyses = subjects.map { |s| Engine.new.analyze(s) }
15
+ mutations = analyses.flat_map(&:mutations)
16
+ invalid_count = analyses.sum(&:invalid_count)
17
+
18
+ fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
19
+ ledger = AcceptedLedger.load(@config.root)
20
+ warn_stale(ledger, fingerprints.values)
21
+
22
+ items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
23
+ pre_results.each { |r| @reporter.on_result(r) }
24
+ scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result))
25
+ results = scheduler.run(items) + pre_results
26
+
27
+ accept_survivors!(ledger, results, fingerprints) if @config.accept_survivors
28
+
29
+ @reporter.summary(results, invalid_count: invalid_count)
30
+ exit_code(results)
31
+ end
32
+
33
+ # Returns [work_items, pre_results]. Public for unit testing.
34
+ def plan_work(mutations, map, ledger: nil, fingerprints: {})
35
+ items = []
36
+ pre_results = []
37
+ mutations.each do |mutation|
38
+ if ledger&.accepted?(fingerprints[mutation])
39
+ pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
40
+ next
41
+ end
42
+ example_ids = map.examples_for(mutation.subject.file, mutation.lines)
43
+ if example_ids.empty?
44
+ pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
45
+ else
46
+ lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
47
+ timeout = map.time_for(example_ids) * @config.timeout_factor + @config.timeout_floor
48
+ timeout += @config.browser_boot_seconds if lane == :serial
49
+ items << WorkItem.new(mutation: mutation, example_ids: example_ids, timeout: timeout, lane: lane)
50
+ end
51
+ end
52
+ [items, pre_results]
53
+ end
54
+
55
+ def exit_code(results)
56
+ results.any? { |r| r.status == :survived } ? 1 : 0
57
+ end
58
+
59
+ private
60
+
61
+ def build_reporter
62
+ @config.format == :json ? Reporter::Json.new : Reporter::Terminal.new
63
+ end
64
+
65
+ def preload!
66
+ # Workers run the test suite, so the app must boot in the test
67
+ # environment (the development database may not even exist).
68
+ ENV["RAILS_ENV"] ||= "test"
69
+ @config.requires.each { |f| require File.expand_path(f, @config.root) }
70
+ environment = File.join(@config.root, "config", "environment.rb")
71
+ if @config.requires.empty? && File.exist?(environment)
72
+ require environment
73
+ Rails.application.eager_load! if defined?(Rails)
74
+ end
75
+ end
76
+
77
+ def discover_subjects
78
+ paths = @config.paths.empty? ? default_paths : @config.paths
79
+ subjects = paths
80
+ .flat_map { |p| Dir[File.join(@config.root, p, "**", "*.rb")] }
81
+ .sort.flat_map { |file| SubjectFinder.call(file) }
82
+ subjects = subjects.select { |s| s.name == @config.subject_filter } if @config.subject_filter
83
+ if @config.since
84
+ filter = SinceFilter.new(ref: @config.since, root: @config.root)
85
+ subjects = subjects.select { |s| filter.cover?(s) }
86
+ end
87
+ subjects
88
+ end
89
+
90
+ def default_paths
91
+ %w[app lib].select { |p| Dir.exist?(File.join(@config.root, p)) }
92
+ end
93
+
94
+ def serial_example?(example_id)
95
+ path = example_id.sub(%r{\A\./}, "")
96
+ @config.serial_patterns.any? { |pattern| path.start_with?(pattern) }
97
+ end
98
+
99
+ def preload_spec_helper!
100
+ return if @config.preload_helper == :none
101
+
102
+ helper = if @config.preload_helper
103
+ File.expand_path(@config.preload_helper, @config.root)
104
+ else
105
+ %w[spec/rails_helper.rb spec/spec_helper.rb]
106
+ .map { |p| File.join(@config.root, p) }
107
+ .find { |p| File.exist?(p) }
108
+ end
109
+ return unless helper && File.exist?(helper)
110
+
111
+ # Mirror what `bundle exec rspec` provides before a helper loads:
112
+ # rspec-core itself (helpers call RSpec.configure at the top level) and
113
+ # the default path (spec/) on $LOAD_PATH (rails_helper.rb relies on it
114
+ # for its bare `require "spec_helper"`).
115
+ require "rspec/core"
116
+ spec_dir = File.dirname(helper)
117
+ $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
118
+ require helper
119
+ disarm_simplecov
120
+ end
121
+
122
+ # A preloaded helper commonly starts SimpleCov. Its at_exit would fire in
123
+ # THIS parent process at the end of the mutation run, clobbering the
124
+ # project's real coverage data — and minimum_coverage would exit(1) for a
125
+ # bogus reason. Neutralize it.
126
+ def disarm_simplecov
127
+ SimpleCov.at_exit {} if defined?(SimpleCov)
128
+ end
129
+
130
+ def accept_survivors!(ledger, results, fingerprints)
131
+ survivors = results.select { |r| r.status == :survived }.map { |r| fingerprints[r.mutation] }
132
+ return if survivors.empty?
133
+
134
+ ledger.accept!(survivors, fingerprints.values)
135
+ end
136
+
137
+ def warn_stale(ledger, all_fingerprints)
138
+ ledger.stale_entries(all_fingerprints).each do |entry|
139
+ warn "active_mutator: stale accepted fingerprint (no matching mutant): #{entry.subject} — #{entry.description}"
140
+ end
141
+ end
142
+ end
143
+ end