saboteur 0.4.0 → 0.6.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. metadata +46 -2
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../score"
5
+
6
+ module Saboteur
7
+ module Reporter
8
+ # One row per source file, worst score first, with survivor counts, and a
9
+ # one-line legend for the statuses in the summary.
10
+ class ScoreTable
11
+ MAX_ROWS = 15
12
+ LEGEND = ["killed: a test failed survived: every selected test passed",
13
+ "timeout: exceeded its deadline",
14
+ "error, resource limit, skipped and the other statuses: not scored"].freeze
15
+
16
+ def initialize(out, palette)
17
+ @out = out
18
+ @palette = palette
19
+ end
20
+
21
+ def call(report)
22
+ rows = rows(report)
23
+ return if rows.size < 2
24
+
25
+ @out.puts "\nScore by file (worst first):"
26
+ width = rows.first(MAX_ROWS).map { |row| row[:path].length }.max
27
+ rows.first(MAX_ROWS).each { |row| @out.puts line(row, width) }
28
+ @out.puts " ... #{rows.size - MAX_ROWS} more files" if rows.size > MAX_ROWS
29
+ end
30
+
31
+ def legend
32
+ LEGEND.each_with_index.map do |text, index|
33
+ @palette.paint(:dim, "#{index.zero? ? 'legend: ' : ' '}#{text}")
34
+ end.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def rows(report)
40
+ by_file = report.results.group_by { |result| result.subject.file }
41
+ rows = by_file.map { |file, results| row(report, file, results) }
42
+ rows.reject { |row| row[:tested].zero? }.sort_by { |row| [row[:value], -row[:survivors], row[:path]] }
43
+ end
44
+
45
+ def row(report, file, results)
46
+ breakdown = Score.of(results)
47
+ { path: file.delete_prefix("#{report.root}/"), value: breakdown.value,
48
+ survivors: results.count(&:unignored_survivor?), tested: breakdown.tested }
49
+ end
50
+
51
+ def line(row, width)
52
+ score = format("%5.1f %%", row[:value] * 100)
53
+ " #{row[:path].ljust(width)} #{score} #{row[:survivors]} survivor#{'s' unless row[:survivors] == 1}"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -35,20 +35,32 @@ module Saboteur
35
35
  files: files,
36
36
  testFiles: test_files(test_ids),
37
37
  skippedSubjects: skipped_subjects(report),
38
+ earlyStop: early_stop(report),
38
39
  rejectedKills: report.rejected_kills,
39
40
  warnings: report.warnings,
40
41
  framework: { name: "saboteur", version: VERSION },
41
- saboteur: { findings: Findings.build(report), scores: scores(report) }
42
- .merge(exit_cause ? { exitCause: exit_cause } : {})
42
+ saboteur: { findings: Findings.build(report), scores: scores(report),
43
+ inlineSuppressions: inline_audit(report) }
44
+ .merge(operatorPolicy: report.operator_policy).merge(exit_cause ? { exitCause: exit_cause } : {})
43
45
  }.merge(report.seed ? { config: { seed: report.seed } } : {})
44
46
  end
45
47
 
48
+ def self.inline_audit(report)
49
+ report.inline_suppressions.map { |entry| entry.merge(file: relative(entry[:file], report.root)) }
50
+ end
51
+
46
52
  # Score with its assertion and crash shares, for all mutants and without
47
53
  # the whole-body ones (body_nil, body_true, body_false, body_raise).
48
54
  def self.scores(report)
49
55
  { all: report.score_breakdown.to_h, withoutWholeBodyMutants: report.fine_score_breakdown.to_h }
50
56
  end
51
57
 
58
+ # nil for a run that covered its whole selection.
59
+ def self.early_stop(report)
60
+ stop = report.early_stop
61
+ stop && { reason: stop.reason, unreachedSubjects: stop.unreached }
62
+ end
63
+
52
64
  def self.skipped_subjects(report)
53
65
  report.skipped_subjects.map do |subject, reason|
54
66
  { subject: subject.id, file: relative(subject.file, report.root), line: subject.start_line, reason: reason }
@@ -101,7 +113,10 @@ module Saboteur
101
113
  when :survived then "Survived"
102
114
  when :timeout then "Timeout"
103
115
  when :error, :resource_limit then "RuntimeError"
