saboteur 0.12.0 → 0.12.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +16 -3
  4. data/lib/saboteur/baseline/command.rb +6 -1
  5. data/lib/saboteur/baseline/comparison.rb +8 -0
  6. data/lib/saboteur/baseline/completeness.rb +14 -4
  7. data/lib/saboteur/baseline/evidence.rb +8 -1
  8. data/lib/saboteur/baseline/run_evidence.rb +10 -1
  9. data/lib/saboteur/baseline/workflow.rb +17 -5
  10. data/lib/saboteur/cli/color_option.rb +26 -0
  11. data/lib/saboteur/cli/doctor_command.rb +1 -0
  12. data/lib/saboteur/cli/exit_status.rb +12 -7
  13. data/lib/saboteur/cli/explain_command.rb +2 -1
  14. data/lib/saboteur/cli/failure_output.rb +13 -2
  15. data/lib/saboteur/cli/help_command.rb +5 -0
  16. data/lib/saboteur/cli/history_command.rb +2 -0
  17. data/lib/saboteur/cli/output_options.rb +1 -5
  18. data/lib/saboteur/cli/run_command.rb +1 -1
  19. data/lib/saboteur/cli/run_reporting.rb +2 -10
  20. data/lib/saboteur/cli/run_validation.rb +0 -1
  21. data/lib/saboteur/cli/selection_options.rb +3 -2
  22. data/lib/saboteur/cli/survivors_command.rb +81 -24
  23. data/lib/saboteur/cli/whole_suite_map_lookup.rb +33 -0
  24. data/lib/saboteur/cli.rb +6 -2
  25. data/lib/saboteur/engine/coverage_store.rb +7 -4
  26. data/lib/saboteur/engine/diagnostics.rb +27 -5
  27. data/lib/saboteur/engine/pipelines.rb +9 -2
  28. data/lib/saboteur/engine/survivor_confirmation.rb +75 -11
  29. data/lib/saboteur/engine/test_guess.rb +2 -1
  30. data/lib/saboteur/engine.rb +37 -18
  31. data/lib/saboteur/execution/runner.rb +3 -0
  32. data/lib/saboteur/gates.rb +1 -1
  33. data/lib/saboteur/help.rb +5 -5
  34. data/lib/saboteur/integration/rspec.rb +30 -0
  35. data/lib/saboteur/reporter/default_findings.rb +1 -1
  36. data/lib/saboteur/reporter/findings.rb +10 -1
  37. data/lib/saboteur/reporter/gap_kinds.rb +6 -0
  38. data/lib/saboteur/reporter/html.rb +12 -1
  39. data/lib/saboteur/reporter/next_steps.rb +3 -1
  40. data/lib/saboteur/reporter/score_table.rb +6 -11
  41. data/lib/saboteur/reporter/stryker.rb +7 -2
  42. data/lib/saboteur/reporter/survivor_classification.rb +15 -0
  43. data/lib/saboteur/reporter/survivor_list.rb +6 -6
  44. data/lib/saboteur/reporter/terminal.rb +6 -3
  45. data/lib/saboteur/reporter/unconfirmed_section.rb +44 -0
  46. data/lib/saboteur/result.rb +39 -2
  47. data/lib/saboteur/selection/coverage_map.rb +11 -1
  48. data/lib/saboteur/version.rb +1 -1
  49. data/lib/saboteur.rb +4 -0
  50. metadata +4 -1
@@ -31,11 +31,6 @@ module Saboteur
31
31
  include GatedRun
32
32
  include SelectionAnnouncement
33
33
 
34
- # Confirmation only guards a partial coverage map, so it is silent unless
35
- # the user opted into partial_coverage = use (#1228).
36
- CONFIRMATION_SKIPPED = "survivor confirmation skipped: partial_coverage = use applies only to a cached " \
37
- "whole-suite run (no --test, one coverage part)"
38
-
39
34
  # Everything besides sources and tests that can change a verdict.
40
35
  VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
41
36
  timeout_factor timeout_min
@@ -46,10 +41,11 @@ module Saboteur
46
41
  attr_reader :guessed_tests
47
42
 
48
43
  def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [], verbose: false,
49
- palette: nil, quiet_start: true)
44
+ palette: nil, quiet_start: true, quiet: false)
50
45
  @config = config
51
46
  @hooks = Hooks.new
52
- @diagnostics = Diagnostics.new(err, verbose: verbose, palette: palette, quiet_start: quiet_start)
47
+ @diagnostics = Diagnostics.new(err, verbose: verbose, palette: palette, quiet_start: quiet_start,
48
+ quiet: quiet)
53
49
  @completed_baseline_passes = 0
