saboteur 0.11.1 → 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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +1 -1
  4. data/lib/saboteur/cli/exit_status.rb +6 -2
  5. data/lib/saboteur/cli/explain_command.rb +51 -0
  6. data/lib/saboteur/cli/flaky_exit.rb +23 -0
  7. data/lib/saboteur/cli/run_command.rb +13 -0
  8. data/lib/saboteur/cli/run_output.rb +10 -0
  9. data/lib/saboteur/cli/run_reporting.rb +13 -2
  10. data/lib/saboteur/cli/survivors_command.rb +1 -1
  11. data/lib/saboteur/cli.rb +3 -0
  12. data/lib/saboteur/config.rb +7 -2
  13. data/lib/saboteur/doctor.rb +8 -1
  14. data/lib/saboteur/engine/coverage_store.rb +54 -3
  15. data/lib/saboteur/engine/distribution.rb +6 -2
  16. data/lib/saboteur/engine/pipelines.rb +14 -2
  17. data/lib/saboteur/engine/scheduler.rb +1 -1
  18. data/lib/saboteur/engine/selection_announcement.rb +5 -4
  19. data/lib/saboteur/engine/subject_selection.rb +10 -0
  20. data/lib/saboteur/engine.rb +35 -5
  21. data/lib/saboteur/help.rb +11 -0
  22. data/lib/saboteur/ignore_list.rb +22 -2
  23. data/lib/saboteur/mutation_source/builtin.rb +1 -0
  24. data/lib/saboteur/mutation_source/operator_explain.rb +181 -0
  25. data/lib/saboteur/reporter/agent_commands.rb +23 -6
  26. data/lib/saboteur/reporter/agent_incomplete.rb +9 -2
  27. data/lib/saboteur/reporter/agent_summary.rb +2 -1
  28. data/lib/saboteur/reporter/gap_kinds.rb +80 -0
  29. data/lib/saboteur/reporter/html.rb +19 -4
  30. data/lib/saboteur/reporter/review_comments.rb +6 -5
  31. data/lib/saboteur/reporter/score_table.rb +8 -7
  32. data/lib/saboteur/reporter/survivor_classification.rb +26 -8
  33. data/lib/saboteur/reporter/survivor_hints.rb +3 -2
  34. data/lib/saboteur/reporter/survivor_list.rb +53 -3
  35. data/lib/saboteur/reporter/terminal.rb +27 -29
  36. data/lib/saboteur/reporter/wording.rb +37 -0
  37. data/lib/saboteur/result.rb +10 -0
  38. data/lib/saboteur/text.rb +6 -0
  39. data/lib/saboteur/version.rb +1 -1
  40. metadata +6 -1
@@ -19,6 +19,11 @@ module Saboteur
19
19
  # --all-mutants gives every survivor its own block. Accepted
20
20
  # (ignored) survivors get one quoted dim line each, never a block.
21
21
  class SurvivorList
22
+ # Default when a caller never wires the whole-suite lookup (unit pins,
23
+ # other renderers): keep the historical bare covered-by line. Terminal
24
+ # always passes the kwarg - nil there means the lookup ran and found
25
+ # no cached whole-suite map (review C1).
26
+ NO_WHOLE_SUITE_LOOKUP = Object.new
22
27
  # Rendered rows in the method fence, elision markers included.
23
28
  FENCE_CAP = 10
24
29
 
@@ -28,11 +33,14 @@ module Saboteur
28
33
  # Sibling digests named on the collapse line; the rest are a count.
29
34
  SIBLING_DIGEST_CAP = 3
30
35
 
31
- def initialize(out, palette, all_mutants: false, classifier: SurvivorClassification)
36
+ def initialize(out, palette, all_mutants: false, classifier: SurvivorClassification,
37
+ whole_suite_map: NO_WHOLE_SUITE_LOOKUP, run_map: nil)
32
38
  @out = out
33
39
  @palette = palette
34
40
  @all_mutants = all_mutants
35
41
  @classifier = classifier
42
+ @whole_suite_map = whole_suite_map
43
+ @run_map = run_map
36
44
  end
37
45
 
38
46
  # Survivors in report (ranked) order; accepted ones trail as dim lines.
@@ -41,7 +49,8 @@ module Saboteur
41
49
 
