saboteur 0.9.0 → 0.11.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +109 -247
  4. data/exe/saboteur +2 -2
  5. data/lib/saboteur/baseline/loader.rb +41 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  7. data/lib/saboteur/baseline/run_gate.rb +51 -0
  8. data/lib/saboteur/baseline/workflow.rb +2 -15
  9. data/lib/saboteur/cli/baseline_gate.rb +58 -0
  10. data/lib/saboteur/cli/error_cause.rb +16 -0
  11. data/lib/saboteur/cli/exit_status.rb +14 -13
  12. data/lib/saboteur/cli/failure_output.rb +23 -0
  13. data/lib/saboteur/cli/output_options.rb +24 -2
  14. data/lib/saboteur/cli/run_budget_options.rb +6 -0
  15. data/lib/saboteur/cli/run_command.rb +14 -1
  16. data/lib/saboteur/cli/run_reporting.rb +34 -6
  17. data/lib/saboteur/cli/run_validation.rb +36 -3
  18. data/lib/saboteur/cli/selection_options.rb +2 -0
  19. data/lib/saboteur/cli.rb +6 -1
  20. data/lib/saboteur/config.rb +23 -2
  21. data/lib/saboteur/crash_report.rb +16 -7
  22. data/lib/saboteur/engine/baseline_guard.rb +2 -2
  23. data/lib/saboteur/engine/coverage_partial.rb +8 -1
  24. data/lib/saboteur/engine/coverage_store.rb +4 -5
  25. data/lib/saboteur/engine/diagnostics.rb +120 -15
  26. data/lib/saboteur/engine/run_fingerprint.rb +1 -1
  27. data/lib/saboteur/engine/scheduler.rb +20 -0
  28. data/lib/saboteur/engine/selection_announcement.rb +8 -1
  29. data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
  30. data/lib/saboteur/engine.rb +43 -6
  31. data/lib/saboteur/help.rb +4 -1
  32. data/lib/saboteur/mutation_source/builtin.rb +9 -3
  33. data/lib/saboteur/mutation_source/copy_replacement.rb +72 -0
  34. data/lib/saboteur/mutation_source/ensure_removal.rb +49 -0
  35. data/lib/saboteur/mutation_source/operator_examples.rb +2 -0
  36. data/lib/saboteur/mutation_source/operator_registry.rb +3 -1
  37. data/lib/saboteur/reporter/agent_summary.rb +3 -1
  38. data/lib/saboteur/reporter/assets/mutation_report.LICENSE +201 -0
  39. data/lib/saboteur/reporter/assets/mutation_report.js +331 -0
  40. data/lib/saboteur/reporter/baseline_tail.rb +93 -0
  41. data/lib/saboteur/reporter/diff.rb +10 -0
  42. data/lib/saboteur/reporter/html.rb +69 -14
  43. data/lib/saboteur/reporter/kill_causes.rb +53 -0
  44. data/lib/saboteur/reporter/next_steps.rb +4 -3
  45. data/lib/saboteur/reporter/palette.rb +64 -13
  46. data/lib/saboteur/reporter/safe_out.rb +1 -1
  47. data/lib/saboteur/reporter/score_table.rb +53 -18
  48. data/lib/saboteur/reporter/stryker.rb +4 -1
  49. data/lib/saboteur/reporter/survivor_classification.rb +12 -3
  50. data/lib/saboteur/reporter/survivor_hints.rb +72 -0
  51. data/lib/saboteur/reporter/survivor_list.rb +207 -75
  52. data/lib/saboteur/reporter/terminal.rb +315 -69
  53. data/lib/saboteur/result.rb +28 -1
  54. data/lib/saboteur/score.rb +6 -0
  55. data/lib/saboteur/version.rb +1 -1
  56. metadata +12 -2
@@ -26,8 +26,10 @@ module Saboteur
26
26
  "endpoint_replacement" => "try a collection with 2 or more elements, to tell first from last",
27
27
  "jump_value" => "try asserting the value this early exit returns",
28
28
  "rescue_removal" => "try an example where the guarded code raises, and assert what the handler returns",
