saboteur 0.6.0 → 0.7.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 +367 -0
- data/README.md +166 -40
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- data/lib/saboteur/cli.rb +67 -46
- data/lib/saboteur/config.rb +66 -11
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +40 -1
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +145 -22
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +166 -95
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/help.rb +55 -11
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +20 -11
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +2 -1
- data/lib/saboteur/reporter/agent_commands.rb +38 -13
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +13 -4
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +34 -10
- data/lib/saboteur/result.rb +22 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- metadata +17 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The run's clock and repeatability switches: seed, baseline check count
|
|
7
|
+
# (#55) and the wall-clock budget (#245). Grouped so the option table in
|
|
8
|
+
# the CLI stays under the house size caps.
|
|
9
|
+
module RunBudgetOptions
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def add_run_budget_options(opts)
|
|
13
|
+
opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
|
|
14
|
+
set(:seed, v)
|
|
15
|
+
end
|
|
16
|
+
opts.on("--baseline-passes N", Integer, "Unchanged-suite checks (default 2)") { |v| set(:baseline_passes, v) }
|
|
17
|
+
opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent") do |v|
|
|
18
|
+
set(:max_duration, parse_duration(v))
|
|
19
|
+
end
|
|
20
|
+
opts.on("--[no-]max-duration-ok", "An exhausted --max-duration budget does not fail the run") do |v|
|
|
21
|
+
set(:max_duration_ok, v)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# "10m" -> 600.0; bare numbers are seconds. A bad value is a usage error.
|
|
26
|
+
def parse_duration(value)
|
|
27
|
+
match = /\A(?<amount>\d+(?:\.\d+)?)(?<unit>[smh])?\z/.match(value.to_s.strip)
|
|
28
|
+
unless match
|
|
29
|
+
raise ConfigError,
|
|
30
|
+
"invalid --max-duration #{value.inspect}: use seconds or an s/m/h suffix, e.g. 10m"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
match[:amount].to_f * { "s" => 1, "m" => 60, "h" => 3600, nil => 1 }.fetch(match[:unit])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -10,9 +10,10 @@ module Saboteur
|
|
|
10
10
|
module RunReporting
|
|
11
11
|
private
|
|
12
12
|
|
|
13
|
-
def text_run(report)
|
|
13
|
+
def text_run(report, engine)
|
|
14
14
|
@err.puts
|
|
15
|
-
Reporter::Terminal.new(@out, verbose: @verbose || false, color: @color || :auto
|
|
15
|
+
Reporter::Terminal.new(@out, verbose: @verbose || false, color: @color || :auto,
|
|
16
|
+
replay_command: survivor_replay_command(report, engine)).call(report)
|
|
16
17
|
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
17
18
|
code = exit_code(report)
|
|
18
19
|
# The JSON carries the exit cause an outer harness trusts, so it is
|
|
@@ -50,7 +51,65 @@ module Saboteur
|
|
|
50
51
|
validate_history
|
|
51
52
|
arguments = { err: @err }
|
|
52
53
|
arguments[:digest] = options[:id] if options[:id]
|
|
53
|
-
|
|
54
|
+
return agent_run(arguments) if agent?
|
|
55
|
+
|
|
56
|
+
# Text mode only: the engine prints a per-subject marks line when
|
|
57
|
+
# --verbose is on (#262); --agent output stays as it is.
|
|
58
|
+
engine = Engine.new(config, **arguments, verbose: @verbose || false)
|
|
59
|
+
text_run(engine.call, engine)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The full replay command for a survivor digest: this run's own
|
|
63
|
+
# selection options, assembled by the same code that writes the agent
|
|
64
|
+
# follow-ups, so the user never reassembles them by hand (#261).
|
|
65
|
+
# Computed lazily: only a run with survivors asks for it.
|
|
66
|
+
def survivor_replay_command(report, engine)
|
|
67
|
+
lambda do |digest|
|
|
68
|
+
tree = WorkingTree.new(config.root, ignored: config.output_paths).state
|
|
69
|
+
Reporter::Agent::Commands.replay(agent_provenance(report, tree, engine), digest, capped: false)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Writes the JSON report and returns its path, or nil when none is configured.
|
|
74
|
+
#
|
|
75
|
+
# An explicitly requested JSON report (--json, c.json_report) is the
|
|
76
|
+
# completion evidence an outer harness trusts: a failed write fails
|
|
77
|
+
# the run (exit 3), because a warning with exit 0 could leave a stale
|
|
78
|
+
# or partial file at the path and present old results as this run's.
|
|
79
|
+
# The default .saboteur/report.json is best-effort like the HTML
|
|
80
|
+
# report and the score history (#296).
|
|
81
|
+
def write_json(report, cause)
|
|
82
|
+
path = config.path("json_report")
|
|
83
|
+
return unless path
|
|
84
|
+
|
|
85
|
+
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
86
|
+
path
|
|
87
|
+
rescue SystemCallError, IOError => e
|
|
88
|
+
if config.explicit?("json_report")
|
|
89
|
+
raise Error, "could not write the JSON report to #{path}: #{e.message} - " \
|
|
90
|
+
"any file at that path may be stale or incomplete"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
warn_report_write_failure("JSON report", path, e)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def write_html(report)
|
|
97
|
+
path = config.path("html_report")
|
|
98
|
+
return unless path
|
|
99
|
+
|
|
100
|
+
Reporter::Html.new(path).call(report)
|
|
101
|
+
path
|
|
102
|
+
rescue SystemCallError, IOError => e
|
|
103
|
+
warn_report_write_failure("HTML report", path, e)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# An auxiliary report artifact that cannot be written - the tree is
|
|
107
|
+
# read-only inside a sandboxed child, as in the dogfood's nested runs
|
|
108
|
+
# (#296) - is a warning, not a run failure: the exit code belongs to
|
|
109
|
+
# the results. The JSON report is not auxiliary; see write_json.
|
|
110
|
+
def warn_report_write_failure(what, path, error)
|
|
111
|
+
@err.puts "saboteur: could not write the #{what} to #{path} (#{error.message}); continuing without it"
|
|
112
|
+
nil
|
|
54
113
|
end
|
|
55
114
|
|
|
56
115
|
def validate_history
|
|
@@ -67,8 +126,18 @@ module Saboteur
|
|
|
67
126
|
|
|
68
127
|
tree = WorkingTree.new(config.root, ignored: config.output_paths).state
|
|
69
128
|
scope = config.selection_settings.except("json_report", "html_report", "score_history", "cache_dir")
|
|
70
|
-
|
|
71
|
-
|
|
129
|
+
# Recording is an auxiliary artifact: when the file cannot be
|
|
130
|
+
# written - read-only tree inside a sandboxed child (#296) - warn
|
|
131
|
+
# and let the run exit by its results instead of failing as a tool
|
|
132
|
+
# error. Only the write's IO failures are soft: validation,
|
|
133
|
+
# integrity, record-size and lock failures (Saboteur::Error) stay
|
|
134
|
+
# hard, because they say the history is unusable, not unwritable.
|
|
135
|
+
message = begin
|
|
136
|
+
Reporter::ScoreHistory.new(path).append(report, head: tree.head, scope: scope)
|
|
137
|
+
rescue SystemCallError, IOError => e
|
|
138
|
+
warn_report_write_failure("score history", path, e)
|
|
139
|
+
end
|
|
140
|
+
@err.puts("saboteur: #{message}") if message
|
|
72
141
|
end
|
|
73
142
|
end
|
|
74
143
|
end
|
data/lib/saboteur/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
require_relative "../saboteur"
|
|
6
|
+
require_relative "crash_report"
|
|
6
7
|
require_relative "doctor"
|
|
7
8
|
require_relative "help"
|
|
8
9
|
require_relative "reporter/replay"
|
|
@@ -10,14 +11,20 @@ require_relative "baseline/command"
|
|
|
10
11
|
require_relative "cli/exit_status"
|
|
11
12
|
require_relative "preview"
|
|
12
13
|
require_relative "cli/run_reporting"
|
|
14
|
+
require_relative "cli/run_budget_options"
|
|
15
|
+
require_relative "cli/presets"
|
|
16
|
+
require_relative "cli/help_options"
|
|
13
17
|
|
|
14
18
|
module Saboteur
|
|
15
19
|
class CLI
|
|
16
20
|
include Baseline::Command
|
|
17
21
|
include ExitStatus
|
|
18
22
|
include RunReporting
|
|
23
|
+
include RunBudgetOptions
|
|
24
|
+
include Presets
|
|
25
|
+
include HelpOptions
|
|
19
26
|
|
|
20
|
-
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
|
|
27
|
+
USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--all] [--threshold PERCENT] [--since REF] " \
|
|
21
28
|
"[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
|
|
22
29
|
" \
|
|
23
30
|
"Run 'saboteur help' for every command, option, config key and exit code."
|
|
@@ -26,10 +33,18 @@ module Saboteur
|
|
|
26
33
|
EXIT_SURVIVORS = 1
|
|
27
34
|
EXIT_USAGE = 2
|
|
28
35
|
EXIT_TOOL = 3
|
|
36
|
+
EXIT_BUDGET = 4
|
|
29
37
|
class UsageError < ConfigError; end
|
|
30
38
|
|
|
31
39
|
attr_reader :options
|
|
32
40
|
|
|
41
|
+
# Found before parsing, so any error, even a bad option that comes first, still
|
|
42
|
+
# answers an agent with JSON. Class-level: the executable's fallback reads raw arguments.
|
|
43
|
+
def self.agent_requested?(argv)
|
|
44
|
+
argv.include?("--agent") || argv.include?("--format=agent") ||
|
|
45
|
+
argv.each_cons(2).any? { |flag, value| flag == "--format" && value == "agent" }
|
|
46
|
+
end
|
|
47
|
+
|
|
33
48
|
def initialize(argv, out: $stdout, err: $stderr)
|
|
34
49
|
@argv = argv.dup
|
|
35
50
|
@out = out
|
|
@@ -39,18 +54,19 @@ module Saboteur
|
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
def call
|
|
42
|
-
|
|
57
|
+
command = @argv.shift
|
|
58
|
+
case command
|
|
43
59
|
when "run" then run_command
|
|
44
60
|
when "baseline" then baseline
|
|
45
61
|
when "doctor" then help_requested? ? show_help("doctor") : doctor
|
|
46
62
|
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
|
47
|
-
when "help" then show_help(@argv.shift)
|
|
63
|
+
when "help", "--help", "-h" then show_help(@argv.shift)
|
|
48
64
|
when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
|
|
49
|
-
|
|
50
|
-
else usage_error
|
|
65
|
+
else PRESETS.key?(command) ? preset(command) : usage_error
|
|
51
66
|
end
|
|
52
67
|
rescue OptionParser::ParseError => e
|
|
53
68
|
usage_error(e.message)
|
|
69
|
+
rescue StandardError => e then internal_error(e)
|
|
54
70
|
end
|
|
55
71
|
|
|
56
72
|
def config
|
|
@@ -68,11 +84,6 @@ module Saboteur
|
|
|
68
84
|
end
|
|
69
85
|
end
|
|
70
86
|
|
|
71
|
-
# Each command's own option lines, as printed under OPTIONS in `saboteur help`.
|
|
72
|
-
def option_lines
|
|
73
|
-
{ "run" => parser.summarize, "doctor" => doctor_parser.summarize, "baseline" => baseline_parser.summarize }
|
|
74
|
-
end
|
|
75
|
-
|
|
76
87
|
def doctor_parser
|
|
77
88
|
OptionParser.new do |opts|
|
|
78
89
|
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
|
|
@@ -114,12 +125,7 @@ module Saboteur
|
|
|
114
125
|
@format == "agent"
|
|
115
126
|
end
|
|
116
127
|
|
|
117
|
-
|
|
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
|
|
128
|
+
def agent_requested? = self.class.agent_requested?(@argv)
|
|
123
129
|
|
|
124
130
|
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
125
131
|
def agent_failure(message, exit_code)
|
|
@@ -129,10 +135,13 @@ module Saboteur
|
|
|
129
135
|
|
|
130
136
|
def run
|
|
131
137
|
parser.parse!(@argv)
|
|
138
|
+
reject_test_selection_conflicts
|
|
132
139
|
use_explicit_sources
|
|
133
140
|
validate_run_options
|
|
134
141
|
warn_dirty_tree
|
|
135
142
|
|
|
143
|
+
config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
|
|
144
|
+
|
|
136
145
|
return preview if options[:list_operators] || options[:dry_run]
|
|
137
146
|
|
|
138
147
|
execute_run
|
|
@@ -150,6 +159,18 @@ module Saboteur
|
|
|
150
159
|
WorkingTree.new(config.root, ignored: config.output_paths).state.warning&.then { |text| @err.puts(text) }
|
|
151
160
|
end
|
|
152
161
|
|
|
162
|
+
# Contradictory selection flags are a usage error before any file or
|
|
163
|
+
# repository check: --test wins over inference by precedence, but only
|
|
164
|
+
# one of them may be named. Config-file combinations are not errors:
|
|
165
|
+
# --test wins there, so generated commands stay valid over any config.
|
|
166
|
+
def reject_test_selection_conflicts
|
|
167
|
+
raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
|
|
168
|
+
raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
|
|
169
|
+
return unless options[:guess_tests] && options[:tests]&.any?
|
|
170
|
+
|
|
171
|
+
raise UsageError, "--guess-tests cannot be combined with --test"
|
|
172
|
+
end
|
|
173
|
+
|
|
153
174
|
# Usage errors found before the target boots.
|
|
154
175
|
def validate_run_options
|
|
155
176
|
reject_threshold_with_agent_format
|
|
@@ -209,16 +230,34 @@ module Saboteur
|
|
|
209
230
|
Reporter::Agent::Provenance.new(
|
|
210
231
|
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
211
232
|
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
212
|
-
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests ||
|
|
233
|
+
subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
|
|
213
234
|
sources: @sources || [],
|
|
214
235
|
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
215
236
|
incremental: config["incremental"], settings: agent_settings
|
|
216
237
|
)
|
|
217
238
|
end
|
|
218
239
|
|
|
240
|
+
# A whole-suite run records its scope as --all, so the generated rerun
|
|
241
|
+
# and replay commands do not default back to inference (which can exit
|
|
242
|
+
# 2 in a project with a spec-less source file).
|
|
219
243
|
def agent_settings
|
|
220
|
-
options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
221
|
-
|
|
244
|
+
settings = options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
245
|
+
.merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
|
|
246
|
+
# The effective mode: named tests win over a configured whole-suite
|
|
247
|
+
# selection by precedence, so all is recorded only when no tests
|
|
248
|
+
# resolved - otherwise the follow-up commands would carry both --all
|
|
249
|
+
# and --test, which the CLI rejects.
|
|
250
|
+
settings[:all] = true if (config["all"] || config["test_selection"] == "all") && config["tests"].empty?
|
|
251
|
+
# CLI-only switches that change candidate availability or boot, so a
|
|
252
|
+
# printed rerun/replay must keep them (config-file settings like
|
|
253
|
+
# arid/hazard patterns, whole-file, require, rspec_args and rails ride
|
|
254
|
+
# along through --config or the default config path).
|
|
255
|
+
settings[:allow_hazardous] = true if options[:allow_hazardous]
|
|
256
|
+
settings[:allow_focus] = true if options[:allow_focus]
|
|
257
|
+
# An explicit false (--no-max-duration-ok over a config true) is a
|
|
258
|
+
# setting too: dropping it would silently re-enable the config value.
|
|
259
|
+
settings[:max_duration_ok] = options[:max_duration_ok] unless options[:max_duration_ok].nil?
|
|
260
|
+
settings
|
|
222
261
|
end
|
|
223
262
|
|
|
224
263
|
def agent_mode
|
|
@@ -228,32 +267,13 @@ module Saboteur
|
|
|
228
267
|
@sources&.any? ? "files" : "full"
|
|
229
268
|
end
|
|
230
269
|
|
|
231
|
-
# Writes the JSON report and returns its path, or nil when none is configured.
|
|
232
|
-
def write_json(report, cause)
|
|
233
|
-
path = config.path("json_report")
|
|
234
|
-
return unless path
|
|
235
|
-
|
|
236
|
-
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
237
|
-
path
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def write_html(report)
|
|
241
|
-
path = config.path("html_report")
|
|
242
|
-
return unless path
|
|
243
|
-
|
|
244
|
-
Reporter::Html.new(path).call(report)
|
|
245
|
-
path
|
|
246
|
-
end
|
|
247
|
-
|
|
248
270
|
# Positional arguments name the files (or directories) to mutate. They
|
|
249
271
|
# replace the configured include and exclude patterns. Each must exist,
|
|
250
272
|
# so a typo is an error and not a run over zero subjects.
|
|
251
273
|
def use_explicit_sources
|
|
252
274
|
if @argv.empty?
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
# With --since the changed files are the sources, so --test may
|
|
256
|
-
# stand alone; the files must still exist.
|
|
275
|
+
# --test may stand alone: the configured include (or the --since
|
|
276
|
+
# diff) names the sources. The files must still exist.
|
|
257
277
|
options[:tests]&.each { |test| require_existing_test(test) }
|
|
258
278
|
return
|
|
259
279
|
end
|
|
@@ -317,9 +337,7 @@ module Saboteur
|
|
|
317
337
|
def define_execution_options(opts)
|
|
318
338
|
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
319
339
|
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
320
|
-
opts
|
|
321
|
-
set(:seed, v)
|
|
322
|
-
end
|
|
340
|
+
add_run_budget_options(opts)
|
|
323
341
|
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
324
342
|
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
325
343
|
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
@@ -332,8 +350,10 @@ module Saboteur
|
|
|
332
350
|
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
333
351
|
set(:allow_focus, true)
|
|
334
352
|
end
|
|
335
|
-
opts.on("--
|
|
336
|
-
|
|
353
|
+
opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
|
|
354
|
+
"mutated files") { set(:all, true) }
|
|
355
|
+
opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
|
|
356
|
+
"--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
|
|
337
357
|
set(:guess_tests, v)
|
|
338
358
|
end
|
|
339
359
|
end
|
|
@@ -351,7 +371,8 @@ module Saboteur
|
|
|
351
371
|
opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
|
|
352
372
|
"strict exit codes") { |v| @format = v }
|
|
353
373
|
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
354
|
-
opts.on("--verbose", "Text report: also print score split, timings and kill causes"
|
|
374
|
+
opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
|
|
375
|
+
"a crash prints its backtrace") { @verbose = true }
|
|
355
376
|
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
356
377
|
@color = v ? :always : :never
|
|
357
378
|
end
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "text"
|
|
5
|
+
|
|
4
6
|
require_relative "mutation_source/builtin"
|
|
5
7
|
require_relative "mutation_source/operator_registry"
|
|
6
8
|
require_relative "mutation_source/hazard_guard"
|
|
@@ -57,8 +59,13 @@ module Saboteur
|
|
|
57
59
|
"allow_focus" => false,
|
|
58
60
|
"fail_fast" => false,
|
|
59
61
|
"guess_tests" => false,
|
|
62
|
+
"all" => false,
|
|
63
|
+
"test_selection" => "auto",
|
|
60
64
|
"matrix" => false,
|
|
61
65
|
"seed" => 1,
|
|
66
|
+
"baseline_passes" => 2,
|
|
67
|
+
"max_duration" => nil,
|
|
68
|
+
"max_duration_ok" => false,
|
|
62
69
|
"tests" => [],
|
|
63
70
|
"fingerprint_extra" => [],
|
|
64
71
|
"threshold" => nil,
|
|
@@ -66,8 +73,9 @@ module Saboteur
|
|
|
66
73
|
"max_errors" => nil
|
|
67
74
|
}.freeze
|
|
68
75
|
|
|
69
|
-
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
|
|
76
|
+
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes].freeze
|
|
70
77
|
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
78
|
+
NILABLE_FLOAT_KEYS = %w[threshold max_duration].freeze
|
|
71
79
|
THRESHOLD_RANGE = (0.0..100.0)
|
|
72
80
|
|
|
73
81
|
LIST_KEYS = %w[require include exclude arid hazard sources hooks rspec_args subjects tests
|
|
@@ -96,11 +104,19 @@ module Saboteur
|
|
|
96
104
|
hash.to_h { |key, value| [key.to_s, value] }.compact
|
|
97
105
|
end
|
|
98
106
|
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
# Deprecations found while loading: printed by the CLI, never fatal.
|
|
108
|
+
attr_reader :deprecation_warnings
|
|
109
|
+
|
|
110
|
+
def initialize(values = {}, root: Dir.pwd, explicit: nil)
|
|
111
|
+
# Retag the root losslessly: under a non-UTF-8 locale it arrives
|
|
112
|
+
# locale-tagged, and every glob, join and expand below would mix
|
|
113
|
+
# encodings with the UTF-8 patterns from the config file (#231).
|
|
114
|
+
@root = Text.utf8_identity(File.expand_path(root))
|
|
101
115
|
unknown = values.keys - DEFAULTS.keys
|
|
102
116
|
raise ConfigError, "unknown config key(s): #{unknown.join(', ')}" if unknown.any?
|
|
103
117
|
|
|
118
|
+
@deprecation_warnings = deprecations(values)
|
|
119
|
+
@explicit_keys = (explicit || values.keys).map(&:to_s).freeze
|
|
104
120
|
@values = DEFAULTS.merge(values)
|
|
105
121
|
coerce!
|
|
106
122
|
validate!
|
|
@@ -115,6 +131,26 @@ module Saboteur
|
|
|
115
131
|
@values.fetch(key.to_s, default)
|
|
116
132
|
end
|
|
117
133
|
|
|
134
|
+
# Keys the user set (config file or CLI flag) rather than relying on
|
|
135
|
+
# the default; carried through Engine's config clone so provenance
|
|
136
|
+
# survives (#296).
|
|
137
|
+
attr_reader :explicit_keys
|
|
138
|
+
|
|
139
|
+
# A copy with some values replaced or dropped, keeping which keys the
|
|
140
|
+
# user set explicitly: the replacements count as explicit, the dropped
|
|
141
|
+
# keys lose it (#296).
|
|
142
|
+
def with(values, except: [])
|
|
143
|
+
merged = to_h.except(*except).merge(values)
|
|
144
|
+
Config.new(merged, root: root, explicit: (explicit_keys - except.map(&:to_s)) | values.keys.map(&:to_s))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Whether the user set a key (config file or CLI flag) rather than
|
|
148
|
+
# relying on the default. An explicitly requested report artifact is
|
|
149
|
+
# completion evidence, so its write failure is a hard error (#296).
|
|
150
|
+
def explicit?(key)
|
|
151
|
+
@explicit_keys.include?(key.to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
118
154
|
def to_h
|
|
119
155
|
@values.dup
|
|
120
156
|
end
|
|
@@ -124,7 +160,8 @@ module Saboteur
|
|
|
124
160
|
matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
125
161
|
excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
|
|
126
162
|
(matched - excluded).select { |file| file.end_with?(".rb") }
|
|
127
|
-
.map { |file| File.realpath(File.expand_path(file, root)) }
|
|
163
|
+
.map { |file| File.realpath(File.expand_path(Text.utf8_identity(file), root)) }
|
|
164
|
+
.uniq.sort
|
|
128
165
|
end
|
|
129
166
|
|
|
130
167
|
# Patterns that match no file. A pattern goes stale when files move;
|
|
@@ -151,11 +188,23 @@ module Saboteur
|
|
|
151
188
|
|
|
152
189
|
private
|
|
153
190
|
|
|
191
|
+
# An explicit guess_tests = false (config file or --no-guess-tests) used
|
|
192
|
+
# to keep the whole suite; inference is the default now, so the opt-out
|
|
193
|
+
# is --all / c.test_selection = :all. guess_tests = true is the default
|
|
194
|
+
# behavior spelled out, so it stays silent.
|
|
195
|
+
def deprecations(values)
|
|
196
|
+
return [] unless values["guess_tests"] == false
|
|
197
|
+
|
|
198
|
+
["guess_tests = false is deprecated: inferred specs are the default and it no longer restores the " \
|
|
199
|
+
"whole suite; use --all or c.test_selection = :all to run every spec"]
|
|
200
|
+
end
|
|
201
|
+
|
|
154
202
|
def coerce!
|
|
155
203
|
INTEGER_KEYS.each { |key| @values[key] = Integer(@values[key]) }
|
|
156
204
|
FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
|
|
157
205
|
LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
|
|
158
|
-
@values[
|
|
206
|
+
NILABLE_FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) unless @values[key].nil? }
|
|
207
|
+
@values["test_selection"] = @values["test_selection"].to_s
|
|
159
208
|
rescue ArgumentError, TypeError => e
|
|
160
209
|
raise ConfigError, "invalid config value: #{e.message}"
|
|
161
210
|
end
|
|
@@ -164,6 +213,13 @@ module Saboteur
|
|
|
164
213
|
raise ConfigError, "max_mutants_per_method must be > 0" unless self["max_mutants_per_method"].positive?
|
|
165
214
|
end
|
|
166
215
|
|
|
216
|
+
# The unchanged-suite checks: how many, and the whole run's clock (#55, #245).
|
|
217
|
+
def validate_baseline!
|
|
218
|
+
raise ConfigError, "seed must be >= 0" if self["seed"].negative?
|
|
219
|
+
raise ConfigError, "baseline_passes must be >= 1" if self["baseline_passes"] < 1
|
|
220
|
+
raise ConfigError, "max_duration must be > 0" if self["max_duration"] && !self["max_duration"].positive?
|
|
221
|
+
end
|
|
222
|
+
|
|
167
223
|
def validate_report_paths!
|
|
168
224
|
json = path("json_report")
|
|
169
225
|
if json && json == path("html_report")
|
|
@@ -179,7 +235,7 @@ module Saboteur
|
|
|
179
235
|
def validate_modes!
|
|
180
236
|
validate_fail_fast!
|
|
181
237
|
validate_matrix!
|
|
182
|
-
|
|
238
|
+
validate_test_selection!
|
|
183
239
|
validate_gates!
|
|
184
240
|
end
|
|
185
241
|
|
|
@@ -208,11 +264,10 @@ module Saboteur
|
|
|
208
264
|
raise ConfigError, "threshold must be between 0 and 100"
|
|
209
265
|
end
|
|
210
266
|
|
|
211
|
-
def
|
|
212
|
-
return
|
|
267
|
+
def validate_test_selection!
|
|
268
|
+
return if %w[auto all].include?(self["test_selection"])
|
|
213
269
|
|
|
214
|
-
raise ConfigError, "
|
|
215
|
-
raise ConfigError, "guess_tests cannot be combined with tests" if self["tests"].any?
|
|
270
|
+
raise ConfigError, "test_selection must be one of: auto, all"
|
|
216
271
|
end
|
|
217
272
|
|
|
218
273
|
def validate_reeval!
|
|
@@ -229,7 +284,7 @@ module Saboteur
|
|
|
229
284
|
validate_mutant_limit!
|
|
230
285
|
validate_threshold!
|
|
231
286
|
validate_modes!
|
|
232
|
-
|
|
287
|
+
validate_baseline!
|
|
233
288
|
raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
|
|
234
289
|
raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
|
|
235
290
|
raise ConfigError, "timeout_factor must be > 0" unless self["timeout_factor"].positive?
|