saboteur 0.3.0 → 0.5.0

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. metadata +58 -2
@@ -1,33 +1,92 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "next_steps"
5
+ require_relative "survivor_ranking"
6
+ require_relative "palette"
7
+ require_relative "score_table"
8
+ require_relative "survivor_list"
9
+ require_relative "test_matrix"
10
+
4
11
  module Saboteur
5
12
  module Reporter
6
- # Plain-text summary: counts per status, score, timings, skip reasons,
7
- # errors, then a unified diff for each survivor.
13
+ # Plain-text report: survivors first (grouped by file, method and line),
14
+ # then errors and notes, then a short summary last so it stays on screen.
15
+ # Debug statistics (score split, timings, kill causes) need verbose.
8
16
  class Terminal
9
17
  MAX_ERRORS = 10
18
+ MAX_PASSING_TESTS = SurvivorList::MAX_PASSING_TESTS
10
19
 
11
- def initialize(out)
20
+ def initialize(out, verbose: false, color: :auto)
12
21
  @out = out
22
+ @verbose = verbose
23
+ @palette = Palette.for(out, mode: color)
24
+ @score_table = ScoreTable.new(out, @palette)
25
+ @next_steps = NextSteps.new(out, @palette)
13
26
  end
14
27
 
15
28
  def call(report)
16
- summary(report)
29
+ survivors(report)
30
+ errors(report)
17
31
  skipped_subjects(report)
32
+ outside_the_score(report)
33
+ inline_suppressions(report)
18
34
  warnings(report)
19
- errors(report)
20
- survivors(report)
35
+ early_stop(report)
36
+ test_matrix(report)
37
+ summary(report)
21
38
  end
22
39
 
23
40
  private
24
41
 
42
+ def test_matrix(report)
43
+ return unless report.matrix?
44
+
45
+ matrix = TestMatrix.new(report.results)
46
+ @out.puts "test matrix: #{matrix.tests.size} tests ran against mutants"
47
+ incomplete_matrix_note(matrix)
48
+ matrix_list("blind (covered mutants, killed none)", matrix.blind) do |test|
49
+ "#{matrix.mutants_covered(test)} covered"
50
+ end
51
+ matrix_list("redundant (every kill had another killer)", matrix.redundant) do |test|
52
+ "#{matrix.mutants_killed(test)} kills"
53
+ end
54
+ end
55
+
56
+ def incomplete_matrix_note(matrix)
57
+ return if matrix.incomplete.zero?
58
+
59
+ @out.puts " #{matrix.incomplete} mutants stopped before every covering test ran (incomplete): " \
60
+ "their tests get no blind or redundant verdict"
61
+ end
62
+
63
+ def matrix_list(title, tests)
64
+ @out.puts " #{title}: #{tests.size}"
65
+ tests.first(TestMatrix::MAX_LISTED).each { |test| @out.puts " #{test} #{yield(test)}" }
66
+ more = tests.size - TestMatrix::MAX_LISTED
67
+ @out.puts " ... #{more} more" if more.positive?
68
+ end
69
+
70
+ def inline_suppressions(report)
71
+ return if report.inline_suppressions.empty?
72
+
73
+ @out.puts("Inline suppressions: #{report.inline_suppressions.size} (outside the score)")
74
+ report.inline_suppressions.each do |entry|
75
+ path = entry[:file].delete_prefix("#{report.root}/")
76
+ @out.puts(" #{path}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
77
+ "#{entry[:digest]}: #{entry[:reason]}")
78
+ end
79
+ end
80
+
25
81
  def summary(report)
26
82
  seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
27
83
  @out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{report.results.size} mutants, " \
28
84
  "wall #{format('%.2f', report.wall_time)} s#{seed}"
29
85
  @out.puts status_line(report)
30
86
  details(report)
87
+ @score_table.call(report)
88
+ @out.puts @score_table.legend
89
+ @next_steps.call(report)
31
90
  end
32
91
 
33
92
  def status_line(report)
