evilution 0.23.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -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 +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. metadata +75 -2
@@ -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?
@@ -90,14 +115,15 @@ class Evilution::Reporter::CLI
90
115
  "#{summary.survived} survived, #{summary.timed_out} timed out"
91
116
  parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
92
117
  parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
118
+ parts += ", #{summary.unresolved} unresolved" if summary.unresolved.positive?
119
+ parts += ", #{summary.unparseable} unparseable" if summary.unparseable.positive?
93
120
  parts += ", #{summary.skipped} skipped" if summary.skipped.positive?
94
121
  parts
95
122
  end
96
123
 
97
124
  def score_line(summary)
98
- denominator = summary.total - summary.errors - summary.neutral - summary.equivalent
99
125
  score_pct = format_pct(summary.score)
100
- "Score: #{score_pct} (#{summary.killed}/#{denominator})"
126
+ "Score: #{score_pct} (#{summary.killed}/#{summary.score_denominator})"
101
127
  end
102
128
 
103
129
  def duration_line(summary)
@@ -118,8 +144,9 @@ class Evilution::Reporter::CLI
118
144
  operators = gap.operator_names.join(", ")
119
145
  " #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{operators}]"
120
146
  end
121
- diff_lines = gap.primary_diff.split("\n").map { |l| " #{l}" }.join("\n")
122
- "#{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}"
123
150
  end
124
151
 
125
152
  def format_neutral(result)