42
50
  live, accepted = survivors.partition(&:unignored_survivor?)
43
51
  @out.puts @palette.paint(:head, "Survivors (#{live.size}) - changes your tests did not catch")
44
- groups(live).each_with_index { |group, index| block(report, group, index + 1, live.size) }
52
+ block_list = groups(live)
53
+ block_list.each_with_index { |group, index| block(report, group, index + 1, block_list.size) }
45
54
  accepted_section(report, accepted)
46
55
  hinted = live.select { |result| hint_for(result) }
47
56
  @out.puts "\n#{@palette.paint(:dim, SurvivorHints::CAVEAT)}" if hinted.any?
@@ -72,9 +81,12 @@ module Saboteur
72
81
  siblings(report, group)
73
82
  end
74
83
 
84
+ # The count names blocks, the unit the leading ordinal uses: grouped
85
+ # siblings read "#3 of 14 blocks", never blocks against mutants (#1147).
75
86
  def label_suffix(result, rank, total)
76
87
  label = @classifier.label(result)
77
- parts = [("coarse" if result.mutation.coarse), label, "##{rank} of #{total}"].compact
88
+ unit = total == 1 ? "block" : "blocks"
89
+ parts = [("coarse" if result.mutation.coarse), label, "##{rank} of #{total} #{unit}"].compact
78
90
  reason = SurvivorHints.label_reason(label)
79
91
  "[#{parts.join(" \u00B7 ")}#{": #{reason}" if reason}]"
80
92
  end
@@ -178,8 +190,38 @@ module Saboteur
178
190
 
179
191
  # The covering tests are a group union (#502): the first names where an
180
192
  # assertion would pin the change, the count says the rest also passed.
193
+ #
194
+ # The list comes from the run's own map, which a scoped run narrows to
195
+ # the specs it loaded. The cached whole-suite map (when one exists)
196
+ # tells the rest of the story (review C1): a covering spec the run never
197
+ # loaded is named as not run here, and a spec pinning the mutant's exact
198
+ # line marks the block as likely covered elsewhere, instead of reading
199
+ # as a bare finding. With no whole-suite map, say so rather than
200
+ # implying the list is complete. A whole-project run's own map IS the
201
+ # whole-suite map, so its line is unchanged.
181
202
  def covered(group)
182
203
  tests = group.flat_map(&:covered_by).uniq
204
+ return legacy_covered(group, tests) if @whole_suite_map.equal?(NO_WHOLE_SUITE_LOOKUP)
205
+
206
+ whole = @whole_suite_map&.tests_for(group.first.subject)
207
+ if whole.nil?
208
+ @out.puts @palette.paint(:dim, " covering tests unknown - run a full coverage build")
209
+ return
210
+ end
211
+
212
+ run_known = @run_map ? @run_map.tests_for(group.first.subject) : tests
213
+ not_run = whole - run_known
214
+ return legacy_covered(group, tests) if not_run.empty?
215
+
216
+ @out.puts " covered by #{whole.size} #{whole.one? ? 'spec' : 'specs'}, " \
217
+ "incl. #{not_run.first} (not run here)"
218
+ exact = exact_line_tests(group.first) - run_known
219
+ return if exact.empty?
220
+
221
+ @out.puts @palette.paint(:dim, " likely covered elsewhere: #{exact.first} pins this line (not run here)")
222
+ end
223
+
224
+ def legacy_covered(group, tests)
183
225
  return if tests.empty?
184
226
 
185
227
  first = group.find { |result| result.covered_by.include?(tests.first) }
@@ -188,6 +230,14 @@ module Saboteur
188
230
  @out.puts @palette.paint(:dim, " #{line}") if line
189
231
  end
190
232
 
233
+ # Tests pinning the mutant's exact edit lines in the whole-suite map,
234
+ # on the same line basis the scheduler used to select the run's tests:
235
+ # the strongest "already pinned elsewhere" evidence a map can give.
236
+ def exact_line_tests(result)
237
+ lines = result.mutation.edits.flat_map { |edit| result.subject.coverage_lines_for(edit) }.uniq
238
+ @whole_suite_map.tests_for_edit(result.subject, lines)
239
+ end
240
+
191
241
  # The verdict names what this run did: executed is nil when the run