29
+ "ensure_removal" => "try asserting the cleanup effect of the ensure clause",
29
30
  "when_removal" => "try a value that only this when branch matches, and assert its result",
30
31
  "unary_removal" => "try a value where the sign or complement changes the result",
32
+ "copy_replacement" => "try changing the result after the call and check the original is untouched",
31
33
  "filter_replacement" => "try items that match and items that do not, to tell select from reject"
32
34
  }.freeze
33
35
 
@@ -43,8 +45,78 @@ module Saboteur
43
45
  EQUAL_CASE = [%w[< <=], %w[> >=]].map(&:to_set).freeze
44
46
  UNEQUAL_CASE = [%w[< >], %w[<= >=]].map(&:to_set).freeze
45
47
 
48
+ # Labels that never get a hint: a generated assertion on an equivalent
49
+ # mutant is the worst kind of hint noise. The assertion_gap/input_gap
50
+ # split (#1064) narrows this further; until it lands, every other
51
+ # label keeps the operator hint unchanged.
52
+ NO_HINT_LABELS = %w[likely_equivalent].freeze
53
+
54
+ # One deterministic template sentence per operator: what the change
55
+ # means for behaviour, never an inference beyond the operator.
56
+ WHY_IT_MATTERS = {
57
+ "comparison" => "the boundary between the two comparisons is untested",
58
+ "arithmetic" => "the computed value can drift without a test noticing",
59
+ "boolean_logic" => "one side of the condition no longer decides the result",
60
+ "condition_negation" => "the condition now picks the opposite branch",
61
+ "negation_removal" => "the negated value is no longer distinguished",
62
+ "literal_number" => "no test depends on this exact number",
63
+ "literal_boolean" => "no test depends on this exact true/false value",
64
+ "literal_string" => "no test asserts on this exact text",
65
+ "statement_removal" => "the statement's effect goes unobserved",
66
+ "and_assign" => "the nil-or-false case of &&= is untested",
67
+ "or_assign" => "the already-set case of ||= is untested",
68
+ "safe_navigation" => "a nil receiver now behaves like any other value",
69
+ "argument_removal" => "the call no longer depends on this argument",
70
+ "argument_swap" => "the argument order no longer matters to any test",
71
+ "pair_removal" => "the hash no longer depends on this key",
72
+ "chain_removal" => "part of the call chain has no observable effect",
73
+ "endpoint_replacement" => "first and last are no longer distinguished",
74
+ "jump_value" => "the early exit's value goes unobserved",
75
+ "rescue_removal" => "the rescue handler's behaviour goes unobserved",
76
+ "ensure_removal" => "the ensure cleanup's effect goes unobserved",
77
+ "when_removal" => "the values only this branch matches are untested",
78
+ "unary_removal" => "the sign or complement change goes unobserved",
79
+ "copy_replacement" => "aliasing and copying are no longer distinguished",
80
+ "filter_replacement" => "select and reject are no longer distinguished",
81
+ "interpolation_removal" => "the interpolated value goes unobserved",
82
+ "regex_replacement" => "the pattern's matched set changed unnoticed",
83
+ "method_swap" => "the swapped method's different behaviour goes unobserved",
84
+ "element_removal" => "the removed element goes unobserved",
85
+ "operand_replacement" => "the result no longer depends on the replaced operand",
86
+ "operator_assignment" => "the combined assignment operation goes unobserved"
87
+ }.freeze
88
+ WHY_FALLBACK = "no test observes this change"
89
+
90
+ # The deterministic per-label reason printed next to a survivor's rank.
91
+ # Labels come from SurvivorClassification; they are heuristics, not
92
+ # diagnoses. #1064 lands the assertion_gap label: add its reason here
93
+ # and its gate in NO_HINT_LABELS when it does.
94
+ LABEL_REASONS = {
95
+ "likely_equivalent" => "compiles to the same instructions as the original",
96
+ "arid" => "every edited line matches an arid pattern",
97
+ "missing_test" => "the covering specs pass without pinning this change",
98
+ "tool_limit" => "the tool could not observe this mutant faithfully"
99
+ }.freeze
100
+
46
101
  module_function