104
- when :skipped then result.reason == "no coverage" ? "NoCoverage" : "Ignored"
116
+ when :skipped then result.reason&.start_with?("no coverage") ? "NoCoverage" : "Ignored"
117
+ # The schema has no status for "ran, but no test can observe a
118
+ # change here". Ignored with the reason is the honest mapping.
119
+ when :ran_at_load, :unreachable then "Ignored"
105
120
  end
106
121
  end
107
122
 
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Suggests a check that might kill a surviving mutant, by operator. It does
7
+ # not say what is missing: the survivor can also be an equivalent mutant.
8
+ module SurvivorHints
9
+ BY_OPERATOR = {
10
+ "comparison" => "try an example at the boundary: equal values, or values one apart",
11
+ "arithmetic" => "try asserting the exact computed number",
12
+ "boolean_logic" => "try an example where each side of the condition alone decides the result",
13
+ "negation_removal" => "try an example where the negated value changes the result",
14
+ "literal_number" => "try asserting something that depends on this number",
15
+ "literal_boolean" => "try an example that depends on this true/false value",
16
+ "literal_string" => "try asserting on this text",
17
+ "statement_removal" => "try asserting the effect of this statement",
18
+ "or_assign" => "try an example where the variable is already set (||= vs =)",
19
+ "safe_navigation" => "try an example where the receiver is nil",
20
+ "argument_removal" => "try have_received(...).with(...), or assert a result that depends on this argument",
21
+ "pair_removal" => "try have_received(...).with(...), or assert the hash content under this key",
22
+ "endpoint_replacement" => "try a collection with 2 or more elements, to tell first from last",
23
+ "filter_replacement" => "try items that match and items that do not, to tell select from reject"
24
+ }.freeze
25
+
26
+ COARSE = "try asserting this method's result or its effect"
27
+
28
+ # Printed once after the survivors: a hint is a place to look, not a diagnosis.
29
+ CAVEAT = "Hints are suggestions, not diagnoses: a survivor can also be an equivalent mutant, or a change " \
30
+ "the selected tests cannot observe."
31
+
32
+ # Comparison operator swaps where the two operators give the same answer
33
+ # except in one named case, so a test that hits that case can tell them
34
+ # apart. Anything else gets the plain text hint.
35
+ EQUAL_CASE = [%w[< <=], %w[> >=]].map(&:to_set).freeze
36
+ UNEQUAL_CASE = [%w[< >], %w[<= >=]].map(&:to_set).freeze
37
+
38
+ module_function
39
+
40
+ def for(operator, coarse: false)
41
+ BY_OPERATOR.fetch(operator) { coarse ? COARSE : nil }
42
+ end
43
+
44
+ # The hint as data for `--agent`: { kind:, text: } and, for a comparison
45
+ # swap, case: the relation between the two operands that separates the
46
+ # original from the mutant ("left == right"). It is where to look, not a
47
+ # promise that a test there will kill the mutant. nil when there is none.
48
+ def structured(operator:, original:, replacement:, coarse: false)
49
+ text = self.for(operator, coarse: coarse)
50
+ return unless text
51
+
52
+ operands = boundary_case(operator, original, replacement)
53
+ return { kind: "boundary", case: operands, text: text } if operands
54
+
55
+ { kind: coarse ? "method_result" : "assertion", text: text }
56
+ end
57
+
58
+ def boundary_case(operator, original, replacement)
59
+ return unless operator == "comparison"
60
+
61
+ pair = Set[original, replacement]
62
+ return "left == right" if EQUAL_CASE.include?(pair)
63
+
64
+ "left != right" if UNEQUAL_CASE.include?(pair)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,126 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "mutation_text"
5
+ require_relative "survivor_hints"
6
+
7
+ module Saboteur
8
+ module Reporter
9
+ # Survivors grouped by file, then method, then line. The source line and
10
+ # the tests that covered it are printed once; each mutation is one compact
11
+ # line: operator, original -> replacement, digest.
12
+ class SurvivorList
13
+ MAX_PASSING_TESTS = 5
14
+
15
+ def initialize(out, palette)
16
+ @out = out
17
+ @palette = palette
18
+ end
19
+
20
+ # Every survivor, in report order; the grouping keeps them readable.
21
+ def call(report, survivors)
22
+ return if survivors.empty?
23
+
24
+ @out.puts "Survivors (#{survivors.size}):"
25
+ survivors.group_by { |result| result.subject.file }.sort_by { |file, _| file }.each do |file, in_file|
26
+ file_section(report, relative(file, report.root), in_file)
27
+ end
28
+ @out.puts "\n#{@palette.paint(:dim, SurvivorHints::CAVEAT)}" if survivors.any? { |result| hint_for(result) }
29
+ end
30
+
31
+ private
32
+
33
+ def file_section(report, path, in_file)
34
+ @out.puts "\n#{@palette.paint(:bold, path)}"
35
+ in_file.group_by { |result| result.subject.id }.each do |subject_id, in_subject|
36
+ @out.puts " #{subject_id}"
37
+ lines(report, path, in_subject)
38
+ end
39
+ end
40
+
41
+ def lines(report, path, in_subject)
42
+ in_subject.group_by { |result| MutationText.line(result) }.sort.each do |line, on_line|
43
+ first = on_line.first
44
+ @out.puts " #{path}:#{line} in #{first.subject.id}"
45
+ @out.puts " #{@palette.paint(:dim, "#{line}:")} #{source_line(first, line)}"
46
+ evidence_groups(report, on_line).each { |group| evidence_group(report, path, group) }
47
+ end
48
+ end
49
+
50
+ # Mutants on one line share the printed evidence only when it is the
51
+ # same for all of them: covering tests (in order), reuse and changed
52
+ # lines can each differ per mutant, so each run of neighbours with the
53
+ # same tuple is one group and shares evidence above its own mutations.
54
+ # Evidence that repeats after another group is printed again, which
55
+ # keeps the ranked order.
56
+ def evidence_groups(report, on_line)
57
+ on_line.chunk { |result| [result.covered_by, result.reused?, report.changed_lines_for(result)] }
58
+ .map(&:last)
59
+ end
60
+
61
+ def evidence_group(report, path, group)
62
+ first = group.first
63
+ changed_lines(report, path, first)
64
+ passing_tests(first)
65
+ group.each { |result| mutation(result) }
66
+ end
67
+
68
+ def source_line(result, line)
69
+ result.subject.file_source.lines[line - 1].to_s.strip
70
+ end
71
+
72
+ def mutation(result)
73
+ text = " #{@palette.paint(:red, '-')} #{result.mutation.operator} " \
74
+ "#{MutationText.original(result)} -> #{@palette.paint(:green, MutationText.replacement(result))} " \
75
+ "#{result.digest}"
76
+ text += " (ignored: #{result.ignored})" if result.ignored
77
+ @out.puts text
78
+ hint(result)
79
+ end
80
+
81
+ def hint(result)
82
+ text = hint_for(result)
83
+ @out.puts " #{@palette.paint(:cyan, 'hint:')} #{text}" if text
84
+ end
85
+
86
+ def hint_for(result)
87
+ return nil if result.ignored
88
+
89
+ SurvivorHints.for(result.mutation.operator, coarse: result.mutation.coarse)
90
+ end
91
+
92
+ # The tests that ran with the mutant applied and still passed: the
93
+ # missing assertion belongs in one of them. They are listed likeliest
94
+ # killers first, the order the run used. Line coverage can overstate which
95
+ # tests exercise the line, so the list is a lead, not proof.
96
+ def passing_tests(result)
97
+ tests = result.covered_by
98
+ return if tests.empty?
99
+
100
+ @out.puts " #{tests.size} #{tests.one? ? 'test' : 'tests'} covered this line and " \
101
+ "#{passed_phrase(result)} (the missing check may belong in one of them):"
102
+ tests.first(MAX_PASSING_TESTS).each { |name| @out.puts " #{name}" }
103
+ more = tests.size - MAX_PASSING_TESTS
104
+ @out.puts " ... #{more} more in the JSON report (coveredBy)" if more.positive?
105
+ end
106
+
107
+ def passed_phrase(result)
108
+ result.reused? ? "passed in an earlier run (--incremental)" : "still passed"
109
+ end
110
+
111
+ # In diff mode: the changed lines this mutant touches.
112
+ def changed_lines(report, path, result)
113
+ lines = report.changed_lines_for(result)
114
+ return if lines.empty?
115
+
116
+ shown = lines.first(3).map { |line| "#{path}:#{line}" }.join(", ")
117
+ more = lines.size - 3
118
+ @out.puts " changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{" (+#{more} more)" if more.positive?}"
119
+ end
120
+
121
+ def relative(file, root)
122
+ file.delete_prefix("#{root}/")
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,34 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Review priority, not a claim that a survivor is a real defect.
7
+ module SurvivorRanking
8
+ ORDERING = "coarse first, then more covering tests, larger edited-line span, " \
9
+ "nearer changed lines, then file, line and digest"
10
+
11
+ def self.ordered(report, results)
12
+ results.sort_by { |result| key(report, result) }
13
+ end
14
+
15
+ def self.key(report, result)
16
+ [result.mutation.coarse ? 0 : 1, -result.covered_by.uniq.size, -edited_lines(result).size,
17
+ report.changed_line_distance(result) || Float::INFINITY,
18
+ result.subject.file.delete_prefix("#{report.root}/"), first_line(result), result.digest]
19
+ end
20
+
21
+ def self.first_line(result)
22
+ result.subject.line_of(result.mutation.edits.map(&:start_byte).min)
23
+ end
24
+
25
+ def self.edited_lines(result)
26
+ result.mutation.edits.flat_map do |edit|
27
+ first = result.subject.line_of(edit.start_byte)
28
+ last = result.subject.line_of([edit.end_byte - 1, edit.start_byte].max)
29
+ (first..last).to_a
30
+ end.uniq
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,35 +1,101 @@
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
10
- MAX_PASSING_TESTS = 5
11
- MAX_CHANGED_LINES = 3
18
+ MAX_PASSING_TESTS = SurvivorList::MAX_PASSING_TESTS
12
19
 
13
- def initialize(out)
20
+ def initialize(out, verbose: false, color: :auto)
14
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)
15
26
  end
