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
@@ -0,0 +1,137 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "findings"
5
+ require_relative "survivor_hints"
6
+ require_relative "agent_incomplete"
7
+ require_relative "agent_clipping"
8
+
9
+ module Saboteur
10
+ module Reporter
11
+ module Agent
12
+ # Builds the payload hash for one run: status, verdict, exit code, counts,
13
+ # provenance, a few findings and the next commands.
14
+ module Summary
15
+ class << self
16
+ include Clipping
17
+
18
+ # Public entry first; the helpers below are private.
19
+ def build(report, provenance, shown, snippet_limit)
20
+ incomplete = Incomplete.reasons(report)
21
+ status = status_of(report, incomplete)
22
+ verdict = verdict_of(report, status)
23
+ {
24
+ version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
25
+ provenance: provenance_hash(provenance),
26
+ counts: counts(report), score: score(report),
27
+ scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
28
+ incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
29
+ findings: findings(report, shown, snippet_limit),
30
+ warnings: report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] },
31
+ next: next_steps(report, provenance)
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def status_of(report, incomplete)
38
+ return "not_applicable" if report.results.empty? && incomplete.empty?
39
+
40
+ incomplete.empty? ? "complete" : "partial"
41
+ end
42
+
43
+ def verdict_of(report, status)
44
+ return "survivors" if report.unignored_survivors.any?
45
+
46
+ { "complete" => "clean", "partial" => "incomplete", "not_applicable" => "not_applicable" }.fetch(status)
47
+ end
48
+
49
+ def exit_code_for(verdict)
50
+ case verdict
51
+ when "clean" then EXIT_CLEAN
52
+ when "survivors" then EXIT_REVIEW
53
+ else EXIT_INCOMPLETE
54
+ end
55
+ end
56
+
57
+ def counts(report)
58
+ report.counts.transform_keys { |status| camel(status) }.merge(executed: report.executed.size)
59
+ end
60
+
61
+ def camel(status)
62
+ status.to_s.gsub(/_(\w)/) { Regexp.last_match(1).upcase }.to_sym
63
+ end
64
+
65
+ def score(report)
66
+ report.score&.round(4)
67
+ end
68
+
69
+ # Display values are clipped; the commands under next use the exact values or are left out.
70
+ def provenance_hash(provenance)
71
+ { ref: clip_value(provenance.ref, 120), seed: bounded_number(provenance.seed),
72
+ head: clip_value(provenance.head, 64),
73
+ workingTree: provenance.working_tree, changedFiles: provenance.changed_files,
74
+ mode: provenance.mode, selection: selection_hash(provenance) }
75
+ end
76
+
77
+ def selection_hash(provenance)
78
+ %i[subjects tests sources].to_h do |key|
79
+ list = provenance.public_send(key)
80
+ [key, { count: list.size, first: list.first(3).map { |entry| clip_value(entry, 120) } }]
81
+ end.merge(config: clip_value(provenance.config_path, 120), digest: provenance.digest,
82
+ settings: provenance.settings.transform_values { |value| clip_value(value, 120) })
83
+ end
84
+
85
+ def findings(report, shown, snippet_limit)
86
+ all = Findings.build(report)
87
+ live = report.unignored_survivors.size
88
+ {
89
+ survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
90
+ omitted: live - [shown, all[:survivors].size].min,
91
+ accepted: report.survivors.size - live, ordering: all[:ordering],
92
+ hintCaveat: SurvivorHints::CAVEAT
93
+ }
94
+ end
95
+
96
+ def shown_findings(listed, shown, snippet_limit)
97
+ listed.first(shown).map do |finding|
98
+ finding.slice(:digest, :line, :endLine, :operator, :coarse)
99
+ .merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
100
+ original: clip(finding[:original], snippet_limit),
101
+ replacement: clip(finding[:replacement], snippet_limit))
102
+ .merge(finding_hints(finding))
103
+ end
104
+ end
105
+
106
+ def finding_hints(finding)
107
+ structured = SurvivorHints.structured(operator: finding[:operator], original: finding[:original],
108
+ replacement: finding[:replacement], coarse: finding[:coarse])
109
+ { hint: SurvivorHints.for(finding[:operator], coarse: finding[:coarse]) }.compact
110
+ .merge(testHint: structured)
111
+ end
112
+
113
+ def next_steps(report, provenance)
114
+ steps = {}
115
+ add_command(steps, :rerun, Commands.rerun(provenance))
116
+ first = Findings.build(report)[:survivors].first
117
+ add_command(steps, :replay, Commands.replay(provenance, first[:digest])) if first
118
+ steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
119
+ steps
120
+ end
121
+
122
+ def short_path?(path)
123
+ path && path.bytesize <= 400
124
+ end
125
+
126
+ def add_command(steps, key, command)
127
+ steps[key] = command
128
+ return if command
129
+
130
+ steps[:"#{key}Unavailable"] =
131
+ "command longer than #{Commands::MAX_BYTES} bytes; rebuild it from provenance"
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "survivor_ranking"
5
+
4
6
  module Saboteur