192
242
  # did not record which tests ran, so only the bare verdict is safe.
193
243
  def more_covered_line(group, tests)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "safe_out"
5
+ require_relative "wording"
5
6
  require_relative "baseline_tail"
6
7
  require_relative "next_steps"
7
8
  require_relative "kill_causes"
@@ -27,17 +28,19 @@ module Saboteur
27
28
  def initialize(out, verbose: false, quiet: false, color: :auto, overrides: nil,
28
29
  replay_command: nil, all_mutants: false, exit_code: nil,
29
30
  threshold: nil, previous_score: nil, baseline_gate: nil,
30
- scope: "whole project", html_report: nil, baseline: nil, coverage: nil, mutant_phase: nil)
31
+ scope: "whole project", baseline: nil, coverage: nil, mutant_phase: nil,
32
+ whole_suite_map: SurvivorList::NO_WHOLE_SUITE_LOOKUP, run_map: nil)
31
33
  @out = SafeOut.new(out)
32
34
  @all_mutants = all_mutants
33
35
  @verbose = verbose
34
36
  @quiet = quiet
35
- # Clean-screen context: scope and report path from the CLI; PR4a plumbs the phase stats.
37
+ # Clean-screen context: scope from the CLI; PR4a plumbs the phase stats.
36
38
  @scope = scope
37
- @html_report = html_report
38
39
  @baseline = baseline
39
40
  @coverage = coverage
40
41
  @mutant_phase = mutant_phase
42
+ @whole_suite_map = whole_suite_map
43
+ @run_map = run_map
41
44
  @palette = Palette.for(out, mode: color, overrides: overrides)
42
45
  @score_table = ScoreTable.new(@out, @palette)
43
46
  @next_steps = NextSteps.new(@out, @palette, replay_command: replay_command)
@@ -143,9 +146,9 @@ module Saboteur
143
146
  # numbers so "4 mutants" is not read as the whole plan (#582).
144
147
  def mutant_counts(report)
145
148
  planned = report.early_stop&.planned_mutants
146
- return "#{report.results.size} mutants" unless planned && planned > report.results.size
149
+ return Text.counted(report.results.size, "mutant") unless planned && planned > report.results.size
147
150
 
148
- "#{planned} mutants selected, #{report.results.size} attempted"
151
+ "#{Text.counted(planned, 'mutant')} selected, #{report.results.size} attempted"
149
152
  end
150
153
 
151
154
  def summary(report)
@@ -153,7 +156,7 @@ module Saboteur
153
156
  return clean_screen(report) if !@baseline_gate && clean_run?(report)
154
157
 
155
158
  seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
156
- tally = "saboteur #{VERSION}: #{report.subjects.size} subjects, #{mutant_counts(report)}, " \
159
+ tally = "saboteur #{VERSION}: #{Text.counted(report.subjects.size, 'subject')}, #{mutant_counts(report)}, " \
157
160
  "wall #{format('%.2f', report.wall_time)} s#{seed}"
158
161
  @out.puts @palette.paint(:dim, tally)
159
162
  @out.puts report.shard.line if report.shard
@@ -185,9 +188,11 @@ module Saboteur
185
188
  total = @palette.paint(:dim, "#{middot} #{format('%.1f', report.wall_time)}s total")
186
189
  @out.puts "#{@palette.paint(:pass, win)} #{total}"
187
190
  untested = untested_subjects(report)
188
- @out.puts " #{@palette.paint(:warn, untested_win_note(untested))}" if untested.any?
191
+ @out.puts " #{@palette.paint(:warn, Wording.untested_win_note(untested))}" if untested.any?
189
192
  ReuseNote.new(@out).print(report)
190
- clean_reports_line
193
+ # The footer owns the report paths (#1187); the clean screen's own
194
+ # hint is the follow-up run a whole-project win suggests.
195
+ @out.puts " #{@palette.paint(:dim, 'next: saboteur run --since origin/main')}" if @scope == "whole project"
191
196
  end
192
197
 
193
198
  # The phase lines and the progress-dots line print only when their data
@@ -209,19 +214,6 @@ module Saboteur
209
214
  "#{report.counts[:killed]}/#{report.executed.size} mutants killed"
210
215
  end
211
216
 