16
27
 
17
28
  def call(report)
18
- summary(report)
29
+ survivors(report)
30
+ errors(report)
19
31
  skipped_subjects(report)
32
+ outside_the_score(report)
33
+ inline_suppressions(report)
20
34
  warnings(report)
21
- errors(report)
22
- survivors(report)
35
+ early_stop(report)
36
+ test_matrix(report)
37
+ operator_policy(report)
38
+ summary(report)
23
39
  end
24
40
 
25
41
  private
26
42
 
43
+ def operator_policy(report)
44
+ policy = report.operator_policy
45
+ return unless policy && policy["excluded"].any?
46
+
47
+ @out.puts("Excluded operators: #{policy['excluded'].join(', ')} " \
48
+ "(policy #{policy['version']}; #{policy['effective'].size} enabled)")
49
+ end
50
+
51
+ def test_matrix(report)
52
+ return unless report.matrix?
53
+
54
+ matrix = TestMatrix.new(report.results)
55
+ @out.puts "test matrix: #{matrix.tests.size} tests ran against mutants"
56
+ incomplete_matrix_note(matrix)
57
+ matrix_list("blind (covered mutants, killed none)", matrix.blind) do |test|
58
+ "#{matrix.mutants_covered(test)} covered"
59
+ end
60
+ matrix_list("redundant (every kill had another killer)", matrix.redundant) do |test|
61
+ "#{matrix.mutants_killed(test)} kills"
62
+ end
63
+ end
64
+
65
+ def incomplete_matrix_note(matrix)
66
+ return if matrix.incomplete.zero?
67
+
68
+ @out.puts " #{matrix.incomplete} mutants stopped before every covering test ran (incomplete): " \
69
+ "their tests get no blind or redundant verdict"
70
+ end
71
+
72
+ def matrix_list(title, tests)
73
+ @out.puts " #{title}: #{tests.size}"
74
+ tests.first(TestMatrix::MAX_LISTED).each { |test| @out.puts " #{test} #{yield(test)}" }
75
+ more = tests.size - TestMatrix::MAX_LISTED
76
+ @out.puts " ... #{more} more" if more.positive?
77
+ end
78
+
79
+ def inline_suppressions(report)
80
+ return if report.inline_suppressions.empty?
81
+
82
+ @out.puts("Inline suppressions: #{report.inline_suppressions.size} (outside the score)")
83
+ report.inline_suppressions.each do |entry|
84
+ path = entry[:file].delete_prefix("#{report.root}/")
85
+ @out.puts(" #{path}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
86
+ "#{entry[:digest]}: #{entry[:reason]}")
87
+ end
88
+ end
89
+
27
90
  def summary(report)