@@ -35,26 +94,72 @@ module Saboteur
35
94
  survived = "survived #{c[:survived]}"
36
95
  ignored = report.survivors.size - report.unignored_survivors.size
37
96
  survived += " (#{ignored} ignored)" if ignored.positive?
38
- "killed #{c[:killed]} #{survived} timeout #{c[:timeout]} resource limit #{c[:resource_limit]} " \
39
- "error #{c[:error]} skipped #{c[:skipped]}"
97
+ line = "#{@palette.paint(:green,
98
+ "killed #{c[:killed]}")} #{@palette.paint(:red,
99
+ survived)} timeout #{c[:timeout]} " \
100
+ "resource limit #{c[:resource_limit]} error #{c[:error]} skipped #{c[:skipped]}"
101
+ line += " ran at load #{c[:ran_at_load]}" if c[:ran_at_load].positive?
102
+ line += " unreachable #{c[:unreachable]}" if c[:unreachable].positive?
103
+ line
104
+ end
105
+
106
+ def hazard_suppressions(report)
107
+ return unless report.hazard_suppressions.positive?
108
+
109
+ @out.puts "hazardous mutations suppressed: #{report.hazard_suppressions} " \
110
+ "(--allow-hazardous runs them anyway; the canary still watches the tree)"
40
111
  end
41
112
 
42
113
  def details(report)
43
- score = report.score
44
- @out.puts "score #{score ? format('%.1f %%', score * 100) : 'n/a'} " \
45
- "(killed+timeout)/(killed+survived+timeout); errors, resource limits and skips excluded"
46
- mean = report.mean_runtime
47
- @out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
48
- @out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
49
- report.invalid_mutations.positive?
114
+ breakdown = report.score_breakdown
115
+ @out.puts "score #{percent(breakdown.value)}" \
116
+ "#{if @verbose
117
+ "#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
118
+ 'errors, resource limits and skips excluded'
119
+ end}"
50
120
  report.skip_reasons.each { |reason, count| @out.puts "skipped: #{reason}: #{count}" }
51
121
  reused = report.results.count(&:reused?)
52
122
  @out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
53
123
  reused.positive?
124
+ @out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
125
+ report.invalid_mutations.positive?
126
+ hazard_suppressions(report)
127
+ debug_details(report) if @verbose
128
+ end
129
+
130
+ def debug_details(report)
131
+ whole_body_score(report)
132
+ mean = report.mean_runtime
133
+ @out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
54
134
  @out.puts "kills rejected by unchanged-method check: #{report.rejected_kills}"
55
135
  kill_causes(report)
56
136
  end
57
137
 
138
+ # " (assertion 50.0 %, crash 50.0 %)": how much of the score is a failed
139
+ # check and how much is a crash. Timeouts and kills with no recorded
140
+ # cause are named only when there are some, so the parts add up.
141
+ def score_split(breakdown)
142
+ return "" unless breakdown.split_known?
143
+
144
+ " (#{breakdown.parts.map { |label, share| "#{label} #{percent(share)}" }.join(', ')})"
145
+ end
146
+
147
+ # Whole-body mutants (body_nil, body_true, body_false, body_raise) are
148
+ # killed by almost any test that runs the method, so they get their own
149
+ # line.
150
+ def whole_body_score(report)
151
+ whole = report.coarse_score_breakdown
152
+ fine = report.fine_score_breakdown
153
+ return if whole.tested.zero? || fine.tested.zero?
154
+
155
+ @out.puts "score without whole-body mutants #{percent(fine.value)}#{score_split(fine)} " \
156
+ "(#{fine.tested} mutants); whole-body mutants alone #{percent(whole.value)} (#{whole.tested})"
157
+ end
158
+
159
+ def percent(share)
160
+ share ? format("%.1f %%", share * 100) : "n/a"
161
+ end
162
+
58
163
  # How many kills came from a failed check and how many from a crash, and
59
164
  # which operators mostly kill by crashing (their score says less).
60
165
  def kill_causes(report)
@@ -91,6 +196,37 @@ module Saboteur
91
196
  end
