evilution 0.24.0 → 0.25.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +205 -0
  3. data/CHANGELOG.md +35 -0
  4. data/README.md +80 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +2 -1
  9. data/lib/evilution/cli/parser/options_builder.rb +21 -1
  10. data/lib/evilution/cli/printers/compare.rb +159 -0
  11. data/lib/evilution/cli.rb +1 -0
  12. data/lib/evilution/compare/categorizer.rb +109 -0
  13. data/lib/evilution/compare/detector.rb +21 -0
  14. data/lib/evilution/compare/fingerprint.rb +83 -0
  15. data/lib/evilution/compare/normalizer.rb +106 -0
  16. data/lib/evilution/compare/record.rb +16 -0
  17. data/lib/evilution/compare.rb +15 -0
  18. data/lib/evilution/config.rb +165 -3
  19. data/lib/evilution/example_filter.rb +143 -0
  20. data/lib/evilution/integration/crash_detector.rb +5 -2
  21. data/lib/evilution/integration/minitest.rb +10 -5
  22. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  23. data/lib/evilution/integration/rspec.rb +82 -7
  24. data/lib/evilution/isolation/fork.rb +25 -0
  25. data/lib/evilution/mcp/info_tool.rb +77 -5
  26. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  27. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  28. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  29. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  30. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  31. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  32. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  33. data/lib/evilution/mutation.rb +43 -3
  34. data/lib/evilution/mutator/base.rb +39 -1
  35. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  36. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  37. data/lib/evilution/parallel/work_queue.rb +149 -31
  38. data/lib/evilution/parallel_db_warning.rb +68 -0
  39. data/lib/evilution/reporter/cli.rb +37 -11
  40. data/lib/evilution/reporter/html/assets/style.css +17 -0
  41. data/lib/evilution/reporter/html/sections/file_section.rb +15 -0
  42. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  43. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  44. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  45. data/lib/evilution/reporter/html/templates/file_section.html.erb +3 -0
  46. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  47. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +3 -0
  48. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  49. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  50. data/lib/evilution/reporter/json.rb +8 -2
  51. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  52. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  53. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  54. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  55. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  56. data/lib/evilution/reporter/suggestion.rb +8 -1327
  57. data/lib/evilution/result/mutation_result.rb +5 -1
  58. data/lib/evilution/result/summary.rb +13 -1
  59. data/lib/evilution/runner/baseline_runner.rb +23 -2
  60. data/lib/evilution/runner/mutation_executor.rb +83 -13
  61. data/lib/evilution/runner.rb +6 -0
  62. data/lib/evilution/source_ast_cache.rb +39 -0
  63. data/lib/evilution/spec_ast_cache.rb +166 -0
  64. data/lib/evilution/spec_resolver.rb +6 -1
  65. data/lib/evilution/spec_selector.rb +39 -0
  66. data/lib/evilution/temp_dir_tracker.rb +23 -3
  67. data/lib/evilution/version.rb +1 -1
  68. data/script/memory_check +7 -5
  69. metadata +34 -2
@@ -27,24 +27,62 @@ class Evilution::Mutator::Base < Prism::Visitor
27
27
  def add_mutation(offset:, length:, replacement:, node:)
28
28
  return if @filter && @filter.skip?(node)
29
29
 