28
91
  seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
29
92
  @out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{report.results.size} mutants, " \
30
93
  "wall #{format('%.2f', report.wall_time)} s#{seed}"
31
94
  @out.puts status_line(report)
32
95
  details(report)
96
+ @score_table.call(report)
97
+ @out.puts @score_table.legend
98
+ @next_steps.call(report)
33
99
  end
34
100
 
35
101
  def status_line(report)
@@ -37,23 +103,43 @@ module Saboteur
37
103
  survived = "survived #{c[:survived]}"
38
104
  ignored = report.survivors.size - report.unignored_survivors.size
39
105
  survived += " (#{ignored} ignored)" if ignored.positive?
40
- "killed #{c[:killed]} #{survived} timeout #{c[:timeout]} resource limit #{c[:resource_limit]} " \
41
- "error #{c[:error]} skipped #{c[:skipped]}"
106
+ line = "#{@palette.paint(:green,
107
+ "killed #{c[:killed]}")} #{@palette.paint(:red,
108
+ survived)} timeout #{c[:timeout]} " \
109
+ "resource limit #{c[:resource_limit]} error #{c[:error]} skipped #{c[:skipped]}"
110
+ line += " ran at load #{c[:ran_at_load]}" if c[:ran_at_load].positive?
111
+ line += " unreachable #{c[:unreachable]}" if c[:unreachable].positive?
112
+ line
113
+ end
114
+
115
+ def hazard_suppressions(report)
116
+ return unless report.hazard_suppressions.positive?
117
+
118
+ @out.puts "hazardous mutations suppressed: #{report.hazard_suppressions} " \
119
+ "(--allow-hazardous runs them anyway; the canary still watches the tree)"
42
120
  end