54
50
  @baseline_elapsed = 0.0
55
51
  @root = File.realpath(config.root)
@@ -129,6 +125,12 @@ module Saboteur
129
125
  @coverage_store&.whole_suite_map
130
126
  end
131
127
 
128
+ # True when this run did not build a whole-suite map: a diff, named
129
+ # subjects, an explicit --test or inferred specs (#1286).
130
+ def scoped_selection?
131
+ !whole_suite_run?
132
+ end
133
+
132
134
  # False when caching is off, where no coverage build can ever leave a
133
135
  # whole-suite map to consult, so advising one would be wrong (review C1).
134
136
  def whole_suite_map_lookup?
@@ -291,15 +293,21 @@ module Saboteur
291
293
  confirm_survivors(run_candidates(subjects, lists, scheduler, cache, ignore_list), scheduler, cache, ignore_list)
292
294
  end
293
295
 
296
+ # Confirm each live survivor against the whole-suite coverage map (#1286):
297
+ # the covering specs the run's own selection never loaded get one budgeted
298
+ # run. Scoped runs (--since, --subject, pr) are the point, so nothing here
299
+ # depends on partial_coverage or on the run being unscoped. Without a
300
+ # whole-suite map there is nothing to confirm against; the run says so
301
+ # elsewhere (survivors unconfirmed: no whole-suite coverage map).
294
302
  def confirm_survivors(results, scheduler, cache, ignore_list)
295
- unless CoveragePartial.use?(config, config["coverage_parts"])
296
- live = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
297
- @diagnostics.inform(CONFIRMATION_SKIPPED) if live && config["partial_coverage"] == "use"
298
- return results
299
- end
303
+ return results if results.none? { |result| result.survived? && !ignore_list.accepts?(result) }
300
304
 
301
- SurvivorConfirmation.new(config: config, map: scheduler.map, scheduler: scheduler, diagnostics: @diagnostics,
302
- budget_exceeded: method(:budget_exceeded?), cache: cache)
305
+ whole = whole_suite_coverage_map
306
+ return results if whole.nil?
307
+
308
+ SurvivorConfirmation.new(config: config, map: whole, scheduler: scheduler, diagnostics: @diagnostics,
309
+ budget_exceeded: method(:budget_exceeded?), cache: cache,
310
+ load_specs: ->(files) { scheduler.runner.integration.load_spec_files(files) })
303
311
  .call(results, ignore_list: ignore_list, early_stop: @early_stop)
304
312
  end
305
313
 
@@ -349,10 +357,19 @@ module Saboteur
349
357
  # baseline's own chatter (demoted to --verbose) would start.
350
358
  def announce_quiet_start(store, files)
351
359
  @map_cached = store.reusable?(files)