30
- mutated_source = Evilution::AST::SourceSurgeon.apply(
30
+ surgery = Evilution::AST::SourceSurgeon.apply(
31
31
  @file_source,
32
32
  offset: offset,
33
33
  length: length,
34
34
  replacement: replacement
35
35
  )
36
+ mutated_source = surgery.source
37
+
38
+ original_slice, mutated_slice = slice_affected_lines(
39
+ mutated_source: mutated_source,
40
+ offset: offset,
41
+ length: length,
42
+ replacement_bytesize: replacement.bytesize
43
+ )
36
44
 
37
45
  @mutations << Evilution::Mutation.new(
38
46
  subject: @subject,
39
47
  operator_name: self.class.operator_name,
40
48
  original_source: @file_source,
41
49
  mutated_source: mutated_source,
50
+ original_slice: original_slice,
51
+ mutated_slice: mutated_slice,
52
+ parse_status: surgery.status,
42
53
  file_path: @subject.file_path,
43
54
  line: node.location.start_line,
44
55
  column: node.location.start_column
45
56
  )
46
57
  end
47
58
 
59
+ NEWLINE_BYTE = 10
60
+ private_constant :NEWLINE_BYTE
61
+
62
+ def slice_affected_lines(mutated_source:, offset:, length:, replacement_bytesize:)
63
+ line_start = line_start_byte(@file_source, offset)
64
+ orig_line_end = line_end_byte(@file_source, [offset + length - 1, line_start].max)
65
+ mut_line_end = line_end_byte(mutated_source, [offset + replacement_bytesize - 1, line_start].max)
66
+
67
+ [
68
+ @file_source.byteslice(line_start, orig_line_end - line_start),
69
+ mutated_source.byteslice(line_start, mut_line_end - line_start)
70
+ ]
71
+ end
72
+
73
+ def line_start_byte(source, offset)
74
+ i = offset - 1
75
+ i -= 1 while i >= 0 && source.getbyte(i) != NEWLINE_BYTE
76
+ i + 1
77
+ end
78
+
79
+ def line_end_byte(source, from)
80
+ limit = source.bytesize
81
+ i = from
82
+ i += 1 while i < limit && source.getbyte(i) != NEWLINE_BYTE
83
+ i < limit ? i + 1 : limit
84
+ end
85
+
48
86
  def byteslice_source(offset, length)
49
87
  @file_source.byteslice(offset, length).force_encoding(@file_source.encoding)
50
88
  end
@@ -13,7 +13,7 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
13
13
  def visit_call_node(node)
14
14
  args = node.arguments&.arguments
15
15
 
16
- if args && args.length >= 1 && positional_only?(args)
16
+ if mutable?(node, args)
17
17
  args.each_index do |i|
18
18
  parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
19
19
  replacement = parts.join(", ")
@@ -32,6 +32,10 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
32
32
 
33
33
  private
34
34
 
35
+ def mutable?(node, args)
36
+ args && args.length >= 1 && positional_only?(args) && node.name != :[]=
37
+ end
38
+
35
39
  def positional_only?(args)
36
40
  args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
37
41
  end
@@ -13,7 +13,7 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
13
13
  def visit_call_node(node)
14
14
  args = node.arguments&.arguments
15
15
 
16
- if args && args.length >= 2 && positional_only?(args)
16
+ if mutable?(node, args)
17
17
  args.each_index do |i|
18
18
  remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
19
19
  replacement = remaining.join(", ")
@@ -32,6 +32,10 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
32
32
 
33
33
  private
34
34
 
35
+ def mutable?(node, args)
36
+ args && args.length >= 2 && positional_only?(args) && node.name != :[]=
37
+ end
38
+
35
39
  def positional_only?(args)
36
40
  args.none? { |arg| SKIP_TYPES.any? { |type| arg.is_a?(type) } }
37
41
  end
@@ -21,31 +21,31 @@ class Evilution::Parallel::WorkQueue
21
21
  end
22
22
  end
23
23
 
24
- def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil)
25
- raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
26
- raise ArgumentError, "prefetch must be a positive integer, got #{prefetch.inspect}" unless prefetch.is_a?(Integer) && prefetch >= 1
27
- unless item_timeout.nil? || (item_timeout.is_a?(Numeric) && item_timeout.positive?)
28
- raise ArgumentError, "item_timeout must be nil or a positive number, got #{item_timeout.inspect}"
29
- end
30
-
24
+ def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil, worker_max_items: nil)
25
+ validate_init_args(size, prefetch, item_timeout, worker_max_items)
31
26
  @size = size
32
27
  @hooks = hooks
33
28
  @prefetch = prefetch
34
29
  @item_timeout = item_timeout
30
+ @worker_max_items = worker_max_items
35
31
  @worker_stats = []
36
32
  end
37
33
 
38
- def map(items, &)
34
+ def map(items, &block)
39
35
  return [] if items.empty?
40
36
 
37
+ @block = block
38
+ @retired_workers = []
41
39
  worker_count = [@size, items.length].min
42
- workers = spawn_workers(worker_count, &)
40
+ workers = spawn_workers(worker_count, &block)
43
41
 
44
42
  begin
45
43
  distribute_and_collect(items, workers)
46
44
  ensure
47
45
  shutdown_workers(workers)
48
- @worker_stats = build_worker_stats(workers)
46
+ @worker_stats = @retired_workers + build_worker_stats(workers)
47
+ @block = nil
48
+ @retired_workers = nil
49
49
  end
50
50
  end
51
51
 
