saboteur 0.11.0 → 0.11.2
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 +86 -0
- data/README.md +2 -2
- data/lib/saboteur/baseline/command.rb +46 -2
- data/lib/saboteur/baseline/comparison.rb +6 -1
- data/lib/saboteur/baseline/completeness.rb +21 -0
- data/lib/saboteur/baseline/evidence.rb +11 -3
- data/lib/saboteur/baseline/loader.rb +18 -5
- data/lib/saboteur/baseline/manifest.rb +13 -4
- data/lib/saboteur/baseline/run_evidence.rb +3 -1
- data/lib/saboteur/baseline/workflow.rb +57 -9
- data/lib/saboteur/baseline_health.rb +4 -1
- data/lib/saboteur/cli/dirty_tree_notice.rb +7 -1
- data/lib/saboteur/cli/doctor_command.rb +10 -1
- data/lib/saboteur/cli/exit_status.rb +10 -5
- data/lib/saboteur/cli/explain_command.rb +51 -0
- data/lib/saboteur/cli/explicit_sources.rb +1 -1
- data/lib/saboteur/cli/failure_output.rb +2 -2
- data/lib/saboteur/cli/flaky_exit.rb +23 -0
- data/lib/saboteur/cli/presets.rb +11 -1
- data/lib/saboteur/cli/run_command.rb +17 -1
- data/lib/saboteur/cli/run_output.rb +10 -0
- data/lib/saboteur/cli/run_reporting.rb +13 -2
- data/lib/saboteur/cli/survivors_command.rb +82 -18
- data/lib/saboteur/cli.rb +3 -0
- data/lib/saboteur/config.rb +7 -2
- data/lib/saboteur/crash_report.rb +3 -2
- data/lib/saboteur/doctor.rb +11 -2
- data/lib/saboteur/engine/boot.rb +4 -2
- data/lib/saboteur/engine/coverage_store.rb +54 -3
- data/lib/saboteur/engine/distribution.rb +6 -2
- data/lib/saboteur/engine/pipelines.rb +14 -2
- data/lib/saboteur/engine/result_cache.rb +4 -2
- data/lib/saboteur/engine/scheduler.rb +23 -21
- data/lib/saboteur/engine/selection_announcement.rb +5 -4
- data/lib/saboteur/engine/subject_selection.rb +11 -1
- data/lib/saboteur/engine/survivor_confirmation.rb +1 -1
- data/lib/saboteur/engine/test_guess.rb +1 -1
- data/lib/saboteur/engine/thread_warnings.rb +9 -4
- data/lib/saboteur/engine.rb +57 -9
- data/lib/saboteur/execution/fiddle_loader.rb +29 -2
- data/lib/saboteur/help.rb +13 -1
- data/lib/saboteur/ignore_list.rb +36 -3
- data/lib/saboteur/install.rb +18 -3
- data/lib/saboteur/mutation_source/builtin.rb +1 -0
- data/lib/saboteur/mutation_source/operator_explain.rb +181 -0
- data/lib/saboteur/preview.rb +6 -1
- data/lib/saboteur/reporter/agent_commands.rb +29 -7
- data/lib/saboteur/reporter/agent_incomplete.rb +9 -2
- data/lib/saboteur/reporter/agent_summary.rb +2 -1
- data/lib/saboteur/reporter/baseline_tail.rb +9 -7
- data/lib/saboteur/reporter/gap_kinds.rb +80 -0
- data/lib/saboteur/reporter/html.rb +160 -10
- data/lib/saboteur/reporter/next_steps.rb +9 -7
- data/lib/saboteur/reporter/notice.rb +24 -0
- data/lib/saboteur/reporter/reuse_note.rb +37 -0
- data/lib/saboteur/reporter/review_comments.rb +17 -12
- data/lib/saboteur/reporter/score_table.rb +8 -7
- data/lib/saboteur/reporter/stryker.rb +11 -1
- data/lib/saboteur/reporter/survivor_classification.rb +26 -8
- data/lib/saboteur/reporter/survivor_hints.rb +3 -2
- data/lib/saboteur/reporter/survivor_list.rb +58 -4
- data/lib/saboteur/reporter/survivor_ranking.rb +13 -5
- data/lib/saboteur/reporter/terminal.rb +32 -33
- data/lib/saboteur/reporter/wording.rb +37 -0
- data/lib/saboteur/result.rb +39 -8
- data/lib/saboteur/selection/git_diff.rb +19 -1
- data/lib/saboteur/survivor_guidance.rb +33 -0
- data/lib/saboteur/text.rb +6 -0
- data/lib/saboteur/version.rb +1 -1
- metadata +10 -2
|
@@ -39,7 +39,7 @@ module Saboteur
|
|
|
39
39
|
# A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
|
|
40
40
|
def require_existing_test(test)
|
|
41
41
|
path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
|
|
42
|
-
raise UsageError, "test file not found: #{test}" unless File.exist?(path)
|
|
42
|
+
raise UsageError, "test file not found: #{test}; check the path or drop --test" unless File.exist?(path)
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -25,7 +25,7 @@ module Saboteur
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def usage_error(message = nil)
|
|
28
|
-
@err.puts(message) if message
|
|
28
|
+
@err.puts(Reporter::Palette.for(@err, mode: @color || :auto).paint(:fail, message)) if message
|
|
29
29
|
@err.puts(USAGE)
|
|
30
30
|
agent_failure(message || USAGE, EXIT_USAGE)
|
|
31
31
|
end
|
|
@@ -43,7 +43,7 @@ module Saboteur
|
|
|
43
43
|
# name must not become a workflow command in the CI log).
|
|
44
44
|
def report_failure(error, exit_code)
|
|
45
45
|
message = Text.display_safe_lines(error.message)
|
|
46
|
-
@err.puts("saboteur: #{message}")
|
|
46
|
+
@err.puts(Reporter::Palette.for(@err, mode: @color || :auto).paint(:fail, "saboteur: #{message}"))
|
|
47
47
|
@err.puts(verdict_line(EXIT_BASELINE_RED)) if exit_code == EXIT_BASELINE_RED
|
|
48
48
|
agent_failure(message, exit_code)
|
|
49
49
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class CLI
|
|
6
|
+
# --flaky-check (#1167): a verdict that flipped on the reordered rerun is
|
|
7
|
+
# unverified, outside the score. The run was not stopped by a budget, so
|
|
8
|
+
# it is not exit 4, but it cannot go green either.
|
|
9
|
+
module FlakyExit
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def flaky_disagreement?(report)
|
|
13
|
+
flaky = report.results.select(&:flaky?)
|
|
14
|
+
return false if flaky.empty?
|
|
15
|
+
|
|
16
|
+
@err.puts("saboteur: #{flaky.size} mutant#{'s' unless flaky.one?} changed verdict on the --flaky-check " \
|
|
17
|
+
"reordered rerun (#{flaky.first(3).map(&:digest).join(', ')}), so they are unverified: " \
|
|
18
|
+
"your tests depend on run order; fix that and run again")
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/saboteur/cli/presets.rb
CHANGED
|
@@ -14,6 +14,11 @@ module Saboteur
|
|
|
14
14
|
--html .saboteur/audit-report.html]
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
|
+
# What a new user types first; there is no such command, the Rails
|
|
18
|
+
# generator writes the starter files.
|
|
19
|
+
SETUP_WORDS = %w[init install setup].freeze
|
|
20
|
+
SETUP_HINT = "To set up a Rails app, run: bin/rails generate saboteur:install"
|
|
21
|
+
|
|
17
22
|
# Preset flags that take a separate value token: the pairs a user
|
|
18
23
|
# flag can override in the banner (#666).
|
|
19
24
|
VALUE_FLAGS = %w[--baseline-passes --max-duration --since --operators --json --html].freeze
|
|
@@ -21,7 +26,12 @@ module Saboteur
|
|
|
21
26
|
private
|
|
22
27
|
|
|
23
28
|
def preset_or_usage_error(command)
|
|
24
|
-
|
|
29
|
+
return preset(command) if PRESETS.key?(command)
|
|
30
|
+
|
|
31
|
+
# A typo'd command is not a run: say so before the usage line.
|
|
32
|
+
@err.puts("unknown command: #{command}. Run 'saboteur help' for the commands.") if command
|
|
33
|
+
@err.puts(SETUP_HINT) if SETUP_WORDS.include?(command)
|
|
34
|
+
usage_error
|
|
25
35
|
end
|
|
26
36
|
|
|
27
37
|
def preset(name)
|
|
@@ -17,6 +17,18 @@ module Saboteur
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
+
# A fresh checkout has no config file: say once that defaults are in
|
|
21
|
+
# use, and who writes one (#1172). Agent output stays machine-clean;
|
|
22
|
+
# there is no init command - the Rails generator writes the config.
|
|
23
|
+
def announce_missing_config_file(palette)
|
|
24
|
+
# @format, not agent_requested?: parse! has already eaten --format.
|
|
25
|
+
return if @format == "agent" || config.config_file_present
|
|
26
|
+
|
|
27
|
+
path = options[:config] || Config::FILE
|
|
28
|
+
notice = "saboteur: no #{path} - using defaults (Rails: bundle exec rails generate saboteur:install writes one)"
|
|
29
|
+
@err.puts(palette.paint(:dim, notice))
|
|
30
|
+
end
|
|
31
|
+
|
|
20
32
|
def baseline_red?(error)
|
|
21
33
|
error.message.to_s.match?(BASELINE_RED_MESSAGE)
|
|
22
34
|
end
|
|
@@ -30,7 +42,11 @@ module Saboteur
|
|
|
30
42
|
validate_run_options
|
|
31
43
|
warn_dirty_tree
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
notices = Reporter::Palette.for(@err, mode: @color || :auto, overrides: config["palette"])
|
|
46
|
+
config.deprecation_warnings.each do |warning|
|
|
47
|
+
@err.puts(Reporter::Notice.paint(notices, "saboteur: #{warning}"))
|
|
48
|
+
end
|
|
49
|
+
announce_missing_config_file(notices)
|
|
34
50
|
|
|
35
51
|
return preview if options[:list_operators] || options[:dry_run]
|
|
36
52
|
|
|
@@ -21,6 +21,16 @@ module Saboteur
|
|
|
21
21
|
@out.puts "\nReports: #{names.join(', ')}"
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# The clean screen's scope label (#1173, #1174): --since, positional
|
|
25
|
+
# sources and --subject each name what the run covered.
|
|
26
|
+
def scope_label
|
|
27
|
+
parts = []
|
|
28
|
+
parts << "--since #{config['since']}" if config["since"]
|
|
29
|
+
parts.concat(@sources) if @sources
|
|
30
|
+
parts << "--subject #{config['subjects'].join(', ')}" if config["subjects"].any?
|
|
31
|
+
parts.empty? ? "whole project" : parts.join(", ")
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
25
35
|
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
26
36
|
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
@@ -22,7 +22,9 @@ module Saboteur
|
|
|
22
22
|
overrides: config["palette"], threshold: config["threshold"],
|
|
23
23
|
replay_command: survivor_replay_command(report, engine),
|
|
24
24
|
all_mutants: options[:all_mutants] || false,
|
|
25
|
-
exit_code: code, previous_score: previous_score,
|
|
25
|
+
exit_code: code, previous_score: previous_score, scope: scope_label,
|
|
26
|
+
whole_suite_map: whole_suite_map_for(engine),
|
|
27
|
+
run_map: engine&.run_coverage_map,
|
|
26
28
|
baseline_gate: (baseline_gate(report) if config["baseline"])).call(report)
|
|
27
29
|
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
28
30
|
# The JSON carries the exit cause an outer harness trusts, so it is
|
|
@@ -38,6 +40,14 @@ module Saboteur
|
|
|
38
40
|
code
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# No engine or no cache: no whole-suite map can exist, so the survivor
|
|
44
|
+
# block keeps its legacy covered-by line over an impossible build (review C1).
|
|
45
|
+
def whole_suite_map_for(engine)
|
|
46
|
+
return engine.whole_suite_coverage_map if engine&.whole_suite_map_lookup?
|
|
47
|
+
|
|
48
|
+
Reporter::SurvivorList::NO_WHOLE_SUITE_LOOKUP
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
def agent_run(arguments)
|
|
42
52
|
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
43
53
|
# The machine path is exempt from the noise diet: quiet_start: false
|
|
@@ -94,7 +104,8 @@ module Saboteur
|
|
|
94
104
|
def survivor_replay_command(report, engine)
|
|
95
105
|
lambda do |digest|
|
|
96
106
|
tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
|
|
97
|
-
Reporter::Agent::Commands.replay(agent_provenance(report, tree, engine), digest, capped: false
|
|
107
|
+
Reporter::Agent::Commands.replay(agent_provenance(report, tree, engine), digest, capped: false,
|
|
108
|
+
report: report)
|
|
98
109
|
end
|
|
99
110
|
end
|
|
100
111
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
|
+
require_relative "../survivor_guidance"
|
|
7
|
+
require_relative "../reporter/survivor_hints"
|
|
8
|
+
|
|
6
9
|
module Saboteur
|
|
7
10
|
class CLI
|
|
8
11
|
# saboteur survivors <report.json> (#475): a read-only, line-oriented
|
|
@@ -11,6 +14,9 @@ module Saboteur
|
|
|
11
14
|
# one digest prints the diff, the exact replay command and the
|
|
12
15
|
# ignore-file template. It never edits the ignore file.
|
|
13
16
|
module SurvivorsCommand
|
|
17
|
+
# Where `saboteur run` writes the JSON report unless configured otherwise.
|
|
18
|
+
DEFAULT_REPORT = Config::DEFAULTS["json_report"]
|
|
19
|
+
|
|
14
20
|
private
|
|
15
21
|
|
|
16
22
|
def survivors_command
|
|
@@ -25,13 +31,12 @@ module Saboteur
|
|
|
25
31
|
opts.on("--ranked", "Review-priority order: the report's ranked findings first")
|
|
26
32
|
opts.on("--file SUBSTR", "Only survivors whose path contains SUBSTR")
|
|
27
33
|
opts.on("--operator NAME", "Only survivors of operator NAME")
|
|
28
|
-
opts.on("--triage LABEL", "Only survivors classified LABEL (for example
|
|
34
|
+
opts.on("--triage LABEL", "Only survivors classified LABEL (for example boundary)")
|
|
29
35
|
opts.on("--show DIGEST", "Diff, replay command and ignore template for one survivor")
|
|
30
36
|
end
|
|
31
37
|
options = {}
|
|
32
38
|
args = parser.parse!(@argv, into: options)
|
|
33
|
-
path = args.shift
|
|
34
|
-
"survivors expects a report path: saboteur survivors .saboteur/report.json"
|
|
39
|
+
path = args.shift || DEFAULT_REPORT
|
|
35
40
|
raise UsageError, "unexpected arguments: #{args.join(' ')}" if args.any?
|
|
36
41
|
|
|
37
42
|
report = load_survivor_report(path)
|
|
@@ -45,7 +50,10 @@ module Saboteur
|
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def load_survivor_report(path)
|
|
48
|
-
|
|
53
|
+
unless File.file?(path)
|
|
54
|
+
hint = path == DEFAULT_REPORT ? "; run saboteur first, or pass a report path" : ""
|
|
55
|
+
raise UsageError, "report not found: #{path}#{hint}"
|
|
56
|
+
end
|
|
49
57
|
|
|
50
58
|
JSON.parse(File.read(path))
|
|
51
59
|
rescue JSON::ParserError => e
|
|
@@ -70,26 +78,77 @@ module Saboteur
|
|
|
70
78
|
end
|
|
71
79
|
|
|
72
80
|
def show(digest)
|
|
73
|
-
survivor =
|
|
74
|
-
raise(UsageError, "no survivor with digest #{digest} in this report")
|
|
81
|
+
survivor = unique_survivor(digest)
|
|
75
82
|
finding = findings_by_digest[survivor[:digest]] || {}
|
|
76
|
-
subject = finding["subject"]
|
|
83
|
+
subject = finding["subject"] || survivor[:subject]
|
|
84
|
+
seed = @report.dig("config", "seed")
|
|
77
85
|
[
|
|
78
|
-
|
|
79
|
-
"",
|
|
80
|
-
"- #{finding['original'] || original_line(survivor)}",
|
|
81
|
-
"+ #{finding['replacement'] || survivor[:replacement]}",
|
|
86
|
+
*detail_lines(survivor, finding),
|
|
82
87
|
"",
|
|
83
|
-
"
|
|
84
|
-
"
|
|
88
|
+
"replay one survivor: #{SurvivorGuidance.replay_command(survivor[:digest], seed: seed)}",
|
|
89
|
+
" #{SurvivorGuidance::REPLAY_CAVEAT}",
|
|
85
90
|
"",
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
SurvivorGuidance::IGNORE_INTRO,
|
|
92
|
+
" #{ignore_entry(survivor, subject)}"
|
|
88
93
|
].join("\n")
|
|
89
94
|
end
|
|
90
95
|
|
|
91
96
|
private
|
|
92
97
|
|
|
98
|
+
def detail_lines(survivor, finding)
|
|
99
|
+
[
|
|
100
|
+
"#{survivor[:path]}:#{survivor[:line]} #{survivor[:operator]} #{survivor[:digest]}",
|
|
101
|
+
*classification_line(survivor[:classification]),
|
|
102
|
+
"",
|
|
103
|
+
"- #{finding['original'] || original_line(survivor)}",
|
|
104
|
+
"+ #{finding['replacement'] || survivor[:replacement]}",
|
|
105
|
+
"why it matters: #{Reporter::SurvivorHints.why(survivor[:operator])}",
|
|
106
|
+
*covered_line(survivor),
|
|
107
|
+
*hint_line(survivor, survivor[:classification])
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The same wording the terminal survivor block uses (#1183).
|
|
112
|
+
def classification_line(label)
|
|
113
|
+
return [] unless label
|
|
114
|
+
|
|
115
|
+
reason = Reporter::SurvivorHints.label_reason(label)
|
|
116
|
+
["classification: #{label}#{" - #{reason}" if reason}"]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def covered_line(survivor)
|
|
120
|
+
names = survivor[:covered].map { |id| test_names[id] }.compact
|
|
121
|
+
return [] if names.empty?
|
|
122
|
+
|
|
123
|
+
rest = " (+#{names.size - 1} more)" if names.size >= 2
|
|
124
|
+
["covered by #{names.first}#{rest} - ran, still passed"]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_names
|
|
128
|
+
@test_names ||= @report.fetch("testFiles", {}).values
|
|
129
|
+
.flat_map { |file| file.fetch("tests", []) }
|
|
130
|
+
.to_h { |test| [test["id"], test["name"]] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def hint_line(survivor, label)
|
|
134
|
+
return [] unless label
|
|
135
|
+
|
|
136
|
+
text = Reporter::SurvivorHints.for_label(survivor[:operator], label: label)
|
|
137
|
+
text ? ["hint: #{text}"] : []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A prefix names one survivor or none: an ambiguous one is refused
|
|
141
|
+
# with the candidates, never resolved to the first match.
|
|
142
|
+
def unique_survivor(digest)
|
|
143
|
+
match, *others = all_survivors.select { |s| s[:digest].start_with?(digest) }
|
|
144
|
+
raise UsageError, "no survivor with digest #{digest} in this report" unless match
|
|
145
|
+
return match if others.empty?
|
|
146
|
+
|
|
147
|
+
digests = [match, *others].map { |m| m[:digest] }.sort
|
|
148
|
+
raise UsageError, "digest prefix #{digest} matches #{digests.size} survivors: " \
|
|
149
|
+
"#{digests.join(', ')}; use more characters"
|
|
150
|
+
end
|
|
151
|
+
|
|
93
152
|
def filtered(file, operator, triage)
|
|
94
153
|
survivors = all_survivors
|
|
95
154
|
survivors = survivors.select { |s| s[:path].include?(file) } if file
|
|
@@ -114,8 +173,13 @@ module Saboteur
|
|
|
114
173
|
end
|
|
115
174
|
|
|
116
175
|
def ignore_entry(survivor, subject)
|
|
117
|
-
|
|
118
|
-
|
|
176
|
+
block = if subject
|
|
177
|
+
SurvivorGuidance.ignore_block(survivor[:digest], subject)
|
|
178
|
+
else
|
|
179
|
+
SurvivorGuidance.ignore_block(survivor[:digest], "Klass#meth",
|
|
180
|
+
todo: "capped findings omit this digest - fill in the subject")
|
|
181
|
+
end
|
|
182
|
+
block.gsub("\n", "\n ")
|
|
119
183
|
end
|
|
120
184
|
|
|
121
185
|
def all_survivors
|
|
@@ -124,7 +188,7 @@ module Saboteur
|
|
|
124
188
|
entry.fetch("mutants", []).select { |m| m["status"] == "Survived" }.map do |m|
|
|
125
189
|
{ path: path, line: m.dig("location", "start", "line").to_i, operator: m["mutatorName"],
|
|
126
190
|
digest: m["id"], replacement: m["replacement"], classification: m["classification"],
|
|
127
|
-
covered: m["coveredBy"] || [], source: entry["source"] }
|
|
191
|
+
subject: m["subject"], covered: m["coveredBy"] || [], source: entry["source"] }
|
|
128
192
|
end
|
|
129
193
|
end
|
|
130
194
|
survivors.sort_by { |s| [s[:path], s[:line], s[:digest]] }
|
data/lib/saboteur/cli.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "cli/baseline_gate"
|
|
|
15
15
|
require_relative "preview"
|
|
16
16
|
require_relative "cli/run_reporting"
|
|
17
17
|
require_relative "cli/survivors_command"
|
|
18
|
+
require_relative "cli/explain_command"
|
|
18
19
|
require_relative "cli/source_set"
|
|
19
20
|
require_relative "cli/run_budget_options"
|
|
20
21
|
require_relative "cli/shard_options"
|
|
@@ -44,6 +45,7 @@ module Saboteur
|
|
|
44
45
|
include BaselineGate
|
|
45
46
|
include RunReporting
|
|
46
47
|
include SurvivorsCommand
|
|
48
|
+
include ExplainCommand
|
|
47
49
|
include SourceSet
|
|
48
50
|
include RunBudgetOptions
|
|
49
51
|
include ShardOptions
|
|
@@ -103,6 +105,7 @@ module Saboteur
|
|
|
103
105
|
when "run" then run_command
|
|
104
106
|
when "baseline" then baseline
|
|
105
107
|
when "survivors" then survivors_command
|
|
108
|
+
when "explain" then explain_command
|
|
106
109
|
when "doctor" then doctor_command
|
|
107
110
|
when "badge", "trend" then history_entry(command)
|
|
108
111
|
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -146,7 +146,7 @@ module Saboteur
|
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
from_file = File.file?(path) ? ConfigFile.load(path) : {}
|
|
149
|
-
new(from_file.merge(stringify(overrides)), root: root)
|
|
149
|
+
new(from_file.merge(stringify(overrides)), root: root, config_file_present: File.file?(path))
|
|
150
150
|
end
|
|
151
151
|
|
|
152
152
|
def self.stringify(hash)
|
|
@@ -156,11 +156,16 @@ module Saboteur
|
|
|
156
156
|
# Deprecations found while loading: printed by the CLI, never fatal.
|
|
157
157
|
attr_reader :deprecation_warnings
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
# Whether the config file existed at load (#1172): the CLI says once
|
|
160
|
+
# when a run falls back to defaults because no file was there.
|
|
161
|
+
attr_reader :config_file_present
|
|
162
|
+
|
|
163
|
+
def initialize(values = {}, root: Dir.pwd, explicit: nil, config_file_present: true)
|
|
160
164
|
# Retag the root losslessly: under a non-UTF-8 locale it arrives
|
|
161
165
|
# locale-tagged, and every glob, join and expand below would mix
|
|
162
166
|
# encodings with the UTF-8 patterns from the config file (#231).
|
|
163
167
|
@root = Text.utf8_identity(File.expand_path(root))
|
|
168
|
+
@config_file_present = config_file_present
|
|
164
169
|
unknown = values.keys - DEFAULTS.keys
|
|
165
170
|
raise ConfigError, "unknown config key(s): #{unknown.join(', ')}" if unknown.any?
|
|
166
171
|
|
|
@@ -38,8 +38,9 @@ module Saboteur
|
|
|
38
38
|
line = "saboteur: #{summary} - please report it: #{ISSUES_URL}"
|
|
39
39
|
written = write_log(log_path, error)
|
|
40
40
|
line = "#{line}; full backtrace: #{display_path(written)}" if written
|
|
41
|
-
|
|
42
|
-
err.puts(
|
|
41
|
+
palette = Reporter::Palette.for(err, mode: color)
|
|
42
|
+
err.puts(palette.paint(:fail, line))
|
|
43
|
+
err.puts(palette.paint(:fail, VERDICT_LINE))
|
|
43
44
|
err.puts(full_text(error)) if verbose
|
|
44
45
|
out.print(agent_json(summary)) if agent
|
|
45
46
|
EXIT_CRASH
|
data/lib/saboteur/doctor.rb
CHANGED
|
@@ -76,7 +76,14 @@ module Saboteur
|
|
|
76
76
|
# post-boot check re-reads the state without printing a second line.
|
|
77
77
|
def check_coverage
|
|
78
78
|
state = Coverage.state
|
|
79
|
-
|
|
79
|
+
# "suspended" alone reads as a finding; it is the expected boot state
|
|
80
|
+
# - coverage is stopped while the app boots and starts for the map
|
|
81
|
+
# build (#1176). Any other state stays terse: verify_coverage raises.
|
|
82
|
+
if state == :suspended
|
|
83
|
+
@out.puts "Coverage: suspended during boot (saboteur starts it for the coverage map)"
|
|
84
|
+
else
|
|
85
|
+
@out.puts "Coverage: #{state}"
|
|
86
|
+
end
|
|
80
87
|
verify_coverage(state)
|
|
81
88
|
end
|
|
82
89
|
|
|
@@ -195,7 +202,9 @@ module Saboteur
|
|
|
195
202
|
@out.puts "#{key}: #{file} (found)"
|
|
196
203
|
end
|
|
197
204
|
end
|
|
198
|
-
@config.unmatched_patterns.each
|
|
205
|
+
@config.unmatched_patterns.each do |pattern|
|
|
206
|
+
@out.puts "Warning: #{pattern} matches no file - point include at your code in config/saboteur.rb"
|
|
207
|
+
end
|
|
199
208
|
end
|
|
200
209
|
end
|
|
201
210
|
end
|
data/lib/saboteur/engine/boot.rb
CHANGED
|
@@ -48,7 +48,8 @@ module Saboteur
|
|
|
48
48
|
return if ::SimpleCov.singleton_class.include?(SimpleCovOff)
|
|
49
49
|
|
|
50
50
|
::SimpleCov.singleton_class.prepend(SimpleCovOff)
|
|
51
|
-
|
|
51
|
+
notice = "saboteur: SimpleCov.start ignored for this run (saboteur needs Ruby's Coverage itself)"
|
|
52
|
+
warn Saboteur::Reporter::Palette.for($stderr).paint(:warn, notice)
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
# The Rails profile when rails: true, else nil (set by load_target).
|
|
@@ -74,7 +75,8 @@ module Saboteur
|
|
|
74
75
|
# Running outside the bundle or beside an Appraisal gemfile set
|
|
75
76
|
# loads the wrong gems; say so before the spec load fails in
|
|
76
77
|
# confusing ways (#598, #609). Advisory only.
|
|
77
|
-
|
|
78
|
+
hints = Saboteur::Reporter::Palette.for($stderr)
|
|
79
|
+
EnvironmentHints.all(@root).each { |hint| warn hints.paint(:warn, "saboteur: warning: #{hint}") }
|
|
78
80
|
integration.setup
|
|
79
81
|
rails&.after_boot
|
|
80
82
|
@hooks.run(:after_boot)
|
|
@@ -16,6 +16,9 @@ module Saboteur
|
|
|
16
16
|
include CoverageParts
|
|
17
17
|
|
|
18
18
|
CACHE_INPUTS = %w[integration require rspec_args rails tests coverage_recorder].freeze
|
|
19
|
+
# Names the freshest whole-suite map build; read as an untrusted hint:
|
|
20
|
+
# only a bare 64-hex key joins the cache dir (#427).
|
|
21
|
+
WHOLE_SUITE_POINTER = "whole-suite-map"
|
|
19
22
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
20
23
|
LOG = "coverage-build.log"
|
|
21
24
|
# RSpec rewrites this on every run (example_status_persistence_file_path),
|
|
@@ -31,7 +34,7 @@ module Saboteur
|
|
|
31
34
|
TAIL_LINE_WIDTH = 300
|
|
32
35
|
|
|
33
36
|
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {}, load_methods: {},
|
|
34
|
-
timeout_cap: nil, parts: 1)
|
|
37
|
+
timeout_cap: nil, parts: 1, whole_suite_run: false)
|
|
35
38
|
@config = config
|
|
36
39
|
@parts = parts
|
|
37
40
|
@integration = integration
|
|
@@ -41,6 +44,7 @@ module Saboteur
|
|
|
41
44
|
@timeout_cap = timeout_cap
|
|
42
45
|
@load_lines = load_lines
|
|
43
46
|
@load_methods = load_methods
|
|
47
|
+
@whole_suite_run = whole_suite_run
|
|
44
48
|
@root = File.realpath(config.root)
|
|
45
49
|
@root_prefixes = Selection::CoverageMap.root_prefixes(config.root)
|
|
46
50
|
end
|
|
@@ -58,14 +62,14 @@ module Saboteur
|
|
|
58
62
|
path = cache_path(files)
|
|
59
63
|
preloaded = LoadCoverage.preloaded_uninstrumented(files)
|
|
60
64
|
cached = cached_map(files)
|
|
61
|
-
return
|
|
65
|
+
return serve_cached(path, cached, files, preloaded) if cached
|
|
62
66
|
|
|
63
67
|
note = miss_note(files)
|
|
64
68
|
@diagnostics.inform(note.message(scoped: !@config["tests"].empty?)) if @config["cache"]
|
|
65
69
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
70
|
map = fresh_map(files, note, preloaded)
|
|
67
71
|
build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
68
|
-
|
|
72
|
+
persist_map(path, map)
|
|
69
73
|
shadow(files, map, note, build_seconds) if shadow?
|
|
70
74
|
note.save
|
|
71
75
|
File.write(File.join(@config.path("cache_dir"), "preview.json"),
|
|
@@ -73,6 +77,31 @@ module Saboteur
|
|
|
73
77
|
map
|
|
74
78
|
end
|
|
75
79
|
|
|
80
|
+
# The map a whole-suite coverage build wrote on this tree, or nil.
|
|
81
|
+
# Survivor presentation consults it (presentation only, never
|
|
82
|
+
# selection): in a scoped run the run's own map holds only the specs
|
|
83
|
+
# the run loaded, so a "covered by" list from it can read as complete
|
|
84
|
+
# when another spec pins the behavior (false survivors by default,
|
|
85
|
+
# review C1). A full run keys its map with tests: [], so the lookup
|
|
86
|
+
# overrides a scoped run's --test value.
|
|
87
|
+
# The map this store's fetch produced (or served from cache): the
|
|
88
|
+
# run's own selection map, read by survivor presentation (review C1).
|
|
89
|
+
attr_reader :fetched_map
|
|
90
|
+
|
|
91
|
+
def whole_suite_map
|
|
92
|
+
return nil unless @config["cache"]
|
|
93
|
+
|
|
94
|
+
pointer = File.join(@config.path("cache_dir"), WHOLE_SUITE_POINTER)
|
|
95
|
+
return nil unless File.file?(pointer)
|
|
96
|
+
|
|
97
|
+
key = File.read(pointer, encoding: Encoding::UTF_8).strip
|
|
98
|
+
return nil unless key.match?(/\A[0-9a-f]{64}\z/)
|
|
99
|
+
|
|
100
|
+
Selection::CoverageMap.load(File.join(@config.path("cache_dir"), "coverage-#{key}.json"))
|
|
101
|
+
rescue SystemCallError, EncodingError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
76
105
|
private
|
|
77
106
|
|
|
78
107
|
# The partial map when it is on and safe, else the full build.
|
|
@@ -106,6 +135,28 @@ module Saboteur
|
|
|
106
135
|
@cached_map ||= Selection::CoverageMap.load(cache_path(files))
|
|
107
136
|
end
|
|
108
137
|
|
|
138
|
+
def serve_cached(path, cached, files, preloaded)
|
|
139
|
+
record_whole_suite_map(path)
|
|
140
|
+
refuse_empty_map(@fetched_map = cached.with_preloaded(preloaded), files, preloaded)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def persist_map(path, map)
|
|
144
|
+
map.save(path)
|
|
145
|
+
record_whole_suite_map(path)
|
|
146
|
+
@fetched_map = map
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The engine tells us when this run is a whole-suite build (no
|
|
150
|
+
# --since, no subjects, no explicit --test): remember the map key for
|
|
151
|
+
# later scoped runs, whose tree has usually drifted past the exact
|
|
152
|
+
# cache key already (review C1).
|
|
153
|
+
def record_whole_suite_map(path)
|
|
154
|
+
return unless @whole_suite_run
|
|
155
|
+
|
|
156
|
+
File.write(File.join(@config.path("cache_dir"), WHOLE_SUITE_POINTER),
|
|
157
|
+
File.basename(path, ".json").delete_prefix("coverage-"), perm: 0o600)
|
|
158
|
+
end
|
|
159
|
+
|
|
109
160
|
def cache_key(files)
|
|
110
161
|
Selection::CoverageMap.cache_key(files: key_files(files), extra: key_inputs)
|
|
111
162
|
end
|
|
@@ -110,7 +110,11 @@ module Saboteur
|
|
|
110
110
|
# scheduler stopped mid-list at the budget deadline. Worker-level error
|
|
111
111
|
# rows synthesize an error per candidate, so they never look truncated.
|
|
112
112
|
def unverified_result?(result)
|
|
113
|
-
result.is_a?(MutantResult) && result.status == :unverified
|
|
113
|
+
result.is_a?(MutantResult) && result.status == :unverified && !result.flaky?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def budget_unverified_row?(result)
|
|
117
|
+
result["status"] == "unverified" && !MutantResult.flaky_reason?(result["reason"])
|
|
114
118
|
end
|
|
115
119
|
|
|
116
120
|
def record_unreached(rows, subjects, lists)
|
|
@@ -125,7 +129,7 @@ module Saboteur
|
|
|
125
129
|
next unless row["results"] && !row["error"]
|
|
126
130
|
|
|
127
131
|
@truncated << [subjects[i], lists[i].size - row["results"].size] if row["results"].size < lists[i].size
|
|
128
|
-
@unverified << subjects[i] if row["results"].any? { |result| result
|
|
132
|
+
@unverified << subjects[i] if row["results"].any? { |result| budget_unverified_row?(result) }
|
|
129
133
|
end
|
|
130
134
|
end
|
|
131
135
|
|
|
@@ -24,7 +24,9 @@ module Saboteur
|
|
|
24
24
|
mutate(subjects, files, integration, scope, ignore_list,
|
|
25
25
|
load_lines:, load_methods:)
|
|
26
26
|
end
|
|
27
|
-
|
|
27
|
+
warnings = ignore_list.apply(results, subjects: subjects,
|
|
28
|
+
unscoped_digests: @unscoped_digests, full_run: unscoped_full_run?)
|
|
29
|
+
@diagnostics.add_warnings(warnings)
|
|
28
30
|
build_report(results, subjects, scope, started)
|
|
29
31
|
ensure
|
|
30
32
|
# Cleanup runs however the call ends - a partway boot failure, no
|
|
@@ -54,7 +56,9 @@ module Saboteur
|
|
|
54
56
|
files = config.included_files
|
|
55
57
|
subjects, scope = select_subjects(files)
|
|
56
58
|
results = subjects.empty? ? [] : mutate_external(subjects, scope, ignore_list)
|
|
57
|
-
|
|
59
|
+
warnings = ignore_list.apply(results, subjects: subjects,
|
|
60
|
+
unscoped_digests: @unscoped_digests, full_run: unscoped_full_run?)
|
|
61
|
+
@diagnostics.add_warnings(warnings)
|
|
58
62
|
build_report(results, subjects, scope, started)
|
|
59
63
|
end
|
|
60
64
|
|
|
@@ -97,6 +101,14 @@ module Saboteur
|
|
|
97
101
|
@diagnostics.warn(warning) if warning
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
# "Full run" for ignore staleness (#1206): no --since diff, no --subject
|
|
105
|
+
# filter, and no narrowed include (explicit file arguments replace the
|
|
106
|
+
# configured include) - only then was every in-scope subject examined, so
|
|
107
|
+
# an entry whose subject never appears really points at gone code.
|
|
108
|
+
def unscoped_full_run?
|
|
109
|
+
!config["since"] && config["subjects"].empty? && config["include"] == Config::DEFAULTS["include"]
|
|
110
|
+
end
|
|
111
|
+
|
|
100
112
|
def external_command? = !config["test_command"].to_s.empty?
|
|
101
113
|
|
|
102
114
|
# The preflight's absolute monotonic deadline: its own
|
|
@@ -16,7 +16,8 @@ module Saboteur
|
|
|
16
16
|
# same: the method's source, the whole file the method lives in (so a
|
|
17
17
|
# constant or helper changed elsewhere in that file is seen), the mutant,
|
|
18
18
|
# the set of covering tests, the content of those tests' files, the
|
|
19
|
-
# lockfile, the run's inputs (config, saboteur version
|
|
19
|
+
# lockfile, the run's inputs (config, the saboteur version, the Ruby
|
|
20
|
+
# runtime's engine, version, patchlevel and platform), and every source
|
|
20
21
|
# file a covering test touched (a method it calls, a constant defined
|
|
21
22
|
# elsewhere - #197). Only killed, survived and timeout are stored;
|
|
22
23
|
# errors and skips always run again.
|
|
@@ -162,7 +163,8 @@ module Saboteur
|
|
|
162
163
|
|
|
163
164
|
def fingerprint(subject, tests)
|
|
164
165
|
digest = Digest::SHA256.new
|
|
165
|
-
digest << [VERSION,
|
|
166
|
+
digest << [VERSION, RUBY_ENGINE, RUBY_VERSION, RUBY_PATCHLEVEL, RUBY_PLATFORM, @inputs, @suite_digest,
|
|
167
|
+
source_set, subject.source].join("\0") << "\0"
|
|
166
168
|
# A hex digest has a fixed width, so it needs no separator after it.
|
|
167
169
|
digest << Digest::SHA256.hexdigest(subject.file_source)
|
|
168
170
|
digest_tests(digest, tests)
|