saboteur 0.12.1 → 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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -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 +4 -3
  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 +1 -9
  20. data/lib/saboteur/cli/run_validation.rb +0 -1
  21. data/lib/saboteur/cli/survivors_command.rb +5 -2
  22. data/lib/saboteur/cli/whole_suite_map_lookup.rb +33 -0
  23. data/lib/saboteur/cli.rb +6 -2
  24. data/lib/saboteur/engine/coverage_store.rb +7 -4
  25. data/lib/saboteur/engine/diagnostics.rb +7 -0
  26. data/lib/saboteur/engine/pipelines.rb +9 -2
  27. data/lib/saboteur/engine/survivor_confirmation.rb +75 -11
  28. data/lib/saboteur/engine/test_guess.rb +2 -1
  29. data/lib/saboteur/engine.rb +34 -16
  30. data/lib/saboteur/execution/runner.rb +3 -0
  31. data/lib/saboteur/gates.rb +1 -1
  32. data/lib/saboteur/integration/rspec.rb +30 -0
  33. data/lib/saboteur/reporter/default_findings.rb +1 -1
  34. data/lib/saboteur/reporter/gap_kinds.rb +6 -0
  35. data/lib/saboteur/reporter/next_steps.rb +3 -1
  36. data/lib/saboteur/reporter/score_table.rb +6 -11
  37. data/lib/saboteur/reporter/stryker.rb +7 -2
  38. data/lib/saboteur/reporter/survivor_classification.rb +15 -0
  39. data/lib/saboteur/reporter/survivor_list.rb +6 -6
  40. data/lib/saboteur/reporter/terminal.rb +4 -2
  41. data/lib/saboteur/reporter/unconfirmed_section.rb +44 -0
  42. data/lib/saboteur/result.rb +39 -2
  43. data/lib/saboteur/selection/coverage_map.rb +11 -1
  44. data/lib/saboteur/version.rb +1 -1
  45. data/lib/saboteur.rb +4 -0
  46. metadata +4 -1
@@ -104,9 +104,16 @@ module Saboteur
104
104
  # "Full run" for ignore staleness (#1206): no --since diff, no --subject
105
105
  # filter, and no narrowed include (explicit file arguments replace the
106
106
  # configured include) - only then was every in-scope subject examined, so
107
- # an entry whose subject never appears really points at gone code.
107
+ # an entry whose subject never appears really points at gone code. The
108
+ # include nobody set counts as unscoped whether it is the generic
109
+ # default or the Rails one a config-less app gets (#1255).
108
110
  def unscoped_full_run?
109
- !config["since"] && config["subjects"].empty? && config["include"] == Config::DEFAULTS["include"]
111
+ !config["since"] && config["subjects"].empty? && default_include?
112
+ end
113
+
114
+ def default_include?
115
+ config["include"] == Config::DEFAULTS["include"] ||
116
+ (config["include"] == Config::RAILS_INCLUDE && !config.explicit?("include"))
110
117
  end
111
118
 
112
119
  def external_command? = !config["test_command"].to_s.empty?
@@ -15,13 +15,14 @@ module Saboteur
15
15
  # confirmation_budget caps the whole pass.
16
16
  MAX_SPECS_PER_SURVIVOR = 8
17
17
 
18
- def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil)
18
+ def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil, load_specs: ->(_files) {})
19
19
  @config = config
20
20
  @map = map
21
21
  @scheduler = scheduler
22
22
  @diagnostics = diagnostics
23
23
  @budget_exceeded = budget_exceeded
24
24
  @cache = cache
25
+ @load_specs = load_specs
25
26
  end
26
27
 
27
28
  def call(results, ignore_list:, early_stop: nil)
@@ -33,19 +34,60 @@ module Saboteur
33
34
  "stopped early (#{early_stop.reason})")
34
35
  end
35
36
  @remaining = @config["confirmation_budget"]
37
+ @map_silent = 0
36
38
  results.map { |result| survivors.include?(result) ? confirm(result) : result }
37
39
  .tap { |confirmed| summarize(confirmed) }
40
+ .tap { warn_map_silent }
38
41
  end
39
42
 
40
43
  private
41
44
 
45
+ # A crash while confirming one survivor downgrades it to unconfirmed
46
+ # with one warning; it never ends the run (#1140).
42
47
  def confirm(result)