47
102
 
103
+ # The hint for a labeled survivor; nil when the label suppresses hints.
104
+ def for_label(operator, label:, coarse: false)
105
+ return nil if NO_HINT_LABELS.include?(label)
106
+
107
+ self.for(operator, coarse: coarse)
108
+ end
109
+
110
+ # The per-operator "why it matters" sentence for a survivor block.
111
+ def why(operator)
112
+ WHY_IT_MATTERS.fetch(operator, WHY_FALLBACK)
113
+ end
114
+
115
+ # The per-label reason sentence; nil when the label has none.
116
+ def label_reason(label)
117
+ LABEL_REASONS[label]
118
+ end
119
+
48
120
  def for(operator, coarse: false)
49
121
  BY_OPERATOR.fetch(operator) { coarse ? COARSE : nil }
50
122
  end
@@ -1,119 +1,168 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "diff"
4
5
  require_relative "mutation_text"
6
+ require_relative "survivor_classification"
5
7
  require_relative "survivor_hints"
6
8
 
7
9
  module Saboteur
8
10
  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.
11
+ # Survivors as per-statement blocks: the finding is the code. Each block
12
+ # is numbered and headed by file:line and subject, shows the enclosing
13
+ # def with the changed line as a -/+ pair (a ^^^ marker under the exact
14
+ # changed span), a per-operator "why it matters" sentence, the tests that
15
+ # covered the line and still passed, and an optional hint. Sibling
16
+ # mutants on the same statement collapse onto one dim line;
17
+ # --all-mutants gives every survivor its own block. Accepted
18
+ # (ignored) survivors get one quoted dim line each, never a block.
12
19
  class SurvivorList
13
- MAX_PASSING_TESTS = 5
20
+ # Rendered rows in the method fence, elision markers included.
21
+ FENCE_CAP = 10
14
22
 
15
- def initialize(out, palette)
23
+ # "- " + three right-aligned line-number columns + " ".
24
+ GUTTER_WIDTH = 6
25
+
26
+ # Sibling digests named on the collapse line; the rest are a count.
27
+ SIBLING_DIGEST_CAP = 3
28
+
29
+ def initialize(out, palette, all_mutants: false, classifier: SurvivorClassification)
16
30
  @out = out
17
31
  @palette = palette
32
+ @all_mutants = all_mutants
33
+ @classifier = classifier
18
34
  end
19
35
 
20
- # Every survivor, in report order; the grouping keeps them readable.
36
+ # Survivors in report (ranked) order; accepted ones trail as dim lines.
21
37
  def call(report, survivors)
22
38
  return if survivors.empty?
23
39
 
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) }
40
+ live, accepted = survivors.partition(&:unignored_survivor?)
41
+ @out.puts @palette.paint(:head, "Survivors (#{live.size}) - changes your tests did not catch")
42
+ groups(live).each_with_index { |group, index| block(report, group, index + 1, live.size) }
43
+ accepted_section(report, accepted)
44
+ hinted = live.select { |result| hint_for(result) }
45
+ @out.puts "\n#{@palette.paint(:dim, SurvivorHints::CAVEAT)}" if hinted.any?
29
46
  end
30
47
 
31
48
  private
32
49
 
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
50
+ # Statement + status is the group key: every survivor here survived.
51
+ # --all-mutants restores the per-mutant list: no collapse, one block
52
+ # each, in the same ranked order.
53
+ def groups(live)
54
+ return live.map { |result| [result] } if @all_mutants
40
55
 
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
56
+ live.group_by { |result| [result.subject.id, MutationText.line(result)] }.values
48
57
  end
49
58
 
