saboteur 0.2.0 → 0.3.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.
data/lib/saboteur/cli.rb CHANGED
@@ -3,10 +3,16 @@
3
3
 
4
4
  require "optparse"
5
5
  require_relative "../saboteur"
6
+ require_relative "doctor"
7
+ require_relative "help"
8
+ require_relative "reporter/replay"
6
9
 
7
10
  module Saboteur
8
11
  class CLI
9
- USAGE = "Usage: saboteur run [--since REF] [--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N]"
12
+ USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
13
+ "[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
14
+ " \
15
+ "Run 'saboteur help' for every command, option, config key and exit code."
10
16
 
11
17
  EXIT_OK = 0
12
18
  EXIT_SURVIVORS = 1
@@ -26,8 +32,11 @@ module Saboteur
26
32
 
27
33
  def call
28
34
  case @argv.shift
29
- when "run" then run
35
+ when "run" then help_requested? ? show_help("run") : run
36
+ when "doctor" then help_requested? ? show_help("doctor") : doctor
30
37
  when "version", "--version" then @out.puts(VERSION) || EXIT_OK
38
+ when "help" then show_help(@argv.shift)
39
+ when "--help", "-h" then show_help(nil)
31
40
  else usage_error
32
41
  end
33
42
  rescue OptionParser::ParseError => e
@@ -38,8 +47,44 @@ module Saboteur
38
47
  @config ||= Config.load(root: Dir.pwd, path: options[:config], overrides: overrides)
39
48
  end
40
49
 
50
+ def parser
51
+ OptionParser.new do |opts|
52
+ opts.banner = USAGE
53
+ define_selection_options(opts)
54
+ define_explicit_file_options(opts)
55
+ define_execution_options(opts)
56
+ define_output_options(opts)
57
+ end
58
+ end
59
+
60
+ # Each command's own option lines, as printed under OPTIONS in `saboteur help`.
61
+ def option_lines
62
+ { "run" => parser.summarize, "doctor" => doctor_parser.summarize }
63
+ end
64
+
65
+ def doctor_parser
66
+ OptionParser.new do |opts|
67
+ opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
68
+ opts.on("--config PATH", "Config file (default .saboteur.yml)") { |value| set(:config, value) }
69
+ opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
70
+ end
71
+ end
72
+
41
73
  private
42
74
 
75
+ def help_requested?
76
+ @argv.intersect?(%w[--help -h])
77
+ end
78
+
79
+ def show_help(command_name)
80
+ help = Help.new(option_lines: option_lines)
81
+ text = command_name ? help.command(command_name) : help.overview
82
+ return @out.puts(text) || EXIT_OK if text
83
+
84
+ @err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
85
+ EXIT_USAGE
86
+ end
87
+
43
88
  def usage_error(message = nil)
44
89
  @err.puts(message) if message
45
90
  @err.puts(USAGE)
@@ -48,12 +93,16 @@ module Saboteur
48
93
 
49
94
  def run
50
95
  parser.parse!(@argv)
51
- raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
96
+ use_explicit_sources
52
97
 
53
- report = Engine.new(config, err: @err).call
98
+ arguments = { err: @err }
99
+ arguments[:digest] = options[:id] if options[:id]
100
+ report = Engine.new(config, **arguments).call
54
101
  @err.puts
55
102
  Reporter::Terminal.new(@out).call(report)
103
+ Reporter::Replay.new(@out).call(report) if options[:id]
56
104
  write_json(report)
105
+ write_html(report)
57
106
  exit_code(report)
58
107
  rescue ConfigError => e
59
108
  @err.puts("saboteur: #{e.message}")
@@ -63,6 +112,20 @@ module Saboteur
63
112
  EXIT_TOOL
64
113
  end
65
114
 
115
+ def doctor
116
+ doctor_parser.parse!(@argv)
117
+ raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
118
+
119
+ Doctor.new(config, out: @out).call
120
+ EXIT_OK
121
+ rescue ConfigError => e
122
+ @err.puts("saboteur: #{e.message}")
123
+ EXIT_USAGE
124
+ rescue Error => e
125
+ @err.puts("saboteur: #{e.message}")
126
+ EXIT_TOOL
127
+ end
128
+
66
129
  def write_json(report)