212
- def untested_win_note(untested)
213
- ids = untested.map(&:id).join(", ")
214
- "#{untested.size} subject#{'s' unless untested.one?} not scored - no tests exist: #{ids}"
215
- end
216
-
217
- def clean_reports_line
218
- return unless @html_report
219
-
220
- line = "reports: #{@html_report}"
221
- line += " #{middot} next: saboteur run --since origin/main" if @scope == "whole project"
222
- @out.puts " #{@palette.paint(:dim, line)}"
223
- end
224
-
225
217
  def hazard_suppressions(report)
226
218
  return unless report.hazard_suppressions.positive?
227
219
 
@@ -250,22 +242,26 @@ module Saboteur
250
242
  end
251
243
 
252
244
  line = "Score: #{painted_score(breakdown.value)}#{headline_context(breakdown.value)}"
253
- line << if breakdown.tested.zero?
254
- " - nothing tested"
255
- else
256
- " (#{breakdown.detected}/#{breakdown.tested} killed, tested subjects)"
257
- end
245
+ line << score_parenthetical(breakdown)
258
246
  if @verbose && breakdown.tested.positive?
259
247
  line << "#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
260
248
  "errors, resource limits and skips excluded"
261
249
  end
262
250
  if untested.any?
263
251
  line << " #{@palette.paint(:warn, "#{untested.size} subject#{'s' unless untested.one?} " \
264
- 'not scored - no tests exist')}"
252
+ "not scored - #{Wording.no_execution(untested.size)}")}"
265
253
  end
266
254
  @out.puts line
267
255
  end
268
256
 
257
+ # The share of tested mutants the run detected: "killed" alone would
258
+ # count timeouts too, so Wording names both when one is in the number.
259
+ def score_parenthetical(breakdown)
260
+ return " - nothing tested" if breakdown.tested.zero?
261
+
262
+ " (#{breakdown.detected}/#{breakdown.tested} #{Wording.detected_word(breakdown)}, tested subjects)"
263
+ end
264
+
269
265
  # What the score is judged against and where it came from: the
270
266
  # threshold it must meet, and the last score the history recorded.
271
267
  def headline_context(value)
@@ -307,7 +303,7 @@ module Saboteur
307
303
  return if untested.empty?
308
304
 
309
305
  @out.puts
310
- @out.puts @palette.paint(:warn, "Not scored - no tests exist (treat these as your biggest gaps):")
306
+ @out.puts @palette.paint(:warn, "Not scored - no test executes these (treat these as your biggest gaps):")
311
307
  entries = untested.map { |subject| "#{subject.id} (#{subject.location(report.root)})" }
312
308
  line = " #{@palette.paint(:warn, '!')} #{entries.first(MAX_NOT_SCORED).join(" #{middot} ")}"
313
309
  line << " #{middot} ... #{entries.size - MAX_NOT_SCORED} more" if entries.size > MAX_NOT_SCORED
@@ -597,7 +593,9 @@ module Saboteur
597
593
  end
598
594
 
599
595
  def survivors(report)
600
- SurvivorList.new(@out, @palette, all_mutants: @all_mutants).call(report, ranked_survivors(report))
596
+ SurvivorList.new(@out, @palette, all_mutants: @all_mutants,
597
+ whole_suite_map: @whole_suite_map, run_map: @run_map)
598
+ .call(report, ranked_survivors(report))
601
599
  @out.puts unless report.survivors.empty?
602
600
  end
603
601
 
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # One wording for the score parenthetical and the not-scored note, so
7
+ # the terminal screen and the HTML page never drift (#1145, #1146).
8
+ module Wording
9
+ module_function
10
+
11
+ # detected counts timeouts, the tally does not: name both when a
12
+ # timeout is in the number, or "killed" means two things in adjacent
13
+ # lines (#1146).
14
+ def detected_word(breakdown)
15
+ breakdown.timeouts.positive? ? "killed or timed out" : "killed"
16
+ end
17
+
18
+ # A subject with no coverage may have tests that stub it away: the
19
+ # honest claim is about execution, not existence (#1145).
20
+ def no_execution(untested_count)
21
+ untested_count == 1 ? "no test executes it" : "no test executes them"
22
+ end
23
+
24
+ # The score table's per-file note fragment: count, subject noun and
25
+ # the execution claim in one shape (#1145).
26
+ def untested_note(count)
27
+ "#{count} subject#{'s' unless count == 1} - #{no_execution(count)}"
28
+ end
29
+
30
+ # The clean screen's one-line note for subjects no run scored.
31
+ def untested_win_note(untested)
32
+ ids = untested.map(&:id).join(", ")
33
+ "#{untested.size} subject#{'s' unless untested.one?} not scored - #{no_execution(untested.size)}: #{ids}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -72,6 +72,16 @@ module Saboteur
72
72
  executed: executed, confirmation: confirmation)