92
197
  end
93
198
 
199
+ # Mutants no test can judge, listed apart so they are not read as
200
+ # misses: code that only ran while the app loaded, and methods a
201
+ # captured Method object still calls in their original form.
202
+ def outside_the_score(report)
203
+ { ran_at_load: "Ran at load (out of the score):",
204
+ unreachable: "Unreachable (out of the score):" }.each do |status, heading|
205
+ results = report.results.select { |result| result.status == status }
206
+ next if results.empty?
207
+
208
+ @out.puts heading
209
+ results.each do |result|
210
+ location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
211
+ @out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
212
+ end
213
+ @out.puts " hint: #{results.first.reason}" if results.first.reason
214
+ end
215
+ end
216
+
217
+ # The verdict covers only the subjects that ran, so say it where the
218
+ # score is read.
219
+ def early_stop(report)
220
+ stop = report.early_stop
221
+ return unless stop
222
+
223
+ @out.puts @palette.paint(:yellow, "stopped early (--fail-fast): #{stop.unreached.size} selected " \
224
+ "subject#{'s' unless stop.unreached.one?} did not run; the score below " \
225
+ "covers only the subjects that ran")
226
+ stop.unreached.first(MAX_ERRORS).each { |id| @out.puts " not run: #{id}" }
227
+ @out.puts " ... #{stop.unreached.size - MAX_ERRORS} more" if stop.unreached.size > MAX_ERRORS
228
+ end
229
+
94
230
  def warnings(report)
95
231
  report.warnings.each { |warning| @out.puts "warning: #{warning}" }
96
232
  end
@@ -108,35 +244,14 @@ module Saboteur
108
244
  end
109
245
 
110
246
  def survivors(report)
111
- return if report.survivors.empty?
112
-
113
- @out.puts "\nSurvivors:"
114
- shown(report).each do |result, hidden|
115
- location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
116
- @out.puts "\n#{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
117
- @out.puts "ignored: #{result.ignored}" if result.ignored
118
- @out.puts result.diff(root: report.root)
119
- @out.puts "#{hidden} more survivor#{'s' if hidden > 1} on this line in the JSON report" if hidden.positive?
120
- end
121
- end
122
-
123
- # [[result, how many more on the same line]]. In review mode survivors
124
- # on one line usually share a fate, so one is shown: live before
125
- # ignored, then coarse before fine, then the earliest edit.
126
- def shown(report)
127
- return report.survivors.map { |result| [result, 0] } unless report.review?
128
-
129
- lines = report.survivors.group_by { |result| [result.subject.file, edit_line(result)] }
130
- lines.values.map do |same_line|
131
- best = same_line.min_by do |result|
132
- [result.ignored ? 1 : 0, result.mutation.coarse ? 0 : 1, result.mutation.edits.first.start_byte]
133
- end
134
- [best, same_line.size - 1]
135
- end
247
+ SurvivorList.new(@out, @palette).call(report, ranked_survivors(report))
248
+ @out.puts unless report.survivors.empty?
136
249
  end
137
250
 
138
- def edit_line(result)
139
- result.subject.line_of(result.mutation.edits.first.start_byte)
251
+ # Live survivors precede accepted ones; each group follows SurvivorRanking.
252
+ def ranked_survivors(report)
253
+ live, accepted = report.survivors.partition(&:unignored_survivor?)
254
+ [live, accepted].flat_map { |group| SurvivorRanking.ordered(report, group) }
140
255
  end
141
256
 