67
130
  path = config.path("json_report")
68
131
  return unless path
@@ -71,33 +134,117 @@ module Saboteur
71
134
  @out.puts "\nJSON report: #{path}"
72
135
  end
73
136
 
137
+ def write_html(report)
138
+ path = config.path("html_report")
139
+ return unless path
140
+
141
+ Reporter::Html.new(path).call(report)
142
+ @out.puts "HTML report: #{path}"
143
+ end
144
+
74
145
  def exit_code(report)
75
146
  executed = report.executed
76
147
  return EXIT_TOOL if executed.any? && executed.all? { |result| result.status == :error }
77
148
 
78
- report.unignored_survivors.any? ? EXIT_SURVIVORS : EXIT_OK
149
+ return EXIT_TOOL if incomplete_under_threshold?(report)
150
+
151
+ failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
152
+ end
153
+
154
+ # With --threshold the score decides; without it any unignored survivor does.
155
+ def failed_quality_gate?(report)
156
+ threshold = config["threshold"]
157
+ return report.unignored_survivors.any? if threshold.nil?
158
+
159
+ score = report.score
160
+ return missing_score_reported? if score.nil?
161
+
162
+ score * 100 < threshold
163
+ end
164
+
165
+ # A gate that cannot measure a score must not pass: CI would go green
166
+ # on a run that tested nothing.
167
+ def missing_score_reported?
168
+ @err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
169
+ true
170
+ end
171
+
172
+ # Errors and resource limits are outside the score. With a threshold they
173
+ # make the run incomplete, so the score alone cannot pass it.
174
+ def incomplete_under_threshold?(report)
175
+ return false if config["threshold"].nil?
176
+
177
+ count = report.counts.values_at(:error, :resource_limit).sum
178
+ @err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
179
+ count.positive?
180
+ count.positive?
181
+ end
182
+
183
+ # Positional arguments name the files (or directories) to mutate. They
184
+ # replace the configured include and exclude patterns. Each must exist,
185
+ # so a typo is an error and not a run over zero subjects.
186
+ def use_explicit_sources
187
+ raise UsageError, "--test needs at least one source file" if options[:tests] && @argv.empty?
188
+ return if @argv.empty?
189
+
190
+ options[:include] = @argv.map { |source| source_pattern(source) }
191
+ options[:exclude] = []
192
+ options[:tests]&.each { |test| require_existing_test(test) }
193
+ end
194
+
195
+ def source_pattern(source)
196
+ return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
197
+ raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
198
+ raise UsageError, "source file not found: #{source}" unless File.file?(source)
199
+
200
+ glob_escape(source)
201
+ end
202
+
203
+ # include is a list of glob patterns; a named file must match only itself.
204
+ def glob_escape(path)
205
+ path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
206
+ end
207
+
208
+ # A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
209
+ def require_existing_test(test)
210
+ path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
211
+ raise UsageError, "test file not found: #{test}" unless File.exist?(path)
79
212
  end
80
213
 
81
214
  def overrides
82
- options.except(:config)
215
+ options.except(:config, :id)
83
216
  end
84
217
 
85
- def parser
86
- OptionParser.new do |opts|
87
- opts.banner = USAGE
88
- opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
89
- opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
90
- opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
91
- opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
92
- opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
93
- set(:seed, v)
94
- end
95
- opts.on("--config PATH", "Config file (default .saboteur.yml)") { |v| set(:config, v) }
96
- opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
97
- opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
98
- opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
99
- opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
100
- opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
218
+ def define_selection_options(opts)
219
+ opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
220
+ opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
221
+ opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
222
+ end
223
+
224
+ def define_execution_options(opts)
225
+ opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
226
+ opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
227
+ opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
228
+ set(:seed, v)
229
+ end
230
+ opts.on("--config PATH", "Config file (default .saboteur.yml)") { |v| set(:config, v) }
231
+ opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
232
+ opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
233
+ opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
234
+ opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
235
+ end
236
+
237
+ def define_output_options(opts)
238
+ opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
239
+ opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
240
+ end
241
+
242
+ def define_explicit_file_options(opts)
243
+ opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
244
+ (options[:tests] ||= []) << v
245
+ end
246
+ opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
247
+ set(:threshold, v)
101
248
  end
102
249
  end