@@ -55,22 +55,64 @@ class Evilution::Parallel::WorkQueue
55
55
 
56
56
  private
57
57
 
58
- def spawn_workers(count, &)
59
- count.times.map do
60
- cmd_read, cmd_write = IO.pipe
61
- res_read, res_write = IO.pipe
58
+ def validate_init_args(size, prefetch, item_timeout, worker_max_items)
59
+ validate_positive_int!(:size, size)
60
+ validate_positive_int!(:prefetch, prefetch)
61
+ validate_optional_positive_number!(:item_timeout, item_timeout)
62
+ validate_optional_positive_int!(:worker_max_items, worker_max_items)
63
+ end
62
64
 
63
- pid = Process.fork do
64
- cmd_write.close
65
- res_read.close
66
- worker_loop(cmd_read, res_write, &)
67
- end
65
+ def validate_positive_int!(name, value)
66
+ return if value.is_a?(Integer) && value >= 1
68
67
 
69
- cmd_read.close
70
- res_write.close
68
+ raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}"
69
+ end
70
+
71
+ def validate_optional_positive_int!(name, value)
72
+ return if value.nil? || (value.is_a?(Integer) && value.positive?)
73
+
74
+ raise ArgumentError, "#{name} must be nil or a positive integer, got #{value.inspect}"
75
+ end
76
+
77
+ def validate_optional_positive_number!(name, value)
78
+ return if value.nil? || (value.is_a?(Numeric) && value.positive?)
79
+
80
+ raise ArgumentError, "#{name} must be nil or a positive number, got #{value.inspect}"
81
+ end
82
+
83
+ def spawn_workers(count, &block)
84
+ count.times.map { |slot| spawn_one_worker(worker_index: slot, &block) }
85
+ end
86
+
87
+ # EV-kdns / GH #817: translate 0-based worker slot to parallel_tests'
88
+ # TEST_ENV_NUMBER convention ("" for slot 0, "2" for slot 1, ...). Rails
89
+ # apps interpolating TEST_ENV_NUMBER into database.yml get per-worker
90
+ # SQLite files, avoiding lock contention under jobs > 1.
91
+ def test_env_number_for(worker_index)
92
+ worker_index.zero? ? "" : (worker_index + 1).to_s
93
+ end
71
94
 
72
- { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0, pending: 0 }
95
+ def spawn_one_worker(worker_index:, &block)
96
+ cmd_read, cmd_write = IO.pipe
97
+ res_read, res_write = IO.pipe
98
+ # Marshal payloads are ASCII-8BIT; pipes default to text mode and may
99
+ # transcode according to their external/internal encodings (influenced by
100
+ # Encoding.default_external and/or Encoding.default_internal — Rails sets
101
+ # the latter to UTF-8), failing on bytes with no mapping. Force binmode on
102
+ # all four ends.
103
+ [cmd_read, cmd_write, res_read, res_write].each(&:binmode)
104
+
105
+ pid = Process.fork do
106
+ cmd_write.close
107
+ res_read.close
108
+ ENV["TEST_ENV_NUMBER"] = test_env_number_for(worker_index)
109
+ worker_loop(cmd_read, res_write, &block)
73
110
  end
111
+
112
+ cmd_read.close
113
+ res_write.close
114
+
115
+ { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0, pending: 0, worker_index: worker_index }
74
116
  end
75
117
 
76
118
  def worker_loop(cmd_read, res_write, &block)
@@ -135,33 +177,109 @@ class Evilution::Parallel::WorkQueue
135
177
  end
136
178
 
137
179
  readable.each do |io|
138
- alive = handle_result(io, io_to_worker[io], items, state)
180
+ alive = handle_result(io, io_to_worker[io], items, state, workers, io_to_worker, result_ios)
139
181
  result_ios.delete(io) unless alive
140
182
  end
141
183
  end
142
184
  end
143
185
 
144
- def handle_result(io, worker, items, state)
186
+ def handle_result(io, worker, items, state, workers, io_to_worker, result_ios)
145
187
  message = read_result(io)
188
+ return handle_dead_worker(worker, state) if message.nil?
146
189
 