43
121
 
44
122
  def details(report)
45
123
  breakdown = report.score_breakdown
46
- @out.puts "score #{percent(breakdown.value)}#{score_split(breakdown)} " \
47
- "(killed+timeout)/(killed+survived+timeout); errors, resource limits and skips excluded"
48
- whole_body_score(report)
49
- mean = report.mean_runtime
50
- @out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
51
- @out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
52
- report.invalid_mutations.positive?
124
+ @out.puts "score #{percent(breakdown.value)}" \
125
+ "#{if @verbose
126
+ "#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
127
+ 'errors, resource limits and skips excluded'
128
+ end}"
53
129
  report.skip_reasons.each { |reason, count| @out.puts "skipped: #{reason}: #{count}" }
54
130
  reused = report.results.count(&:reused?)
55
131
  @out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
56
132
  reused.positive?
133
+ @out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
134
+ report.invalid_mutations.positive?
135
+ hazard_suppressions(report)
136
+ debug_details(report) if @verbose
137
+ end
138
+
139
+ def debug_details(report)
140
+ whole_body_score(report)
141
+ mean = report.mean_runtime
142
+ @out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
57
143
  @out.puts "kills rejected by unchanged-method check: #{report.rejected_kills}"
58
144
  kill_causes(report)
59
145
  end
@@ -119,6 +205,37 @@ module Saboteur
119
205
  end
120
206
  end
121
207
 
208
+ # Mutants no test can judge, listed apart so they are not read as
209
+ # misses: code that only ran while the app loaded, and methods a
210
+ # captured Method object still calls in their original form.
211
+ def outside_the_score(report)
212
+ { ran_at_load: "Ran at load (out of the score):",
213
+ unreachable: "Unreachable (out of the score):" }.each do |status, heading|
214
+ results = report.results.select { |result| result.status == status }
215
+ next if results.empty?
216
+
217
+ @out.puts heading
218
+ results.each do |result|
219
+ location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
220
+ @out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
221
+ end
222
+ @out.puts " hint: #{results.first.reason}" if results.first.reason
223
+ end
224
+ end
225
+
226
+ # The verdict covers only the subjects that ran, so say it where the
227
+ # score is read.
228
+ def early_stop(report)
229
+ stop = report.early_stop
230
+ return unless stop
231
+
232
+ @out.puts @palette.paint(:yellow, "stopped early (--fail-fast): #{stop.unreached.size} selected " \
233
+ "subject#{'s' unless stop.unreached.one?} did not run; the score below " \
234
+ "covers only the subjects that ran")
235
+ stop.unreached.first(MAX_ERRORS).each { |id| @out.puts " not run: #{id}" }
236
+ @out.puts " ... #{stop.unreached.size - MAX_ERRORS} more" if stop.unreached.size > MAX_ERRORS
237
+ end
238
+
122
239
  def warnings(report)