142
257
  def relative(file, root)
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # What a --matrix run says about the tests themselves. Every covering test
7
+ # ran against every mutant, so each killed mutant lists all of its
8
+ # killers. A blind test covered mutants and killed none of them. A
9
+ # redundant test killed some, and every one of those had another killer.
10
+ #
11
+ # Only mutants whose every planned test is known to have run (see
12
+ # MutantResult#complete?) count. A run that stopped early or could not
13
+ # report what it ran is an incomplete mutant: the tests planned for it
14
+ # are unverified, and no blind or redundant verdict is given for them.
15
+ class TestMatrix
16
+ MAX_LISTED = 20
17
+
18
+ attr_reader :blind, :redundant, :tests, :incomplete
19
+
20
+ def initialize(results)
21
+ executed = results.select { |result| %i[killed survived].include?(result.status) }
22
+ complete, partial = executed.partition(&:complete?)
23
+ @incomplete = partial.size
24
+ @covered = Hash.new(0)
25
+ @killed = Hash.new { |hash, test| hash[test] = [] }
26
+ complete.each { |result| tally(result) }
27
+ @unverified = partial.flat_map(&:covered_by).uniq
28
+ @tests = @covered.keys.sort
29
+ judged = @tests - @unverified
30
+ @blind = judged.select { |test| @killed[test].empty? }
31
+ @redundant = judged.select { |test| redundant?(test) }
32
+ end
33
+
34
+ def mutants_covered(test)
35
+ @covered.fetch(test)
36
+ end
37
+
38
+ def mutants_killed(test)
39
+ @killed[test].size
40
+ end
41
+
42
+ private
43
+
44
+ def tally(result)
45
+ key = [result.subject.id, result.digest]
46
+ result.covered_by.each { |test| @covered[test] += 1 }
47
+ killers = result.killed_by & result.covered_by
48
+ killers.each { |test| @killed[test] << [key, killers.size - 1] }
49
+ end
50
+
51
+ def redundant?(test)
52
+ kills = @killed[test]
53
+ kills.any? && kills.all? { |_key, others| others.positive? }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -2,22 +2,27 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "reporter/diff"
5
+ require_relative "score"
5
6
 
6
7
  module Saboteur
7
8
  # resource_limit: the mutant's process tree hit the memory or process cap.
8
9
  # Like error it is outside the score: a mutant that eats all memory was
9
10
  # stopped by saboteur, not detected by a test.
10
- STATUSES = %i[killed survived timeout resource_limit error skipped].freeze
11
+ # ran_at_load: the mutated code only ran while the app loaded; no forked
12
+ # test can observe a change there, so it is outside the score.
13
+ # unreachable: a Method object captured before re-evaluation still calls
14
+ # the original body, so no test can observe the mutant. Outside the score.
15
+ STATUSES = %i[killed survived timeout resource_limit error skipped ran_at_load unreachable].freeze
11
16
 
12
17
  # Outcome of one mutation. reason carries the skip reason, the error
13
18
  # message or the timeout limit. ignored is the ignore-list reason, if any.
14
19
  class MutantResult
15
20
  attr_reader :subject, :mutation, :status, :reason, :runtime, :killed_by, :covered_by, :mutated_source,
16
- :kill_cause
21
+ :kill_cause, :executed
17
22
  attr_accessor :ignored
18
23
 
19
24
  def initialize(subject:, mutation:, status:, mutated_source:, reason: nil, runtime: nil, killed_by: [],
20
- covered_by: [], kill_cause: nil, reused: false, rejected_kill: false)
25
+ covered_by: [], kill_cause: nil, reused: false, rejected_kill: false, executed: nil)
21
26
  raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
22
27
 
23
28
  @subject = subject
@@ -29,6 +34,7 @@ module Saboteur
29
34
  @covered_by = covered_by
30
35
  @mutated_source = mutated_source
31
36
  @kill_cause = kill_cause
37
+ @executed = executed
32
38
  @rejected_kill = rejected_kill
33
39
  @reused = reused
34
40
  @ignored = nil
@@ -38,7 +44,14 @@ module Saboteur
38
44
  def as_error(reason, rejected_kill: false)
39
45
  MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: mutated_source,
40
46
  reason: reason, runtime: runtime, killed_by: killed_by, covered_by: covered_by,