147
- if message.nil?
148
- state.first_error = Evilution::Error.new("worker process exited unexpectedly") if state.first_error.nil?
149
- state.in_flight -= worker[:pending]
150
- worker[:pending] = 0
151
- return false
152
- end
190
+ record_result(message, worker, state)
191
+ return false if recycle_and_dispatch(worker, items, state, workers, io_to_worker, result_ios)
192
+ return true if draining_for_recycle?(worker)
193
+
194
+ send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
195
+ true
196
+ end
197
+
198
+ # Once worker hits K, stop dispatching so pending drains to 0; recycle fires
199
+ # on the next result. Prevents prefetch > 1 from refilling pending forever.
200
+ def draining_for_recycle?(worker)
201
+ @worker_max_items && worker[:items_completed] >= @worker_max_items && worker[:pending].positive?
202
+ end
153
203
 
204
+ def handle_dead_worker(worker, state)
205
+ state.first_error = Evilution::Error.new("worker process exited unexpectedly") if state.first_error.nil?
206
+ state.in_flight -= worker[:pending]
207
+ worker[:pending] = 0
208
+ false
209
+ end
210
+
211
+ def record_result(message, worker, state)
154
212
  index, status, value = message
155
213
  state.first_error = value if status == :error && state.first_error.nil?
156
214
  state.results[index] = value if status == :ok
157
215
  state.in_flight -= 1
158
216
  worker[:pending] -= 1
159
217
  worker[:items_completed] += 1
218
+ end
219
+
220
+ def recycle_and_dispatch(worker, items, state, workers, io_to_worker, result_ios)
221
+ return false unless should_recycle?(worker, state, items)
222
+
223
+ new_worker = recycle_worker(worker, workers, io_to_worker, result_ios)
224
+ send_item(new_worker, items, state) if state.next_index < items.length && state.first_error.nil?
225
+ true
226
+ end
227
+
228
+ def should_recycle?(worker, state, items)
229
+ return false unless @worker_max_items
230
+ return false if worker[:items_completed] < @worker_max_items
231
+ return false unless worker[:pending].zero?
232
+ return false unless state.next_index < items.length
233
+ return false unless state.first_error.nil?
160
234
 
161
- send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
162
235
  true
163
236
  end
164
237
 
238
+ def recycle_worker(old_worker, workers, io_to_worker, result_ios)
239
+ io_to_worker.delete(old_worker[:res_read])
240
+ result_ios.delete(old_worker[:res_read])
241
+ retire_worker(old_worker)
242
+
243
+ new_worker = spawn_one_worker(worker_index: old_worker[:worker_index], &@block)
244
+ workers[workers.index(old_worker)] = new_worker
245
+ io_to_worker[new_worker[:res_read]] = new_worker
246
+ result_ios << new_worker[:res_read]
247
+
248
+ new_worker
249
+ end
250
+
251
+ def retire_worker(worker)
252
+ begin
253
+ write_message(worker[:cmd_write], SHUTDOWN)
254
+ rescue Errno::EPIPE
255
+ nil
256
+ end
257
+
258
+ busy, wall = drain_worker_stats(worker)
259
+
260
+ worker[:cmd_write].close unless worker[:cmd_write].closed?
261
+ worker[:res_read].close unless worker[:res_read].closed?
262
+ begin
263
+ Process.wait(worker[:pid])
264
+ rescue Errno::ECHILD
265
+ nil
266
+ end
267
+
268
+ @retired_workers << WorkerStat.new(worker[:pid], worker[:items_completed], busy, wall)
269
+ end
270
+
271
+ def drain_worker_stats(worker)
272
+ return [0.0, 0.0] unless worker[:res_read].wait_readable(TIMING_GRACE_PERIOD)
273
+
274
+ message = read_result(worker[:res_read])
275
+ return [0.0, 0.0] if message.nil?
276
+
277
+ tag, busy_time, wall_time = message
278
+ return [0.0, 0.0] unless tag == STATS
279
+
280
+ [busy_time, wall_time]
281
+ end
282
+
165
283
  def send_item(worker, items, state)
166
284
  write_message(worker[:cmd_write], [state.next_index, items[state.next_index]])