50
- # Mutants on one line share the printed evidence when it names the
51
- # same tests: the covering-test list is compared as a set (#502), so
52
- # one line's mutants no longer reprint an identical block only because
53
- # the run measured the killer order per mutant. Reuse and changed
54
- # lines still group exactly. Each run of neighbours with the same key
55
- # is one group and shares evidence above its own mutations; evidence
56
- # that repeats after another group is printed again, which keeps the
57
- # ranked order. The block prints in the first mutant's run order.
58
- def evidence_groups(report, on_line)
59
- on_line.chunk { |result| [result.covered_by.uniq.sort, result.reused?, report.changed_lines_for(result)] }
60
- .map(&:last)
61
- end
62
-
63
- def evidence_group(report, path, group)
59
+ def block(report, group, rank, total)
64
60
  first = group.first
61
+ line = MutationText.line(first)
62
+ path = relative(first.subject.file, report.root)
63
+ @out.puts "\n#{@palette.paint(:head, "#{rank}. #{path}:#{line}")} #{first.subject.id} " \
64
+ "#{@palette.paint(:dim, label_suffix(first, rank, total))}".rstrip
65
+ fence(first, line)
66
+ @out.puts " #{@palette.paint(:dim, "why it matters: #{why(first.mutation.operator)}")}"
65
67
  changed_lines(report, path, first)
66
- passing_tests(first)
67
- group.each { |result| mutation(result) }
68
+ covered(group)
69
+ hint_line(first)
70
+ siblings(report, group)
68
71
  end
69
72
 
70
- def source_line(result, line)
71
- result.subject.file_source.lines[line - 1].to_s.strip
73
+ def label_suffix(result, rank, total)
74
+ label = @classifier.label(result)
75
+ parts = [label, "##{rank} of #{total}"].compact
76
+ reason = SurvivorHints.label_reason(label)
77
+ "[#{parts.join(" \u00B7 ")}#{": #{reason}" if reason}]"
72
78
  end
73
79
 
74
- def mutation(result)
75
- change = if MutationText.removal?(result)
76
- "removes #{MutationText.original(result)}"
77
- else
78
- "#{MutationText.original(result)} -> #{@palette.paint(:green, MutationText.replacement(result))}"
79
- end
80
- text = " #{@palette.paint(:red, '-')} #{result.mutation.operator} " \
81
- "#{change} #{result.digest}"
82
- text += " (ignored: #{result.ignored})" if result.ignored
83
- @out.puts text
84
- hint(result)
80
+ # The enclosing def with the changed line as a -/+ pair; longer
81
+ # methods keep head, the changed line and tail, the middle elided.
82
+ def fence(result, changed_line)
83
+ all = result.subject.source.lines
84
+ index = changed_line - result.subject.start_line
85
+ lines = fenced_rows(all, index).map do |row|
86
+ if row == :elision
87
+ @palette.paint(:dim, "#{gutter(' ', '')}...")
88
+ elsif row == index
89
+ changed_pair(result, all[row], changed_line)
90
+ else
91
+ @palette.paint(:dim, "#{gutter(' ', result.subject.start_line + row)}#{all[row].rstrip}")
92
+ end
93
+ end
94
+ @out.puts lines
85
95
  end
86
96
 
87
- def hint(result)
88
- text = hint_for(result)
89
- @out.puts " #{@palette.paint(:cyan, 'hint:')} #{text}" if text
97
+ # Head, the changed line with one neighbour each side, and the tail
98
+ # always survive the cap; only true middle lines elide.
99
+ def fenced_rows(all, changed_index)
100
+ return (0...all.size).to_a if all.size <= FENCE_CAP
101
+
102
+ keep = [0, 1, 2, changed_index - 1, changed_index, changed_index + 1, all.size - 2, all.size - 1]
103
+ .uniq.grep(0...all.size)
104
+ keep.each_with_object([]) do |i, rows|
105
+ rows << :elision if rows.any? && !rows.include?(i - 1)
106
+ rows << i
107
+ end
90
108
  end
91
109
 
92
- def hint_for(result)
93
- return nil if result.ignored
110
+ def changed_pair(result, original_line, line_number)
111
+ replacement_line = replaced_line(result, original_line, line_number)
112
+ minus = @palette.paint(:fail, "#{gutter('-', line_number)}#{original_line.rstrip}")
113
+ return [minus] if replacement_line.strip.empty?
94
114
 
