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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +2 -2
  4. data/lib/saboteur/baseline/command.rb +46 -2
  5. data/lib/saboteur/baseline/comparison.rb +6 -1
  6. data/lib/saboteur/baseline/completeness.rb +21 -0
  7. data/lib/saboteur/baseline/evidence.rb +11 -3
  8. data/lib/saboteur/baseline/loader.rb +18 -5
  9. data/lib/saboteur/baseline/manifest.rb +13 -4
  10. data/lib/saboteur/baseline/run_evidence.rb +3 -1
  11. data/lib/saboteur/baseline/workflow.rb +57 -9
  12. data/lib/saboteur/baseline_health.rb +4 -1
  13. data/lib/saboteur/cli/dirty_tree_notice.rb +7 -1
  14. data/lib/saboteur/cli/doctor_command.rb +10 -1
  15. data/lib/saboteur/cli/exit_status.rb +10 -5
  16. data/lib/saboteur/cli/explain_command.rb +51 -0
  17. data/lib/saboteur/cli/explicit_sources.rb +1 -1
  18. data/lib/saboteur/cli/failure_output.rb +2 -2
  19. data/lib/saboteur/cli/flaky_exit.rb +23 -0
  20. data/lib/saboteur/cli/presets.rb +11 -1
  21. data/lib/saboteur/cli/run_command.rb +17 -1
  22. data/lib/saboteur/cli/run_output.rb +10 -0
  23. data/lib/saboteur/cli/run_reporting.rb +13 -2
  24. data/lib/saboteur/cli/survivors_command.rb +82 -18
  25. data/lib/saboteur/cli.rb +3 -0
  26. data/lib/saboteur/config.rb +7 -2
  27. data/lib/saboteur/crash_report.rb +3 -2
  28. data/lib/saboteur/doctor.rb +11 -2
  29. data/lib/saboteur/engine/boot.rb +4 -2
  30. data/lib/saboteur/engine/coverage_store.rb +54 -3
  31. data/lib/saboteur/engine/distribution.rb +6 -2
  32. data/lib/saboteur/engine/pipelines.rb +14 -2
  33. data/lib/saboteur/engine/result_cache.rb +4 -2
  34. data/lib/saboteur/engine/scheduler.rb +23 -21
  35. data/lib/saboteur/engine/selection_announcement.rb +5 -4
  36. data/lib/saboteur/engine/subject_selection.rb +11 -1
  37. data/lib/saboteur/engine/survivor_confirmation.rb +1 -1
  38. data/lib/saboteur/engine/test_guess.rb +1 -1
  39. data/lib/saboteur/engine/thread_warnings.rb +9 -4
  40. data/lib/saboteur/engine.rb +57 -9
  41. data/lib/saboteur/execution/fiddle_loader.rb +29 -2
  42. data/lib/saboteur/help.rb +13 -1
  43. data/lib/saboteur/ignore_list.rb +36 -3
  44. data/lib/saboteur/install.rb +18 -3
  45. data/lib/saboteur/mutation_source/builtin.rb +1 -0
  46. data/lib/saboteur/mutation_source/operator_explain.rb +181 -0
  47. data/lib/saboteur/preview.rb +6 -1
  48. data/lib/saboteur/reporter/agent_commands.rb +29 -7
  49. data/lib/saboteur/reporter/agent_incomplete.rb +9 -2
  50. data/lib/saboteur/reporter/agent_summary.rb +2 -1
  51. data/lib/saboteur/reporter/baseline_tail.rb +9 -7
  52. data/lib/saboteur/reporter/gap_kinds.rb +80 -0
  53. data/lib/saboteur/reporter/html.rb +160 -10
  54. data/lib/saboteur/reporter/next_steps.rb +9 -7
  55. data/lib/saboteur/reporter/notice.rb +24 -0
  56. data/lib/saboteur/reporter/reuse_note.rb +37 -0
  57. data/lib/saboteur/reporter/review_comments.rb +17 -12
  58. data/lib/saboteur/reporter/score_table.rb +8 -7
  59. data/lib/saboteur/reporter/stryker.rb +11 -1
  60. data/lib/saboteur/reporter/survivor_classification.rb +26 -8
  61. data/lib/saboteur/reporter/survivor_hints.rb +3 -2
  62. data/lib/saboteur/reporter/survivor_list.rb +58 -4
  63. data/lib/saboteur/reporter/survivor_ranking.rb +13 -5
  64. data/lib/saboteur/reporter/terminal.rb +32 -33
  65. data/lib/saboteur/reporter/wording.rb +37 -0
  66. data/lib/saboteur/result.rb +39 -8
  67. data/lib/saboteur/selection/git_diff.rb +19 -1
  68. data/lib/saboteur/survivor_guidance.rb +33 -0
  69. data/lib/saboteur/text.rb +6 -0
  70. data/lib/saboteur/version.rb +1 -1
  71. 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
@@ -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
- PRESETS.key?(command) ? preset(command) : usage_error
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
- config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
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 missing_test)")
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 or raise UsageError,
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
- raise UsageError, "report not found: #{path}" unless File.file?(path)
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 = all_survivors.find { |s| s[:digest].start_with?(digest) } ||
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
- "#{survivor[:path]}:#{survivor[:line]} #{survivor[:operator]} #{survivor[:digest]}",
79
- "",
80
- "- #{finding['original'] || original_line(survivor)}",
81
- "+ #{finding['replacement'] || survivor[:replacement]}",
86
+ *detail_lines(survivor, finding),
82
87
  "",
83
- "Replay exactly this mutant:",
84
- " bundle exec saboteur run --id #{survivor[:digest]}",
88
+ "replay one survivor: #{SurvivorGuidance.replay_command(survivor[:digest], seed: seed)}",
89
+ " #{SurvivorGuidance::REPLAY_CAVEAT}",
85
90
  "",
86
- "Ignore-file entry (config/saboteur_ignore.rb):",
87
- *ignore_entry(survivor, subject)
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
- note = subject ? subject.inspect : '"Klass#meth" # TODO: capped findings omit this digest - fill it in'
118
- [%( i.mutation "#{survivor[:digest]}",), " subject: #{note},", %( reason: "...")]
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
@@ -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
- def initialize(values = {}, root: Dir.pwd, explicit: nil)
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
- err.puts(line)
42
- err.puts(Reporter::Palette.for(err, mode: color).paint(:fail, VERDICT_LINE))
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
@@ -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
- @out.puts "Coverage: #{state}"
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 { |pattern| @out.puts "Warning: #{pattern} matches no file" }
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
@@ -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
- warn "saboteur: SimpleCov.start ignored for this run (saboteur needs Ruby's Coverage itself)"
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
- EnvironmentHints.all(@root).each { |hint| warn "saboteur: warning: #{hint}" }
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 refuse_empty_map(cached.with_preloaded(preloaded), files, preloaded) if cached
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
- map.save(path)
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["status"] == "unverified" }
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
- @diagnostics.add_warnings(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
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
- @diagnostics.add_warnings(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
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), and every source
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, @inputs, @suite_digest, source_set, subject.source].join("\0") << "\0"
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)