5
7
  module Reporter
6
8
  # Findings for a code-review surface such as the saboteur GitHub Action:
@@ -15,12 +17,7 @@ module Saboteur
15
17
  # GitHub shows at most 10 annotations of one kind per step.
16
18
  LIMIT = 10
17
19
 
18
- # Published ordering, coarse survivors first: a surviving coarse mutant
19
- # replaced the whole method body, which hints that no test pins the
20
- # method's behavior - though it can also be a re-evaluation limit or an
21
- # equivalent mutant, so this is a heuristic, not a guarantee. Ties break
22
- # by file, line and digest, so one report always reads the same way.
23
- ORDERING = "coarse survivors first, then file, line and digest"
20
+ ORDERING = SurvivorRanking::ORDERING
24
21
 
25
22
  class << self
26
23
  def build(report)
@@ -46,20 +43,8 @@ module Saboteur
46
43
  [results.size - LIMIT, 0].max
47
44
  end
48
45
 
49
- # partition keeps the coarse group ahead; inside a group the key is
50
- # total (no two mutants share a digest), so the order is stable.
51
46
  def ordered(report, results)
52
- results.partition { |result| result.mutation.coarse }
53
- .map { |group| group.sort_by { |result| tie_break(report, result) } }
54
- .flatten
55
- end
56
-
57
- def tie_break(report, result)
58
- [path(report, result), first_line(result), result.digest]
59
- end
60
-
61
- def first_line(result)
62
- result.subject.line_of(result.mutation.edits.map(&:start_byte).min)
47
+ SurvivorRanking.ordered(report, results)
63
48
  end
64
49
 
65
50
  def finding(report, result)
@@ -76,9 +61,17 @@ module Saboteur
76
61
  edits: entries,
77
62
  coarse: result.mutation.coarse
78
63
  }
64
+ entry = with_changed_lines(report, result, entry)
79
65
  result.ignored ? entry.merge(reason: result.ignored) : entry
80
66
  end
81
67
 
68
+ # In diff mode, the changed lines the mutant touches. line above is where
69
+ # the mutated statement starts, which can be above the changed line.
70
+ def with_changed_lines(report, result, entry)
71
+ lines = report.changed_lines_for(result)
72
+ lines.empty? ? entry : entry.merge(changedLines: lines)
73
+ end
74
+
82
75
  # Subject#apply applies edits in start-byte order; findings use the
83
76
  # same order, so an unsorted edit list from a custom mutation source