123
240
  report.warnings.each { |warning| @out.puts "warning: #{warning}" }
124
241
  end
@@ -136,70 +253,14 @@ module Saboteur
136
253
  end
137
254
 
138
255
  def survivors(report)
139
- return if report.survivors.empty?
140
-
141
- @out.puts "\nSurvivors:"
142
- shown(report).each do |result, hidden|
143
- location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
144
- @out.puts "\n#{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
145
- changed_lines(report, result)
146
- @out.puts "ignored: #{result.ignored}" if result.ignored
147
- @out.puts result.diff(root: report.root)
148
- passing_tests(result)
149
- @out.puts "#{hidden} more survivor#{'s' if hidden > 1} on this line in the JSON report" if hidden.positive?
150
- end
151
- end
152
-
153
- # The tests that ran with the mutant applied and still passed: the
154
- # missing assertion belongs in one of them. They are listed likeliest
155
- # killers first, the order the run used. Line coverage can overstate which
156
- # tests exercise the line, so the list is a lead, not proof.
157
- def passing_tests(result)
158
- tests = result.covered_by
159
- return if tests.empty?
160
-
161
- @out.puts "#{tests.size} #{tests.one? ? 'test' : 'tests'} covered this line and #{passed_phrase(result)} " \
162
- "(the missing check may belong in one of them):"
163
- tests.first(MAX_PASSING_TESTS).each { |name| @out.puts " #{name}" }
164
- more = tests.size - MAX_PASSING_TESTS
165
- @out.puts " ... #{more} more in the JSON report (coveredBy)" if more.positive?
166
- end
167
-
168
- # A reused verdict was not run again: its tests passed in the earlier run.
169
- def passed_phrase(result)
170
- result.reused? ? "passed in an earlier run (--incremental)" : "still passed"
171
- end
172
-
173
- # In diff mode: the changed lines this mutant touches. The location above
174
- # is where the mutated statement starts, which can be a line or two above
175
- # the line that changed.
176
- def changed_lines(report, result)
177
- lines = report.changed_lines_for(result)
178
- return if lines.empty?
179
-
180
- path = relative(result.subject.file, report.root)
181
- shown = lines.first(MAX_CHANGED_LINES).map { |line| "#{path}:#{line}" }.join(", ")
182
- more = lines.size - MAX_CHANGED_LINES
183
- @out.puts "changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{" (+#{more} more)" if more.positive?}"
184
- end
185
-
186
- # [[result, how many more on the same line]]. In review mode survivors
187
- # on one line usually share a fate, so one is shown: live before
188
- # ignored, then coarse before fine, then the earliest edit.
189
- def shown(report)
190
- return report.survivors.map { |result| [result, 0] } unless report.review?
191
-
192
- lines = report.survivors.group_by { |result| [result.subject.file, edit_line(result)] }
193
- lines.values.map do |same_line|
194
- best = same_line.min_by do |result|
195
- [result.ignored ? 1 : 0, result.mutation.coarse ? 0 : 1, result.mutation.edits.first.start_byte]
196
- end
197
- [best, same_line.size - 1]
198
- end
256
+ SurvivorList.new(@out, @palette).call(report, ranked_survivors(report))
257
+ @out.puts unless report.survivors.empty?
199
258
  end
200
259
 
201
- def edit_line(result)
202
- result.subject.line_of(result.mutation.edits.first.start_byte)
260
+ # Live survivors precede accepted ones; each group follows SurvivorRanking.
261
+ def ranked_survivors(report)
262
+ live, accepted = report.survivors.partition(&:unignored_survivor?)
263
+ [live, accepted].flat_map { |group| SurvivorRanking.ordered(report, group) }
203
264
  end
204
265
 
205
266
  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