48
+ confirm_checked(result)
49
+ rescue RefusedSpecPath => e
50
+ warn_refused(e)
51
+ result.with_confirmation("unconfirmed_map(a covering spec from the whole-suite map was refused)")
52
+ rescue StandardError => e
53
+ warn_failed(result, e)
54
+ unconfirmed(result, "confirmation failed: #{e.class}")
55
+ end
56
+
57
+ # The map named a spec that is not a spec file inside the project: the
58
+ # map cannot be trusted, so its survivors stay counted (#1286).
59
+ def warn_refused(error)
60
+ return if @warned_refused
61
+
62
+ @warned_refused = true
63
+ @diagnostics.warn("survivor confirmation refused the whole-suite coverage map: " \
64
+ "#{error.message.lines.first.strip}; its survivors stay counted; " \
65
+ "run `saboteur run --all` to rebuild it")
66
+ end
67
+
68
+ # One line per run, however many survivors hit the same failure.
69
+ def warn_failed(result, error)
70
+ return if @warned_failure
71
+
72
+ @warned_failure = true
73
+ @diagnostics.warn("survivor confirmation failed for #{result.subject.id} #{result.digest} " \
74
+ "(#{error.class}: #{error.message.lines.map(&:strip).reject(&:empty?).first}); " \
75
+ "its survivors stay unconfirmed, outside the score")
76
+ end
77
+
78
+ def confirm_checked(result)
43
79
  return unconfirmed(result, "the confirmation budget is spent") if @remaining <= 0
44
80
  return unconfirmed(result, "the --max-duration budget is spent") if @budget_exceeded.call
45
81
 
46
- tests = unrun_covering_tests(result)
47
- return unconfirmed(result, "no covering tests") if tests.empty?
82
+ # Nothing in the whole-suite map covers the edited lines (the tree
83
+ # drifted since the map was built): the map cannot confirm anything.
84
+ covering = covering_tests(result)
85
+ return map_silent(result) if covering.empty?
48
86
 
87
+ tests = unrun_covering_tests(result, covering)
88
+ return result.with_confirmation("confirmed") if tests.empty?
89
+
90
+ load_unselected_specs(tests)
49
91
  outcome = run_confirmation(result, tests)
50
92
  @remaining -= outcome.runtime || 0
51
93
  case outcome.status
@@ -58,8 +100,14 @@ module Saboteur
58
100
  # The whole-suite map's covering tests for the mutant's edited lines
59
101
  # (branch evidence intersected, mirroring the scheduler), minus the
60
102
  # tests the mutant already ran, fastest first, capped.
61
- def unrun_covering_tests(result)
62
- fastest_first(covering_tests(result) - (result.executed || result.covered_by)).first(MAX_SPECS_PER_SURVIVOR)
103
+ def unrun_covering_tests(result, covering)
104
+ fastest_first(covering - (result.executed || result.covered_by)).first(MAX_SPECS_PER_SURVIVOR)
105
+ end
106
+
107
+ # The covering specs may live in files this run never loaded; the
108
+ # forked confirmation child can only run examples already in its world.
109
+ def load_unselected_specs(tests)
110
+ @load_specs.call(tests.map { |id| id.sub(/\[[\d:]+\]\z/, "") })
63
111
  end
64
112
 
65
113
  def covering_tests(result)
@@ -86,11 +134,12 @@ module Saboteur
86
134
  # becomes an error; a check the budgets never let run comes back
87
135
  # unverified - outside the score and never cached.
88
136
  def checked_kill(result, tests, outcome)
89
- kill = result.with_confirmation("confirmed", status: :killed, reason: outcome.message,
90
- killed_by: outcome.failed_ids, covered_by: tests,
91
- kill_cause: outcome.kill_cause,
92
- runtime: (result.runtime || 0) + (outcome.runtime || 0),
93
- executed: (result.executed || []) + outcome.executed_ids)
137
+ kill = result.with_confirmation(
138
+ "killed_by_confirmation", status: :killed, reason: outcome.message, killed_by: outcome.failed_ids,
139
+ covered_by: tests, kill_cause: outcome.kill_cause,
140
+ runtime: (result.runtime || 0) + (outcome.runtime || 0),
141
+ executed: (result.executed || []) + outcome.executed_ids
142
+ )
94
143
  checked = VerdictCheck.new(run_unchanged: unchanged_runner(result.subject),
95
144
  baseline_timeout: @config["baseline_timeout"]).call([kill]).first
96
145
  return checked unless checked.status == :killed
@@ -108,7 +157,7 @@ module Saboteur
108
157
  # (#1079). ResultCache#record replaces the subject+mutation entry, so
109
158
  # the stale survivor verdict cannot win a later lookup.
110
159
  def record_kill(result, kill)
111
- @cache&.record(kill.with_confirmation("confirmed", covered_by: covering_tests(result)))
160
+ @cache&.record(kill.with_confirmation("killed_by_confirmation", covered_by: covering_tests(result)))
112
161
  end
113
162
 
114
163
  # The unchanged-method re-run VerdictCheck checks a kill against, in
@@ -129,6 +178,13 @@ module Saboteur
129
178
  end
130
179
  end
131
180
 