84
77
  # still gets a sane range and one entry per edit.
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Refuses linked or non-regular history/report destinations before writes.
7
+ module HistoryPaths
8
+ module_function
9
+
10
+ def validate(paths)
11
+ paths = paths.compact.map { |path| File.expand_path(path) }
12
+ identities = paths.map do |path|
13
+ reject_links(path)
14
+ next unless File.exist?(path)
15
+
16
+ stat = File.stat(path)
17
+ raise Error, "score history/report destination must be a regular file" unless stat.file?
18
+
19
+ [stat.dev, stat.ino]
20
+ end.compact
21
+ raise Error, "score history/report destinations alias the same file" unless identities.uniq == identities
22
+ rescue SystemCallError => e
23
+ raise Error, "score history/report paths cannot be verified: #{e.message}"
24
+ end
25
+
26
+ def reject_links(path)
27
+ loop do
28
+ raise Error, "score history/report paths must not contain symlinks" if File.symlink?(path)
29
+
30
+ parent = File.dirname(path)
31
+ break if parent == path
32
+
33
+ path = parent
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,132 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+
7
+ module Saboteur
8
+ module Reporter
9
+ # Strict version-one observation schema. Never repairs imported evidence.
10
+ module HistoryRecord
11
+ FIELDS = %w[version date commit selection inventory score coveredScore counts breakdown incomplete].freeze
12
+ BREAKDOWN = %w[score tested assertionKills crashKills unclassifiedKills timeouts survivors
13
+ assertionShare crashShare].freeze
14
+ COUNTS = STATUSES.map(&:to_s).freeze
15
+
16
+ class UniqueObject < Hash
17
+ def []=(key, value)
18
+ raise Error, "score history contains duplicate keys" if key?(key)
19
+
20
+ super
21
+ end
22
+ end
23
+
24
+ module_function
25
+
26
+ def parse(line)
27
+ record = JSON.parse(line, object_class: UniqueObject)
28
+ validate(record)
29
+ record
30
+ rescue JSON::ParserError, ArgumentError, TypeError => e
31
+ raise Error, "score history contains an invalid record: #{e.message}"
32
+ end
33
+
34
+ def validate(record)
35
+ fields(record, FIELDS)
36
+ unless record.fetch("version").is_a?(Integer) && record.fetch("version") == 1
37
+ raise Error,
38
+ "score history contains an invalid version"
39
+ end
40
+
41
+ date(record.fetch("date"))
42
+ commit = record.fetch("commit")
43
+ raise Error, "score history contains an invalid commit" unless commit.nil? || sha?(commit, 40)
44
+
45
+ %w[selection inventory].each do |key|
46
+ raise Error, "score history contains an invalid digest" unless sha?(record.fetch(key), 64)
47
+ end
48
+ %w[score coveredScore].each { |key| fraction(record.fetch(key)) }
49
+ counts = record.fetch("counts")
50
+ fields(counts, COUNTS)
51
+ counts.each_value { |value| count(value) }
52
+ breakdown(record.fetch("breakdown"), counts, record.fetch("coveredScore"))
53
+ score_consistency(record)
54
+ reasons(record.fetch("incomplete"))
55
+ end
56
+
57
+ def date(value)
58
+ return if Time.iso8601(value).utc.iso8601 == value
59
+
60
+ raise Error,
61
+ "score history contains an invalid date"
62
+ rescue ArgumentError, TypeError, NoMethodError => e
63
+ raise Error, "score history contains an invalid date: #{e.message}"
64
+ end
65
+
66
+ def reasons(value)
67
+ unless value.is_a?(Array) && value.all? do |reason|
68
+ reason.is_a?(String) && !reason.empty?
69
+ end && value.uniq == value
70
+ raise Error, "score history contains invalid incomplete reasons"
71
+ end
72
+ end
73
+
74
+ def score_consistency(record)
75
+ value = record.fetch("breakdown")
76
+ detected = value.values_at("assertionKills", "crashKills", "unclassifiedKills", "timeouts").sum
77
+ tested = value.fetch("tested")
78
+ expected = tested.zero? ? nil : detected.fdiv(tested)
79
+ raise Error, "score history contains an inconsistent score" unless record.fetch("coveredScore") == expected
80
+
81
+ %w[assertion crash].each do |kind|
82
+ expected_share = tested.zero? ? nil : value.fetch("#{kind}Kills").fdiv(tested)
83
+ unless value.fetch("#{kind}Share") == expected_share
84
+ raise Error,
85
+ "score history contains an inconsistent share"
86
+ end
87
+ end
88
+ overall = record.fetch("score")
89
+ return unless overall && ((record.fetch("coveredScore").nil? && !overall.zero?) ||
90
+ (record.fetch("coveredScore") && overall > record.fetch("coveredScore")))
91
+
92
+ raise Error, "score history contains an inconsistent overall score"
93
+ end
94
+
95
+ def fields(object, expected)
96
+ return if object.is_a?(Hash) && object.keys.sort == expected.sort
97
+
98
+ raise Error,
99
+ "score history contains an invalid record schema"
100
+ end
101
+
102
+ def sha?(value, length)
103
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{#{length}}\z/)
104
+ end
105
+
106
+ def fraction(value)
107
+ return if value.nil? || (0..1).cover?(value)
108
+
109
+ raise Error,
110
+ "score history contains an invalid score"
111
+ end
112
+
113
+ def count(value)
114
+ raise Error, "score history contains an invalid count" unless value.is_a?(Integer) && value >= 0
115
+ end
116
+
117
+ def breakdown(value, counts, covered)
118
+ fields(value, BREAKDOWN)
119
+ %w[score assertionShare crashShare].each { |key| fraction(value.fetch(key)) }
120
+ (BREAKDOWN - %w[score assertionShare crashShare]).each { |key| count(value.fetch(key)) }
121
+ kills = value.values_at("assertionKills", "crashKills", "unclassifiedKills").sum
122
+ tested = counts.values_at("killed", "survived", "timeout").sum
123
+ unless kills == counts.fetch("killed") && value.fetch("tested") == tested &&
124
+ value.fetch("timeouts") == counts.fetch("timeout") &&
125
+ value.fetch("survivors") == counts.fetch("survived") &&
126
+ value.fetch("score") == covered
127
+ raise Error, "score history contains inconsistent counts"
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -57,7 +57,7 @@ module Saboteur
57
57
  <title>saboteur report</title>
58
58
  <style>#{STYLE}</style></head><body>
59
59
  <h1>saboteur report</h1>
60
- #{summary(report, data)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
60
+ #{summary(report, data)}#{inline_suppressions(report)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
61
61
  <h2>Files</h2>
62
62
  #{data[:files].map { |file, entry| file_section(file, entry, names, results) }.join("\n")}
63
63
  </body></html>
@@ -66,6 +66,17 @@ module Saboteur
66
66
 
67
67
  private
68
68
 
69
+ def inline_suppressions(report)
70
+ return "" if report.inline_suppressions.empty?
71
+
72
+ items = report.inline_suppressions.map do |entry|
73
+ path = entry[:file].delete_prefix("#{report.root}/")
74
+ "<li>#{escape(path)}:#{entry[:line]} #{escape(entry[:subject])} " \
75
+ "#{escape(entry[:operator])} <code>#{escape(entry[:digest])}</code>: #{escape(entry[:reason])}</li>"
76
+ end
77
+ "<h2>Inline suppressions (#{items.size}, outside the score)</h2><ul>#{items.join}</ul>"
78
+ end
79
+
69
80
  def summary(report, data)
70
81
  seed = data.dig(:config, :seed)
71
82
  seed_note = seed ? ", seed #{seed}" : ""
@@ -76,6 +87,7 @@ module Saboteur
76
87
  <p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
77
88
  mutants are not counted. Survivors accepted on the ignore list stay in the denominator. It covers only the subjects of this run, not the whole project. A tool that reads the
78
89
  Stryker JSON may show a different score, because the schema counts NoCoverage as undetected.</p>
90
+ #{score_split(report)}
79
91
  <p class="note">Wide tables scroll sideways.</p>
80
92
  <p>#{counts.map { |status, count| "#{status_label(status)} #{count}" }.join(' ')}</p>
81
93
  <p>Survivors: #{report.unignored_survivors.size} to fix, #{report.survivors.size - report.unignored_survivors.size}
@@ -83,6 +95,37 @@ module Saboteur
83
95
  HTML
84
96
  end
85
97
 
98
+ # How the score splits into assertion and crash kills, and the score
99
+ # without the whole-body mutants, which almost any test that runs the
100
+ # method kills. Each part shows only when it has something to say.
101
+ def score_split(report)
102
+ breakdown = report.score_breakdown
103
+ lines = []
104
+ lines << kill_split_line(breakdown) if breakdown.split_known?
105
+ lines << whole_body_line(report)
106
+ lines.compact.join("\n")
107
+ end
108
+
109
+ def kill_split_line(breakdown)
110
+ "<p>Of the tested mutants: #{parts_text(breakdown)}. A crash shows the code ran and broke, " \
111
+ "not that a test checked a value.</p>"
112
+ end
113
+
114
+ def parts_text(breakdown)
115
+ breakdown.parts.map { |label, share| "#{score_text(share)} #{label}" }.join(", ")
116
+ end
117
+
118
+ def whole_body_line(report)
119
+ whole = report.coarse_score_breakdown
120
+ fine = report.fine_score_breakdown
121
+ return if whole.tested.zero? || fine.tested.zero?
122
+
123
+ split = fine.split_known? ? " (#{parts_text(fine)})" : ""
124
+ "<p>Without whole-body mutants (body_nil, body_true, body_false, body_raise): #{score_text(fine.value)}" \
125
+ "#{split} over #{fine.tested} mutants. Whole-body mutants alone: #{score_text(whole.value)} " \
126
+ "over #{whole.tested}.</p>"
127
+ end
128
+
86
129
  def score_text(score)
87
130
  score ? format("%.1f%%", score * 100) : "n/a"
88
131
  end
@@ -126,13 +169,29 @@ module Saboteur
126
169
  def mutant_row(mutant, names, result)
127
170
  start = mutant[:location][:start]
128
171
  tests = Array(mutant[:killedBy]).map { |id| names.fetch(id) }.join(", ")
129
- notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}"].compact.join("; ")
172
+ notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}",
173
+ passing_tests_note(mutant, names, result)].compact.join("; ")
130
174
  "<tr><td class=\"fit\">line #{start[:line]}, column #{start[:column]}</td><td>#{row_status(mutant,
