saboteur 0.9.0 → 0.11.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 +66 -0
- data/README.md +109 -247
- data/exe/saboteur +2 -2
- data/lib/saboteur/baseline/loader.rb +41 -0
- data/lib/saboteur/baseline/run_evidence.rb +1 -1
- data/lib/saboteur/baseline/run_gate.rb +51 -0
- data/lib/saboteur/baseline/workflow.rb +2 -15
- data/lib/saboteur/cli/baseline_gate.rb +58 -0
- data/lib/saboteur/cli/error_cause.rb +16 -0
- data/lib/saboteur/cli/exit_status.rb +14 -13
- data/lib/saboteur/cli/failure_output.rb +23 -0
- data/lib/saboteur/cli/output_options.rb +24 -2
- data/lib/saboteur/cli/run_budget_options.rb +6 -0
- data/lib/saboteur/cli/run_command.rb +14 -1
- data/lib/saboteur/cli/run_reporting.rb +34 -6
- data/lib/saboteur/cli/run_validation.rb +36 -3
- data/lib/saboteur/cli/selection_options.rb +2 -0
- data/lib/saboteur/cli.rb +6 -1
- data/lib/saboteur/config.rb +23 -2
- data/lib/saboteur/crash_report.rb +16 -7
- data/lib/saboteur/engine/baseline_guard.rb +2 -2
- data/lib/saboteur/engine/coverage_partial.rb +8 -1
- data/lib/saboteur/engine/coverage_store.rb +4 -5
- data/lib/saboteur/engine/diagnostics.rb +120 -15
- data/lib/saboteur/engine/run_fingerprint.rb +1 -1
- data/lib/saboteur/engine/scheduler.rb +20 -0
- data/lib/saboteur/engine/selection_announcement.rb +8 -1
- data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
- data/lib/saboteur/engine.rb +43 -6
- data/lib/saboteur/help.rb +4 -1
- data/lib/saboteur/mutation_source/builtin.rb +9 -3
- data/lib/saboteur/mutation_source/copy_replacement.rb +72 -0
- data/lib/saboteur/mutation_source/ensure_removal.rb +49 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +2 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +3 -1
- data/lib/saboteur/reporter/agent_summary.rb +3 -1
- data/lib/saboteur/reporter/assets/mutation_report.LICENSE +201 -0
- data/lib/saboteur/reporter/assets/mutation_report.js +331 -0
- data/lib/saboteur/reporter/baseline_tail.rb +93 -0
- data/lib/saboteur/reporter/diff.rb +10 -0
- data/lib/saboteur/reporter/html.rb +69 -14
- data/lib/saboteur/reporter/kill_causes.rb +53 -0
- data/lib/saboteur/reporter/next_steps.rb +4 -3
- data/lib/saboteur/reporter/palette.rb +64 -13
- data/lib/saboteur/reporter/safe_out.rb +1 -1
- data/lib/saboteur/reporter/score_table.rb +53 -18
- data/lib/saboteur/reporter/stryker.rb +4 -1
- data/lib/saboteur/reporter/survivor_classification.rb +12 -3
- data/lib/saboteur/reporter/survivor_hints.rb +72 -0
- data/lib/saboteur/reporter/survivor_list.rb +207 -75
- data/lib/saboteur/reporter/terminal.rb +315 -69
- data/lib/saboteur/result.rb +28 -1
- data/lib/saboteur/score.rb +6 -0
- data/lib/saboteur/version.rb +1 -1
- metadata +12 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class CLI
|
|
8
|
+
# The `saboteur run --baseline PATH` gate: loading the accepted baseline
|
|
9
|
+
# (fail fast, before the suite boots) and mapping the comparison to the
|
|
10
|
+
# run's exit code. Mixed into the CLI, which supplies `config`.
|
|
11
|
+
module BaselineGate
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# The --baseline comparison replaces the ordinary quality-gate verdict.
|
|
15
|
+
def exit_code(report) = config["baseline"] ? baseline_verdict(report) : super
|
|
16
|
+
|
|
17
|
+
# --baseline: the comparison against the accepted baseline is the
|
|
18
|
+
# verdict. Accepted legacy survivors no longer fail the run; new
|
|
19
|
+
# survivors or a score drop beyond --epsilon do (exit 1). An
|
|
20
|
+
# incompatible baseline is a usage error; an incomplete comparison
|
|
21
|
+
# means the run's own evidence cannot judge it (exit 3).
|
|
22
|
+
def baseline_verdict(report)
|
|
23
|
+
gate = baseline_gate(report)
|
|
24
|
+
raise ConfigError, gate.comparison.issues.join("; ") if gate.incompatible?
|
|
25
|
+
return EXIT_TOOL if gate.incomplete?
|
|
26
|
+
|
|
27
|
+
gate.failed? ? EXIT_SURVIVORS : EXIT_OK
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def baseline_gate(report)
|
|
31
|
+
@baseline_gate ||= begin
|
|
32
|
+
path = config["baseline"]
|
|
33
|
+
if Digest::SHA256.file(path).hexdigest != @baseline_digest
|
|
34
|
+
raise ConfigError, "baseline changed during the run"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Baseline::RunGate.build(manifest: baseline_manifest, report: report, epsilon: config["epsilon"] || 0.0)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The accepted baseline at config["baseline"], loaded once (and before
|
|
42
|
+
# the suite boots) so a missing or corrupt file fails fast as a usage
|
|
43
|
+
# error naming the fix.
|
|
44
|
+
def baseline_manifest
|
|
45
|
+
return @baseline_manifest if @baseline_manifest
|
|
46
|
+
|
|
47
|
+
path = config["baseline"]
|
|
48
|
+
raise UsageError, "--baseline file not found: #{path} - run `saboteur baseline propose` first" unless
|
|
49
|
+
File.exist?(path)
|
|
50
|
+
|
|
51
|
+
@baseline_manifest, @baseline_digest = Baseline::Loader.call(path)
|
|
52
|
+
@baseline_manifest
|
|
53
|
+
rescue ConfigError => e
|
|
54
|
+
raise UsageError, "#{e.message} - run `saboteur baseline propose` first"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -15,6 +15,22 @@ module Saboteur
|
|
|
15
15
|
|
|
16
16
|
LIMIT = 200
|
|
17
17
|
|
|
18
|
+
# The machine cause and the one-line human reason for the two exits no
|
|
19
|
+
# report can describe, because the run never produced one. The verdict
|
|
20
|
+
# line names the reason for a nonzero exit; the report-driven codes get
|
|
21
|
+
# theirs from the terminal summary's verdict line.
|
|
22
|
+
# Keyed by exit code. Literals for EXIT_BASELINE_RED and EXIT_CRASH:
|
|
23
|
+
# this file loads before the CLI defines them; the cause pins spec
|
|
24
|
+
# asserts the keys are those two constants.
|
|
25
|
+
CAUSE_LINES = {
|
|
26
|
+
5 => ["baselineRed", "baseline suite red: fix your tests first"],
|
|
27
|
+
70 => ["crash", "internal crash (a saboteur bug, never your code)"]
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def self.cause(code) = CAUSE_LINES.fetch(code).first
|
|
31
|
+
|
|
32
|
+
def self.verdict_line(code) = "exit #{code} - #{CAUSE_LINES.fetch(code).last}"
|
|
33
|
+
|
|
18
34
|
# "" when no result carries a reason.
|
|
19
35
|
def self.line(errored)
|
|
20
36
|
reasons = errored.filter_map { |result| first_line(result.reason) }.reject(&:empty?)
|
|
@@ -8,22 +8,11 @@ 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: no log.
|
|
18
|
-
def crash_log_path
|
|
19
|
-
config.path("cache_dir")&.then { |dir| File.join(dir, CrashReport::LOG_NAME) }
|
|
20
|
-
rescue StandardError
|
|
21
|
-
nil
|
|
22
|
-
end
|
|
23
|
-
|
|
24
11
|
def exit_code(report)
|
|
25
12
|
return EXIT_BUDGET if budget_exhausted?(report)
|
|
26
13
|
|
|
14
|
+
return EXIT_OK if ignore_empty_diff?(report)
|
|
15
|
+
|
|
27
16
|
return EXIT_TOOL if untested_partial?(report)
|
|
28
17
|
|
|
29
18
|
return EXIT_TOOL if unverifiable_scope?(report)
|
|
@@ -36,6 +25,17 @@ module Saboteur
|
|
|
36
25
|
gates.failed?(report, @err, record_cause: !quality_failed) || quality_failed ? EXIT_SURVIVORS : EXIT_OK
|
|
37
26
|
end
|
|
38
27
|
|
|
28
|
+
# --ignore-empty: a --since diff that selected no mutants exits 0 with
|
|
29
|
+
# a note; suppressed, omitted or undiscovered mutants still gate.
|
|
30
|
+
def ignore_empty_diff?(report)
|
|
31
|
+
return false unless config["ignore_empty"] && config["since"] && report.results.empty?
|
|
32
|
+
return false unless report.omissions.empty? && report.inline_suppressions.empty? &&
|
|
33
|
+
report.discovery_failures.empty?
|
|
34
|
+
|
|
35
|
+
@err.puts("saboteur: no mutants in the diff - nothing to gate (--ignore-empty)")
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
39
|
# A spent --max-duration budget exits 4 on its own, before the gates:
|
|
40
40
|
# the run is partial by design, and --max-duration-ok opts back into
|
|
41
41
|
# the plain verdict.
|
|
@@ -112,6 +112,7 @@ module Saboteur
|
|
|
112
112
|
when EXIT_OK then "ok"
|
|
113
113
|
when EXIT_BUDGET then "budgetExhausted"
|
|
114
114
|
when EXIT_SURVIVORS then survivors_cause(report)
|
|
115
|
+
when EXIT_BASELINE_RED, EXIT_CRASH then ErrorCause.cause(code)
|
|
115
116
|
else
|
|
116
117
|
if report.majority_errored?
|
|
117
118
|
"errorMajority"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "../reporter/palette"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class CLI
|
|
6
8
|
# Where a failed command speaks: stderr for people, and in agent mode the
|
|
@@ -8,6 +10,20 @@ module Saboteur
|
|
|
8
10
|
module FailureOutput
|
|
9
11
|
private
|
|
10
12
|
|
|
13
|
+
# Last resort: a bug must not escape as a bare stack trace and an accidental exit 1.
|
|
14
|
+
def internal_error(error)
|
|
15
|
+
CrashReport.call(error, verbose: @verbose, agent: agent?, err: @err, out: @out,
|
|
16
|
+
log_path: crash_log_path, color: @color || :auto)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The crash log lives with the other run state. A broken config (or a
|
|
20
|
+
# nil cache_dir) must not swallow the crash report itself: no log.
|
|
21
|
+
def crash_log_path
|
|
22
|
+
config.path("cache_dir")&.then { |dir| File.join(dir, CrashReport::LOG_NAME) }
|
|
23
|
+
rescue StandardError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
11
27
|
def usage_error(message = nil)
|
|
12
28
|
@err.puts(message) if message
|
|
13
29
|
@err.puts(USAGE)
|
|
@@ -28,8 +44,15 @@ module Saboteur
|
|
|
28
44
|
def report_failure(error, exit_code)
|
|
29
45
|
message = Text.display_safe_lines(error.message)
|
|
30
46
|
@err.puts("saboteur: #{message}")
|
|
47
|
+
@err.puts(verdict_line(EXIT_BASELINE_RED)) if exit_code == EXIT_BASELINE_RED
|
|
31
48
|
agent_failure(message, exit_code)
|
|
32
49
|
end
|
|
50
|
+
|
|
51
|
+
# The terminal verdict names the reason for a nonzero exit. Painted
|
|
52
|
+
# red under the same colour rules as the report.
|
|
53
|
+
def verdict_line(code)
|
|
54
|
+
Reporter::Palette.for(@err, mode: @color || :auto).paint(:fail, ErrorCause.verdict_line(code))
|
|
55
|
+
end
|
|
33
56
|
end
|
|
34
57
|
end
|
|
35
58
|
end
|
|
@@ -8,19 +8,41 @@ module Saboteur
|
|
|
8
8
|
private
|
|
9
9
|
|
|
10
10
|
def define_output_options(opts)
|
|
11
|
+
define_report_destination_options(opts)
|
|
12
|
+
define_text_report_options(opts)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Report destinations and the format switch.
|
|
16
|
+
def define_report_destination_options(opts)
|
|
11
17
|
opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
|
|
12
18
|
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
13
19
|
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
14
20
|
opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
|
|
15
21
|
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Flags that shape the text report. Kept under the per-method mutant cap
|
|
25
|
+
# by the destination/text split above.
|
|
26
|
+
def define_text_report_options(opts)
|
|
16
27
|
opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
|
|
17
28
|
"a crash prints its backtrace") do
|
|
18
29
|
@verbose = true
|
|
19
30
|
set(:verbose, true)
|
|
20
31
|
end
|
|
21
|
-
opts.on("--[
|
|
22
|
-
|
|
32
|
+
opts.on("--color [MODE]", %w[always never auto],
|
|
33
|
+
"Colour the text report: always, never or auto (default: auto)") do |v|
|
|
34
|
+
@color = (v || :always).to_sym
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# PR6: CI and pre-push output - the findings and the verdict only.
|
|
38
|
+
opts.on("--quiet", "Text report: print only the findings and the verdict;",
|
|
39
|
+
"no effect with --format json/agent; ignores --all-mutants") do
|
|
40
|
+
@quiet = true
|
|
41
|
+
set(:quiet, true)
|
|
23
42
|
end
|
|
43
|
+
opts.on("--all-mutants", "Text report: list every survived mutant;",
|
|
44
|
+
"default: one finding per statement, with the label mix") { set(:all_mutants, true) }
|
|
45
|
+
opts.on("--no-color", "Deprecated alias for --color=never") { @color = :never }
|
|
24
46
|
end
|
|
25
47
|
end
|
|
26
48
|
end
|
|
@@ -22,6 +22,12 @@ module Saboteur
|
|
|
22
22
|
"coverage map spend it too") do |v|
|
|
23
23
|
set(:max_duration, parse_duration(v))
|
|
24
24
|
end
|
|
25
|
+
opts.on("--baseline PATH", "Gate the run on an accepted baseline: only new survivors or a score " \
|
|
26
|
+
"drop beyond --epsilon fail (propose one with: saboteur baseline propose)") do |v|
|
|
27
|
+
set(:baseline, v)
|
|
28
|
+
end
|
|
29
|
+
opts.on("--epsilon POINTS", Float, "With --baseline: allowed score drop in percentage points " \
|
|
30
|
+
"(default 0: any drop fails)") { |v| set(:epsilon, v) }
|
|
25
31
|
opts.on("--[no-]max-duration-ok", "An exhausted --max-duration budget does not fail the run") do |v|
|
|
26
32
|
set(:max_duration_ok, v)
|
|
27
33
|
end
|
|
@@ -6,8 +6,21 @@ module Saboteur
|
|
|
6
6
|
# `saboteur run`: parse, validate, then preview or execute (#334).
|
|
7
7
|
# Extracted from CLI by command.
|
|
8
8
|
module RunCommand
|
|
9
|
+
# The baseline-red shapes the engine raises as Saboteur::Error when the
|
|
10
|
+
# unchanged suite itself fails: the user's tests are broken, so no
|
|
11
|
+
# mutant was ever judged. These route to EXIT_BASELINE_RED; every other
|
|
12
|
+
# deliberate raise keeps EXIT_TOOL.
|
|
13
|
+
BASELINE_RED_MESSAGE = /\A#{Regexp.union(
|
|
14
|
+
'baseline check ', 'baseline has no tests', 'the unchanged suite failed',
|
|
15
|
+
'the test command fails on the unchanged tree'
|
|
16
|
+
)}/
|
|
17
|
+
|
|
9
18
|
private
|
|
10
19
|
|
|
20
|
+
def baseline_red?(error)
|
|
21
|
+
error.message.to_s.match?(BASELINE_RED_MESSAGE)
|
|
22
|
+
end
|
|
23
|
+
|
|
11
24
|
def run_command = help_requested? ? show_help("run") : run
|
|
12
25
|
|
|
13
26
|
def run
|
|
@@ -26,7 +39,7 @@ module Saboteur
|
|
|
26
39
|
rescue ConfigError => e
|
|
27
40
|
report_failure(e, EXIT_USAGE)
|
|
28
41
|
rescue Error => e
|
|
29
|
-
report_failure(e, EXIT_TOOL)
|
|
42
|
+
report_failure(e, baseline_red?(e) ? EXIT_BASELINE_RED : EXIT_TOOL)
|
|
30
43
|
end
|
|
31
44
|
end
|
|
32
45
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "../reporter/score_history"
|
|
5
|
+
require_relative "../reporter/history_reader"
|
|
5
6
|
require_relative "../reporter/history_paths"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
@@ -12,10 +13,18 @@ module Saboteur
|
|
|
12
13
|
|
|
13
14
|
def text_run(report, engine)
|
|
14
15
|
@err.puts
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# Classify before printing: the terminal's closing verdict line names
|
|
17
|
+
# the exit and its reason, so the code must be known up front. The
|
|
18
|
+
# gate and skip notes exit_code writes to @err now precede the
|
|
19
|
+
# report instead of following it.
|
|
18
20
|
code = exit_code(report)
|
|
21
|
+
Reporter::Terminal.new(@out, verbose: @verbose || false, quiet: @quiet || false, color: @color || :auto,
|
|
22
|
+
overrides: config["palette"], threshold: config["threshold"],
|
|
23
|
+
replay_command: survivor_replay_command(report, engine),
|
|
24
|
+
all_mutants: options[:all_mutants] || false,
|
|
25
|
+
exit_code: code, previous_score: previous_score,
|
|
26
|
+
baseline_gate: (baseline_gate(report) if config["baseline"])).call(report)
|
|
27
|
+
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
19
28
|
# The JSON carries the exit cause an outer harness trusts, so it is
|
|
20
29
|
# the completion evidence for the whole run: write it only after
|
|
21
30
|
# every other fallible reporting stage, or a late failure would
|
|
@@ -31,7 +40,9 @@ module Saboteur
|
|
|
31
40
|
|
|
32
41
|
def agent_run(arguments)
|
|
33
42
|
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
34
|
-
|
|
43
|
+
# The machine path is exempt from the noise diet: quiet_start: false
|
|
44
|
+
# keeps agent stderr byte-identical to the pre-diet default output.
|
|
45
|
+
engine = Engine.new(config, **arguments, quiet_start: false)
|
|
35
46
|
report = stdout_on_stderr { engine.call }
|
|
36
47
|
payload = Reporter::Agent.call(report, agent_provenance(report, tree, engine))
|
|
37
48
|
rendered = Reporter::Agent.render(payload)
|
|
@@ -49,8 +60,13 @@ module Saboteur
|
|
|
49
60
|
|
|
50
61
|
def execute_run
|
|
51
62
|
validate_history
|
|
52
|
-
|
|
63
|
+
# One palette per stream: stdout and stderr detect colour on their
|
|
64
|
+
# own, so a forced terminal report still leaves piped progress plain.
|
|
65
|
+
arguments = { err: @err,
|
|
66
|
+
palette: Reporter::Palette.for(@err, mode: @color || :auto, overrides: config["palette"]) }
|
|
53
67
|
arguments[:digest] = options[:id] if options[:id]
|
|
68
|
+
arguments[:capture_evidence] = true if config["baseline"]
|
|
69
|
+
arguments[:evidence_files] = [config["baseline"]] if config["baseline"]
|
|
54
70
|
return agent_run(arguments) if agent?
|
|
55
71
|
|
|
56
72
|
# Text mode only: the engine prints a per-subject marks line when
|
|
@@ -63,6 +79,18 @@ module Saboteur
|
|
|
63
79
|
# selection options, assembled by the same code that writes the agent
|
|
64
80
|
# follow-ups, so the user never reassembles them by hand (#261).
|
|
65
81
|
# Computed lazily: only a run with survivors asks for it.
|
|
82
|
+
# The headline's delta: the covered score of the last history record.
|
|
83
|
+
# The history is auxiliary evidence (#296): anything unreadable simply
|
|
84
|
+
# means no delta, since validate_history reports real corruption.
|
|
85
|
+
def previous_score
|
|
86
|
+
path = config.path("score_history")
|
|
87
|
+
return unless path && File.exist?(path)
|
|
88
|
+
|
|
89
|
+
Reporter::HistoryReader.read(path).last&.fetch("coveredScore", nil)
|
|
90
|
+
rescue Error
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
66
94
|
def survivor_replay_command(report, engine)
|
|
67
95
|
lambda do |digest|
|
|
68
96
|
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
@@ -126,7 +154,7 @@ module Saboteur
|
|
|
126
154
|
return unless path
|
|
127
155
|
|
|
128
156
|
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
129
|
-
scope = config.selection_settings.except(*Config::OUTPUT_KEYS)
|
|
157
|
+
scope = config.selection_settings.except(*Config::OUTPUT_KEYS, *Config::PRESENTATION_KEYS)
|
|
130
158
|
# Recording is an auxiliary artifact: when the file cannot be
|
|
131
159
|
# written - read-only tree inside a sandboxed child (#296) - warn
|
|
132
160
|
# and let the run exit by its results instead of failing as a tool
|
|
@@ -36,19 +36,52 @@ module Saboteur
|
|
|
36
36
|
def validate_run_options
|
|
37
37
|
reject_format_conflicts
|
|
38
38
|
validate_shard_options
|
|
39
|
+
raise UsageError, "--ignore-empty applies to --since runs" if config["ignore_empty"] && !config["since"]
|
|
40
|
+
|
|
41
|
+
validate_baseline_options
|
|
42
|
+
|
|
39
43
|
Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
# The run gate needs fresh whole-scope execution evidence, so it
|
|
47
|
+
# refuses every flag the evidence capture rejects, up front. A missing
|
|
48
|
+
# or corrupt baseline file is a usage error before the suite boots.
|
|
49
|
+
def validate_baseline_options
|
|
50
|
+
raise UsageError, "--epsilon applies with --baseline" if config["epsilon"] && !config["baseline"]
|
|
51
|
+
if config["epsilon"] && !(0.0..100.0).cover?(config["epsilon"])
|
|
52
|
+
raise UsageError, "--epsilon must be between 0 and 100"
|
|
53
|
+
end
|
|
54
|
+
return unless config["baseline"]
|
|
55
|
+
|
|
56
|
+
conflicts = %w[since incremental threshold resume shard].select { |key| config[key] }
|
|
57
|
+
conflicts << "id" if options[:id]
|
|
58
|
+
unless conflicts.empty?
|
|
59
|
+
flags = conflicts.map { |key| "--#{key}" }.join(", ")
|
|
60
|
+
raise UsageError, "--baseline cannot be combined with #{flags}"
|
|
61
|
+
end
|
|
62
|
+
baseline_manifest
|
|
63
|
+
end
|
|
64
|
+
|
|
42
65
|
# Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
|
|
43
66
|
def reject_format_conflicts
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
# --quiet prints only the findings and the verdict; --verbose adds
|
|
68
|
+
# the debug statistics. One output cannot be both.
|
|
69
|
+
raise UsageError, "--quiet cannot be combined with --verbose" if options[:quiet] && options[:verbose]
|
|
70
|
+
|
|
71
|
+
if agent? && (config["threshold"] || config["baseline"] || gates.any?)
|
|
72
|
+
raise UsageError, "#{agent_gate_name} cannot be combined with --agent"
|
|
47
73
|
end
|
|
48
74
|
return unless @format == "json" && !options[:dry_run]
|
|
49
75
|
|
|
50
76
|
raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
|
|
51
77
|
end
|
|
78
|
+
|
|
79
|
+
def agent_gate_name
|
|
80
|
+
return "--threshold" if config["threshold"]
|
|
81
|
+
return "--baseline" if config["baseline"]
|
|
82
|
+
|
|
83
|
+
gates.agent_option_name
|
|
84
|
+
end
|
|
52
85
|
end
|
|
53
86
|
end
|
|
54
87
|
end
|
|
@@ -22,6 +22,8 @@ module Saboteur
|
|
|
22
22
|
opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
|
|
23
23
|
opts.on("--shard K/N", "Run one of N deterministic candidate shards (for example 1/4)") { |v| set(:shard, v) }
|
|
24
24
|
opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
|
|
25
|
+
opts.on("--ignore-empty", "A --since diff that selects no mutants exits 0 with a note instead of " \
|
|
26
|
+
"failing a score gate") { set(:ignore_empty, true) }
|
|
25
27
|
flaky_desc = "Confirm a fresh verdict (survivors, decisive) with one reordered rerun"
|
|
26
28
|
opts.on("--flaky-check MODE", %w[off survivors decisive], flaky_desc) { |v| set(:flaky_check, v) }
|
|
27
29
|
opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
|
data/lib/saboteur/cli.rb
CHANGED
|
@@ -8,8 +8,10 @@ require_relative "doctor"
|
|
|
8
8
|
require_relative "help"
|
|
9
9
|
require_relative "reporter/replay"
|
|
10
10
|
require_relative "baseline/command"
|
|
11
|
+
require_relative "baseline/run_gate"
|
|
11
12
|
require_relative "cli/error_cause"
|
|
12
13
|
require_relative "cli/exit_status"
|
|
14
|
+
require_relative "cli/baseline_gate"
|
|
13
15
|
require_relative "preview"
|
|
14
16
|
require_relative "cli/run_reporting"
|
|
15
17
|
require_relative "cli/survivors_command"
|
|
@@ -39,6 +41,7 @@ module Saboteur
|
|
|
39
41
|
class CLI
|
|
40
42
|
include Baseline::Command
|
|
41
43
|
include ExitStatus
|
|
44
|
+
include BaselineGate
|
|
42
45
|
include RunReporting
|
|
43
46
|
include SurvivorsCommand
|
|
44
47
|
include SourceSet
|
|
@@ -73,6 +76,8 @@ module Saboteur
|
|
|
73
76
|
EXIT_USAGE = 2
|
|
74
77
|
EXIT_TOOL = 3
|
|
75
78
|
EXIT_BUDGET = 4
|
|
79
|
+
EXIT_BASELINE_RED = 5
|
|
80
|
+
EXIT_CRASH = 70
|
|
76
81
|
class UsageError < ConfigError; end
|
|
77
82
|
|
|
78
83
|
attr_reader :options
|
|
@@ -131,7 +136,7 @@ module Saboteur
|
|
|
131
136
|
|
|
132
137
|
def agent_requested? = self.class.agent_requested?(@argv)
|
|
133
138
|
|
|
134
|
-
def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
|
|
139
|
+
def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run, :all_mutants, :quiet)
|
|
135
140
|
|
|
136
141
|
def add_subject(expr) = (options[:subjects] ||= []) << expr
|
|
137
142
|
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -22,6 +22,11 @@ module Saboteur
|
|
|
22
22
|
# out of the run fingerprint, baseline evidence and score-history scope.
|
|
23
23
|
OUTPUT_KEYS = %w[cache_dir json_report html_report score_history resume].freeze
|
|
24
24
|
|
|
25
|
+
# Presentation-only settings: like OUTPUT_KEYS they never enter the run
|
|
26
|
+
# fingerprint, baseline evidence or score-history scope, but they are
|
|
27
|
+
# not paths.
|
|
28
|
+
PRESENTATION_KEYS = %w[palette].freeze
|
|
29
|
+
|
|
25
30
|
DEFAULTS = {
|
|
26
31
|
"integration" => "rspec",
|
|
27
32
|
"require" => [],
|
|
@@ -77,6 +82,9 @@ module Saboteur
|
|
|
77
82
|
# cache miss and compares it with the full map, which the run still uses.
|
|
78
83
|
# "use" uses the partial map when it is safe and builds the full map otherwise.
|
|
79
84
|
"partial_coverage" => "off",
|
|
85
|
+
# Total seconds survivor confirmation may spend per run when
|
|
86
|
+
# partial_coverage=use (#1063); the per-survivor spec cap is a constant.
|
|
87
|
+
"confirmation_budget" => 300.0,
|
|
80
88
|
# coverage (default) reads Coverage around every test; trace gets lines and
|
|
81
89
|
# methods from a TracePoint and reads branches once per spec file (#725).
|
|
82
90
|
"coverage_recorder" => "coverage",
|
|
@@ -95,6 +103,9 @@ module Saboteur
|
|
|
95
103
|
"coverage_parts" => 1,
|
|
96
104
|
"max_duration" => nil,
|
|
97
105
|
"max_duration_ok" => false,
|
|
106
|
+
"ignore_empty" => false,
|
|
107
|
+
"baseline" => nil,
|
|
108
|
+
"epsilon" => nil,
|
|
98
109
|
"tests" => [],
|
|
99
110
|
"test_command" => nil,
|
|
100
111
|
"fingerprint_extra" => [],
|
|
@@ -103,12 +114,15 @@ module Saboteur
|
|
|
103
114
|
"max_errors" => nil,
|
|
104
115
|
# --verbose also lands here, so boot-time output filters (the rspec
|
|
105
116
|
# load-error report, #608) can keep their raw text behind the flag.
|
|
106
|
-
"verbose" => false
|
|
117
|
+
"verbose" => false,
|
|
118
|
+
# Text-report colour overrides: token -> ANSI colour name, validated
|
|
119
|
+
# against Reporter::Palette. Presentation only, never fingerprinted.
|
|
120
|
+
"palette" => {}
|
|
107
121
|
}.freeze
|
|
108
122
|
|
|
109
123
|
INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes
|
|
110
124
|
coverage_parts].freeze
|
|
111
|
-
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
125
|
+
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout confirmation_budget].freeze
|
|
112
126
|
STRING_KEYS = %w[test_selection partial_coverage coverage_recorder].freeze
|
|
113
127
|
NILABLE_FLOAT_KEYS = %w[threshold max_duration].freeze
|
|
114
128
|
THRESHOLD_RANGE = (0.0..100.0)
|
|
@@ -321,6 +335,7 @@ module Saboteur
|
|
|
321
335
|
end
|
|
322
336
|
|
|
323
337
|
def validate_modes!
|
|
338
|
+
validate_palette!
|
|
324
339
|
validate_fail_fast!
|
|
325
340
|
validate_matrix!
|
|
326
341
|
validate_resume!
|
|
@@ -427,6 +442,12 @@ module Saboteur
|
|
|
427
442
|
raise ConfigError, 'test_command runs the command unsandboxed, so sandbox "strict" cannot be honored'
|
|
428
443
|
end
|
|
429
444
|
|
|
445
|
+
def validate_palette!
|
|
446
|
+
Reporter::Palette.validate_overrides!(self["palette"])
|
|
447
|
+
rescue ArgumentError => e
|
|
448
|
+
raise ConfigError, e.message
|
|
449
|
+
end
|
|
450
|
+
|
|
430
451
|
def validate_reeval!
|
|
431
452
|
raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
|
|
432
453
|
Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
|
|
@@ -6,18 +6,26 @@ require "json"
|
|
|
6
6
|
require "tmpdir"
|
|
7
7
|
require "pathname"
|
|
8
8
|
|
|
9
|
+
require_relative "reporter/palette"
|
|
10
|
+
|
|
9
11
|
module Saboteur
|
|
10
12
|
# Last-resort crash reporting, shared by the CLI's StandardError rescue
|
|
11
13
|
# and the exe-level wrapper: one bounded line on stderr, the full
|
|
12
14
|
# backtrace in a log file the line names, the backtrace on stderr with
|
|
13
15
|
# --verbose, and the same failure as agent JSON when an agent listens.
|
|
14
|
-
# Always returns
|
|
16
|
+
# Always returns EXIT_CRASH (70): a crash is a saboteur bug, never a
|
|
17
|
+
# check the user can fix.
|
|
15
18
|
module CrashReport
|
|
16
19
|
ISSUES_URL = "https://github.com/vamsipavanmahesh/saboteur/issues"
|
|
17
20
|
|
|
18
|
-
# Same value as CLI::
|
|
21
|
+
# Same value as CLI::EXIT_CRASH, kept local so the reporter still works
|
|
19
22
|
# when the CLI itself never finished loading.
|
|
20
|
-
|
|
23
|
+
EXIT_CRASH = 70
|
|
24
|
+
|
|
25
|
+
# The crash cause's verdict line, kept in sync with
|
|
26
|
+
# CLI::ErrorCause::CAUSE_LINES["crash"] by the cause pins: the reporter
|
|
27
|
+
# cannot load the CLI's vocabulary when the CLI itself never loaded.
|
|
28
|
+
VERDICT_LINE = "exit 70 - internal crash (a saboteur bug, never your code)"
|
|
21
29
|
|
|
22
30
|
# The crash log's file name under the run's cache dir; the runner reads
|
|
23
31
|
# it back to explain a nested run's crash in the failure detail (#296).
|
|
@@ -25,15 +33,16 @@ module Saboteur
|
|
|
25
33
|
|
|
26
34
|
module_function
|
|
27
35
|
|
|
28
|
-
def call(error, verbose:, agent:, err:, out:, log_path: nil)
|
|
36
|
+
def call(error, verbose:, agent:, err:, out:, log_path: nil, color: :auto)
|
|
29
37
|
summary = "internal error (#{error.class}: #{one_line(error.message)})"
|
|
30
38
|
line = "saboteur: #{summary} - please report it: #{ISSUES_URL}"
|
|
31
39
|
written = write_log(log_path, error)
|
|
32
40
|
line = "#{line}; full backtrace: #{display_path(written)}" if written
|
|
33
41
|
err.puts(line)
|
|
42
|
+
err.puts(Reporter::Palette.for(err, mode: color).paint(:fail, VERDICT_LINE))
|
|
34
43
|
err.puts(full_text(error)) if verbose
|
|
35
44
|
out.print(agent_json(summary)) if agent
|
|
36
|
-
|
|
45
|
+
EXIT_CRASH
|
|
37
46
|
end
|
|
38
47
|
|
|
39
48
|
# A crash message can span lines or carry bytes that are invalid in
|
|
@@ -59,9 +68,9 @@ module Saboteur
|
|
|
59
68
|
def agent_json(summary)
|
|
60
69
|
payload =
|
|
61
70
|
if defined?(Reporter::Agent)
|
|
62
|
-
Reporter::Agent.failure(summary,
|
|
71
|
+
Reporter::Agent.failure(summary, EXIT_CRASH)
|
|
63
72
|
else
|
|
64
|
-
{ version: 1, status: "error", verdict: "tool_failure", exitCode:
|
|
73
|
+
{ version: 1, status: "error", verdict: "tool_failure", exitCode: EXIT_CRASH,
|
|
65
74
|
message: summary, next: { help: "saboteur help run" } }
|
|
66
75
|
end
|
|
67
76
|
"#{JSON.pretty_generate(payload)}\n"
|
|
@@ -46,7 +46,7 @@ module Saboteur
|
|
|
46
46
|
expected.empty?
|
|
47
47
|
|
|
48
48
|
passes = @config["baseline_passes"]
|
|
49
|
-
@diagnostics.
|
|
49
|
+
@diagnostics.detail(announce(passes))
|
|
50
50
|
previous = nil
|
|
51
51
|
passes.times do |index|
|
|
52
52
|
break if budget_spent?(index)
|
|
@@ -114,7 +114,7 @@ module Saboteur
|
|
|
114
114
|
work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
|
|
115
115
|
work << "the coverage map build" if coverage_pending
|
|
116
116
|
total = remaining + (coverage_pending ? 1 : 0)
|
|
117
|
-
@diagnostics.
|
|
117
|
+
@diagnostics.detail("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
|
|
118
118
|
"#{work.join(' and ')} at that rate")
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -12,12 +12,19 @@ module Saboteur
|
|
|
12
12
|
# shadow helpers it shares (shadow_invalidation, shadow_rerun,
|
|
13
13
|
# shadow_clean).
|
|
14
14
|
module CoveragePartial
|
|
15
|
+
# The full opt-in predicate, factored out for survivor confirmation
|
|
16
|
+
# (#1063): partial coverage only applies to a cached, unscoped,
|
|
17
|
+
# single-part run.
|
|
18
|
+
def self.use?(config, parts)
|
|
19
|
+
config["partial_coverage"] == "use" && config["cache"] && config["tests"].empty? && parts == 1
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
private
|
|
16
23
|
|
|
17
24
|
# Same preconditions as the shadow run: a scoped or parted map is not
|
|
18
25
|
# what a partial rebuild starts from.
|
|
19
26
|
def partial_use?
|
|
20
|
-
|
|
27
|
+
CoveragePartial.use?(@config, @parts)
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
def partial_map(files, note, preloaded)
|
|
@@ -393,11 +393,10 @@ module Saboteur
|
|
|
393
393
|
|
|
394
394
|
def preloaded_message(files)
|
|
395
395
|
names = Text.name_list(files.map { |file| file.delete_prefix("#{@root}/") })
|
|
396
|
-
"#{names} loaded before saboteur started coverage
|
|
397
|
-
"executable's Coverage.start
|
|
398
|
-
"evaluates it, or RUBYOPT -r
|
|
399
|
-
"Let the test suite load these "
|
|
400
|
-
"files; a gemspec should require only its version file."
|
|
396
|
+
"#{names} can never be covered: loaded before saboteur started coverage\n " \
|
|
397
|
+
"a require ran before the executable's Coverage.start, commonly the gemspec requiring the " \
|
|
398
|
+
"library while bundler/setup evaluates it, or RUBYOPT -r\n " \
|
|
399
|
+
"Let the test suite load these files; a gemspec should require only its version file."
|
|
401
400
|
end
|
|
402
401
|
|
|
403
402
|
# Bootsnap is named only when it is loaded: blaming a library the app
|