95
- SurvivorHints.for(result.mutation.operator, coarse: result.mutation.coarse)
115
+ plus = "#{gutter('+', line_number)}#{replacement_line.rstrip}"
116
+ span = Diff.edit_span(line_start(result, line_number), MutationText.sorted_edits(result))
117
+ column, width = char_span(original_line, span)
118
+ [minus,
119
+ @palette.paint(:pass, plus) +
120
+ @palette.paint(:dim, " #{result.mutation.operator} \u00B7 #{result.digest}"),
121
+ @palette.paint(:pass, "#{' ' * (GUTTER_WIDTH + column)}#{'^' * width}")]
96
122
  end
97
123
 
98
- # The tests that ran with the mutant applied and still passed: the
99
- # missing assertion belongs in one of them. They are listed likeliest
100
- # killers first, the order the run used. Line coverage can overstate which
101
- # tests exercise the line, so the list is a lead, not proof.
102
- def passing_tests(result)
103
- tests = result.covered_by
104
- return if tests.empty?
124
+ # The span comes in file bytes; the caret lines up in printed
125
+ # characters, so a multibyte character before the edit must not push
126
+ # the caret one column right per byte.
127
+ def char_span(line, (offset, length))
128
+ bytes = line.b
129
+ # scrub() would not change the count: invalid bytes count as one
130
+ # character with or without it.
131
+ decode = ->(part) { part.force_encoding(line.encoding).length }
132
+ [decode.call(bytes.byteslice(0, offset)), decode.call(bytes.byteslice(offset, length))]
133
+ end
105
134
 
106
- @out.puts " #{tests.size} #{tests.one? ? 'test' : 'tests'} covered this line and " \
107
- "#{passed_phrase(result)} (the missing check may belong in one of them):"
108
- tests.first(MAX_PASSING_TESTS).each { |name| @out.puts " #{name}" }
109
- more = tests.size - MAX_PASSING_TESTS
110
- @out.puts " ... #{more} more in the JSON report (coveredBy)" if more.positive?
135
+ # The changed line with this mutant's edits applied, so a line count
136
+ # change in the full def text cannot misalign the pair.
137
+ def replaced_line(result, original_line, line_number)
138
+ start = line_start(result, line_number)
139
+ MutationText.sorted_edits(result).reverse_each.reduce(original_line.b) do |text, edit|
140
+ from = edit.start_byte - start
141
+ text[0...from].to_s + edit.replacement.b + text[(from + (edit.end_byte - edit.start_byte))..].to_s
142
+ end.force_encoding(original_line.encoding)
111
143
  end
112
144
 
113
- def passed_phrase(result)
114
- result.reused? ? "passed in an earlier run (--incremental)" : "still passed"
145
+ # Byte offset where the printable form of this def line starts. The
146
+ # first line of a subject begins at the def keyword (subject.source is
147
+ # not the whole file line), later lines start at the file line start.
148
+ def line_start(result, line_number)
149
+ subject = result.subject
150
+ return subject.start_byte if line_number == subject.start_line
151
+
152
+ subject.file_source.lines.take(line_number - 1).join.bytesize
115
153
  end
116
154
 
155
+ def gutter(sign, number)
156
+ "#{sign} #{number.to_s.rjust(3)} "
157
+ end
158
+
159
+ def why(operator)
160
+ SurvivorHints.why(operator)
161
+ end
162
+
163
+ # The tests that ran with the mutant applied and still passed: the
164
+ # missing assertion belongs in one of them. Line coverage can
165
+ # overstate which tests exercise the line, so the list is a lead.
117
166
  # In diff mode: the changed lines this mutant touches.
118
167
  def changed_lines(report, path, result)
119
168
  lines = report.changed_lines_for(result)
@@ -121,7 +170,90 @@ module Saboteur
121
170
 
122
171
  shown = lines.first(3).map { |line| "#{path}:#{line}" }.join(", ")
123
172
  more = lines.size - 3