103
250
 
@@ -21,6 +21,7 @@ module Saboteur
21
21
  "coverage_timeout" => 3600.0,
22
22
  "max_memory_mb" => 2048,
23
23
  "max_processes" => 64,
24
+ "max_mutants_per_method" => 100,
24
25
  "jobs" => 1,
25
26
  "ignore_file" => ".saboteur-ignore.yml",
26
27
  "sources" => ["builtin"],
@@ -29,17 +30,22 @@ module Saboteur
29
30
  "reeval" => "nesting",
30
31
  "cache_dir" => ".saboteur",
31
32
  "json_report" => ".saboteur/report.json",
33
+ "html_report" => ".saboteur/report.html",
32
34
  "rspec_args" => [],
33
35
  "subjects" => [],
34
36
  "since" => nil,
35
37
  "cache" => true,
36
38
  "incremental" => false,
37
- "seed" => 1
39
+ "seed" => 1,
40
+ "tests" => [],
41
+ "threshold" => nil
38
42
  }.freeze
39
43
 
40
- INTEGER_KEYS = %w[jobs max_memory_mb max_processes seed].freeze
44
+ INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
41
45
  FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
42
- LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects].freeze
46
+ THRESHOLD_RANGE = (0.0..100.0)
47
+
48
+ LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests].freeze
43
49
 
44
50
  attr_reader :root
45
51
 
@@ -70,6 +76,7 @@ module Saboteur
70
76
  @values = DEFAULTS.merge(values)
71
77
  coerce!
72
78
  validate!
79
+ validate_report_paths!
73
80
  end
74
81
 
75
82
  def [](key)
@@ -111,19 +118,46 @@ module Saboteur
111
118
  INTEGER_KEYS.each { |key| @values[key] = Integer(@values[key]) }
112
119
  FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
113
120
  LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
121
+ @values["threshold"] = Float(@values["threshold"]) unless @values["threshold"].nil?
114
122
  rescue ArgumentError, TypeError => e
115
123
  raise ConfigError, "invalid config value: #{e.message}"
116
124
  end
117
125
 
126
+ def validate_mutant_limit!
127
+ raise ConfigError, "max_mutants_per_method must be > 0" unless self["max_mutants_per_method"].positive?
128
+ end
129
+
130
+ def validate_report_paths!
131
+ json = path("json_report")
132
+ return unless json && json == path("html_report")
133
+
134
+ raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
135
+ end
136
+
137
+ def validate_threshold!
138
+ threshold = self["threshold"]
139
+ return if threshold.nil? || THRESHOLD_RANGE.cover?(threshold)
140
+
141
+ raise ConfigError, "threshold must be between 0 and 100"
142
+ end
143
+
144
+ def validate_reeval!
145
+ raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
146
+ Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
147
+ end
148
+
118
149
  def validate!
119
150
  raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
151
+
152
+ validate_mutant_limit!
153
+ validate_threshold!
120
154
  raise ConfigError, "seed must be >= 0" if self["seed"].negative?
121
155
  raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
122
156
  raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
123
157
  raise ConfigError, "timeout_factor must be > 0" unless self["timeout_factor"].positive?
124
158
  raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
125
- raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
126
- Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
159
+
160
+ validate_reeval!
127
161
 
128
162
  self["arid"].each { |pattern| Regexp.new(pattern) }
