saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -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 +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -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 +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- 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 +62 -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 +7 -5
- 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 +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- 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/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -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_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -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 +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- 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 +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -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 +46 -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,16 +205,22 @@ 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
|
-
incremental: config["incremental"], settings:
|
|
215
|
+
incremental: config["incremental"], settings: agent_settings
|
|
206
216
|
)
|
|
207
217
|
end
|
|
208
218
|
|
|
219
|
+
def agent_settings
|
|
220
|
+
options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
221
|
+
.merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
|
|
222
|
+
end
|
|
223
|
+
|
|
209
224
|
def agent_mode
|
|
210
225
|
return "replay" if options[:id]
|
|
211
226
|
return "diff" if config["since"]
|
|
@@ -213,89 +228,21 @@ module Saboteur
|
|
|
213
228
|
@sources&.any? ? "files" : "full"
|
|
214
229
|
end
|
|
215
230
|
|
|
216
|
-
|
|
231
|
+
# Writes the JSON report and returns its path, or nil when none is configured.
|
|
232
|
+
def write_json(report, cause)
|
|
217
233
|
path = config.path("json_report")
|
|
218
234
|
return unless path
|
|
219
235
|
|
|
220
236
|
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
221
|
-
|
|
237
|
+
path
|
|
222
238
|
end
|
|
223
239
|
|
|
224
|
-
def write_html(report
|
|
240
|
+
def write_html(report)
|
|
225
241
|
path = config.path("html_report")
|
|
226
242
|
return unless path
|
|
227
243
|
|
|
228
244
|
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?
|
|
245
|
+
path
|
|
299
246
|
end
|
|
300
247
|
|
|
301
248
|
# Positional arguments name the files (or directories) to mutate. They
|
|
@@ -337,10 +284,31 @@ module Saboteur
|
|
|
337
284
|
end
|
|
338
285
|
|
|
339
286
|
def overrides
|
|
340
|
-
options.except(:config, :id)
|
|
287
|
+
options.except(:config, :id, :suite, :list_operators, :dry_run)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def preview
|
|
291
|
+
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
|
|
292
|
+
raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
|
|
293
|
+
|
|
294
|
+
if options[:list_operators]
|
|
295
|
+
MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
|
|
296
|
+
return EXIT_OK
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
Preview.new(config, out: @out, err: @err).call
|
|
300
|
+
EXIT_OK
|
|
341
301
|
end
|
|
342
302
|
|
|
343
303
|
def define_selection_options(opts)
|
|
304
|
+
opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
|
|
305
|
+
set(:operators, v)
|
|
306
|
+
end
|
|
307
|
+
opts.on("--exclude-operators NAMES", Array, "Exclude operator names from the selected set") do |v|
|
|
308
|
+
set(:exclude_operators, v)
|
|
309
|
+
end
|
|
310
|
+
opts.on("--list-operators", "List operator names and descriptions") { set(:list_operators, true) }
|
|
311
|
+
opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
|
|
344
312
|
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
345
313
|
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
346
314
|
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
@@ -356,15 +324,37 @@ module Saboteur
|
|
|
356
324
|
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
357
325
|
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
358
326
|
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
327
|
+
opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
|
|
328
|
+
set(:allow_hazardous, true)
|
|
329
|
+
end
|
|
330
|
+
opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
|
|
359
331
|
opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
|
|
332
|
+
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
333
|
+
set(:allow_focus, true)
|
|
334
|
+
end
|
|
335
|
+
opts.on("--[no-]guess-tests", "With --since: run only the specs named after the changed files " \
|
|
336
|
+
"(lib/a.rb -> spec/a_spec.rb); the whole suite if any has none") do |v|
|
|
337
|
+
set(:guess_tests, v)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def define_matrix_option(opts)
|
|
342
|
+
opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
|
|
343
|
+
set(:matrix, true)
|
|
344
|
+
end
|
|
360
345
|
end
|
|
361
346
|
|
|
362
347
|
def define_output_options(opts)
|
|
348
|
+
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
363
349
|
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
364
350
|
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
365
351
|
opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
|
|
366
352
|
"strict exit codes") { |v| @format = v }
|
|
367
353
|
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
354
|
+
opts.on("--verbose", "Text report: also print score split, timings and kill causes") { @verbose = true }
|
|
355
|
+
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
356
|
+
@color = v ? :always : :never
|
|
357
|
+
end
|
|
368
358
|
end
|
|
369
359
|
|
|
370
360
|
def define_explicit_file_options(opts)
|
|
@@ -374,6 +364,7 @@ module Saboteur
|
|
|
374
364
|
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
375
365
|
set(:threshold, v)
|
|
376
366
|
end
|
|
367
|
+
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
377
368
|
end
|
|
378
369
|
|
|
379
370
|
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,40 @@ 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" => [],
|
|
42
|
+
"exclude_operators" => [],
|
|
34
43
|
"hooks" => [],
|
|
35
44
|
"rails" => false,
|
|
45
|
+
# Hosts the user asserts are disposable test databases (issue #258).
|
|
46
|
+
"allow_db_hosts" => [],
|
|
36
47
|
"reeval" => "nesting",
|
|
37
48
|
"cache_dir" => ".saboteur",
|
|
38
49
|
"json_report" => ".saboteur/report.json",
|
|
39
50
|
"html_report" => ".saboteur/report.html",
|
|
51
|
+
"score_history" => nil,
|
|
40
52
|
"rspec_args" => [],
|
|
41
53
|
"subjects" => [],
|
|
42
54
|
"since" => nil,
|
|
43
55
|
"cache" => true,
|
|
44
56
|
"incremental" => false,
|
|
57
|
+
"allow_focus" => false,
|
|
58
|
+
"fail_fast" => false,
|
|
59
|
+
"guess_tests" => false,
|
|
60
|
+
"matrix" => false,
|
|
45
61
|
"seed" => 1,
|
|
46
62
|
"tests" => [],
|
|
47
63
|
"fingerprint_extra" => [],
|
|
48
|
-
"threshold" => nil
|
|
64
|
+
"threshold" => nil,
|
|
65
|
+
"max_timeouts" => nil,
|
|
66
|
+
"max_errors" => nil
|
|
49
67
|
}.freeze
|
|
50
68
|
|
|
51
69
|
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
|
|
52
70
|
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
53
71
|
THRESHOLD_RANGE = (0.0..100.0)
|
|
54
72
|
|
|
55
|
-
LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
|
|
56
|
-
fingerprint_extra].freeze
|
|
73
|
+
LIST_KEYS = %w[require include exclude arid hazard sources hooks rspec_args subjects tests
|
|
74
|
+
fingerprint_extra sandbox_writable operators exclude_operators whole_file allow_db_hosts].freeze
|
|
57
75
|
|
|
58
76
|
attr_reader :root
|
|
59
77
|
|
|
@@ -106,7 +124,7 @@ module Saboteur
|
|
|
106
124
|
matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
107
125
|
excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
108
126
|
(matched - excluded).select { |file| file.end_with?(".rb") }
|
|
109
|
-
.map { |file| File.realpath(File.
|
|
127
|
+
.map { |file| File.realpath(File.expand_path(file, root)) }.uniq.sort
|
|
110
128
|
end
|
|
111
129
|
|
|
112
130
|
# Patterns that match no file. A pattern goes stale when files move;
|
|
@@ -122,6 +140,15 @@ module Saboteur
|
|
|
122
140
|
value && File.expand_path(value, root)
|
|
123
141
|
end
|
|
124
142
|
|
|
143
|
+
# Paths of saboteur's own output, relative to the root, that do not count as source changes.
|
|
144
|
+
def output_paths
|
|
145
|
+
prefix = File.join(File.realpath(root), "")
|
|
146
|
+
%w[cache_dir json_report html_report score_history].filter_map { |key| path(key) }
|
|
147
|
+
.map { |file| File.expand_path(file) }
|
|
148
|
+
.select { |file| file.start_with?(prefix) }
|
|
149
|
+
.map { |file| file.delete_prefix(prefix) }
|
|
150
|
+
end
|
|
151
|
+
|
|
125
152
|
private
|
|
126
153
|
|
|
127
154
|
def coerce!
|
|
@@ -139,9 +166,39 @@ module Saboteur
|
|
|
139
166
|
|
|
140
167
|
def validate_report_paths!
|
|
141
168
|
json = path("json_report")
|
|
142
|
-
|
|
169
|
+
if json && json == path("html_report")
|
|
170
|
+
raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
paths = %w[json_report html_report score_history].filter_map { |key| path(key) }
|
|
174
|
+
return if paths.uniq.size == paths.size
|
|
175
|
+
|
|
176
|
+
raise ConfigError, "json_report, html_report and score_history must have distinct paths"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate_modes!
|
|
180
|
+
validate_fail_fast!
|
|
181
|
+
validate_matrix!
|
|
182
|
+
validate_guess_tests!
|
|
183
|
+
validate_gates!
|
|
184
|
+
end
|
|
143
185
|
|
|
144
|
-
|
|
186
|
+
def validate_gates!
|
|
187
|
+
Gates::SETTINGS.each_key { |key| Gates.limit(self[key], key) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_fail_fast!
|
|
191
|
+
return unless self["fail_fast"] && self["jobs"] > 1
|
|
192
|
+
|
|
193
|
+
raise ConfigError, "fail_fast needs jobs 1: parallel workers have already started the other subjects"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A reused verdict was computed by a run that stopped at the first
|
|
197
|
+
# failing test, so it lists too few killers for a matrix.
|
|
198
|
+
def validate_matrix!
|
|
199
|
+
return unless self["matrix"] && self["incremental"]
|
|
200
|
+
|
|
201
|
+
raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
|
|
145
202
|
end
|
|
146
203
|
|
|
147
204
|
def validate_threshold!
|
|
@@ -151,6 +208,13 @@ module Saboteur
|
|
|
151
208
|
raise ConfigError, "threshold must be between 0 and 100"
|
|
152
209
|
end
|
|
153
210
|
|
|
211
|
+
def validate_guess_tests!
|
|
212
|
+
return unless self["guess_tests"]
|
|
213
|
+
|
|
214
|
+
raise ConfigError, "guess_tests needs since" unless self["since"]
|
|
215
|
+
raise ConfigError, "guess_tests cannot be combined with tests" if self["tests"].any?
|
|
216
|
+
end
|
|
217
|
+
|
|
154
218
|
def validate_reeval!
|
|
155
219
|
raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
|
|
156
220
|
Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
|
|
@@ -158,9 +222,13 @@ module Saboteur
|
|
|
158
222
|
|
|
159
223
|
def validate!
|
|
160
224
|
raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
|
|
225
|
+
unless [true, false, "strict"].include?(self["sandbox"])
|
|
226
|
+
raise ConfigError, 'sandbox must be true, false, or "strict"'
|
|
227
|
+
end
|
|
161
228
|
|
|
162
229
|
validate_mutant_limit!
|
|
163
230
|
validate_threshold!
|
|
231
|
+
validate_modes!
|
|
164
232
|
raise ConfigError, "seed must be >= 0" if self["seed"].negative?
|
|
165
233
|
raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
|
|
166
234
|
raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
|
|
@@ -168,6 +236,28 @@ module Saboteur
|
|
|
168
236
|
raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
|
|
169
237
|
|
|
170
238
|
validate_reeval!
|
|
239
|
+
validate_operator_config!
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
public
|
|
243
|
+
|
|
244
|
+
def operator_policy
|
|
245
|
+
MutationSource::OperatorRegistry.policy(self["operators"], self["exclude_operators"])
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Versioned policy travels with every identity, not with mutation digests.
|
|
249
|
+
def selection_settings
|
|
250
|
+
to_h.merge("operator_policy" => operator_policy)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
def validate_operator_config!
|
|
256
|
+
effective = MutationSource::OperatorRegistry.select(self["operators"], self["exclude_operators"])
|
|
257
|
+
if effective.empty?
|
|
258
|
+
raise ConfigError,
|
|
259
|
+
"operator selection is empty: enable at least one operator or clear exclusions"
|
|
260
|
+
end
|
|
171
261
|
|
|
172
262
|
self["arid"].each { |pattern| Regexp.new(pattern) }
|
|
173
263
|
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,32 @@ 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'}"
|
|
28
|
+
check_database_hosts
|
|
25
29
|
@out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
|
|
26
30
|
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."
|
|
31
|
+
check_suite(integration, hooks)
|
|
30
32
|
true
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
private
|
|
34
36
|
|
|
37
|
+
def check_suite(integration, hooks)
|
|
38
|
+
unless @suite
|
|
39
|
+
@out.puts "Baseline: not run. Pass --suite to run the unchanged suite twice " \
|
|
40
|
+
"(add --test PATH to limit it to some specs)."
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
|
|
45
|
+
@out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
|
|
46
|
+
@out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
|
|
47
|
+
end
|
|
48
|
+
|
|
35
49
|
def boot_target(hooks)
|
|
36
50
|
Engine::Boot.new(@config, hooks).load_target
|
|
37
51
|
rescue LoadError, SyntaxError, SystemExit, StandardError => e
|
|
@@ -52,10 +66,29 @@ module Saboteur
|
|
|
52
66
|
def check_coverage
|
|
53
67
|
state = Coverage.state
|
|
54
68
|
@out.puts "Coverage: #{state}"
|
|
55
|
-
return if Coverage.supported?(:lines) && state == :suspended
|
|
69
|
+
return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
|
|
56
70
|
|
|
57
|
-
raise Error, "Coverage must be suspended with line support before boot; use the saboteur
|
|
58
|
-
"disable SimpleCov and remove other Coverage.start calls"
|
|
71
|
+
raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
|
|
72
|
+
"executable, disable SimpleCov and remove other Coverage.start calls"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Match against all included source definitions, not the current diff or
|
|
76
|
+
# selected tests. A limited run must not make a valid ignore look stale.
|
|
77
|
+
def check_ignore_subjects
|
|
78
|
+
ignores = IgnoreList.load(@config.path("ignore_file"))
|
|
79
|
+
return if ignores.entries.empty?
|
|
80
|
+
|
|
81
|
+
finder = SubjectFinder.new
|
|
82
|
+
parsed = @config.included_files.map { |file| finder.call(file) }
|
|
83
|
+
if parsed.any? { |result| result.errors.any? }
|
|
84
|
+
@out.puts "Warning: ignore subject check incomplete: an included file does not parse"
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
subjects = parsed.flat_map(&:subjects).to_set(&:id)
|
|
88
|
+
ignores.entries.map(&:subject).uniq.reject { |subject| subjects.include?(subject) }.each do |subject|
|
|
89
|
+
@out.puts "Warning: ignore subject #{subject} matches no discovered subject in included files; " \
|
|
90
|
+
"check include/exclude settings and the exact Class#method or Class.method form"
|
|
91
|
+
end
|
|
59
92
|
end
|
|
60
93
|
|
|
61
94
|
def check_custom_map
|
|
@@ -73,11 +106,33 @@ module Saboteur
|
|
|
73
106
|
@out.puts "Custom map: #{map.entries.size} entries (loaded)"
|
|
74
107
|
end
|
|
75
108
|
|
|
109
|
+
# Prints the test database hosts the booted app reports, so a non-local
|
|
110
|
+
# refusal names the host here instead of only at target boot (issue #258).
|
|
111
|
+
def check_database_hosts
|
|
112
|
+
return unless @config["rails"]
|
|
113
|
+
return @out.puts("Databases: ActiveRecord did not load; host check skipped") unless defined?(::ActiveRecord::Base)
|
|
114
|
+
|
|
115
|
+
configs = ::ActiveRecord::Base.configurations.configs_for(env_name: "test")
|
|
116
|
+
configs.each do |db|
|
|
117
|
+
host = db.configuration_hash[:host]
|
|
118
|
+
@out.puts "Database: #{db.name} (host #{host.to_s.empty? ? 'local socket' : host})"
|
|
119
|
+
end
|
|
120
|
+
remote = Frameworks::Rails.remote_databases(configs, @config["allow_db_hosts"])
|
|
121
|
+
return if remote.empty?
|
|
122
|
+
|
|
123
|
+
raise ConfigError, "non-local test databases: #{remote.join(', ')}; if they are disposable test databases, " \
|
|
124
|
+
"allow them with c.allow_db_hosts in #{Config::FILE}"
|
|
125
|
+
end
|
|
126
|
+
|
|
76
127
|
def check_boot_files
|
|
77
128
|
%w[require hooks].each do |key|
|
|
78
129
|
@config[key].each do |file|
|
|
79
130
|
path = File.expand_path(file, @config.root)
|
|
80
|
-
|
|
131
|
+
# Resolve the way Kernel#require does: the path as given, or with
|
|
132
|
+
# .rb appended (the Rails quickstart sets ./config/environment, issue #257).
|
|
133
|
+
unless File.file?(path) || File.file?("#{path}.rb")
|
|
134
|
+
raise ConfigError, "#{key} file missing: #{file}; fix config/saboteur.rb"
|
|
135
|
+
end
|
|
81
136
|
|
|
82
137
|
@out.puts "#{key}: #{file} (found)"
|
|
83
138
|
end
|