saboteur 0.4.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -0
- data/README.md +393 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +101 -121
- data/lib/saboteur/config.rb +76 -6
- data/lib/saboteur/doctor.rb +40 -8
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +6 -4
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +192 -18
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +31 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/reporter/agent_commands.rb +7 -1
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +11 -5
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +15 -2
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +131 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +41 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +43 -2
data/lib/saboteur/cli.rb
CHANGED
|
@@ -6,9 +6,17 @@ require_relative "../saboteur"
|
|
|
6
6
|
require_relative "doctor"
|
|
7
7
|
require_relative "help"
|
|
8
8
|
require_relative "reporter/replay"
|
|
9
|
+
require_relative "baseline/command"
|
|
10
|
+
require_relative "cli/exit_status"
|
|
11
|
+
require_relative "preview"
|
|
12
|
+
require_relative "cli/run_reporting"
|
|
9
13
|
|
|
10
14
|
module Saboteur
|
|
11
15
|
class CLI
|
|
16
|
+
include Baseline::Command
|
|
17
|
+
include ExitStatus
|
|
18
|
+
include RunReporting
|
|
19
|
+
|
|
12
20
|
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
|
|
13
21
|
"[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
|
|
14
22
|
" \
|
|
@@ -32,7 +40,8 @@ module Saboteur
|
|
|
32
40
|
|
|
33
41
|
def call
|
|
34
42
|
case @argv.shift
|
|
35
|
-
when "run" then
|
|
43
|
+
when "run" then run_command
|
|
44
|
+
when "baseline" then baseline
|
|
36
45
|
when "doctor" then help_requested? ? show_help("doctor") : doctor
|
|
37
46
|
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
|
38
47
|
when "help" then show_help(@argv.shift)
|
|
@@ -54,25 +63,34 @@ module Saboteur
|
|
|
54
63
|
define_selection_options(opts)
|
|
55
64
|
define_explicit_file_options(opts)
|
|
56
65
|
define_execution_options(opts)
|
|
66
|
+
define_matrix_option(opts)
|
|
57
67
|
define_output_options(opts)
|
|
58
68
|
end
|
|
59
69
|
end
|
|
60
70
|
|
|
61
71
|
# Each command's own option lines, as printed under OPTIONS in `saboteur help`.
|
|
62
72
|
def option_lines
|
|
63
|
-
{ "run" => parser.summarize, "doctor" => doctor_parser.summarize }
|
|
73
|
+
{ "run" => parser.summarize, "doctor" => doctor_parser.summarize, "baseline" => baseline_parser.summarize }
|
|
64
74
|
end
|
|
65
75
|
|
|
66
76
|
def doctor_parser
|
|
67
77
|
OptionParser.new do |opts|
|
|
68
|
-
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
|
|
78
|
+
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
|
|
69
79
|
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
70
80
|
opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
|
|
81
|
+
opts.on("--suite", "Also run the unchanged suite twice (slow on a large suite)") { set(:suite, true) }
|
|
82
|
+
opts.on("--test PATH", "Spec file or directory for the suite runs; repeatable. Implies --suite") do |value|
|
|
83
|
+
(options[:tests] ||= []) << value
|
|
84
|
+
end
|
|
71
85
|
end
|
|
72
86
|
end
|
|
73
87
|
|
|
74
88
|
private
|
|
75
89
|
|
|
90
|
+
def run_command
|
|
91
|
+
help_requested? ? show_help("run") : run
|
|
92
|
+
end
|
|
93
|
+
|
|
76
94
|
def help_requested?
|
|
77
95
|
@argv.intersect?(%w[--help -h])
|
|
78
96
|
end
|
|
@@ -112,11 +130,12 @@ module Saboteur
|
|
|
112
130
|
def run
|
|
113
131
|
parser.parse!(@argv)
|
|
114
132
|
use_explicit_sources
|
|
115
|
-
|
|
133
|
+
validate_run_options
|
|
134
|
+
warn_dirty_tree
|
|
116
135
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
136
|
+
return preview if options[:list_operators] || options[:dry_run]
|
|
137
|
+
|
|
138
|
+
execute_run
|
|
120
139
|
rescue ConfigError => e
|
|
121
140
|
@err.puts("saboteur: #{e.message}")
|
|
122
141
|
agent_failure(e.message, EXIT_USAGE)
|
|
@@ -125,32 +144,45 @@ module Saboteur
|
|
|
125
144
|
agent_failure(e.message, EXIT_TOOL)
|
|
126
145
|
end
|
|
127
146
|
|
|
147
|
+
# Mutants run your code, and a mutant of code that writes or deletes files
|
|
148
|
+
# can change the tree. Say so up front when uncommitted work is at stake.
|
|
149
|
+
def warn_dirty_tree
|
|
150
|
+
WorkingTree.new(config.root, ignored: config.output_paths).state.warning&.then { |text| @err.puts(text) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Usage errors found before the target boots.
|
|
154
|
+
def validate_run_options
|
|
155
|
+
reject_threshold_with_agent_format
|
|
156
|
+
Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
|
|
157
|
+
end
|
|
158
|
+
|
|
128
159
|
# Agent mode has one strict meaning for exit 0, so a score threshold cannot also decide it.
|
|
129
160
|
def reject_threshold_with_agent_format
|
|
130
|
-
return unless agent? && config["threshold"]
|
|
161
|
+
return unless agent? && (config["threshold"] || gates.any?)
|
|
131
162
|
|
|
132
|
-
raise UsageError, "--threshold cannot be combined with --agent
|
|
163
|
+
raise UsageError, "#{config['threshold'] ? '--threshold' : '--max-* gates'} cannot be combined with --agent"
|
|
133
164
|
end
|
|
134
165
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
166
|
+
# One line naming the reports written, paths relative to the root when
|
|
167
|
+
# they sit under it (a blank line before it, as the old JSON line had).
|
|
168
|
+
def announce_reports(reports)
|
|
169
|
+
written = reports.select { |path, _label| path }
|
|
170
|
+
return if written.empty?
|
|
171
|
+
|
|
172
|
+
root = File.join(File.realpath(config.root), "")
|
|
173
|
+
names = written.map do |path, label|
|
|
174
|
+
shown = File.expand_path(path).delete_prefix(root)
|
|
175
|
+
"#{shown} (#{label})"
|
|
176
|
+
end
|
|
177
|
+
@out.puts "\nReports: #{names.join(', ')}"
|
|
147
178
|
end
|
|
148
179
|
|
|
149
180
|
def doctor
|
|
150
181
|
doctor_parser.parse!(@argv)
|
|
151
182
|
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
152
183
|
|
|
153
|
-
|
|
184
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
185
|
+
Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
|
|
154
186
|
EXIT_OK
|
|
155
187
|
rescue ConfigError => e
|
|
156
188
|
@err.puts("saboteur: #{e.message}")
|
|
@@ -162,29 +194,6 @@ module Saboteur
|
|
|
162
194
|
|
|
163
195
|
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
164
196
|
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
165
|
-
def agent_run(arguments)
|
|
166
|
-
tree = WorkingTree.new(config.root, ignored: output_paths).state
|
|
167
|
-
report = stdout_on_stderr { Engine.new(config, **arguments).call }
|
|
168
|
-
payload = Reporter::Agent.call(report, agent_provenance(report, tree))
|
|
169
|
-
rendered = Reporter::Agent.render(payload)
|
|
170
|
-
# Same publish order as text_run: the JSON is the completion
|
|
171
|
-
# evidence, so it is written only after every other fallible stage,
|
|
172
|
-
# including the agent payload itself.
|
|
173
|
-
write_html(report, announce: false)
|
|
174
|
-
write_json(report, agent_exit_cause(report, payload), announce: false)
|
|
175
|
-
@out.print(rendered)
|
|
176
|
-
Reporter::Agent.exit_code(payload)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Paths of saboteur's own output, relative to the root, that do not count as source changes.
|
|
180
|
-
def output_paths
|
|
181
|
-
root = File.join(File.realpath(config.root), "")
|
|
182
|
-
%w[cache_dir json_report html_report].filter_map { |key| config.path(key) }
|
|
183
|
-
.map { |path| File.expand_path(path) }
|
|
184
|
-
.select { |path| path.start_with?(root) }
|
|
185
|
-
.map { |path| path.delete_prefix(root) }
|
|
186
|
-
end
|
|
187
|
-
|
|
188
197
|
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
189
198
|
# children cannot write into the JSON on stdout.
|
|
190
199
|
def stdout_on_stderr
|
|
@@ -196,11 +205,12 @@ module Saboteur
|
|
|
196
205
|
saved&.close
|
|
197
206
|
end
|
|
198
207
|
|
|
199
|
-
def agent_provenance(report, tree)
|
|
208
|
+
def agent_provenance(report, tree, engine)
|
|
200
209
|
Reporter::Agent::Provenance.new(
|
|
201
210
|
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
202
211
|
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
203
|
-
subjects: Array(config["subjects"]), tests: Array(options[:tests]),
|
|
212
|
+
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || options[:tests]),
|
|
213
|
+
sources: @sources || [],
|
|
204
214
|
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
205
215
|
incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
206
216
|
)
|
|
@@ -213,89 +223,21 @@ module Saboteur
|
|
|
213
223
|
@sources&.any? ? "files" : "full"
|
|
214
224
|
end
|
|
215
225
|
|
|
216
|
-
|
|
226
|
+
# Writes the JSON report and returns its path, or nil when none is configured.
|
|
227
|
+
def write_json(report, cause)
|
|
217
228
|
path = config.path("json_report")
|
|
218
229
|
return unless path
|
|
219
230
|
|
|
220
231
|
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
221
|
-
|
|
232
|
+
path
|
|
222
233
|
end
|
|
223
234
|
|
|
224
|
-
def write_html(report
|
|
235
|
+
def write_html(report)
|
|
225
236
|
path = config.path("html_report")
|
|
226
237
|
return unless path
|
|
227
238
|
|
|
228
239
|
Reporter::Html.new(path).call(report)
|
|
229
|
-
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def exit_code(report)
|
|
233
|
-
return EXIT_TOOL if mostly_errored?(report)
|
|
234
|
-
|
|
235
|
-
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
236
|
-
|
|
237
|
-
failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def mostly_errored?(report)
|
|
241
|
-
return false unless report.majority_errored?
|
|
242
|
-
|
|
243
|
-
executed = report.executed
|
|
244
|
-
errors = executed.count { |result| result.status == :error }
|
|
245
|
-
@err.puts("saboteur: #{errors} of #{executed.size} mutants errored, so the run tested almost nothing; " \
|
|
246
|
-
"fix the errors and run again")
|
|
247
|
-
true
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# Agent mode: the recorded cause is the payload's own verdict, so the
|
|
251
|
-
# JSON and the process exit classify the run the same way. An
|
|
252
|
-
# incomplete run that mostly errored keeps its distinct cause, so the
|
|
253
|
-
# harness rule means the same thing in both modes.
|
|
254
|
-
def agent_exit_cause(report, payload)
|
|
255
|
-
case payload[:verdict]
|
|
256
|
-
when "clean" then "ok"
|
|
257
|
-
when "survivors" then "survivors"
|
|
258
|
-
else report.majority_errored? ? "errorMajority" : "incomplete"
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# Why the run exits the way it does, recorded in the JSON report so an
|
|
263
|
-
# outer harness can tell "most mutants errored" from the other exit 3s.
|
|
264
|
-
def exit_cause(report, code)
|
|
265
|
-
case code
|
|
266
|
-
when EXIT_OK then "ok"
|
|
267
|
-
when EXIT_SURVIVORS then config["threshold"].nil? ? "survivors" : "belowThreshold"
|
|
268
|
-
else report.majority_errored? ? "errorMajority" : "incompleteUnderThreshold"
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# With --threshold the score decides; without it any unignored survivor does.
|
|
273
|
-
def failed_quality_gate?(report)
|
|
274
|
-
threshold = config["threshold"]
|
|
275
|
-
return report.unignored_survivors.any? if threshold.nil?
|
|
276
|
-
|
|
277
|
-
score = report.score
|
|
278
|
-
return missing_score_reported? if score.nil?
|
|
279
|
-
|
|
280
|
-
score * 100 < threshold
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
# A gate that cannot measure a score must not pass: CI would go green
|
|
284
|
-
# on a run that tested nothing.
|
|
285
|
-
def missing_score_reported?
|
|
286
|
-
@err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
|
|
287
|
-
true
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# Errors and resource limits are outside the score. With a threshold they
|
|
291
|
-
# make the run incomplete, so the score alone cannot pass it.
|
|
292
|
-
def incomplete_under_threshold?(report)
|
|
293
|
-
return false if config["threshold"].nil?
|
|
294
|
-
|
|
295
|
-
count = report.counts.values_at(:error, :resource_limit).sum
|
|
296
|
-
@err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
|
|
297
|
-
count.positive?
|
|
298
|
-
count.positive?
|
|
240
|
+
path
|
|
299
241
|
end
|
|
300
242
|
|
|
301
243
|
# Positional arguments name the files (or directories) to mutate. They
|
|
@@ -337,10 +279,25 @@ module Saboteur
|
|
|
337
279
|
end
|
|
338
280
|
|
|
339
281
|
def overrides
|
|
340
|
-
options.except(:config, :id)
|
|
282
|
+
options.except(:config, :id, :suite, :list_operators, :dry_run)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def preview
|
|
286
|
+
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
|
|
287
|
+
raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
|
|
288
|
+
|
|
289
|
+
return MutationSource::OperatorRegistry.list(@out) || EXIT_OK if options[:list_operators]
|
|
290
|
+
|
|
291
|
+
Preview.new(config, out: @out, err: @err).call
|
|
292
|
+
EXIT_OK
|
|
341
293
|
end
|
|
342
294
|
|
|
343
295
|
def define_selection_options(opts)
|
|
296
|
+
opts.on("--operators NAMES", Array, "Choose operator names or tiers (core, extended, all)") do |v|
|
|
297
|
+
set(:operators, v)
|
|
298
|
+
end
|
|
299
|
+
opts.on("--list-operators", "List operator names, descriptions and tiers") { set(:list_operators, true) }
|
|
300
|
+
opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
|
|
344
301
|
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
345
302
|
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
346
303
|
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
@@ -356,15 +313,37 @@ module Saboteur
|
|
|
356
313
|
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
357
314
|
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
358
315
|
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
316
|
+
opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
|
|
317
|
+
set(:allow_hazardous, true)
|
|
318
|
+
end
|
|
319
|
+
opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
|
|
359
320
|
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
321
|
+
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
322
|
+
set(:allow_focus, true)
|
|
323
|
+
end
|
|
324
|
+
opts.on("--[no-]guess-tests", "With --since: run only the specs named after the changed files " \
|
|
325
|
+
"(lib/a.rb -> spec/a_spec.rb); the whole suite if any has none") do |v|
|
|
326
|
+
set(:guess_tests, v)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def define_matrix_option(opts)
|
|
331
|
+
opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
|
|
332
|
+
set(:matrix, true)
|
|
333
|
+
end
|
|
360
334
|
end
|
|
361
335
|
|
|
362
336
|
def define_output_options(opts)
|
|
337
|
+
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
363
338
|
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
364
339
|
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
365
340
|
opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
|
|
366
341
|
"strict exit codes") { |v| @format = v }
|
|
367
342
|
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
343
|
+
opts.on("--verbose", "Text report: also print score split, timings and kill causes") { @verbose = true }
|
|
344
|
+
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
345
|
+
@color = v ? :always : :never
|
|
346
|
+
end
|
|
368
347
|
end
|
|
369
348
|
|
|
370
349
|
def define_explicit_file_options(opts)
|
|
@@ -374,6 +353,7 @@ module Saboteur
|
|
|
374
353
|
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
375
354
|
set(:threshold, v)
|
|
376
355
|
end
|
|
356
|
+
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
377
357
|
end
|
|
378
358
|
|
|
379
359
|
def add_subject(expr)
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "mutation_source/builtin"
|
|
5
|
+
require_relative "mutation_source/operator_registry"
|
|
6
|
+
require_relative "mutation_source/hazard_guard"
|
|
5
7
|
|
|
6
8
|
module Saboteur
|
|
7
9
|
# Settings from config/saboteur.rb, overridden by CLI flags.
|
|
@@ -20,6 +22,11 @@ module Saboteur
|
|
|
20
22
|
"include" => ["lib/**/*.rb"],
|
|
21
23
|
"exclude" => [],
|
|
22
24
|
"arid" => MutationSource::Builtin::DEFAULT_ARID,
|
|
25
|
+
"whole_file" => [],
|
|
26
|
+
"hazard" => MutationSource::HazardGuard::DEFAULT_HAZARD,
|
|
27
|
+
"allow_hazardous" => false,
|
|
28
|
+
"sandbox" => true, # true, false, or "strict": strict refuses to run without full Landlock
|
|
29
|
+
"sandbox_writable" => [],
|
|
23
30
|
"timeout_factor" => 3.0,
|
|
24
31
|
"timeout_min" => 5.0,
|
|
25
32
|
"baseline_timeout" => 300.0,
|
|
@@ -31,29 +38,37 @@ module Saboteur
|
|
|
31
38
|
"ignore_file" => "config/saboteur_ignore.rb",
|
|
32
39
|
"custom_map" => "config/saboteur_custom_map.rb",
|
|
33
40
|
"sources" => ["builtin"],
|
|
41
|
+
"operators" => [],
|
|
34
42
|
"hooks" => [],
|
|
35
43
|
"rails" => false,
|
|
36
44
|
"reeval" => "nesting",
|
|
37
45
|
"cache_dir" => ".saboteur",
|
|
38
46
|
"json_report" => ".saboteur/report.json",
|
|
39
47
|
"html_report" => ".saboteur/report.html",
|
|
48
|
+
"score_history" => nil,
|
|
40
49
|
"rspec_args" => [],
|
|
41
50
|
"subjects" => [],
|
|
42
51
|
"since" => nil,
|
|
43
52
|
"cache" => true,
|
|
44
53
|
"incremental" => false,
|
|
54
|
+
"allow_focus" => false,
|
|
55
|
+
"fail_fast" => false,
|
|
56
|
+
"guess_tests" => false,
|
|
57
|
+
"matrix" => false,
|
|
45
58
|
"seed" => 1,
|
|
46
59
|
"tests" => [],
|
|
47
60
|
"fingerprint_extra" => [],
|
|
48
|
-
"threshold" => nil
|
|
61
|
+
"threshold" => nil,
|
|
62
|
+
"max_timeouts" => nil,
|
|
63
|
+
"max_errors" => nil
|
|
49
64
|
}.freeze
|
|
50
65
|
|
|
51
66
|
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
|
|
52
67
|
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
53
68
|
THRESHOLD_RANGE = (0.0..100.0)
|
|
54
69
|
|
|
55
|
-
LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
|
|
56
|
-
fingerprint_extra].freeze
|
|
70
|
+
LIST_KEYS = %w[require include exclude arid hazard sources hooks rspec_args subjects tests
|
|
71
|
+
fingerprint_extra sandbox_writable operators whole_file].freeze
|
|
57
72
|
|
|
58
73
|
attr_reader :root
|
|
59
74
|
|
|
@@ -106,7 +121,7 @@ module Saboteur
|
|
|
106
121
|
matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
107
122
|
excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
108
123
|
(matched - excluded).select { |file| file.end_with?(".rb") }
|
|
109
|
-
.map { |file| File.realpath(File.
|
|
124
|
+
.map { |file| File.realpath(File.expand_path(file, root)) }.uniq.sort
|
|
110
125
|
end
|
|
111
126
|
|
|
112
127
|
# Patterns that match no file. A pattern goes stale when files move;
|
|
@@ -122,6 +137,15 @@ module Saboteur
|
|
|
122
137
|
value && File.expand_path(value, root)
|
|
123
138
|
end
|
|
124
139
|
|
|
140
|
+
# Paths of saboteur's own output, relative to the root, that do not count as source changes.
|
|
141
|
+
def output_paths
|
|
142
|
+
prefix = File.join(File.realpath(root), "")
|
|
143
|
+
%w[cache_dir json_report html_report score_history].filter_map { |key| path(key) }
|
|
144
|
+
.map { |file| File.expand_path(file) }
|
|
145
|
+
.select { |file| file.start_with?(prefix) }
|
|
146
|
+
.map { |file| file.delete_prefix(prefix) }
|
|
147
|
+
end
|
|
148
|
+
|
|
125
149
|
private
|
|
126
150
|
|
|
127
151
|
def coerce!
|
|
@@ -139,9 +163,39 @@ module Saboteur
|
|
|
139
163
|
|
|
140
164
|
def validate_report_paths!
|
|
141
165
|
json = path("json_report")
|
|
142
|
-
|
|
166
|
+
if json && json == path("html_report")
|
|
167
|
+
raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
paths = %w[json_report html_report score_history].filter_map { |key| path(key) }
|
|
171
|
+
return if paths.uniq.size == paths.size
|
|
172
|
+
|
|
173
|
+
raise ConfigError, "json_report, html_report and score_history must have distinct paths"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_modes!
|
|
177
|
+
validate_fail_fast!
|
|
178
|
+
validate_matrix!
|
|
179
|
+
validate_guess_tests!
|
|
180
|
+
validate_gates!
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_gates!
|
|
184
|
+
Gates::SETTINGS.each_key { |key| Gates.limit(self[key], key) }
|
|
185
|
+
end
|
|
143
186
|
|
|
144
|
-
|
|
187
|
+
def validate_fail_fast!
|
|
188
|
+
return unless self["fail_fast"] && self["jobs"] > 1
|
|
189
|
+
|
|
190
|
+
raise ConfigError, "fail_fast needs jobs 1: parallel workers have already started the other subjects"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A reused verdict was computed by a run that stopped at the first
|
|
194
|
+
# failing test, so it lists too few killers for a matrix.
|
|
195
|
+
def validate_matrix!
|
|
196
|
+
return unless self["matrix"] && self["incremental"]
|
|
197
|
+
|
|
198
|
+
raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
|
|
145
199
|
end
|
|
146
200
|
|
|
147
201
|
def validate_threshold!
|
|
@@ -151,6 +205,13 @@ module Saboteur
|
|
|
151
205
|
raise ConfigError, "threshold must be between 0 and 100"
|
|
152
206
|
end
|
|
153
207
|
|
|
208
|
+
def validate_guess_tests!
|
|
209
|
+
return unless self["guess_tests"]
|
|
210
|
+
|
|
211
|
+
raise ConfigError, "guess_tests needs since" unless self["since"]
|
|
212
|
+
raise ConfigError, "guess_tests cannot be combined with tests" if self["tests"].any?
|
|
213
|
+
end
|
|
214
|
+
|
|
154
215
|
def validate_reeval!
|
|
155
216
|
raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
|
|
156
217
|
Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
|
|
@@ -158,9 +219,13 @@ module Saboteur
|
|
|
158
219
|
|
|
159
220
|
def validate!
|
|
160
221
|
raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
|
|
222
|
+
unless [true, false, "strict"].include?(self["sandbox"])
|
|
223
|
+
raise ConfigError, 'sandbox must be true, false, or "strict"'
|
|
224
|
+
end
|
|
161
225
|
|
|
162
226
|
validate_mutant_limit!
|
|
163
227
|
validate_threshold!
|
|
228
|
+
validate_modes!
|
|
164
229
|
raise ConfigError, "seed must be >= 0" if self["seed"].negative?
|
|
165
230
|
raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
|
|
166
231
|
raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
|
|
@@ -168,6 +233,11 @@ module Saboteur
|
|
|
168
233
|
raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
|
|
169
234
|
|
|
170
235
|
validate_reeval!
|
|
236
|
+
validate_operator_config!
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def validate_operator_config!
|
|
240
|
+
MutationSource::OperatorRegistry.resolve(self["operators"])
|
|
171
241
|
|
|
172
242
|
self["arid"].each { |pattern| Regexp.new(pattern) }
|
|
173
243
|
rescue RegexpError => e
|
data/lib/saboteur/doctor.rb
CHANGED
|
@@ -5,11 +5,13 @@ require "coverage"
|
|
|
5
5
|
require_relative "baseline_health"
|
|
6
6
|
|
|
7
7
|
module Saboteur
|
|
8
|
-
# Reports the current environment
|
|
8
|
+
# Reports the current environment, never mutations. The two unchanged suite
|
|
9
|
+
# passes are slow on a large suite, so they run only when asked for.
|
|
9
10
|
class Doctor
|
|
10
|
-
def initialize(config, out: $stdout)
|
|
11
|
+
def initialize(config, out: $stdout, suite: false)
|
|
11
12
|
@config = config
|
|
12
13
|
@out = out
|
|
14
|
+
@suite = suite
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def call
|
|
@@ -18,20 +20,31 @@ module Saboteur
|
|
|
18
20
|
check_coverage
|
|
19
21
|
check_boot_files
|
|
20
22
|
check_custom_map
|
|
23
|
+
check_ignore_subjects
|
|
21
24
|
hooks = Hooks.new
|
|
22
25
|
integration = boot_target(hooks)
|
|
23
26
|
@out.puts "Integration: #{@config['integration']} (loaded)"
|
|
24
27
|
@out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
|
|
25
28
|
@out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
|
|
26
29
|
check_coverage
|
|
27
|
-
|
|
28
|
-
@out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
|
|
29
|
-
@out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
|
|
30
|
+
check_suite(integration, hooks)
|
|
30
31
|
true
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
36
|
+
def check_suite(integration, hooks)
|
|
37
|
+
unless @suite
|
|
38
|
+
@out.puts "Baseline: not run. Pass --suite to run the unchanged suite twice " \
|
|
39
|
+
"(add --test PATH to limit it to some specs)."
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
|
|
44
|
+
@out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
|
|
45
|
+
@out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
|
|
46
|
+
end
|
|
47
|
+
|
|
35
48
|
def boot_target(hooks)
|
|
36
49
|
Engine::Boot.new(@config, hooks).load_target
|
|
37
50
|
rescue LoadError, SyntaxError, SystemExit, StandardError => e
|
|
@@ -52,10 +65,29 @@ module Saboteur
|
|
|
52
65
|
def check_coverage
|
|
53
66
|
state = Coverage.state
|
|
54
67
|
@out.puts "Coverage: #{state}"
|
|
55
|
-
return if Coverage.supported?(:lines) && state == :suspended
|
|
68
|
+
return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
|
|
69
|
+
|
|
70
|
+
raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
|
|
71
|
+
"executable, disable SimpleCov and remove other Coverage.start calls"
|
|
72
|
+
end
|
|
56
73
|
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
# Match against all included source definitions, not the current diff or
|
|
75
|
+
# selected tests. A limited run must not make a valid ignore look stale.
|
|
76
|
+
def check_ignore_subjects
|
|
77
|
+
ignores = IgnoreList.load(@config.path("ignore_file"))
|
|
78
|
+
return if ignores.entries.empty?
|
|
79
|
+
|
|
80
|
+
finder = SubjectFinder.new
|
|
81
|
+
parsed = @config.included_files.map { |file| finder.call(file) }
|
|
82
|
+
if parsed.any? { |result| result.errors.any? }
|
|
83
|
+
@out.puts "Warning: ignore subject check incomplete: an included file does not parse"
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
subjects = parsed.flat_map(&:subjects).to_set(&:id)
|
|
87
|
+
ignores.entries.map(&:subject).uniq.reject { |subject| subjects.include?(subject) }.each do |subject|
|
|
88
|
+
@out.puts "Warning: ignore subject #{subject} matches no discovered subject in included files; " \
|
|
89
|
+
"check include/exclude settings and the exact Class#method or Class.method form"
|
|
90
|
+
end
|
|
59
91
|
end
|
|
60
92
|
|
|
61
93
|
def check_custom_map
|