129
163
  rescue RegexpError => e
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "coverage"
5
+ require_relative "baseline_health"
6
+
7
+ module Saboteur
8
+ # Reports the current environment and runs baseline checks, never mutations.
9
+ class Doctor
10
+ def initialize(config, out: $stdout)
11
+ @config = config
12
+ @out = out
13
+ end
14
+
15
+ def call
16
+ Engine::Boot.refuse_recursion!
17
+ check_runtime
18
+ check_coverage
19
+ check_boot_files
20
+ hooks = Hooks.new
21
+ integration = boot_target(hooks)
22
+ @out.puts "Integration: #{@config['integration']} (loaded)"
23
+ @out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
24
+ @out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
25
+ check_coverage
26
+ tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
27
+ @out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
28
+ @out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
29
+ true
30
+ end
31
+
32
+ private
33
+
34
+ def boot_target(hooks)
35
+ Engine::Boot.new(@config, hooks).load_target
36
+ rescue LoadError, SyntaxError, SystemExit, StandardError => e
37
+ raise Error, "target boot failed: #{e.class}: #{e.message}; check require files, hooks and rspec_args"
38
+ end
39
+
40
+ def check_runtime
41
+ @out.puts "Ruby: #{RUBY_DESCRIPTION}"
42
+ unless Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.3") && Process.respond_to?(:fork)
43
+ raise Error, "doctor needs Ruby 3.3+ with fork; use supported Ruby on Linux or macOS"
44
+ end
45
+ return if @config["integration"] == "rspec"
46
+
47
+ raise ConfigError,
48
+ "doctor supports the rspec integration; set integration: rspec"
49
+ end
50
+
51
+ def check_coverage
52
+ state = Coverage.state
53
+ @out.puts "Coverage: #{state}"
54
+ return if Coverage.supported?(:lines) && state == :suspended
55
+
56
+ raise Error, "Coverage must be suspended with line support before boot; use the saboteur executable, " \
57
+ "disable SimpleCov and remove other Coverage.start calls"
58
+ end
59
+
60
+ def check_boot_files
61
+ %w[require hooks].each do |key|
62
+ @config[key].each do |file|
63
+ path = File.expand_path(file, @config.root)
64
+ raise ConfigError, "#{key} file missing: #{file}; fix .saboteur.yml" unless File.file?(path)
65
+
66
+ @out.puts "#{key}: #{file} (found)"
67
+ end
68
+ end
69
+ @config.unmatched_patterns.each { |pattern| @out.puts "Warning: #{pattern} matches no file" }
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Saboteur
8
+ class Engine
9
+ # Two fresh children check the unchanged suite before any cached verdict
10
+ # or mutation source is used. This catches observed instability, not all
11
+ # possible flaky behaviour.
12
+ class BaselineGuard
13
+ def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
14
+ @config = config
15
+ @integration = integration
16
+ @hooks = hooks
17
+ @supervisor = supervisor
18
+ @diagnostics = diagnostics
19
+ end
20
+
21
+ def call
22
+ expected = @integration.all_tests.map(&:id)
23
+ raise Error, "baseline has no tests. Check rspec_args and the suite filters before running saboteur again." if
24
+ expected.empty?
25
+
26
+ @diagnostics.inform("checking the unchanged suite twice (seed #{@config['seed']})")
27
+ previous = nil
28
+ 2.times do |index|
29
+ number = index + 1
30
+ data = check(number)
31
+ unless data["passed"] && data["failed"].empty?
32
+ refuse(number,
33
+ "the suite failed: #{Array(data['failed']).join(', ')}")
34
+ end
35
+ executed = data["executed"]
36
+ refuse(number, "executed tests do not match the loaded suite") unless
37
+ executed.sort == expected.sort
38
+ refuse(number, "test order changed between baseline checks") if previous && executed != previous
39
+ previous = executed
40
+ end
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def check(number)
47
+ Execution::ForkReadiness.prepare_heap
48
+ result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, number) }
49
+ refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
50
+ refuse(number, "the child did not report a result") if result.payload.empty?
51
+ refuse(number, "the child exited unsuccessfully") if
52
+ result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
53
+ refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
54
+ parse_result(number, result.payload)
55
+ end
56
+
57
+ def parse_result(number, payload)
58
+ data = JSON.parse(payload)
59
+ refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
60
+ refuse(number, data["error"]) if data["error"]
61
+ refuse(number, "the child reported invalid test results") unless valid_result?(data)
62
+ data
63
+ rescue JSON::ParserError
64
+ refuse(number, "the child reported invalid JSON")
65
+ end
66
+
67
+ def valid_result?(data)
68
+ [true, false].include?(data["passed"]) && data["failed"].is_a?(Array) &&
69
+ data["executed"].is_a?(Array) && data["executed"].all?(String)
70
+ end
71
+
72
+ def record(writer, number)
73
+ path = log_path(number)
74
+ FileUtils.mkdir_p(File.dirname(path))
75
+ $stdout.reopen(path, "w")
76
+ $stdout.sync = true
77
+ $stderr.reopen($stdout)
78
+ @hooks.run(:after_fork)
79
+ Kernel.srand(@config["seed"])
80
+ result = @integration.run_baseline(seed: @config["seed"])
81
+ writer.write(JSON.generate(passed: result.passed, failed: result.failed_ids, executed: result.executed_ids))
82
+ rescue Exception => e # rubocop:disable Lint/RescueException
83
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
84
+ ensure
85
+ writer.close
86
+ end
87
+
88
+ def log_path(number)
89
+ File.join(@config.path("cache_dir"), "baseline-#{number}.log")
90
+ end
91
+
92
+ def refuse(number, reason)
93
+ raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
94
+ "See the log at #{log_path(number)}. Fix the suite or its setup, then run saboteur again " \
95
+ "with the same seed. No mutants were started."
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,35 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Selects a bounded list for each covered subject before workers fork.
7
+ # Subject skips are not mutants and do not enter the mutation score.
8
+ class CandidateSelection
9
+ attr_reader :skipped_subjects
10
+
11
+ def initialize(candidates:, map:, limit:, diagnostics:)
12
+ @candidates = candidates
13
+ @map = map
14
+ @limit = limit
15
+ @diagnostics = diagnostics
16
+ @skipped_subjects = {}
17
+ end
18
+
19
+ def for(subject, scope: nil)
20
+ if @map.tests_for(subject).empty?
21
+ @skipped_subjects[subject] = "no coverage"
22
+ return []
23
+ end
24
+
25
+ list = @candidates.for(subject)
26
+ list = scope.restrict(subject, list) if scope
27
+ omitted = list.size - @limit
28
+ if omitted.positive?
29
+ @diagnostics.warn("#{subject.id}: omitted #{omitted} mutant(s) above max_mutants_per_method (#{@limit})")
30
+ end
31
+ list.first(@limit)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -11,7 +11,7 @@ module Saboteur
11
11
  # unchanged, otherwise built by running the whole suite once in a
