saboteur 0.3.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -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 +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- 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 +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -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 +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -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 +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- 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 +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- 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/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -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 +61 -2
- 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 +25 -5
- 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 +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- 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 +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -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
|
" \
|
|
@@ -18,7 +26,6 @@ module Saboteur
|
|
|
18
26
|
EXIT_SURVIVORS = 1
|
|
19
27
|
EXIT_USAGE = 2
|
|
20
28
|
EXIT_TOOL = 3
|
|
21
|
-
|
|
22
29
|
class UsageError < ConfigError; end
|
|
23
30
|
|
|
24
31
|
attr_reader :options
|
|
@@ -28,14 +35,17 @@ module Saboteur
|
|
|
28
35
|
@out = out
|
|
29
36
|
@err = err
|
|
30
37
|
@options = {}
|
|
38
|
+
@format = "agent" if agent_requested?
|
|
31
39
|
end
|
|
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)
|
|
48
|
+
when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
|
|
39
49
|
when "--help", "-h" then show_help(nil)
|
|
40
50
|
else usage_error
|
|
41
51
|
end
|
|
@@ -53,25 +63,34 @@ module Saboteur
|
|
|
53
63
|
define_selection_options(opts)
|
|
54
64
|
define_explicit_file_options(opts)
|
|
55
65
|
define_execution_options(opts)
|
|
66
|
+
define_matrix_option(opts)
|
|
56
67
|
define_output_options(opts)
|
|
57
68
|
end
|
|
58
69
|
end
|
|
59
70
|
|
|
60
71
|
# Each command's own option lines, as printed under OPTIONS in `saboteur help`.
|
|
61
72
|
def option_lines
|
|
62
|
-
{ "run" => parser.summarize, "doctor" => doctor_parser.summarize }
|
|
73
|
+
{ "run" => parser.summarize, "doctor" => doctor_parser.summarize, "baseline" => baseline_parser.summarize }
|
|
63
74
|
end
|
|
64
75
|
|
|
65
76
|
def doctor_parser
|
|
66
77
|
OptionParser.new do |opts|
|
|
67
|
-
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
|
|
68
|
-
opts.on("--config PATH", "Config file (default
|
|
78
|
+
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
|
|
79
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
69
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
|
|
70
85
|
end
|
|
71
86
|
end
|
|
72
87
|
|
|
73
88
|
private
|
|
74
89
|
|
|
90
|
+
def run_command
|
|
91
|
+
help_requested? ? show_help("run") : run
|
|
92
|
+
end
|
|
93
|
+
|
|
75
94
|
def help_requested?
|
|
76
95
|
@argv.intersect?(%w[--help -h])
|
|
77
96
|
end
|
|
@@ -88,35 +107,82 @@ module Saboteur
|
|
|
88
107
|
def usage_error(message = nil)
|
|
89
108
|
@err.puts(message) if message
|
|
90
109
|
@err.puts(USAGE)
|
|
91
|
-
EXIT_USAGE
|
|
110
|
+
agent_failure(message || USAGE, EXIT_USAGE)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def agent?
|
|
114
|
+
@format == "agent"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Found before parsing, so any error, even a bad option that comes first, still
|
|
118
|
+
# answers an agent with JSON.
|
|
119
|
+
def agent_requested?
|
|
120
|
+
@argv.include?("--agent") || @argv.include?("--format=agent") ||
|
|
121
|
+
@argv.each_cons(2).any? { |flag, value| flag == "--format" && value == "agent" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
125
|
+
def agent_failure(message, exit_code)
|
|
126
|
+
@out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
|
|
127
|
+
exit_code
|
|
92
128
|
end
|
|
93
129
|
|
|
94
130
|
def run
|
|
95
131
|
parser.parse!(@argv)
|
|
96
132
|
use_explicit_sources
|
|
133
|
+
validate_run_options
|
|
134
|
+
warn_dirty_tree
|
|
135
|
+
|
|
136
|
+
return preview if options[:list_operators] || options[:dry_run]
|
|
97
137
|
|
|
98
|
-
|
|
99
|
-
arguments[:digest] = options[:id] if options[:id]
|
|
100
|
-
report = Engine.new(config, **arguments).call
|
|
101
|
-
@err.puts
|
|
102
|
-
Reporter::Terminal.new(@out).call(report)
|
|
103
|
-
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
104
|
-
write_json(report)
|
|
105
|
-
write_html(report)
|
|
106
|
-
exit_code(report)
|
|
138
|
+
execute_run
|
|
107
139
|
rescue ConfigError => e
|
|
108
140
|
@err.puts("saboteur: #{e.message}")
|
|
109
|
-
EXIT_USAGE
|
|
141
|
+
agent_failure(e.message, EXIT_USAGE)
|
|
110
142
|
rescue Error => e
|
|
111
143
|
@err.puts("saboteur: #{e.message}")
|
|
112
|
-
EXIT_TOOL
|
|
144
|
+
agent_failure(e.message, EXIT_TOOL)
|
|
145
|
+
end
|
|
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
|
+
|
|
159
|
+
# Agent mode has one strict meaning for exit 0, so a score threshold cannot also decide it.
|
|
160
|
+
def reject_threshold_with_agent_format
|
|
161
|
+
return unless agent? && (config["threshold"] || gates.any?)
|
|
162
|
+
|
|
163
|
+
raise UsageError, "#{config['threshold'] ? '--threshold' : '--max-* gates'} cannot be combined with --agent"
|
|
164
|
+
end
|
|
165
|
+
|
|
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(', ')}"
|
|
113
178
|
end
|
|
114
179
|
|
|
115
180
|
def doctor
|
|
116
181
|
doctor_parser.parse!(@argv)
|
|
117
182
|
raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
|
|
118
183
|
|
|
119
|
-
|
|
184
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
185
|
+
Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
|
|
120
186
|
EXIT_OK
|
|
121
187
|
rescue ConfigError => e
|
|
122
188
|
@err.puts("saboteur: #{e.message}")
|
|
@@ -126,67 +192,68 @@ module Saboteur
|
|
|
126
192
|
EXIT_TOOL
|
|
127
193
|
end
|
|
128
194
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
195
|
+
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
196
|
+
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
197
|
+
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
198
|
+
# children cannot write into the JSON on stdout.
|
|
199
|
+
def stdout_on_stderr
|
|
200
|
+
saved = $stdout.dup
|
|
201
|
+
$stdout.reopen($stderr)
|
|
202
|
+
yield
|
|
203
|
+
ensure
|
|
204
|
+
$stdout.reopen(saved) if saved
|
|
205
|
+
saved&.close
|
|
135
206
|
end
|
|
136
207
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
208
|
+
def agent_provenance(report, tree, engine)
|
|
209
|
+
Reporter::Agent::Provenance.new(
|
|
210
|
+
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
211
|
+
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
212
|
+
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || options[:tests]),
|
|
213
|
+
sources: @sources || [],
|
|
214
|
+
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
215
|
+
incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
216
|
+
)
|
|
143
217
|
end
|
|
144
218
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
return
|
|
219
|
+
def agent_mode
|
|
220
|
+
return "replay" if options[:id]
|
|
221
|
+
return "diff" if config["since"]
|
|
148
222
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
|
|
223
|
+
@sources&.any? ? "files" : "full"
|
|
152
224
|
end
|
|
153
225
|
|
|
154
|
-
#
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
return
|
|
158
|
-
|
|
159
|
-
score = report.score
|
|
160
|
-
return missing_score_reported? if score.nil?
|
|
161
|
-
|
|
162
|
-
score * 100 < threshold
|
|
163
|
-
end
|
|
226
|
+
# Writes the JSON report and returns its path, or nil when none is configured.
|
|
227
|
+
def write_json(report, cause)
|
|
228
|
+
path = config.path("json_report")
|
|
229
|
+
return unless path
|
|
164
230
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def missing_score_reported?
|
|
168
|
-
@err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
|
|
169
|
-
true
|
|
231
|
+
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
232
|
+
path
|
|
170
233
|
end
|
|
171
234
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return false if config["threshold"].nil?
|
|
235
|
+
def write_html(report)
|
|
236
|
+
path = config.path("html_report")
|
|
237
|
+
return unless path
|
|
176
238
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
count.positive?
|
|
180
|
-
count.positive?
|
|
239
|
+
Reporter::Html.new(path).call(report)
|
|
240
|
+
path
|
|
181
241
|
end
|
|
182
242
|
|
|
183
243
|
# Positional arguments name the files (or directories) to mutate. They
|
|
184
244
|
# replace the configured include and exclude patterns. Each must exist,
|
|
185
245
|
# so a typo is an error and not a run over zero subjects.
|
|
186
246
|
def use_explicit_sources
|
|
187
|
-
|
|
188
|
-
|
|
247
|
+
if @argv.empty?
|
|
248
|
+
raise UsageError, "--test needs at least one source file" if options[:tests] && !config["since"]
|
|
189
249
|
|
|
250
|
+
# With --since the changed files are the sources, so --test may
|
|
251
|
+
# stand alone; the files must still exist.
|
|
252
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
@sources = @argv.dup
|
|
190
257
|
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
191
258
|
options[:exclude] = []
|
|
192
259
|
options[:tests]&.each { |test| require_existing_test(test) }
|
|
@@ -212,10 +279,25 @@ module Saboteur
|
|
|
212
279
|
end
|
|
213
280
|
|
|
214
281
|
def overrides
|
|
215
|
-
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
|
|
216
293
|
end
|
|
217
294
|
|
|
218
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) }
|
|
219
301
|
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
220
302
|
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
221
303
|
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
|
@@ -227,16 +309,41 @@ module Saboteur
|
|
|
227
309
|
opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
|
|
228
310
|
set(:seed, v)
|
|
229
311
|
end
|
|
230
|
-
opts.on("--config PATH", "Config file (default
|
|
312
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
231
313
|
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
232
314
|
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
233
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) }
|
|
234
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
|
|
235
334
|
end
|
|
236
335
|
|
|
237
336
|
def define_output_options(opts)
|
|
337
|
+
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
238
338
|
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
239
339
|
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
340
|
+
opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
|
|
341
|
+
"strict exit codes") { |v| @format = v }
|
|
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
|
|
240
347
|
end
|
|
241
348
|
|
|
242
349
|
def define_explicit_file_options(opts)
|
|
@@ -246,6 +353,7 @@ module Saboteur
|
|
|
246
353
|
opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
|
|
247
354
|
set(:threshold, v)
|
|
248
355
|
end
|
|
356
|
+
Gates.define_options(opts) { |key, value| set(key, value) }
|
|
249
357
|
end
|
|
250
358
|
|
|
251
359
|
def add_subject(expr)
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "yaml"
|
|
5
4
|
require_relative "mutation_source/builtin"
|
|
5
|
+
require_relative "mutation_source/operator_registry"
|
|
6
|
+
require_relative "mutation_source/hazard_guard"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
|
-
# Settings from
|
|
9
|
+
# Settings from config/saboteur.rb, overridden by CLI flags.
|
|
9
10
|
class Config
|
|
10
|
-
FILE = "
|
|
11
|
+
FILE = "config/saboteur.rb"
|
|
12
|
+
|
|
13
|
+
# Died with the YAML port: a leftover file means settings are
|
|
14
|
+
# silently ignored, which could turn off a threshold. Fail loud.
|
|
15
|
+
LEGACY_FILE = ".saboteur.yml"
|
|
16
|
+
LEGACY_IGNORE_FILE = ".saboteur-ignore.yml"
|
|
17
|
+
IGNORE_FILE = "config/saboteur_ignore.rb"
|
|
11
18
|
|
|
12
19
|
DEFAULTS = {
|
|
13
20
|
"integration" => "rspec",
|
|
@@ -15,6 +22,11 @@ module Saboteur
|
|
|
15
22
|
"include" => ["lib/**/*.rb"],
|
|
16
23
|
"exclude" => [],
|
|
17
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" => [],
|
|
18
30
|
"timeout_factor" => 3.0,
|
|
19
31
|
"timeout_min" => 5.0,
|
|
20
32
|
"baseline_timeout" => 300.0,
|
|
@@ -23,45 +35,58 @@ module Saboteur
|
|
|
23
35
|
"max_processes" => 64,
|
|
24
36
|
"max_mutants_per_method" => 100,
|
|
25
37
|
"jobs" => 1,
|
|
26
|
-
"ignore_file" => ".
|
|
38
|
+
"ignore_file" => "config/saboteur_ignore.rb",
|
|
39
|
+
"custom_map" => "config/saboteur_custom_map.rb",
|
|
27
40
|
"sources" => ["builtin"],
|
|
41
|
+
"operators" => [],
|
|
28
42
|
"hooks" => [],
|
|
29
43
|
"rails" => false,
|
|
30
44
|
"reeval" => "nesting",
|
|
31
45
|
"cache_dir" => ".saboteur",
|
|
32
46
|
"json_report" => ".saboteur/report.json",
|
|
33
47
|
"html_report" => ".saboteur/report.html",
|
|
48
|
+
"score_history" => nil,
|
|
34
49
|
"rspec_args" => [],
|
|
35
50
|
"subjects" => [],
|
|
36
51
|
"since" => nil,
|
|
37
52
|
"cache" => true,
|
|
38
53
|
"incremental" => false,
|
|
54
|
+
"allow_focus" => false,
|
|
55
|
+
"fail_fast" => false,
|
|
56
|
+
"guess_tests" => false,
|
|
57
|
+
"matrix" => false,
|
|
39
58
|
"seed" => 1,
|
|
40
59
|
"tests" => [],
|
|
41
|
-
"
|
|
60
|
+
"fingerprint_extra" => [],
|
|
61
|
+
"threshold" => nil,
|
|
62
|
+
"max_timeouts" => nil,
|
|
63
|
+
"max_errors" => nil
|
|
42
64
|
}.freeze
|
|
43
65
|
|
|
44
66
|
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
|
|
45
67
|
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
46
68
|
THRESHOLD_RANGE = (0.0..100.0)
|
|
47
69
|
|
|
48
|
-
LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
|
|
70
|
+
LIST_KEYS = %w[require include exclude arid hazard sources hooks rspec_args subjects tests
|
|
71
|
+
fingerprint_extra sandbox_writable operators whole_file].freeze
|
|
49
72
|
|
|
50
73
|
attr_reader :root
|
|
51
74
|
|
|
52
75
|
def self.load(root: Dir.pwd, path: nil, overrides: {})
|
|
53
76
|
path ||= File.join(root, FILE)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
legacy = File.join(root, LEGACY_FILE)
|
|
78
|
+
if File.file?(legacy)
|
|
79
|
+
raise ConfigError,
|
|
80
|
+
"saboteur no longer reads #{LEGACY_FILE}; move its settings to #{FILE} (see README)."
|
|
81
|
+
end
|
|
82
|
+
legacy_ignore = File.join(root, LEGACY_IGNORE_FILE)
|
|
83
|
+
if File.file?(legacy_ignore)
|
|
84
|
+
raise ConfigError,
|
|
85
|
+
"saboteur no longer reads #{LEGACY_IGNORE_FILE}; move its entries to #{IGNORE_FILE} (see README)."
|
|
86
|
+
end
|
|
61
87
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
raise ConfigError, "#{path}: #{e.message}"
|
|
88
|
+
from_file = File.file?(path) ? ConfigFile.load(path) : {}
|
|
89
|
+
new(from_file.merge(stringify(overrides)), root: root)
|
|
65
90
|
end
|
|
66
91
|
|
|
67
92
|
def self.stringify(hash)
|
|
@@ -96,7 +121,7 @@ module Saboteur
|
|
|
96
121
|
matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
97
122
|
excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
98
123
|
(matched - excluded).select { |file| file.end_with?(".rb") }
|
|
99
|
-
.map { |file| File.realpath(File.
|
|
124
|
+
.map { |file| File.realpath(File.expand_path(file, root)) }.uniq.sort
|
|
100
125
|
end
|
|
101
126
|
|
|
102
127
|
# Patterns that match no file. A pattern goes stale when files move;
|
|
@@ -112,6 +137,15 @@ module Saboteur
|
|
|
112
137
|
value && File.expand_path(value, root)
|
|
113
138
|
end
|
|
114
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
|
+
|
|
115
149
|
private
|
|
116
150
|
|
|
117
151
|
def coerce!
|
|
@@ -129,9 +163,39 @@ module Saboteur
|
|
|
129
163
|
|
|
130
164
|
def validate_report_paths!
|
|
131
165
|
json = path("json_report")
|
|
132
|
-
|
|
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
|
|
186
|
+
|
|
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"]
|
|
133
197
|
|
|
134
|
-
raise ConfigError, "
|
|
198
|
+
raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
|
|
135
199
|
end
|
|
136
200
|
|
|
137
201
|
def validate_threshold!
|
|
@@ -141,6 +205,13 @@ module Saboteur
|
|
|
141
205
|
raise ConfigError, "threshold must be between 0 and 100"
|
|
142
206
|
end
|
|
143
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
|
+
|
|
144
215
|
def validate_reeval!
|
|
145
216
|
raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
|
|
146
217
|
Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
|
|
@@ -148,9 +219,13 @@ module Saboteur
|
|
|
148
219
|
|
|
149
220
|
def validate!
|
|
150
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
|
|
151
225
|
|
|
152
226
|
validate_mutant_limit!
|
|
153
227
|
validate_threshold!
|
|
228
|
+
validate_modes!
|
|
154
229
|
raise ConfigError, "seed must be >= 0" if self["seed"].negative?
|
|
155
230
|
raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
|
|
156
231
|
raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
|
|
@@ -158,6 +233,11 @@ module Saboteur
|
|
|
158
233
|
raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
|
|
159
234
|
|
|
160
235
|
validate_reeval!
|
|
236
|
+
validate_operator_config!
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def validate_operator_config!
|
|
240
|
+
MutationSource::OperatorRegistry.resolve(self["operators"])
|
|
161
241
|
|
|
162
242
|
self["arid"].each { |pattern| Regexp.new(pattern) }
|
|
163
243
|
rescue RegexpError => e
|
|
@@ -165,3 +245,5 @@ module Saboteur
|
|
|
165
245
|
end
|
|
166
246
|
end
|
|
167
247
|
end
|
|
248
|
+
|
|
249
|
+
require_relative "config_file"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "did_you_mean"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# Loads config/saboteur.rb: a plain Ruby file of `Saboteur.configure`
|
|
8
|
+
# blocks assigning settings on a strict object. It runs before the app
|
|
9
|
+
# boots, so it cannot reference app constants - plain Ruby and ENV only.
|
|
10
|
+
module ConfigFile
|
|
11
|
+
# The assignment target. One setter per Config key; anything else is an
|
|
12
|
+
# unknown setting. Records the line of each assignment so validation
|
|
13
|
+
# errors can point at it.
|
|
14
|
+
class Settings
|
|
15
|
+
def initialize
|
|
16
|
+
@values = {}
|
|
17
|
+
@lines = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :values, :lines
|
|
21
|
+
|
|
22
|
+
Config::DEFAULTS.each_key do |key|
|
|
23
|
+
define_method("#{key}=") do |value|
|
|
24
|
+
@values[key] = value
|
|
25
|
+
@lines[key] = caller_locations(1, 1).first.lineno
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Config::INTEGER_KEYS.each do |key|
|
|
30
|
+
define_method("#{key}=") do |value|
|
|
31
|
+
unless value.is_a?(Integer)
|
|
32
|
+
line = caller_locations(1, 1).first.lineno
|
|
33
|
+
raise ConfigError, "#{line}: #{key} must be an Integer, got #{value.inspect}"
|
|
34
|
+
end
|
|
35
|
+
@values[key] = value
|
|
36
|
+
@lines[key] = caller_locations(1, 1).first.lineno
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# The collector is process-global on purpose: the config file calls the
|
|
44
|
+
# top-level Saboteur.configure, which must reach the load in progress.
|
|
45
|
+
# Loads are therefore serialized - never load two config files at once.
|
|
46
|
+
def load(path)
|
|
47
|
+
@settings = Settings.new
|
|
48
|
+
Kernel.load(path)
|
|
49
|
+
@settings.values
|
|
50
|
+
rescue ConfigError => e
|
|
51
|
+
raise ConfigError, "#{path}:#{e.message}"
|
|
52
|
+
rescue NoMethodError => e
|
|
53
|
+
raise ConfigError, unknown_setting_message(path, e) if e.receiver.is_a?(Settings) && e.name.to_s.end_with?("=")
|
|
54
|
+
|
|
55
|
+
# A typo'd method call, not a setting: same treatment as a bare
|
|
56
|
+
# constant, or the raw NoMethodError points at our internals.
|
|
57
|
+
raise ConfigError, name_error_message(path, e)
|
|
58
|
+
rescue SyntaxError, LoadError => e
|
|
59
|
+
raise ConfigError, "#{path}: #{e.message.lines.first.to_s.chomp}"
|
|
60
|
+
rescue NameError => e
|
|
61
|
+
raise ConfigError, name_error_message(path, e)
|
|
62
|
+
ensure
|
|
63
|
+
@settings = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unknown_setting_message(path, error)
|
|
67
|
+
key = error.name.to_s.delete_suffix("=")
|
|
68
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: Config::DEFAULTS.keys).correct(key).first
|
|
69
|
+
hint = suggestion ? " (did you mean #{suggestion.inspect}?)" : ""
|
|
70
|
+
line = error.backtrace_locations.find { |loc| loc.path == path }&.lineno
|
|
71
|
+
"#{path}:#{line}: unknown setting #{key.inspect}#{hint}. " \
|
|
72
|
+
"Valid settings: #{Config::DEFAULTS.keys.join(', ')}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def name_error_message(path, error)
|
|
76
|
+
line = error.backtrace_locations&.find { |loc| loc.path == path }&.lineno
|
|
77
|
+
"#{path}:#{line}: #{error.message.lines.first.to_s.chomp} " \
|
|
78
|
+
"- this file loads before the app boots; use plain Ruby or ENV"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private_class_method :unknown_setting_message, :name_error_message
|
|
82
|
+
|
|
83
|
+
# The Settings block Saboteur.configure hands to the file being loaded.
|
|
84
|
+
def current_settings
|
|
85
|
+
@settings
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Entry point used by config/saboteur.rb.
|
|
90
|
+
def self.configure
|
|
91
|
+
settings = ConfigFile.current_settings
|
|
92
|
+
raise "Saboteur.configure is only valid while config/saboteur.rb loads" unless settings
|
|
93
|
+
|
|
94
|
+
yield settings
|
|
95
|
+
settings
|
|
96
|
+
end
|
|
97
|
+
end
|