41
- rejected_kill: rejected_kill)
47
+ executed: executed, rejected_kill: rejected_kill)
48
+ end
49
+
50
+ # True when every test planned for this mutant is known to have run.
51
+ # A killed run that stopped early, a test that called exit, or a verdict
52
+ # whose executed tests were never recorded is not complete.
53
+ def complete?
54
+ !executed.nil? && (covered_by - executed).empty?
42
55
  end
43
56
 
44
57
  def rejected_kill?
@@ -59,6 +72,11 @@ module Saboteur
59
72
  status == :killed && !kill_cause.nil? && kill_cause != "assertion"
60
73
  end
61
74
 
75
+ # Killed because a test's check failed: the test looked at a value.
76
+ def assertion_kill?
77
+ status == :killed && kill_cause == "assertion"
78
+ end
79
+
62
80
  # Taken from an earlier run (--incremental) instead of run again.
63
81
  def reused?
64
82
  @reused
@@ -82,14 +100,25 @@ module Saboteur
82
100
  end
83
101
  end
84
102
 
103
+ # A run that stopped before every selected subject was mutated (--fail-fast).
104
+ # unreached lists the subject ids that never ran; the verdict covers only
105
+ # the subjects that did.
106
+ EarlyStop = Data.define(:reason, :unreached)
107
+
85
108
  # Everything one run produced.
86
109
  class Report
87
- attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects
110
+ # More than half of the executed mutants errored: the run tested
111
+ # almost nothing and must not pass a gate.
112
+ MAX_ERROR_FRACTION = 0.5
113
+
114
+ attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects,
115
+ :generation_events, :omissions, :run_evidence, :hazard_suppressions, :early_stop, :inline_suppressions
88
116
 
89
117
  # REVIEW: diff mode; the terminal then shows one survivor per line.
90
118
  # seed: the random seed every test run started from (nil when unknown).
91
119
  def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
92
- seed: nil, skipped_subjects: {})
120
+ seed: nil, skipped_subjects: {}, diff_scope: nil, generation_events: [], omissions: [],
121
+ run_evidence: nil, hazard_suppressions: 0, early_stop: nil, matrix: false, inline_suppressions: [])
93
122
  @results = results
94
123
  @subjects = subjects
95
124
  @wall_time = wall_time
@@ -99,12 +128,46 @@ module Saboteur
99
128
  @review = review
100
129
  @seed = seed
101
130
  @skipped_subjects = skipped_subjects
131
+ @diff_scope = diff_scope
132
+ @generation_events = generation_events.dup.freeze
133
+ @omissions = omissions.dup.freeze
134
+ @run_evidence = run_evidence
135
+ @hazard_suppressions = hazard_suppressions
136
+ @early_stop = early_stop
137
+ @matrix = matrix
138
+ @inline_suppressions = inline_suppressions.dup.freeze
139
+ end
140
+
141
+ # A --matrix run: every covering test ran per mutant.
142
+ def matrix?
143
+ @matrix
144
+ end
145
+
146
+ # In diff mode, the changed lines (sorted) that a mutant touches; empty
147
+ # outside diff mode, or when the mutant touches none (a whole-body mutant
148
+ # of a method whose only change is its signature).
149
+ def changed_lines_for(result)
150
+ @diff_scope ? @diff_scope.changed_lines_for(result.subject, result.mutation) : []
151
+ end
152
+
153
+ def changed_line_distance(result)
154
+ @diff_scope&.distance_for(result.subject, result.mutation)
102
155
  end
103
156
 
104
157
  def rejected_kills
105
158
  results.count(&:rejected_kill?)
106
159
  end
107
160
 
161
+ # Errored mutants (a crash, a neutral check that failed) are outside
162
+ # the score, so a run whose executed mutants mostly errored tested
163
+ # almost nothing. Skipped mutants never ran: they are neither the
164
+ # numerator nor the denominator.
165
+ def majority_errored?
166
+ return false if executed.empty?
167
+
168
+ executed.count { |result| result.status == :error }.fdiv(executed.size) > MAX_ERROR_FRACTION
169
+ end
170
+
108
171
  def review?
109
172
  @review
110
173
  end
@@ -115,10 +178,23 @@ module Saboteur
115
178
 