131
175
  result)}</td>" \
132
176
  "<td>#{escape(mutant[:mutatorName])}</td><td class=\"wide\">#{change(mutant, result)}</td>" \
133
177
  "<td class=\"fit\"><code>#{escape(mutant[:id])}</code></td><td class=\"wide\">#{escape(notes)}</td></tr>"
134
178
  end
135
179
 
180
+ # A survivor's covering tests all passed with the mutant applied, or, for
181
+ # a reused verdict, in the earlier run it came from.
182
+ def passing_tests_note(mutant, names, result)
183
+ return unless mutant[:status] == "Survived"
184
+
185
+ passed = Array(mutant[:coveredBy]).map { |id| names.fetch(id) }
186
+ return if passed.empty?
187
+
188
+ shown = passed.first(Terminal::MAX_PASSING_TESTS).join(", ")
189
+ more = passed.size - Terminal::MAX_PASSING_TESTS
190
+ extra = more.positive? ? " (+#{more} more)" : ""
191
+ when_passed = result.reused? ? "passed in an earlier run" : "passed"
192
+ "covered by #{passed.size} #{passed.one? ? 'test' : 'tests'} that #{when_passed}: #{shown}#{extra}"
193
+ end
194
+
136
195
  def row_status(mutant, result)
137
196
  badge = status_label(mutant[:status])