352
- @diagnostics.quiet_start_header(scope: config["since"] ? "--since #{config['since']}" : "whole project",
360
+ @diagnostics.quiet_start_header(scope: quiet_scope,
353
361
  seed: config["seed"], operators: config.operator_policy["effective"].size)
354
362
  end
355
363
 
364
+ # Names what the run covers: the diff, the --subject filter, or the
365
+ # whole project (P6) - a --subject run is not "whole project".
366
+ def quiet_scope
367
+ parts = []
368
+ parts << "--since #{config['since']}" if config["since"]
369
+ parts << "--subject #{config['subjects'].join(', ')}" if config["subjects"].any?
370
+ parts.empty? ? "whole project" : parts.join(", ")
371
+ end
372
+
356
373
  def inform_about_run(subjects, map)
357
374
  if @diagnostics.quiet_start && !@diagnostics.verbose
358
375
  @diagnostics.quiet_baseline(passes: @completed_baseline_passes, elapsed: @baseline_elapsed,
@@ -366,11 +383,13 @@ module Saboteur
366
383
  end
367
384
 
368
385
  # This run's map is the whole-suite map only when nothing scoped it:
369
- # no --since, no subjects, and tests either unset or inferred over every
370
- # included file (never an explicit --test list) (review C1).
386
+ # no --since, no subjects, and every spec file loaded (--all or
387
+ # c.test_selection = :all). Inferred specs are a selection, never the
388
+ # whole suite: a spec with no matching source file is not in the map
389
+ # (#1286).
371
390
  def whole_suite_run?
372
391
  config["since"].nil? && config["subjects"].empty? &&
373
- (config["tests"].empty? || !@guessed_tests.nil?)
392
+ config["tests"].empty? && @guessed_tests.nil?
374
393
  end
375
394
 
376
395
  def coverage_store(integration, load_lines, load_methods = {})
@@ -74,6 +74,9 @@ module Saboteur
74
74
  # sandbox: when given, a callable run first in every child (Landlock
75
75
  # makes the project tree read-only there, #152). It raises rather than
76
76
  # run half-restricted, which the child reports as an error.
77
+ # Survivor confirmation loads the spec files a scoped run left out (#1286).
78
+ attr_reader :integration
79
+
77
80
  def initialize(integration:, reevaluator:, log_dir:, hooks: Hooks.new, supervisor: Supervisor.new, seed: 1,
78
81
  sandbox: nil, canary: nil, crash_log: nil)
79
82
  @integration = integration
@@ -205,7 +205,7 @@ module Saboteur
205
205
 
206
206
  # A subject fails a gate when at least one of its mutants survived.
207
207
  def failing_subjects(scored)
208
- scored.select { |result| result.status == :survived }.map(&:subject).uniq.size
208
+ scored.select(&:survived?).map(&:subject).uniq.size
209
209
  end
210
210
 
211
211
  def over?(count, limit, executed)
data/lib/saboteur/help.rb CHANGED
@@ -140,15 +140,15 @@ module Saboteur
140
140
  name: "survivors",
141
141
  summary: "Browse a report's survivors in the terminal, read-only",
142
142
  description: "Reads the Stryker JSON report without booting the project or rerunning tests. " \
143
- "Lists every survivor with rank, file, line, operator, digest and triage label; " \
144
- "filters with --file, --operator and --triage; --ranked uses the report's ranked " \
145
- "findings order. --show DIGEST prints the diff, the exact replay command and an " \
146
- "ignore-file entry template for that mutant. It never edits the ignore file.",
143
+ "Lists every survivor with rank, file, line, operator, digest and triage label, " \
144
+ "in the report's ranked findings order; filters with --file, --operator and " \
145
+ "--triage; --ranked is kept for compatibility. --show DIGEST prints the diff, " \
146
+ "the exact replay command and an ignore-file entry template for that mutant. " \
147
+ "It never edits the ignore file.",
147
148
  takes_options: false,
148
149
  exit_codes: { 0 => "Survivors listed or shown (including none).",
149
150
  2 => "Missing, malformed or unreadable report; bad arguments." },
150
151
  examples: [["List survivors from the last run", "saboteur survivors .saboteur/report.json"],
151
- ["Review-priority order", "saboteur survivors .saboteur/report.json --ranked"],
152
152
  ["Diff, replay command and ignore template for one mutant",
153
153
  "saboteur survivors .saboteur/report.json --show 1a2b3c4d5e6f7a8b"]]
154
154
  ),
@@ -124,6 +124,36 @@ module Saboteur
124
124
  world.example_groups.map { |group| File.expand_path(group.metadata[:file_path]) }.uniq
125
125
  end
126
126
 
127
+ # Loads spec files the run's own selection left out, in the parent, so a
128
+ # forked confirmation run can execute their examples (#1286). Files
129
+ # already loaded are skipped; a file that defines no group is an error,
130
+ # since its examples could never run.
131
+ def load_spec_files(paths)
132
+ loaded = test_files
133
+ absolute = paths.map { |path| File.expand_path(path, config.root) }.uniq
134
+ (absolute - loaded).each do |path|
135
+ require_inside_root(path)
136
+ before = world.example_groups.size
137
+ from_directory(config.root) { load(path) }
138
+ raise RefusedSpecPath, "#{path} defines no example group" if world.example_groups.size == before
139
+ end
140
+ end
141
+
142
+ # The ids come from a cached coverage map, which is untrusted input
143
+ # (#427): only a spec file that really lives under the project root,
144
+ # symlinks resolved, may be loaded.
145
+ def require_inside_root(path)
146
+ raise RefusedSpecPath, "refusing to load #{path}: not a *_spec.rb file" unless path.end_with?("_spec.rb")
147
+
148
+ root = File.realpath(config.root)
149
+ real = File.realpath(path)
150
+ return if real.start_with?("#{root}#{File::SEPARATOR}") && real.end_with?("_spec.rb")
151
+
152
+ raise RefusedSpecPath, "refusing to load #{path}: it resolves outside the project root"
153
+ rescue SystemCallError
154
+ raise RefusedSpecPath, "refusing to load #{path}: it does not exist under the project root"
155
+ end
156
+
127
157
  # Runs the tests in the order given: groups by their earliest example,
128
158
  # examples by position.
129
159
  def run(ids)
@@ -57,7 +57,7 @@ module Saboteur
57
57
  def list_lines(report, live)
58
58
  groups(report, live).map do |(path, line, subject_id), results|
59
59
  first, *rest = results
60
- label = SurvivorClassification.label(first)
60
+ label = SurvivorClassification.group_label(results)
61
61
  text = "#{path}:#{line} #{subject_id}#{" [#{label}]" if label} - #{first.digest}"
62
62
  rest.any? ? "#{text} (+#{rest.size} more)" : text
63
63
  end
@@ -12,6 +12,8 @@ module Saboteur
12
12
  # Survivors accepted on the ignore list are listed apart, with their
13
13
  # reason. The full, unbounded mutant data stays in the report's files
14
14
  # section; survivorsTruncated says how many more findings live only there.
15
+ # The uncapped ranked digest list lets offline readers (the survivors
16
+ # command) print the terminal's ordering without recomputing it (#1150).
15
17
  module Findings
16
18
  VERSION = 1
17
19
 
@@ -30,7 +32,8 @@ module Saboteur
30
32
  survivors: listed(report, live),
31
33
  survivorsTruncated: truncated(live),
32
34
  accepted: listed(report, accepted),
33
- acceptedTruncated: truncated(accepted)
35
+ acceptedTruncated: truncated(accepted),
36
+ order: full_order(report, live, accepted)
34
37
  }
35
38
  end
36
39
 
@@ -44,6 +47,12 @@ module Saboteur
44
47
  [results.size - LIMIT, 0].max
45
48
  end
46
49
 
50
+ # Every survivor digest in terminal order: live ranked first, then
51
+ # accepted - uncapped, unlike the findings lists themselves.
52
+ def full_order(report, live, accepted)
53
+ (ordered(report, live) + ordered(report, accepted)).map(&:digest)
54
+ end
55
+
47
56
  def ordered(report, results)
48
57
  SurvivorRanking.ordered(report, results)
49
58
  end
@@ -58,6 +58,12 @@ module Saboteur
58
58
 
59
59
  FALLBACK = "result unchecked"
60
60
 
61
+ # Which kind names a statement when its surviving mutants disagree
62
+ # (#P4): the first one listed that any of them carries. The broadest
63
+ # unchecked thing wins, so a branch condition is not called a
64
+ # boundary because one of its mutants happened to flip a comparison.
65
+ PRECEDENCE = ["result unchecked", "error path", "branch", "boundary", "effect", "number"].freeze
66
+
61
67
  # The deterministic per-kind reason printed next to a survivor's rank.
62
68
  REASONS = {
63
69
  "boundary" => "the covering specs pass without pinning this boundary",
@@ -151,6 +151,8 @@ module Saboteur
151
151
  # the same contract as the terminal headline.
152
152
  def headline(report)
153
153
  breakdown = report.score_breakdown
154
+ return error_dominated_headline(report) if report.majority_errored?
155
+
154
156
  text = "<p><strong>Covered mutation score: #{score_text(report.score)}</strong>"
155
157
  if breakdown.tested.positive?
156
158
  text << " (#{breakdown.detected}/#{breakdown.tested} #{Wording.detected_word(breakdown)}, tested subjects)"
@@ -195,12 +197,21 @@ module Saboteur
195
197
  "#{Text.counted(report.subjects.size, 'subject')}, #{Text.counted(report.results.size, 'mutant')}"
196
198
  end
197
199
 
200
+ # An error-dominated run shows no bare score, like the terminal headline
201
+ # (#557, #1272): the share covers only the few mutants that were scored.
202
+ def error_dominated_headline(report)
203
+ breakdown = report.score_breakdown
204
+ "<p><strong>Covered mutation score: n/a</strong> (#{breakdown.tested} of #{report.executed.size} " \
205
+ "mutants scored, #{score_text(breakdown.value)} of scored) - most executed mutants errored, " \
206
+ "so the run tested almost nothing</p>"
207
+ end
208
+
198
209
  def summary(report, data)
199
210
  seed = data.dig(:config, :seed)
200
211
  seed_note = seed ? ", seed #{seed}" : ""
201
212
  counts = data[:files].values.flat_map { |entry| entry[:mutants] }.map { |mutant| mutant[:status] }.tally
202
213
  <<~HTML
203
- <p><strong>Covered mutation score: #{score_text(report.score)}</strong>
214
+ <p><strong>Covered mutation score: #{score_text(report.majority_errored? ? nil : report.score)}</strong>
204
215
  (#{counts_note(report)}, #{format('%.1f', report.wall_time)}s#{seed_note})</p>
205
216
  <p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
206
217
  mutants are not counted. Survivors accepted on the ignore list stay in the denominator. It covers only the subjects of this run, not the whole project. A tool that reads the
@@ -20,7 +20,9 @@ module Saboteur
20
20
  end
21
21
 
22
22
  def call(report)
23
- survivor = report.unignored_survivors.first
23
+ # The replay example is the #1 survivor of the ranked list, not
24
+ # whichever one the results hold first.
25
+ survivor = SurvivorRanking.ordered(report, report.unignored_survivors).first
24
26
  return unless survivor
25
27
 
26
28
  @out.puts "\n#{@palette.paint(:head, 'Next steps:')}"
@@ -15,7 +15,6 @@ module Saboteur
15
15
  # it. Untested subjects are never dropped silently, even at 100 %.
16
16
  class ScoreTable
17
17
  MAX_ROWS = 15
18
- MAX_UNTESTED_NAMES = 3
19
18
  # The skip reason CandidateSelection gives a subject no test covers.
20
19
  NO_COVERAGE = "no coverage"
21
20
 
@@ -63,7 +62,7 @@ module Saboteur
63
62
  detected: breakdown.detected, tested: breakdown.tested, untested: untested }
64
63
  end
65
64
 
66
- # "cart.rb 100.0% (2/2) · n/a - 2 subjects - no test executes them: ...": the
65
+ # "cart.rb 100.0% (2/2) · n/a - 2 subjects - no test executes them": the
67
66
  # tested score and the untested count stand next to each other; a
68
67
  # file with nothing tested gets no percentage at all.
69
68
  def line(row, width)
@@ -75,18 +74,14 @@ module Saboteur
75
74
  note ? "#{base} #{@palette.glyph(:middot)} #{note}" : base
76
75
  end
77
76
 
78
- # "n/a - 2 subjects - no test executes them: Cart#total, Cart#biggest":
79
- # names the subjects the score cannot speak for, capped so a long
80
- # list does not swallow the row; the Not scored block lists them all.
81
- # Wording owns the claim (#1145): a stubbed-away subject has tests,
82
- # they just never execute it.
77
+ # "n/a - 2 subjects - no test executes them": only the count. The
78
+ # Not scored block below names every subject once (P6), so the table
79
+ # does not repeat the names. Wording owns the claim (#1145): a
80
+ # stubbed-away subject has tests, they just never execute it.
83
81
  def untested_note(untested)
84
82
  return if untested.empty?
85
83
 
86
- names = untested.map(&:id)
87
- shown = names.first(MAX_UNTESTED_NAMES)
88
- shown << "+#{names.size - MAX_UNTESTED_NAMES} more" if names.size > MAX_UNTESTED_NAMES
89
- @palette.paint(:warn, "n/a - #{Wording.untested_note(untested.size)}: #{shown.join(', ')}")
84
+ @palette.paint(:warn, "n/a - #{Wording.untested_note(untested.size)}")
90
85
  end
91
86
  end
92
87
  end
@@ -98,7 +98,7 @@ module Saboteur
98
98
  end
99
99
 
100
100
  def self.test_index(results)
101
- names = results.flat_map { |result| result.covered_by + result.killed_by + result.executed.to_a }.uniq
101
+ names = results.flat_map { |result| result.covered_by + result.killed_by + result.executed.to_a }.uniq.sort
102
102
  names.each_with_index.to_h { |name, index| [name, index.to_s] }
103
103
  end
104
104
 
@@ -139,6 +139,10 @@ module Saboteur
139
139
  # Why the survivor is worth a look, as a label only: scores and
140
140
  # the exit code never change (#192).
141
141
  classification: SurvivorClassification.label(result),
142
+ # Additive (#1286): confirmed, killed_by_confirmation or unconfirmed.
143
+ # An unconfirmed survivor reports Ignored: outside the score.
144
+ confirmation: result.confirmation_state,
145
+ creditedTest: result.credited_test&.then { |name| test_ids.fetch(name) },
142
146
  killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
143
147
  coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
144
148
  # Which of the covering tests actually ran against the mutant; absent
@@ -151,7 +155,7 @@ module Saboteur
151
155
  def self.status(result)
152
156
  case result.status
153
157
  when :killed then "Killed"
154
- when :survived then "Survived"
158
+ when :survived then result.unconfirmed? ? "Ignored" : "Survived"
155
159
  when :timeout then "Timeout"
156
160
  when :error, :resource_limit then "RuntimeError"
157
161
  when :skipped then result.reason&.start_with?("no coverage") ? "NoCoverage" : "Ignored"
@@ -169,6 +173,7 @@ module Saboteur
169
173
  # ignore, and only wont_fix adds a suffix after the reason.
170
174
  return "ignored: #{result.ignored} (wont_fix)" if result.triage_state == "wont_fix"
171
175
  return "ignored: #{result.ignored}" if result.ignored
176
+ return "unconfirmed: #{result.unconfirmed_reason}" if result.unconfirmed?
172
177
  return "resource limit: #{result.reason}" if result.status == :resource_limit
173
178
  return result.crash_kill? ? "crash: #{result.kill_cause}" : result.kill_cause if result.kill_cause
174
179
 
@@ -55,6 +55,21 @@ module Saboteur
55
55
  nil
56
56
  end
57
57
 
58
+ # The label of one statement's surviving mutants as a whole, the
59
+ # same whichever mutant sorts first. Any gap kind decides, by
60
+ # GapKinds::PRECEDENCE, so one equivalent, logging or input_gap
61
+ # sibling never hides a real survivor. With no gap kind, the first
62
+ # label present stands: likely_equivalent, arid or tool_limit then
63
+ # describe the statement when all its mutants carry it.
64
+ #
65
+ # Among gap kinds, result unchecked wins because it is the strictly
66
+ # larger gap: nothing pins the statement's value at all, which
67
+ # subsumes a missing branch, boundary or effect check on it.
68
+ def group_label(results)
69
+ labels = results.map { |result| label(result) }.compact
70
+ GapKinds::PRECEDENCE.find { |kind| labels.include?(kind) } || labels.first
71
+ end
72
+
58
73
  private
59
74
 
60
75
  # The mutant's own def text, compiled side by side with the
@@ -77,8 +77,8 @@ module Saboteur
77
77
  line = MutationText.line(first)
78
78
  path = relative(first.subject.file, report.root)
79
79
  @out.puts "\n#{@palette.paint(:head, "#{rank}. #{path}:#{line}")} #{first.subject.id} " \
80
- "#{@palette.paint(:dim, label_suffix(first, rank, total))}".rstrip
81
- reason = SurvivorHints.label_reason(@classifier.label(first))
80
+ "#{@palette.paint(:dim, label_suffix(group, rank, total))}".rstrip
81
+ reason = SurvivorHints.label_reason(@classifier.group_label(group))
82
82
  @out.puts " #{@palette.paint(:dim, reason)}" if reason
83
83
  fence(first, line)
84
84
  @out.puts " #{@palette.paint(:dim, "why it matters: #{why(first.mutation.operator)}")}"
@@ -105,10 +105,10 @@ module Saboteur
105
105
  # siblings read "#3 of 14 blocks", never blocks against mutants (#1147).
106
106
  # Label and rank stay on the header line; the reason moves to its own
107
107
  # dim line so the fixed text cannot push the header past 80 columns.
108
- def label_suffix(result, rank, total)
109
- label = @classifier.label(result)
108
+ def label_suffix(group, rank, total)
109
+ label = @classifier.group_label(group)
110
110
  unit = total == 1 ? "block" : "blocks"
111
- parts = [("coarse" if result.mutation.coarse), label, "##{rank} of #{total} #{unit}"].compact
111
+ parts = [("coarse" if group.first.mutation.coarse), label, "##{rank} of #{total} #{unit}"].compact
112
112
  "[#{parts.join(' · ')}]"
113
113
  end
114
114
 
@@ -226,7 +226,7 @@ module Saboteur
226
226
 
227
227
  whole = @whole_suite_map&.tests_for(group.first.subject)
228
228
  if whole.nil?
229
- @out.puts @palette.paint(:dim, " covering tests unknown - run a full coverage build")
229
+ @out.puts @palette.paint(:dim, " covering list is this run's selection only; run `saboteur run --all`")
230
230
  return
231
231
  end
232
232
 
@@ -6,6 +6,7 @@ require_relative "wording"
6
6
  require_relative "baseline_tail"
7
7
  require_relative "next_steps"
8
8
  require_relative "kill_causes"
9
+ require_relative "unconfirmed_section"
9
10
  require_relative "survivor_ranking"
10
11
  require_relative "palette"
11
12
  require_relative "notice"
@@ -62,6 +63,7 @@ module Saboteur
62
63
  return quiet_call(report) if @quiet
63
64
 
64
65
  findings(report)
66
+ UnconfirmedSection.new(@out, @palette).call(report)
65
67
  errors(report)
66
68
  skipped_subjects(report) if @verbose
67
69
  test_command_gaps(report)
@@ -94,7 +96,7 @@ module Saboteur
94
96
 
95
97
  def quiet_survivor_line(path, line, subject_id, results)
96
98
  first, *rest = results
97
- label = SurvivorClassification.label(first)
99
+ label = SurvivorClassification.group_label(results)
98
100
  text = "#{path}:#{line} #{subject_id}#{" [#{label}]" if label} - #{first.digest}"
99
101
  rest.any? ? "#{text} (+#{rest.size} more)" : text
100
102
  end
@@ -332,7 +334,8 @@ module Saboteur
332
334
  parts = state_parts(report)
333
335
  parts << "no mutants" if parts.empty?
334
336
  line = "saboteur: #{parts.join(', ')} in #{duration(report.wall_time)}"
335
- line += " - score #{painted_score(report.score)}"
337
+ # The headline withholds the score on an error-dominated run (#557); so does this line (#1271).
338
+ line += " - score #{painted_score(report.majority_errored? ? nil : report.score)}"
336
339
  line += " (threshold #{format('%g', @threshold)})" if @threshold
337
340
  @out.puts line
338
341
  end
@@ -345,7 +348,7 @@ module Saboteur
345
348
  untested = untested_subjects(report).size
346
349
  [counted(counts[:killed], "killed", plural: false, color: :pass),
347
350
  counted(live, "survived", plural: false, color: :fail),
348
- counted(report.survivors.size - live, "accepted", plural: false),
351
+ counted(report.survivors.size - live, "accepted", plural: false), UnconfirmedSection.part(report),
349
352
  counted(counts[:timeout], "timeout", color: :warn), counted(counts[:resource_limit], "resource limit"),
350
353
  error_part(report, counts[:error]), counted(counts[:unverified], "unverified", plural: false),
351
354
  ("#{untested} subject#{'s' unless untested == 1} untested" if untested.positive?)].compact
@@ -0,0 +1,44 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "mutation_text"
5
+ require_relative "survivor_ranking"
6
+
7
+ module Saboteur
8
+ module Reporter
9
+ # Survivors whose covering tests were not all run (#1286): outside the
10
+ # score and the exit code, so they get their own short list instead of
11
+ # a place among the findings.
12
+ class UnconfirmedSection
13
+ LIMIT = 10
14
+
15
+ # The count for the one-line (--quiet) tally, nil when there is none.
16
+ def self.part(report)
17
+ count = report.unconfirmed_survivors.size
18
+ "#{count} unconfirmed" if count.positive?
19
+ end
20
+
21
+ def initialize(out, palette)
22
+ @out = out
23
+ @palette = palette
24
+ end
25
+
26
+ def call(report)
27
+ pending = report.unconfirmed_survivors
28
+ return if pending.empty?
29
+
30
+ @out.puts @palette.paint(:head, "unconfirmed (covering tests not run) - #{pending.size}, not in the score")
31
+ SurvivorRanking.ordered(report, pending).first(LIMIT).each { |result| @out.puts line(report, result) }
32
+ @out.puts " ... #{pending.size - LIMIT} more" if pending.size > LIMIT
33
+ @out.puts
34
+ end
35
+
36
+ private
37
+
38
+ def line(report, result)
39
+ path = result.subject.file.delete_prefix("#{report.root}/")
40
+ " #{path}:#{MutationText.line(result)} #{result.subject.id} - #{result.digest} (#{result.unconfirmed_reason})"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -112,8 +112,37 @@ module Saboteur
112
112
  @digest ||= mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
113
113
  end
114
114
 
115
+ # A survivor whose confirmation run could not happen (budget spent, or the
116
+ # run failed) is outside the score and the exit code until it is
117
+ # confirmed (#1286). A survivor the whole-suite map cannot speak for
118
+ # ("unconfirmed_map(...)") stays counted: a stale map never lowers the gate.
115
119
  def survived?
116
- status == :survived
120
+ status == :survived && !unconfirmed?
121
+ end
122
+
123
+ def unconfirmed?
124
+ status == :survived && @confirmation.to_s.start_with?("unconfirmed(")
125
+ end
126
+
127
+ # The additive report field: confirmed, killed_by_confirmation or
128
+ # unconfirmed; nil when confirmation never applied (#1286).
129
+ def confirmation_state
130
+ return nil if @confirmation.nil?
131
+
132
+ label = @confirmation.to_s
133
+ label.start_with?("unconfirmed") ? "unconfirmed" : label
134
+ end
135
+
136
+ # The test a confirmation kill is credited to.
137
+ def credited_test
138
+ killed_by.first if confirmation_state == "killed_by_confirmation"
139
+ end
140
+
141
+ # Why the survivor stayed unconfirmed: the text inside unconfirmed(...).
142
+ def unconfirmed_reason
143
+ return unless unconfirmed?
144
+
145
+ @confirmation.delete_prefix("unconfirmed(").delete_suffix(")")
117
146
  end
118
147
 
119
148
  # Killed because a test's error, not its check, fired: NoMethodError and
@@ -280,7 +309,9 @@ module Saboteur
280
309
  end
281
310
 
282
311
  def counts
283
- STATUSES.to_h { |status| [status, results.count { |result| result.status == status }] }
312
+ STATUSES.to_h do |status|
313
+ [status, results.count { |result| status == :survived ? result.survived? : result.status == status }]
314
+ end
284
315
  end
285
316
 
286
317
  # (killed + timeout) / (killed + survived + timeout); nil with nothing tested.
@@ -308,6 +339,12 @@ module Saboteur
308
339
  results.select(&:survived?)
309
340
  end
310
341
 
342
+ # Survivors whose covering tests were not all run: outside the score and
343
+ # the exit code, listed on their own (#1286).
344
+ def unconfirmed_survivors
345
+ results.select(&:unconfirmed?)
346
+ end
347
+
311
348
  def unignored_survivors
312
349
  results.select(&:unignored_survivor?)
313
350
  end
@@ -443,9 +443,19 @@ module Saboteur
443
443
  [prefix.count("\n") + 1, offset - ((prefix.rindex("\n") || -1) + 1)]
444
444
  end
445
445
 
446
+ # Durations are compared to the millisecond. Two tests within a
447
+ # millisecond of each other swap places from one map build to the next,
448
+ # and with fail-fast the first failing test names a mutant's killer and
449
+ # kill cause, so sub-millisecond noise must not decide the order (#1274).
450
+ # Suite order breaks the tie instead.
446
451
  def sort_test_ids(ids)
447
452
  order = test_order
448
- ids.sort_by { |id| [durations.fetch(id, Float::INFINITY), order.fetch(id, Float::INFINITY)] }
453
+ ids.sort_by { |id| [duration_bucket(id), order.fetch(id, Float::INFINITY)] }
454
+ end
455
+
456
+ def duration_bucket(id)
457
+ seconds = durations[id]
458
+ seconds ? (seconds * 1000).ceil : Float::INFINITY
449
459
  end
450
460
 
451
461
  def test_order
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.12.0"
5
+ VERSION = "0.12.2"
6
6
  end
data/lib/saboteur.rb CHANGED
@@ -6,6 +6,10 @@ require_relative "saboteur/version"
6
6
  module Saboteur
7
7
  class Error < StandardError; end
8
8
 
9
+ # A spec path from the cached coverage map that is not a spec file inside
10
+ # the project: the map is untrusted input (#427, #1286).
11
+ class RefusedSpecPath < Error; end
12
+
9
13
  # A phase timeout that --max-duration shortened: budget exhaustion, not a
10
14
  # broken suite. The engine turns it into the budget partial-report path
11
15
  # instead of a phase error (#296).
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.12.0
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
@@ -63,6 +63,7 @@ files:
63
63
  - lib/saboteur/cli.rb
64
64
  - lib/saboteur/cli/agent_provenance.rb
65
65
  - lib/saboteur/cli/baseline_gate.rb
66
+ - lib/saboteur/cli/color_option.rb
66
67
  - lib/saboteur/cli/dirty_tree_notice.rb
67
68
  - lib/saboteur/cli/doctor_command.rb
68
69
  - lib/saboteur/cli/error_cause.rb
@@ -89,6 +90,7 @@ files:
89
90
  - lib/saboteur/cli/shard_options.rb
90
91
  - lib/saboteur/cli/source_set.rb
91
92
  - lib/saboteur/cli/survivors_command.rb
93
+ - lib/saboteur/cli/whole_suite_map_lookup.rb
92
94
  - lib/saboteur/config.rb
93
95
  - lib/saboteur/config_file.rb
94
96
  - lib/saboteur/constant_reads.rb
@@ -253,6 +255,7 @@ files:
253
255
  - lib/saboteur/reporter/survivor_ranking.rb
254
256
  - lib/saboteur/reporter/terminal.rb
255
257
  - lib/saboteur/reporter/test_matrix.rb
258
+ - lib/saboteur/reporter/unconfirmed_section.rb
256
259
  - lib/saboteur/reporter/wording.rb
257
260
  - lib/saboteur/result.rb
258
261
  - lib/saboteur/score.rb