181
+ # The map says nothing about the edited lines (the tree drifted since it
182
+ # was built): the survivor stays counted and the pass says so once.
183
+ def map_silent(result)
184
+ @map_silent += 1
185
+ result.with_confirmation("unconfirmed_map(no covering tests in the whole-suite map)")
186
+ end
187
+
132
188
  def unconfirmed(result, reason)
133
189
  result.with_confirmation("unconfirmed(#{reason})")
134
190
  end
@@ -148,6 +204,14 @@ module Saboteur
148
204
  durations.empty? ? nil : durations.sum / durations.size
149
205
  end
150
206
 
207
+ def warn_map_silent
208
+ return if @map_silent.zero?
209
+
210
+ @diagnostics.warn("survivor confirmation: the whole-suite coverage map covers none of the edited lines of " \
211
+ "#{@map_silent} survivor#{'s' unless @map_silent == 1}, so they stay counted; " \
212
+ "run `saboteur run --all` to rebuild it")
213
+ end
214
+
151
215
  def summarize(results)
152
216
  counts = results.filter_map(&:confirmation).tally
153
217
  return if counts.empty?
@@ -116,7 +116,8 @@ module Saboteur
116
116
  # Why the subjectless files needed no spec.
117
117
  def subjectless_message(since:)
118
118
  detail = since ? "no changed mutation subjects" : "no mutation subjects"
119
- "inferred specs: no spec is required for #{names(subjectless)}: they have #{detail}"
119
+ who = subjectless.size == 1 ? "it has" : "they have"
120
+ "inferred specs: no spec is required for #{names(subjectless)}: #{who} #{detail}"
120
121
  end
121
122
 
122
123
  # The per-file warning when the run proceeds with the mapped
@@ -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
@@ -130,6 +125,12 @@ module Saboteur
130
125
  @coverage_store&.whole_suite_map
131
126
  end
132
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
+
133
134
  # False when caching is off, where no coverage build can ever leave a
134
135
  # whole-suite map to consult, so advising one would be wrong (review C1).
135
136
  def whole_suite_map_lookup?
@@ -292,15 +293,21 @@ module Saboteur
292
293
  confirm_survivors(run_candidates(subjects, lists, scheduler, cache, ignore_list), scheduler, cache, ignore_list)
293
294
  end
294
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).
295
302
  def confirm_survivors(results, scheduler, cache, ignore_list)
296
- unless CoveragePartial.use?(config, config["coverage_parts"])
297
- live = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
298
- @diagnostics.inform(CONFIRMATION_SKIPPED) if live && config["partial_coverage"] == "use"
299
- return results
300
- end
303
+ return results if results.none? { |result| result.survived? && !ignore_list.accepts?(result) }
301
304
 
302
- SurvivorConfirmation.new(config: config, map: scheduler.map, scheduler: scheduler, diagnostics: @diagnostics,
303
- 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) })
304
311
  .call(results, ignore_list: ignore_list, early_stop: @early_stop)
305
312
  end
306
313
 
@@ -350,10 +357,19 @@ module Saboteur
350
357
  # baseline's own chatter (demoted to --verbose) would start.
351
358
  def announce_quiet_start(store, files)
352
359
  @map_cached = store.reusable?(files)
353
- @diagnostics.quiet_start_header(scope: config["since"] ? "--since #{config['since']}" : "whole project",
360
+ @diagnostics.quiet_start_header(scope: quiet_scope,
354
361
  seed: config["seed"], operators: config.operator_policy["effective"].size)
355
362
  end
356
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
+
357
373
  def inform_about_run(subjects, map)
358
374
  if @diagnostics.quiet_start && !@diagnostics.verbose
359
375
  @diagnostics.quiet_baseline(passes: @completed_baseline_passes, elapsed: @baseline_elapsed,
@@ -367,11 +383,13 @@ module Saboteur
367
383
  end
368
384
 
369
385
  # This run's map is the whole-suite map only when nothing scoped it:
370
- # no --since, no subjects, and tests either unset or inferred over every
371
- # 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).
372
390
  def whole_suite_run?
373
391
  config["since"].nil? && config["subjects"].empty? &&
374
- (config["tests"].empty? || !@guessed_tests.nil?)
392
+ config["tests"].empty? && @guessed_tests.nil?
375
393
  end
376
394
 
377
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)
@@ -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
@@ -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",
@@ -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
@@ -346,7 +348,7 @@ module Saboteur
346
348
  untested = untested_subjects(report).size
347
349
  [counted(counts[:killed], "killed", plural: false, color: :pass),
348
350
  counted(live, "survived", plural: false, color: :fail),
349
- counted(report.survivors.size - live, "accepted", plural: false),
351
+ counted(report.survivors.size - live, "accepted", plural: false), UnconfirmedSection.part(report),
350
352
  counted(counts[:timeout], "timeout", color: :warn), counted(counts[:resource_limit], "resource limit"),
351
353
  error_part(report, counts[:error]), counted(counts[:unverified], "unverified", plural: false),
352
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