138
197
  # Only survivors are ever put on the ignore list.
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # The original and replacement text of one mutant, and the line it
7
+ # changes, for compact one-line output.
8
+ module MutationText
9
+ module_function
10
+
11
+ def line(result)
12
+ result.subject.line_of(sorted_edits(result).first.start_byte)
13
+ end
14
+
15
+ def original(result)
16
+ join(result) { |edit| result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte) }
17
+ end
18
+
19
+ def replacement(result)
20
+ join(result, &:replacement)
21
+ end
22
+
23
+ def sorted_edits(result)
24
+ result.mutation.edits.sort_by(&:start_byte)
25
+ end
26
+
27
+ # Whole-body mutants carry a long text; one line keeps the output compact.
28
+ def join(result, &pick)
29
+ text = sorted_edits(result).map { |edit| pick.call(edit).to_s.dup.force_encoding(Encoding::UTF_8).scrub }
30
+ .join(" | ")
31
+ flat = text.gsub(/\s*\n\s*/, " ").strip
32
+ flat.length > 60 ? "#{flat[0, 57]}..." : flat
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # The footer under a run that left survivors: how to look at one, how to
7
+ # accept one, and how to hand the run to an agent. Silent when nothing
8
+ # survived, so a clean run stays short.
9
+ class NextSteps
10
+ def initialize(out, palette)
11
+ @out = out
12
+ @palette = palette
13
+ end
14
+
15
+ def call(report)
16
+ survivor = report.unignored_survivors.first
17
+ return unless survivor
18
+
19
+ @out.puts "\n#{@palette.paint(:bold, 'Next steps:')}"
20
+ seed = " --seed #{report.seed}" if report.seed
21
+ @out.puts " replay one survivor: saboteur run --id #{survivor.digest}#{seed}"
22
+ @out.puts " (add this run's own --config, --since, --subject, --test and source options)"
23
+ @out.puts " accept an equivalent one: add to your ignore file (c.ignore_file, default #{Config::IGNORE_FILE}):"
24
+ entry = %(i.mutation "#{survivor.digest}", subject: "#{survivor.subject.id}", reason: "why it is equivalent")
25
+ @out.puts " #{entry}"
26
+ @out.puts " let an agent close the gaps: saboteur run --agent, with the same options"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Wraps text in ANSI colour codes when colour is on, and returns it
7
+ # unchanged when it is off. Colour is on only when asked for or when the
8
+ # output is a terminal and NO_COLOR is not set (https://no-color.org).
9
+ class Palette
10
+ CODES = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36, dim: 2, bold: 1 }.freeze
11
+
12
+ # mode: :auto, :always or :never.
13
+ def self.for(out, mode: :auto, environment: ENV)
14
+ enabled = case mode
15
+ when :always then true
16
+ when :never then false
17
+ else out.respond_to?(:tty?) && out.tty? && environment["NO_COLOR"].to_s.empty?
18
+ end
19
+ new(enabled: enabled)
20
+ end
21
+
22
+ def initialize(enabled:)
23
+ @enabled = enabled
24
+ end
25
+
26
+ def enabled?
27
+ @enabled
28
+ end
29
+
30
+ def paint(color, text)
31
+ return text unless @enabled
32
+
33
+ "\e[#{CODES.fetch(color)}m#{text}\e[0m"
34
+ end
35
+ end
36
+ end
37
+ end