167
285
  state.next_index += 1
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "yaml"
5
+ require_relative "../evilution"
6
+
7
+ # EV-kdns / GH #817: nudge users running parallel jobs against SQLite to adopt
8
+ # the parallel_tests convention. WorkQueue sets TEST_ENV_NUMBER per worker, but
9
+ # that only helps if config/database.yml interpolates it into the database path.
10
+ # Without per-worker DB files, concurrent workers pile up on one SQLite file and
11
+ # surface ActiveRecord::StatementTimeout / SQLite3::BusyException — noise that
12
+ # MutationExecutor demotes to :neutral but still wastes wall-clock time.
13
+ module Evilution::ParallelDbWarning
14
+ DATABASE_YML = File.join("config", "database.yml")
15
+ MESSAGE = "[evilution] Parallel run (jobs > 1) detected with SQLite in " \
16
+ "config/database.yml. Interpolate ENV['TEST_ENV_NUMBER'] into the " \
17
+ "test database path for per-worker DB isolation. See README."
18
+
19
+ @warned_roots = {}
20
+ @mutex = Mutex.new
21
+
22
+ class << self
23
+ def warn_if_sqlite_parallel(config, output: $stderr, root: Dir.pwd)
24
+ return unless config.jobs > 1
25
+ return unless sqlite_in_test_config?(root)
26
+
27
+ @mutex.synchronize do
28
+ return if @warned_roots[root]
29
+
30
+ @warned_roots[root] = true
31
+ end
32
+
33
+ output.puts(MESSAGE)
34
+ end
35
+
36
+ def reset!
37
+ @mutex.synchronize { @warned_roots.clear }
38
+ end
39
+
40
+ private
41
+
42
+ # Only the `test` section matters for parallel mutation runs. Scanning the
43
+ # whole file would false-positive when dev/prod use SQLite but test uses
44
+ # Postgres/MySQL. Parse failures (ERB errors, custom helpers, anchor
45
+ # gymnastics) fall back to "no warning" rather than guessing.
46
+ def sqlite_in_test_config?(root)
47
+ path = File.join(root, DATABASE_YML)
48
+ return false unless File.exist?(path)
49
+
50
+ parsed = parse_database_yml(path)
51
+ return false unless parsed.is_a?(Hash)
52
+
53
+ test_config = parsed["test"]
54
+ return false unless test_config.is_a?(Hash)
55
+
56
+ adapter = test_config["adapter"]
57
+ adapter.is_a?(String) && adapter.downcase.include?("sqlite")
58
+ end
59
+
60
+ def parse_database_yml(path)
61
+ content = File.read(path)
62
+ rendered = ERB.new(content).result
63
+ YAML.safe_load(rendered, aliases: true)
64
+ rescue StandardError, SyntaxError
65
+ nil
66
+ end
67
+ end
68
+ end
@@ -7,6 +7,17 @@ class Evilution::Reporter::CLI
7
7
 
8
8
  def call(summary)
9
9
  lines = []
10
+ append_metrics(lines, summary)
11
+ append_sections(lines, summary)
12
+ lines << ""
13
+ lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
14
+ lines << result_line(summary)
15
+ lines.join("\n")
16
+ end
17
+
18
+ private
19
+
20
+ def append_metrics(lines, summary)
10
21
  lines << header
11
22
  lines << SEPARATOR
12
23
  lines << ""
@@ -16,20 +27,18 @@ class Evilution::Reporter::CLI
16
27
  lines << efficiency_line(summary) if summary.duration.positive?
17
28
  peak = summary.peak_memory_mb
18
29
  lines << peak_memory_line(peak) if peak
30
+ end
31
+
32
+ def append_sections(lines, summary)
19
33
  append_survived(lines, summary)
20
34
  append_neutral(lines, summary)
21
35
  append_equivalent(lines, summary)
36
+ append_unresolved(lines, summary)
37
+ append_unparseable(lines, summary)
22
38
  append_errors(lines, summary)
23
39
  append_disabled(lines, summary)
24
- lines << ""
25
- lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
26
- lines << result_line(summary)
27
-
28
- lines.join("\n")
29
40
  end
30
41
 
31
- private
32
-
33
42
  def append_survived(lines, summary)
34
43
  gaps = summary.coverage_gaps
35
44
  return unless gaps.any?
@@ -55,6 +64,22 @@ class Evilution::Reporter::CLI
55
64
  summary.equivalent_results.each { |result| lines << format_neutral(result) }
56
65
  end
57
66
 
67
+ def append_unresolved(lines, summary)
68
+ return unless summary.unresolved_results.any?
69
+
70
+ lines << ""
71
+ lines << "Unresolved mutations (no test file resolved):"
72
+ summary.unresolved_results.each { |result| lines << format_neutral(result) }
73
+ end
74
+
75
+ def append_unparseable(lines, summary)
76
+ return unless summary.unparseable_results.any?
77
+
78
+ lines << ""
79
+ lines << "Unparseable mutations (mutated source did not parse):"
80
+ summary.unparseable_results.each { |result| lines << format_neutral(result) }
81
+ end
82
+
58
83
  def append_errors(lines, summary)