116
179
  # (killed + timeout) / (killed + survived + timeout); nil with nothing tested.
117
180
  def score
118
- c = counts
119
- detected = c[:killed] + c[:timeout]
120
- tested = detected + c[:survived]
121
- tested.zero? ? nil : detected.fdiv(tested)
181
+ score_breakdown.value
182
+ end
183
+
184
+ # The score with its split into assertion kills and crash kills.
185
+ def score_breakdown
186
+ @score_breakdown ||= Score.of(results)
187
+ end
188
+
189
+ # The same breakdown without the whole-body mutants (every
190
+ # mutation marked coarse: body_nil, body_true, body_false, body_raise). Almost any test that runs the method kills
191
+ # those, so they flatter the score.
192
+ def fine_score_breakdown
193
+ @fine_score_breakdown ||= Score.of(results.reject { |result| result.mutation.coarse })
194
+ end
195
+
196
+ def coarse_score_breakdown
197
+ @coarse_score_breakdown ||= Score.of(results.select { |result| result.mutation.coarse })
122
198
  end
123
199
 
124
200
  def survivors
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # The mutation score of a group of results, with the share of it that came
6
+ # from failed assertions and the share that came from crashes.
7
+ #
8
+ # value is (killed + timeout) / (killed + survived + timeout). A crash kill
9
+ # shows the code ran and broke, not that a test checked a value, so the
10
+ # split lets a reader see how much of the score is checked behavior.
11
+ # Errors, resource limits and skips are outside every number here.
12
+ class Score
13
+ def self.of(results)
14
+ new(results)
15
+ end
16
+
17
+ attr_reader :assertion_kills, :crash_kills, :unclassified_kills, :timeouts, :survivors
18
+
19
+ def initialize(results)
20
+ killed = results.select { |result| result.status == :killed }
21
+ @assertion_kills = killed.count(&:assertion_kill?)
22
+ @crash_kills = killed.count(&:crash_kill?)
23
+ @unclassified_kills = killed.size - assertion_kills - crash_kills
24
+ @timeouts = results.count { |result| result.status == :timeout }
25
+ @survivors = results.count(&:survived?)
26
+ end
27
+
28
+ def detected
29
+ assertion_kills + crash_kills + unclassified_kills + timeouts
30
+ end
31
+
32
+ def tested
33
+ detected + survivors
34
+ end
35
+
36
+ # nil with nothing tested.
37
+ def value
38
+ share(detected)
39
+ end
40
+
41
+ def assertion_share
42
+ share(assertion_kills)
43
+ end
44
+
45
+ def crash_share
46
+ share(crash_kills)
47
+ end
48
+
49
+ def unclassified_share
50
+ share(unclassified_kills)
51
+ end
52
+
53
+ def timeout_share
54
+ share(timeouts)
55
+ end
56
+
57
+ # False when no kill has a recorded cause: a split would then say
58
+ # nothing. A cause is missing for verdicts reused from a run made before
59
+ # causes were recorded, for a test that called exit, and for a failure
60
+ # outside any example (suite level).
61
+ def split_known?
62
+ assertion_kills.positive? || crash_kills.positive?
63
+ end
64
+
65
+ # [[label, share]]: assertion and crash always, then timeout and
66
+ # "cause not recorded" only when there are some, so the parts add up to
67
+ # value.
68
+ def parts
69
+ list = [["assertion", assertion_share], ["crash", crash_share]]
70
+ list << ["timeout", timeout_share] if timeouts.positive?
71
+ list << ["cause not recorded", unclassified_share] if unclassified_kills.positive?
72
+ list
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ score: value, tested: tested, assertionKills: assertion_kills, crashKills: crash_kills,
78
+ unclassifiedKills: unclassified_kills, timeouts: timeouts, survivors: survivors,
79
+ assertionShare: assertion_share, crashShare: crash_share
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def share(count)
86
+ tested.zero? ? nil : count.fdiv(tested)
87
+ end
88
+ end
89
+ end