124
- @out.puts " changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{" (+#{more} more)" if more.positive?}"
173
+ suffix = more.positive? ? " (+#{more} more)" : ""
174
+ @out.puts @palette.paint(:dim, " changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{suffix}")
175
+ end
176
+
177
+ # The covering tests are a group union (#502): the first names where an
178
+ # assertion would pin the change, the count says the rest also passed.
179
+ def covered(group)
180
+ tests = group.flat_map(&:covered_by).uniq
181
+ return if tests.empty?
182
+
183
+ first = group.find { |result| result.covered_by.include?(tests.first) }
184
+ @out.puts " covered by #{tests.first} #{@palette.paint(:dim, "- #{passed_phrase(first)}")}"
185
+ line = more_covered_line(group, tests)
186
+ @out.puts @palette.paint(:dim, " #{line}") if line
187
+ end
188
+
189
+ # The verdict names what this run did: executed is nil when the run
190
+ # did not record which tests ran, so only the bare verdict is safe.
191
+ def more_covered_line(group, tests)
192
+ more = tests.size - 1
193
+ return unless more.positive?
194
+
195
+ executed = group.filter_map(&:executed).flatten.uniq
196
+ ran = tests.drop(1).count { |test| executed.include?(test) }
197
+ suffix = if executed.empty?
198
+ # Only a run that recorded nothing about this group may
199
+ # keep the bare verdict; recorded-but-not-run is named.
200
+ if group.all? { |result| result.executed.nil? }
201
+ ", all passed"
202
+ else
203
+ " (none ran this run)"
204
+ end
205
+ elsif ran == more
206
+ " (all #{more} ran, all passed)"
207
+ elsif ran.zero?
208
+ " (none ran this run)"
209
+ else
210
+ " (#{ran} ran, all passed; #{more - ran} did not run this run)"
211
+ end
212
+ "+#{more} more covered#{suffix}"
213
+ end
214
+
215
+ def passed_phrase(result)
216
+ result.reused? ? "passed in an earlier run (--incremental)" : "ran, still passed"
217
+ end
218
+
219
+ def hint_line(result)
220
+ text = hint_for(result)
221
+ @out.puts " #{@palette.paint(:info, "\u2192")} #{text}" if text
222
+ end
223
+
224
+ def hint_for(result)
225
+ SurvivorHints.for_label(result.mutation.operator, label: @classifier.label(result),
226
+ coarse: result.mutation.coarse)
227
+ end
228
+
229
+ def siblings(report, group)
230
+ rest = group.drop(1)
231
+ return if rest.empty?
232
+
233
+ list = rest.first(SIBLING_DIGEST_CAP)
234
+ .map { |result| "#{result.digest} (#{result.mutation.operator})" }.join(", ")
235
+ overflow = rest.size - SIBLING_DIGEST_CAP
236
+ list = "#{list} (+#{overflow} more)" if overflow.positive?
237
+ @out.puts @palette.paint(:dim, " #{rest.size} more #{rest.one? ? 'mutant' : 'mutants'} here: #{list}")
238
+ @out.puts @palette.paint(:dim, " expand: saboteur run --all-mutants#{' --since main' if report.diff?}")
239
+ end
240
+
241
+ # Accepted (ignored) survivors stay visible but never earn a block:
242
+ # one quoted dim line each, marked not scored.
243
+ def accepted_section(report, accepted)
244
+ return if accepted.empty?
245
+
246
+ @out.puts ""
247
+ entries = accepted.map do |result|
248
+ "#{relative(result.subject.file, report.root)}:#{MutationText.line(result)} " \
249
+ "#{result.mutation.operator} - \"#{result.ignored}\""
250
+ end
251
+ if accepted.one?
252
+ @out.puts @palette.paint(:dim, "Accepted (1, not scored): #{entries.join}")
253
+ else
254
+ lines = ["Accepted (#{accepted.size}, not scored):"] + entries.map { |entry| " #{entry}" }
255
+ @out.puts @palette.paint(:dim, lines.join("\n"))
256
+ end
125
257
  end
126
258
 
127
259
  def relative(file, root)