59
84
  errored = summary.results.select(&:error?)
60
85
  return if errored.empty?
@@ -91,14 +116,14 @@ class Evilution::Reporter::CLI
91
116
  parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
92
117
  parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
93
118
  parts += ", #{summary.unresolved} unresolved" if summary.unresolved.positive?
119
+ parts += ", #{summary.unparseable} unparseable" if summary.unparseable.positive?
94
120
  parts += ", #{summary.skipped} skipped" if summary.skipped.positive?
95
121
  parts
96
122
  end
97
123
 
98
124
  def score_line(summary)
99
- denominator = summary.total - summary.errors - summary.neutral - summary.equivalent - summary.unresolved
100
125
  score_pct = format_pct(summary.score)
101
- "Score: #{score_pct} (#{summary.killed}/#{denominator})"
126
+ "Score: #{score_pct} (#{summary.killed}/#{summary.score_denominator})"
102
127
  end
103
128
 
104
129
  def duration_line(summary)
@@ -119,8 +144,9 @@ class Evilution::Reporter::CLI
119
144
  operators = gap.operator_names.join(", ")
120
145
  " #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{operators}]"
121
146
  end
122
- diff_lines = gap.primary_diff.split("\n").map { |l| " #{l}" }.join("\n")
123
- "#{header}\n#{diff_lines}"
147
+ body = gap.mutation_results.first.mutation.unified_diff || gap.primary_diff
148
+ indented = body.split("\n").map { |l| " #{l}" }.join("\n")
149
+ "#{header}\n#{indented}"
124
150
  end
125
151
 
126
152
  def format_neutral(result)