@@ -0,0 +1,85 @@
1
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
3
+ header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
4
+ h1 { font-size: 1.5rem; color: #f0f6fc; }
5
+ .version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
6
+ .score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
7
+ .score-high { background: #1a4731; color: #3fb950; }
8
+ .score-medium { background: #4a3a10; color: #d29922; }
9
+ .score-low { background: #4a1a1a; color: #f85149; }
10
+ .summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
11
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
12
+ .card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
13
+ .card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
14
+ .card-killed { border-color: #238636; }
15
+ .card-survived { border-color: #da3633; }
16
+ .truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
17
+ .file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
18
+ .file-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: #1c2129; border-bottom: 1px solid #30363d; font-size: 0.9rem; }
19
+ .file-path { color: #58a6ff; font-family: monospace; }
20
+ .file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
21
+ .mutation-map { padding: 0.5rem 1rem; }
22
+ .map-line { display: flex; align-items: center; gap: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; font-family: monospace; }
23
+ .map-line.killed { color: #3fb950; }
24
+ .map-line.survived { color: #f85149; }
25
+ .map-line.timeout { color: #d29922; }
26
+ .map-line.error { color: #f85149; }
27
+ .map-line.neutral { color: #8b949e; }
28
+ .map-line.equivalent { color: #8b949e; }
29
+ .map-line.unresolved { color: #8b949e; }
30
+ .map-line.unparseable { color: #8b949e; }
31
+ .line-number { min-width: 60px; color: #8b949e; }
32
+ .operator { flex: 1; }
33
+ .status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
34
+ .status-badge.killed { background: #1a4731; }
35
+ .status-badge.survived { background: #4a1a1a; }
36
+ .status-badge.timeout { background: #4a3a10; }
37
+ .status-badge.neutral { background: #21262d; }
38
+ .status-badge.equivalent { background: #21262d; }
39
+ .status-badge.error { background: #4a3a10; }
40
+ .status-badge.unresolved { background: #21262d; }
41
+ .status-badge.unparseable { background: #21262d; }
42
+ .survived-details { border-top: 1px solid #30363d; padding: 1rem; }
43
+ .survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
44
+ .survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
45
+ .survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
46
+ .survived-header .operator { color: #f85149; font-weight: bold; }
47
+ .survived-header .location { color: #8b949e; font-family: monospace; }
48
+ .diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
49
+ .diff-removed { color: #f85149; display: block; }
50
+ .diff-added { color: #3fb950; display: block; }
51
+ .suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
52
+ .coverage-gap { border: 1px solid #30363d; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; background: #161b22; }
53
+ .gap-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; font-size: 0.85rem; padding-bottom: 0.5rem; border-bottom: 1px solid #21262d; }
54
+ .gap-header .location { color: #58a6ff; font-family: monospace; }
55
+ .gap-count { background: #4a1a1a; color: #f85149; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: bold; }
56
+ .operator-tag { background: #21262d; color: #8b949e; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 4px; font-family: monospace; }
57
+ .empty { color: #8b949e; text-align: center; padding: 2rem; }
58
+ .baseline-comparison { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
59
+ .baseline-comparison h2 { font-size: 1rem; color: #f0f6fc; margin-bottom: 0.75rem; }
60
+ .comparison-scores { display: flex; gap: 2rem; font-size: 0.9rem; }
61
+ .delta-positive { color: #3fb950; font-weight: bold; }
62
+ .delta-negative { color: #f85149; font-weight: bold; }
63
+ .delta-neutral { color: #8b949e; font-weight: bold; }
64
+ .error-details { border-top: 1px solid #30363d; padding: 1rem; }
65
+ .error-details h3 { color: #d29922; font-size: 0.9rem; margin-bottom: 0.75rem; }
66
+ .error-entry { background: #1c1a10; border: 1px solid #4a3a10; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
67
+ .error-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
68
+ .error-header .operator { color: #d29922; font-weight: bold; }
69
+ .error-header .location { color: #8b949e; font-family: monospace; }
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; }
83
+ .survived-entry.regression { border-color: #f85149; background: #2a1010; }
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; }
85
+ footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "namespace"
4
+
5
+ class Evilution::Reporter::HTML::BaselineKeys
6
+ def initialize(baseline)
7
+ @keys = build(baseline)
8
+ end
9
+
10
+ def regression?(mutation)
11
+ return false if @keys.nil?
12
+
13
+ !@keys.include?(key_for(mutation))
14
+ end
15
+
16
+ private
17
+
18
+ def build(baseline)
19
+ return nil unless baseline
20
+
21
+ survived = baseline["survived"] || []
22
+ survived.to_set { |m| [m["operator"], m["file"], m["line"], m["subject"]] }
23
+ end
24
+
25
+ def key_for(mutation)
26
+ [mutation.operator_name, mutation.file_path, mutation.line, mutation.subject.name]
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "namespace"
4
+ require_relative "escape"
5
+
6
+ module Evilution::Reporter::HTML::DiffFormatter
7
+ module_function
8
+
9
+ def call(diff)
10
+ diff.split("\n").map { |line| format_line(line) }.join("\n")
11
+ end
12
+
13
+ def format_line(line)
14
+ css_class = line_class(line)
15
+ %(<span class="#{css_class}">#{Evilution::Reporter::HTML::Escape.call(line)}</span>)
16
+ end
17
+
18
+ def line_class(line)
19
+ if line.start_with?("- ")
20
+ "diff-removed"
21
+ elsif line.start_with?("+ ")
22
+ "diff-added"
23
+ else
24
+ ""
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require_relative "namespace"
5
+
6
+ module Evilution::Reporter::HTML::Escape
7
+ module_function
8
+
9
+ def call(text)
10
+ CGI.escapeHTML(text.to_s)
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../reporter"
4
+
5
+ # rubocop:disable Style/OneClassPerFile
6
+ class Evilution::Reporter::HTML # rubocop:disable Lint/EmptyClass
7
+ end
8
+
9
+ module Evilution::Reporter::HTML::Sections
10
+ end
11
+ # rubocop:enable Style/OneClassPerFile
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+ require_relative "stylesheet"
5
+ require_relative "baseline_keys"
6
+ require_relative "sections/header"
7
+ require_relative "sections/summary_cards"
8
+ require_relative "sections/baseline_comparison"
9
+ require_relative "sections/truncation_notice"
10
+ require_relative "sections/file_section"
11
+
12
+ class Evilution::Reporter::HTML::Report < Evilution::Reporter::HTML::Section
13
+ template "report"
14
+
15
+ def initialize(summary, baseline:, baseline_keys:, suggestion:)
16
+ @summary = summary
17
+ @baseline = baseline
18
+ @baseline_keys = baseline_keys
19
+ @suggestion = suggestion
20
+ end
21
+
22
+ private
23
+
24
+ def stylesheet
25
+ Evilution::Reporter::HTML::Stylesheet.call
26
+ end
27
+
28
+ def header
29
+ Evilution::Reporter::HTML::Sections::Header.new(@summary).render
30
+ end
31
+
32
+ def summary_cards
33
+ Evilution::Reporter::HTML::Sections::SummaryCards.new(@summary).render
34
+ end
35
+
36
+ def baseline_comparison
37
+ Evilution::Reporter::HTML::Sections::BaselineComparison.render_if(@baseline, @summary)
38
+ end
39
+
40
+ def truncation_notice
41
+ Evilution::Reporter::HTML::Sections::TruncationNotice.render_if(@summary)
42
+ end
43
+
44
+ def file_sections
45
+ files = group_by_file(@summary.results)
46
+ return '<p class="empty">No mutations generated.</p>' if files.empty?
47
+
48
+ files.map { |path, results| file_section(path, results) }.join("\n")
49
+ end
50
+
51
+ def file_section(path, results)
52
+ Evilution::Reporter::HTML::Sections::FileSection.new(
53
+ path, results,
54
+ suggestion: @suggestion,
55
+ baseline_keys: @baseline_keys
56
+ ).render
57
+ end
58
+
59
+ def group_by_file(results)
60
+ grouped = {}
61
+ results.each do |result|
62
+ path = result.mutation.file_path
63
+ grouped[path] ||= []
64
+ grouped[path] << result
65
+ end
66
+ grouped.sort_by { |path, _| path }.to_h
67
+ end
68
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require_relative "namespace"
5
+ require_relative "escape"
6
+
7
+ class Evilution::Reporter::HTML::Section
8
+ TEMPLATE_DIR = File.expand_path("templates", __dir__)
9
+
10
+ def self.template(name)
11
+ path = File.join(TEMPLATE_DIR, "#{name}.html.erb")
12
+ erb = ERB.new(File.read(path), trim_mode: "-")
13
+ erb.def_method(self, "render")
14
+ end
15
+
16
+ private
17
+
18
+ def h(text)
19
+ Evilution::Reporter::HTML::Escape.call(text)
20
+ end
21
+ end