saboteur 0.5.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 +399 -0
- data/README.md +202 -42
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/comparison.rb +1 -0
- data/lib/saboteur/baseline/evidence.rb +11 -1
- data/lib/saboteur/baseline/run_evidence.rb +1 -1
- 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 +75 -6
- data/lib/saboteur/cli.rb +80 -48
- data/lib/saboteur/config.rb +88 -13
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +64 -2
- 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/candidates.rb +4 -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 +5 -1
- 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 +183 -23
- 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 +175 -96
- 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/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/help.rb +55 -10
- 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 +22 -13
- 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 +38 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +12 -2
- data/lib/saboteur/reporter/agent_commands.rb +49 -16
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/agent_summary.rb +1 -1
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +4 -5
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +19 -3
- data/lib/saboteur/reporter/stryker.rb +19 -8
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +43 -10
- data/lib/saboteur/result.rb +25 -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 +20 -2
|
@@ -8,7 +8,26 @@ module Saboteur
|
|
|
8
8
|
module ExitStatus
|
|
9
9
|
private
|
|
10
10
|
|
|
11
|
+
# Last resort: a bug must not escape as a bare stack trace and an accidental exit 1.
|
|
12
|
+
def internal_error(error)
|
|
13
|
+
CrashReport.call(error, verbose: @verbose, agent: agent?, err: @err, out: @out, log_path: crash_log_path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The crash log lives with the other run state. A broken config (or a
|
|
17
|
+
# nil cache_dir) must not swallow the crash report itself, so fall
|
|
18
|
+
# back to no log.
|
|
19
|
+
def crash_log_path
|
|
20
|
+
dir = config.path("cache_dir")
|
|
21
|
+
dir && File.join(dir, CrashReport::LOG_NAME)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
11
26
|
def exit_code(report)
|
|
27
|
+
return EXIT_BUDGET if budget_exhausted?(report)
|
|
28
|
+
|
|
29
|
+
return EXIT_TOOL if untested_partial?(report)
|
|
30
|
+
|
|
12
31
|
return EXIT_TOOL if mostly_errored?(report)
|
|
13
32
|
|
|
14
33
|
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
@@ -16,6 +35,24 @@ module Saboteur
|
|
|
16
35
|
failed_quality_gate?(report) || gates.failed?(report, @err) ? EXIT_SURVIVORS : EXIT_OK
|
|
17
36
|
end
|
|
18
37
|
|
|
38
|
+
# A spent --max-duration budget exits 4 on its own, before the gates:
|
|
39
|
+
# the run is partial by design, and --max-duration-ok opts back into
|
|
40
|
+
# the plain verdict.
|
|
41
|
+
def budget_exhausted?(report)
|
|
42
|
+
report.early_stop&.reason == "max_duration" && !config["max_duration_ok"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A run stopped before any mutant was tested has no verdict to fall
|
|
46
|
+
# back on: --max-duration-ok must not turn it green. Unverified
|
|
47
|
+
# results are not tested verdicts, so an unverified-only stop has no
|
|
48
|
+
# score either (#296). Exit 3, like the other incomplete shapes.
|
|
49
|
+
def untested_partial?(report)
|
|
50
|
+
return false unless report.early_stop && report.score_breakdown.tested.zero?
|
|
51
|
+
|
|
52
|
+
@err.puts("saboteur: the run stopped before any mutant was tested, so there is no score to judge")
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
19
56
|
def gates = (@gates ||= Gates.new(config))
|
|
20
57
|
|
|
21
58
|
def mostly_errored?(report)
|
|
@@ -33,9 +70,11 @@ module Saboteur
|
|
|
33
70
|
# incomplete run that mostly errored keeps its distinct cause, so the
|
|
34
71
|
# harness rule means the same thing in both modes.
|
|
35
72
|
def agent_exit_cause(report, payload)
|
|
73
|
+
return "budgetExhausted" if budget_exhausted?(report)
|
|
74
|
+
|
|
36
75
|
case payload[:verdict]
|
|
37
76
|
when "clean" then "ok"
|
|
38
|
-
when "survivors" then report.early_stop ? "failFast" : "survivors"
|
|
77
|
+
when "survivors" then report.early_stop&.reason == "fail_fast" ? "failFast" : "survivors"
|
|
39
78
|
else report.majority_errored? ? "errorMajority" : "incomplete"
|
|
40
79
|
end
|
|
41
80
|
end
|
|
@@ -45,22 +84,28 @@ module Saboteur
|
|
|
45
84
|
def exit_cause(report, code)
|
|
46
85
|
case code
|
|
47
86
|
when EXIT_OK then "ok"
|
|
87
|
+
when EXIT_BUDGET then "budgetExhausted"
|
|
48
88
|
when EXIT_SURVIVORS then survivors_cause(report)
|
|
49
|
-
else
|
|
89
|
+
else
|
|
90
|
+
if report.majority_errored?
|
|
91
|
+
"errorMajority"
|
|
92
|
+
else
|
|
93
|
+
report.early_stop && report.score_breakdown.tested.zero? ? "incomplete" : "incompleteUnderThreshold"
|
|
94
|
+
end
|
|
50
95
|
end
|
|
51
96
|
end
|
|
52
97
|
|
|
53
98
|
# A run stopped by --fail-fast always exits 1: it stopped on a survivor, and
|
|
54
99
|
# its score covers only the subjects that ran, so no threshold can pass it.
|
|
55
100
|
def survivors_cause(report)
|
|
56
|
-
return "failFast" if report.early_stop
|
|
101
|
+
return "failFast" if report.early_stop&.reason == "fail_fast"
|
|
57
102
|
|
|
58
103
|
gates.cause || (config["threshold"].nil? ? "survivors" : "belowThreshold")
|
|
59
104
|
end
|
|
60
105
|
|
|
61
106
|
# With --threshold the score decides; without it any unignored survivor does.
|
|
62
107
|
def failed_quality_gate?(report)
|
|
63
|
-
return true if report.early_stop
|
|
108
|
+
return true if report.early_stop&.reason == "fail_fast"
|
|
64
109
|
|
|
65
110
|
threshold = config["threshold"]
|
|
66
111
|
return report.unignored_survivors.any? if threshold.nil?
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# The option map `saboteur help` prints per command. run, pr and audit
|
|
7
|
+
# share the run parser: the presets take every run option.
|
|
8
|
+
module HelpOptions
|
|
9
|
+
def option_lines
|
|
10
|
+
run = parser.summarize
|
|
11
|
+
{ "run" => run, "pr" => run, "audit" => run, "doctor" => doctor_parser.summarize,
|
|
12
|
+
"baseline" => baseline_parser.summarize }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# Opinionated bundles (#244): pr is the fast changed-lines loop (one
|
|
7
|
+
# baseline check, a 10-minute budget that does not fail the build),
|
|
8
|
+
# audit is the whole project with both reports. Preset flags go first,
|
|
9
|
+
# so an explicit flag after them wins on the usual last-flag rule.
|
|
10
|
+
module Presets
|
|
11
|
+
PRESETS = {
|
|
12
|
+
"pr" => %w[--baseline-passes 1 --max-duration 10m --max-duration-ok],
|
|
13
|
+
"audit" => %w[--all --operators all --baseline-passes 2 --json .saboteur/audit-report.json
|
|
14
|
+
--html .saboteur/audit-report.html]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def preset(name)
|
|
20
|
+
flags = PRESETS.fetch(name).dup
|
|
21
|
+
if name == "pr"
|
|
22
|
+
ref = @argv.first.nil? || @argv.first.start_with?("-") ? "main" : @argv.shift
|
|
23
|
+
flags += ["--since", ref]
|
|
24
|
+
end
|
|
25
|
+
# An explicit scope beats the injected whole-suite --all, which
|
|
26
|
+
# would otherwise reject the combination: named tests, and the
|
|
27
|
+
# deprecated inference alias (#245).
|
|
28
|
+
explicit_scope = @argv.any? { |arg| arg == "--test" || arg.start_with?("--test=") || arg == "--guess-tests" }
|
|
29
|
+
flags.delete("--all") if explicit_scope
|
|
30
|
+
@err.puts("saboteur: preset '#{name}': #{flags.join(' ')} (explicit flags override)")
|
|
31
|
+
@argv = flags + @argv
|
|
32
|
+
run_command
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -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
|
|
@@ -66,9 +125,19 @@ module Saboteur
|
|
|
66
125
|
return unless path
|
|
67
126
|
|
|
68
127
|
tree = WorkingTree.new(config.root, ignored: config.output_paths).state
|
|
69
|
-
scope = config.
|
|
70
|
-
|
|
71
|
-
|
|
128
|
+
scope = config.selection_settings.except("json_report", "html_report", "score_history", "cache_dir")
|
|
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,13 +230,36 @@ 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
|
-
incremental: config["incremental"], settings:
|
|
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).
|
|
243
|
+
def agent_settings
|
|
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
|
|
261
|
+
end
|
|
262
|
+
|
|
219
263
|
def agent_mode
|
|
220
264
|
return "replay" if options[:id]
|
|
221
265
|
return "diff" if config["since"]
|
|
@@ -223,32 +267,13 @@ module Saboteur
|
|
|
223
267
|
@sources&.any? ? "files" : "full"
|
|
224
268
|
end
|
|
225
269
|
|
|
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
|
|
230
|
-
|
|
231
|
-
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
232
|
-
path
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def write_html(report)
|
|
236
|
-
path = config.path("html_report")
|
|
237
|
-
return unless path
|
|
238
|
-
|
|
239
|
-
Reporter::Html.new(path).call(report)
|
|
240
|
-
path
|
|
241
|
-
end
|
|
242
|
-
|
|
243
270
|
# Positional arguments name the files (or directories) to mutate. They
|
|
244
271
|
# replace the configured include and exclude patterns. Each must exist,
|
|
245
272
|
# so a typo is an error and not a run over zero subjects.
|
|
246
273
|
def use_explicit_sources
|
|
247
274
|
if @argv.empty?
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# With --since the changed files are the sources, so --test may
|
|
251
|
-
# 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.
|
|
252
277
|
options[:tests]&.each { |test| require_existing_test(test) }
|
|
253
278
|
return
|
|
254
279
|
end
|
|
@@ -286,17 +311,23 @@ module Saboteur
|
|
|
286
311
|
raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
|
|
287
312
|
raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
|
|
288
313
|
|
|
289
|
-
|
|
314
|
+
if options[:list_operators]
|
|
315
|
+
MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
|
|
316
|
+
return EXIT_OK
|
|
317
|
+
end
|
|
290
318
|
|
|
291
319
|
Preview.new(config, out: @out, err: @err).call
|
|
292
320
|
EXIT_OK
|
|
293
321
|
end
|
|
294
322
|
|
|
295
323
|
def define_selection_options(opts)
|
|
296
|
-
opts.on("--operators NAMES", Array, "Choose operator names
|
|
324
|
+
opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
|
|
297
325
|
set(:operators, v)
|
|
298
326
|
end
|
|
299
|
-
opts.on("--
|
|
327
|
+
opts.on("--exclude-operators NAMES", Array, "Exclude operator names from the selected set") do |v|
|
|
328
|
+
set(:exclude_operators, v)
|
|
329
|
+
end
|
|
330
|
+
opts.on("--list-operators", "List operator names and descriptions") { set(:list_operators, true) }
|
|
300
331
|
opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
|
|
301
332
|
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
302
333
|
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
@@ -306,9 +337,7 @@ module Saboteur
|
|
|
306
337
|
def define_execution_options(opts)
|
|
307
338
|
opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
|
|
308
339
|
opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
|
|
309
|
-
opts
|
|
310
|
-
set(:seed, v)
|
|
311
|
-
end
|
|
340
|
+
add_run_budget_options(opts)
|
|
312
341
|
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
313
342
|
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
314
343
|
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
@@ -321,8 +350,10 @@ module Saboteur
|
|
|
321
350
|
opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
|
|
322
351
|
set(:allow_focus, true)
|
|
323
352
|
end
|
|
324
|
-
opts.on("--
|
|
325
|
-
|
|
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|
|
|
326
357
|
set(:guess_tests, v)
|
|
327
358
|
end
|
|
328
359
|
end
|
|
@@ -340,7 +371,8 @@ module Saboteur
|
|
|
340
371
|
opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
|
|
341
372
|
"strict exit codes") { |v| @format = v }
|
|
342
373
|
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
343
|
-
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 }
|
|
344
376
|
opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
|
|
345
377
|
@color = v ? :always : :never
|
|
346
378
|
end
|