73
73
  end
74
74
 
75
+ # The reason a --flaky-check disagreement starts with: unverified, but
76
+ # not because a budget ran out (#1167).
77
+ FLAKY_PREFIX = "flaky verdict"
78
+
79
+ def self.flaky_reason?(reason) = reason.to_s.start_with?(FLAKY_PREFIX)
80
+
81
+ # Unverified because the reordered confirmation run disagreed, not
82
+ # because --max-duration cut the check short.
83
+ def flaky? = status == :unverified && self.class.flaky_reason?(reason)
84
+
75
85
  # The same mutant, marked unverified: the budget ran out before its
76
86
  # verdict could be checked, so it must not count as detected (#296).
77
87
  def as_unverified(reason)
data/lib/saboteur/text.rb CHANGED
@@ -11,6 +11,12 @@ module Saboteur
11
11
  module Text
12
12
  module_function
13
13
 
14
+ # "1 subject" / "2 subjects": the count with the noun in the right
15
+ # number (review C9). An irregular plural passes explicitly.
16
+ def counted(count, singular, plural = "#{singular}s")
17
+ "#{count} #{count == 1 ? singular : plural}"
18
+ end
19
+
14
20
  # A list for a warning or note: the first five items, then "and N more".
15
21
  # One rule for every message that names files, subjects or specs (#332).
16
22
  # Items are repo-controlled text, so each is display_safe (#834).
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.11.1"
5
+ VERSION = "0.11.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 0.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
@@ -68,9 +68,11 @@ files:
68
68
  - lib/saboteur/cli/error_cause.rb
69
69
  - lib/saboteur/cli/execution_options.rb
70
70
  - lib/saboteur/cli/exit_status.rb
71
+ - lib/saboteur/cli/explain_command.rb
71
72
  - lib/saboteur/cli/explicit_file_options.rb
72
73
  - lib/saboteur/cli/explicit_sources.rb
73
74
  - lib/saboteur/cli/failure_output.rb
75
+ - lib/saboteur/cli/flaky_exit.rb
74
76
  - lib/saboteur/cli/help_command.rb
75
77
  - lib/saboteur/cli/help_options.rb
76
78
  - lib/saboteur/cli/history_command.rb
@@ -203,6 +205,7 @@ files:
203
205
  - lib/saboteur/mutation_source/operand_replacement.rb
204
206
  - lib/saboteur/mutation_source/operator_assignment.rb
205
207
  - lib/saboteur/mutation_source/operator_examples.rb
208
+ - lib/saboteur/mutation_source/operator_explain.rb
206
209
  - lib/saboteur/mutation_source/operator_registry.rb
207
210
  - lib/saboteur/mutation_source/operator_visit.rb
208
211
  - lib/saboteur/mutation_source/pair_removal.rb
@@ -224,6 +227,7 @@ files:
224
227
  - lib/saboteur/reporter/baseline_tail.rb
225
228
  - lib/saboteur/reporter/diff.rb
226
229
  - lib/saboteur/reporter/findings.rb
230
+ - lib/saboteur/reporter/gap_kinds.rb
227
231
  - lib/saboteur/reporter/history_paths.rb
228
232
  - lib/saboteur/reporter/history_policy.rb
229
233
  - lib/saboteur/reporter/history_reader.rb
@@ -247,6 +251,7 @@ files:
247
251
  - lib/saboteur/reporter/survivor_ranking.rb
248
252
  - lib/saboteur/reporter/terminal.rb
249
253
  - lib/saboteur/reporter/test_matrix.rb
254
+ - lib/saboteur/reporter/wording.rb
250
255
  - lib/saboteur/result.rb
251
256
  - lib/saboteur/score.rb
252
257
  - lib/saboteur/selection/coverage_map.rb