@@ -26,6 +26,8 @@ h1 { font-size: 1.5rem; color: #f0f6fc; }
26
26
  .map-line.error { color: #f85149; }
27
27
  .map-line.neutral { color: #8b949e; }
28
28
  .map-line.equivalent { color: #8b949e; }
29
+ .map-line.unresolved { color: #8b949e; }
30
+ .map-line.unparseable { color: #8b949e; }
29
31
  .line-number { min-width: 60px; color: #8b949e; }
30
32
  .operator { flex: 1; }
31
33
  .status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
@@ -34,6 +36,9 @@ h1 { font-size: 1.5rem; color: #f0f6fc; }
34
36
  .status-badge.timeout { background: #4a3a10; }
35
37
  .status-badge.neutral { background: #21262d; }
36
38
  .status-badge.equivalent { background: #21262d; }
39
+ .status-badge.error { background: #4a3a10; }
40
+ .status-badge.unresolved { background: #21262d; }
41
+ .status-badge.unparseable { background: #21262d; }
37
42
  .survived-details { border-top: 1px solid #30363d; padding: 1rem; }
38
43
  .survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
39
44
  .survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
@@ -63,6 +68,18 @@ h1 { font-size: 1.5rem; color: #f0f6fc; }
63
68
  .error-header .operator { color: #d29922; font-weight: bold; }
64
69
  .error-header .location { color: #8b949e; font-family: monospace; }
65
70
  .error-message { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; color: #f85149; margin-top: 0.5rem; white-space: pre-wrap; overflow-x: auto; }
71
+ .unresolved-details { border-top: 1px solid #30363d; padding: 1rem; }
72
+ .unresolved-details h3 { color: #8b949e; font-size: 0.9rem; margin-bottom: 0.75rem; }
73
+ .unresolved-details ul { list-style: none; }
74
+ .unresolved-details li { font-size: 0.85rem; padding: 0.2rem 0; color: #8b949e; }
75
+ .unresolved-details .operator { color: #c9d1d9; font-family: monospace; margin-right: 0.75rem; }
76
+ .unresolved-details .location { font-family: monospace; }
77
+ .unparseable-details { border-top: 1px solid #30363d; padding: 1rem; }
78
+ .unparseable-details h3 { color: #8b949e; font-size: 0.9rem; margin-bottom: 0.75rem; }
79
+ .unparseable-details ul { list-style: none; }
80
+ .unparseable-details li { font-size: 0.85rem; padding: 0.2rem 0; color: #8b949e; }
81
+ .unparseable-details .operator { color: #c9d1d9; font-family: monospace; margin-right: 0.75rem; }
82
+ .unparseable-details .location { font-family: monospace; }
66
83
  .survived-entry.regression { border-color: #f85149; background: #2a1010; }
67
84
  .regression-badge { background: #da3633; color: #fff; font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 4px; margin-left: 0.5rem; text-transform: uppercase; font-weight: bold; }
68
85
  footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
@@ -4,6 +4,9 @@ require_relative "../sections"
4
4
  require_relative "mutation_map"
5
5
  require_relative "survived_details"
6
6
  require_relative "error_details"
7
+ require_relative "neutral_details"
8
+ require_relative "unresolved_details"
9
+ require_relative "unparseable_details"
7
10
 
8
11
  class Evilution::Reporter::HTML::Sections::FileSection < Evilution::Reporter::HTML::Section
9
12
  template "file_section"
@@ -44,4 +47,16 @@ class Evilution::Reporter::HTML::Sections::FileSection < Evilution::Reporter::HT
44
47
  def error_html
45
48
  Evilution::Reporter::HTML::Sections::ErrorDetails.render_if(@results.select(&:error?))
46
49
  end
50
+
51
+ def neutral_html
52
+ Evilution::Reporter::HTML::Sections::NeutralDetails.render_if(@results.select(&:neutral?))
53
+ end
54
+
55
+ def unresolved_html
56
+ Evilution::Reporter::HTML::Sections::UnresolvedDetails.render_if(@results.select(&:unresolved?))
57
+ end
58
+
59
+ def unparseable_html
60
+ Evilution::Reporter::HTML::Sections::UnparseableDetails.render_if(@results.select(&:unparseable?))
61
+ end
47
62
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sections"
4
+
5
+ class Evilution::Reporter::HTML::Sections::NeutralDetails < Evilution::Reporter::HTML::Section
6
+ template "neutral_details"
7
+
8
+ def self.render_if(neutral)
9
+ return "" if neutral.empty?
10
+
11
+ new(neutral).render
12
+ end
13
+
14
+ def initialize(neutral)
15
+ @neutral = neutral
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :neutral
21
+
22
+ def sorted
23
+ neutral.sort_by { |r| [r.mutation.operator_name, r.mutation.line] }
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sections"
4
+
5
+ class Evilution::Reporter::HTML::Sections::UnparseableDetails < Evilution::Reporter::HTML::Section
6
+ template "unparseable_details"
7
+
8
+ def self.render_if(unparseable)
9
+ return "" if unparseable.empty?
10
+
11
+ new(unparseable).render
12
+ end
13
+
14
+ def initialize(unparseable)
15
+ @unparseable = unparseable
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :unparseable
21
+
22
+ def sorted
23
+ unparseable.sort_by { |r| [r.mutation.operator_name, r.mutation.line] }
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sections"
4
+
5
+ class Evilution::Reporter::HTML::Sections::UnresolvedDetails < Evilution::Reporter::HTML::Section
6
+ template "unresolved_details"
7
+
8
+ def self.render_if(unresolved)
9
+ return "" if unresolved.empty?
10
+
11
+ new(unresolved).render
12
+ end
13
+
14
+ def initialize(unresolved)
15
+ @unresolved = unresolved
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :unresolved
21
+
22
+ def sorted
23
+ unresolved.sort_by { |r| [r.mutation.operator_name, r.mutation.line] }
24
+ end
25
+ end
@@ -6,4 +6,7 @@
6
6
  <div class="mutation-map"><%= map_html %></div>
7
7
  <%= survived_html %>
8
8
  <%= error_html %>
9
+ <%= neutral_html %>
10
+ <%= unresolved_html %>
11
+ <%= unparseable_html %>
9
12
  </section>
@@ -0,0 +1,14 @@
1
+ <div class="neutral-details">
2
+ <h3>Neutral (<%= neutral.length %>) — test infra error, excluded from score</h3>
3
+ <ul>
4
+ <%- sorted.each do |r| -%>
5
+ <li>
6
+ <span class="operator"><%= h(r.mutation.operator_name) %></span>
7
+ <span class="location"><%= h(r.mutation.file_path) %>:<%= r.mutation.line %></span>
8
+ <%- if r.error_class -%>
9
+ <span class="error-class">(<%= h(r.error_class) %>)</span>
10
+ <%- end -%>
11
+ </li>
12
+ <%- end -%>
13
+ </ul>
14
+ </div>