12
12
  # supervised child and saved.
13
13
  class CoverageStore
14
- CACHE_INPUTS = %w[integration require rspec_args rails].freeze
14
+ CACHE_INPUTS = %w[integration require rspec_args rails tests].freeze
15
15
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
16
16
  LOG = "coverage-build.log"
17
17
 
@@ -53,6 +53,9 @@ module Saboteur
53
53
  end
54
54
 
55
55
  @diagnostics.inform("building coverage map (runs the whole suite once)")
56
+ # The suite child forked next inherits this heap; pack it first, to
57
+ # reduce later copying of shared pages.
58
+ Execution::ForkReadiness.prepare_heap
56
59
  result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, files) }
57
60
  raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
58
61
  raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
@@ -86,7 +89,11 @@ module Saboteur
86
89
  recorder = Selection::Recorder.new(files)
87
90
  recorder.start
88
91
  Kernel.srand(@config["seed"])
89
- @integration.run_all(recorder)
92
+ passed = @integration.run_all(recorder)
93
+ if passed == false
94
+ raise Error, "the unchanged suite failed while building coverage. Fix the suite, then run again " \
95
+ "with seed #{@config['seed']}."
96
+ end
90
97
  build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
91
98
  writer.write(recorder.result(build_seconds: build_seconds).to_json)
92
99
  writer.close
@@ -42,7 +42,8 @@ module Saboteur
42
42
  "position" => list.index { |mutation, _| mutation.equal?(result.mutation) },
43
43
  "status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
44
44
  "killed_by" => result.killed_by, "covered_by" => result.covered_by,
45
- "kill_cause" => result.kill_cause, "reused" => result.reused?
45
+ "kill_cause" => result.kill_cause, "reused" => result.reused?,
46
+ "rejected_kill" => result.rejected_kill?
46
47
  }
47
48
  end
48
49
 
@@ -55,7 +56,8 @@ module Saboteur
55
56
  result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
56
57
  mutated_source: text, reason: data["reason"], runtime: data["runtime"],
57
58
  killed_by: data["killed_by"], covered_by: data["covered_by"],
58
- kill_cause: data["kill_cause"], reused: data["reused"])
59
+ kill_cause: data["kill_cause"], reused: data["reused"],
60
+ rejected_kill: data.fetch("rejected_kill", false))
59
61
  @cache&.record(result) unless result.reused?
60
62
  result
61
63
  end