evilution 0.17.0 → 0.19.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +103 -33
  4. data/CHANGELOG.md +50 -0
  5. data/README.md +144 -50
  6. data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
  7. data/lib/evilution/baseline.rb +9 -1
  8. data/lib/evilution/cli.rb +398 -23
  9. data/lib/evilution/config.rb +10 -2
  10. data/lib/evilution/disable_comment.rb +90 -0
  11. data/lib/evilution/integration/rspec.rb +74 -5
  12. data/lib/evilution/isolation/fork.rb +10 -6
  13. data/lib/evilution/isolation/in_process.rb +14 -10
  14. data/lib/evilution/mcp/session_diff_tool.rb +5 -35
  15. data/lib/evilution/mutator/operator/collection_return.rb +33 -0
  16. data/lib/evilution/mutator/operator/defined_check.rb +16 -0
  17. data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
  18. data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
  19. data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
  20. data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
  21. data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
  22. data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
  23. data/lib/evilution/mutator/registry.rb +9 -1
  24. data/lib/evilution/parallel/pool.rb +7 -53
  25. data/lib/evilution/parallel/work_queue.rb +265 -0
  26. data/lib/evilution/reporter/cli.rb +21 -1
  27. data/lib/evilution/reporter/html.rb +69 -3
  28. data/lib/evilution/reporter/json.rb +23 -2
  29. data/lib/evilution/reporter/suggestion.rb +29 -1
  30. data/lib/evilution/result/mutation_result.rb +5 -2
  31. data/lib/evilution/result/summary.rb +19 -2
  32. data/lib/evilution/runner.rb +123 -12
  33. data/lib/evilution/session/diff.rb +85 -0
  34. data/lib/evilution/spec_resolver.rb +13 -1
  35. data/lib/evilution/version.rb +1 -1
  36. data/lib/evilution.rb +11 -0
  37. data/script/memory_check +22 -0
  38. metadata +14 -2
@@ -1,63 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../parallel"
3
+ require_relative "work_queue"
4
4
 
5
5
  class Evilution::Parallel::Pool
6
- def initialize(size:, hooks: nil)
7
- raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
8
-
9
- @size = size
10
- @hooks = hooks
6
+ def initialize(size:, hooks: nil, item_timeout: nil)
7
+ @queue = Evilution::Parallel::WorkQueue.new(size: size, hooks: hooks, item_timeout: item_timeout)
11
8
  end
12
9
 
13
- def map(items, &block)
14
- results = []
15
-
16
- items.each_slice(@size) do |batch|
17
- results.concat(run_batch(batch, &block))
18
- end
19
-
20
- results
10
+ def map(items, &)
11
+ @queue.map(items, &)
21
12
  end
22
13
 
23
- private
24
-
25
- def run_batch(items, &block)
26
- entries = items.map do |item|
27
- read_io, write_io = IO.pipe
28
- pid = fork_worker(item, read_io, write_io, &block)
29
- write_io.close
30
- { pid: pid, read_io: read_io }
31
- end
32
-
33
- collect_results(entries)
34
- end
35
-
36
- def fork_worker(item, read_io, write_io, &block)
37
- Process.fork do
38
- read_io.close
39
- @hooks.fire(:worker_process_start) if @hooks
40
- result = block.call(item)
41
- Marshal.dump(result, write_io)
42
- rescue Exception => e # rubocop:disable Lint/RescueException
43
- Marshal.dump(e, write_io)
44
- ensure
45
- write_io.close
46
- exit!
47
- end
48
- end
49
-
50
- def collect_results(entries)
51
- entries.map do |entry|
52
- data = entry[:read_io].read
53
- entry[:read_io].close
54
- Process.wait(entry[:pid])
55
- raise Evilution::Error, "worker process failed with no result" if data.empty?
56
-
57
- result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
58
- raise result if result.is_a?(Exception)
59
-
60
- result
61
- end
14
+ def worker_stats
15
+ @queue.worker_stats
62
16
  end
63
17
  end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parallel"
4
+
5
+ class Evilution::Parallel::WorkQueue
6
+ SHUTDOWN = :__shutdown__
7
+
8
+ STATS = :__stats__
9
+
10
+ TIMING_GRACE_PERIOD = 5
11
+
12
+ WorkerStat = Struct.new(:pid, :items_completed, :busy_time, :wall_time) do
13
+ def idle_time
14
+ wall_time - busy_time
15
+ end
16
+
17
+ def utilization
18
+ return 0.0 if wall_time.nil? || wall_time.zero?
19
+
20
+ busy_time / wall_time
21
+ end
22
+ end
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
+
31
+ @size = size
32
+ @hooks = hooks
33
+ @prefetch = prefetch
34
+ @item_timeout = item_timeout
35
+ @worker_stats = []
36
+ end
37
+
38
+ def map(items, &)
39
+ return [] if items.empty?
40
+
41
+ worker_count = [@size, items.length].min
42
+ workers = spawn_workers(worker_count, &)
43
+
44
+ begin
45
+ distribute_and_collect(items, workers)
46
+ ensure
47
+ shutdown_workers(workers)
48
+ @worker_stats = build_worker_stats(workers)
49
+ end
50
+ end
51
+
52
+ def worker_stats
53
+ @worker_stats.map { |stat| stat.dup.freeze }
54
+ end
55
+
56
+ private
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
62
+
63
+ pid = Process.fork do
64
+ cmd_write.close
65
+ res_read.close
66
+ worker_loop(cmd_read, res_write, &)
67
+ end
68
+
69
+ cmd_read.close
70
+ res_write.close
71
+
72
+ { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0, pending: 0 }
73
+ end
74
+ end
75
+
76
+ def worker_loop(cmd_read, res_write, &block)
77
+ @hooks.fire(:worker_process_start) if @hooks
78
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ busy_time = 0.0
80
+
81
+ loop do
82
+ data = read_command(cmd_read)
83
+ break if data == SHUTDOWN
84
+
85
+ index, item = data
86
+ begin
87
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ result = block.call(item)
89
+ busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
90
+ write_message(res_write, [index, :ok, result])
91
+ rescue Exception => e # rubocop:disable Lint/RescueException
92
+ busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
93
+ write_message(res_write, [index, :error, e])
94
+ end
95
+ end
96
+
97
+ wall_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
98
+ write_message(res_write, [STATS, busy_time, wall_time])
99
+ ensure
100
+ cmd_read.close
101
+ res_write.close
102
+ exit!
103
+ end
104
+
105
+ def distribute_and_collect(items, workers)
106
+ state = CollectionState.new(items.length)
107
+ seed_workers(items, workers, state)
108
+ collect_results(items, workers, state)
109
+ raise state.first_error if state.first_error
110
+
111
+ state.results
112
+ end
113
+
114
+ def seed_workers(items, workers, state)
115
+ @prefetch.times do
116
+ workers.each do |worker|
117
+ break unless state.next_index < items.length
118
+
119
+ send_item(worker, items, state)
120
+ end
121
+ end
122
+ end
123
+
124
+ def collect_results(items, workers, state)
125
+ io_to_worker = workers.to_h { |w| [w[:res_read], w] }
126
+ result_ios = io_to_worker.keys
127
+
128
+ while state.in_flight.positive?
129
+ readable, = IO.select(result_ios, nil, nil, @item_timeout)
130
+
131
+ if readable.nil?
132
+ terminate_stuck_workers(workers)
133
+ state.first_error = Evilution::Error.new("worker timed out after #{@item_timeout}s") if state.first_error.nil?
134
+ break
135
+ end
136
+
137
+ readable.each do |io|
138
+ alive = handle_result(io, io_to_worker[io], items, state)
139
+ result_ios.delete(io) unless alive
140
+ end
141
+ end
142
+ end
143
+
144
+ def handle_result(io, worker, items, state)
145
+ message = read_result(io)
146
+
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
153
+
154
+ index, status, value = message
155
+ state.first_error = value if status == :error && state.first_error.nil?
156
+ state.results[index] = value if status == :ok
157
+ state.in_flight -= 1
158
+ worker[:pending] -= 1
159
+ worker[:items_completed] += 1
160
+
161
+ send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
162
+ true
163
+ end
164
+
165
+ def send_item(worker, items, state)
166
+ write_message(worker[:cmd_write], [state.next_index, items[state.next_index]])
167
+ state.next_index += 1
168
+ state.in_flight += 1
169
+ worker[:pending] += 1
170
+ end
171
+
172
+ def build_worker_stats(workers)
173
+ workers.map do |worker|
174
+ WorkerStat.new(worker[:pid], worker[:items_completed], worker[:busy_time] || 0.0, worker[:wall_time] || 0.0)
175
+ end
176
+ end
177
+
178
+ def terminate_stuck_workers(workers)
179
+ workers.each do |worker|
180
+ Process.kill("KILL", worker[:pid])
181
+ rescue Errno::ESRCH
182
+ nil # Already exited
183
+ end
184
+ end
185
+
186
+ def shutdown_workers(workers)
187
+ workers.each do |worker|
188
+ write_message(worker[:cmd_write], SHUTDOWN)
189
+ rescue Errno::EPIPE
190
+ # Worker already exited
191
+ end
192
+
193
+ collect_worker_timing(workers)
194
+
195
+ workers.each do |worker|
196
+ worker[:cmd_write].close unless worker[:cmd_write].closed?
197
+ worker[:res_read].close unless worker[:res_read].closed?
198
+ Process.wait(worker[:pid])
199
+ rescue Errno::ECHILD
200
+ # Already reaped
201
+ end
202
+ end
203
+
204
+ def collect_worker_timing(workers)
205
+ io_to_worker = workers.reject { |w| w[:res_read].closed? }.to_h { |w| [w[:res_read], w] }
206
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TIMING_GRACE_PERIOD
207
+
208
+ until io_to_worker.empty?
209
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
210
+ break if remaining <= 0
211
+
212
+ readable, = IO.select(io_to_worker.keys, nil, nil, remaining)
213
+ break unless readable
214
+
215
+ readable.each { |io| apply_worker_timing(io_to_worker.delete(io), io) }
216
+ end
217
+ end
218
+
219
+ def apply_worker_timing(worker, io)
220
+ message = read_result(io)
221
+ return if message.nil?
222
+
223
+ tag, busy_time, wall_time = message
224
+ return unless tag == STATS
225
+
226
+ worker[:busy_time] = busy_time
227
+ worker[:wall_time] = wall_time
228
+ end
229
+
230
+ def write_message(io, data)
231
+ payload = Marshal.dump(data)
232
+ io.write([payload.bytesize].pack("N"))
233
+ io.write(payload)
234
+ io.flush
235
+ end
236
+
237
+ def read_command(io)
238
+ header = io.read(4)
239
+ return SHUTDOWN if header.nil? || header.bytesize < 4
240
+
241
+ length = header.unpack1("N")
242
+ payload = io.read(length)
243
+ return SHUTDOWN if payload.nil? || payload.bytesize < length
244
+
245
+ Marshal.load(payload) # rubocop:disable Security/MarshalLoad
246
+ end
247
+
248
+ def read_result(io)
249
+ header = io.read(4)
250
+ return nil if header.nil? || header.bytesize < 4
251
+
252
+ length = header.unpack1("N")
253
+ payload = io.read(length)
254
+ return nil if payload.nil? || payload.bytesize < length
255
+
256
+ Marshal.load(payload) # rubocop:disable Security/MarshalLoad
257
+ end
258
+
259
+ CollectionState = Struct.new(:results, :in_flight, :next_index, :first_error) do
260
+ def initialize(item_count)
261
+ super(Array.new(item_count), 0, 0, nil)
262
+ end
263
+ end
264
+ private_constant :CollectionState
265
+ end
@@ -13,11 +13,13 @@ class Evilution::Reporter::CLI
13
13
  lines << mutations_line(summary)
14
14
  lines << score_line(summary)
15
15
  lines << duration_line(summary)
16
+ lines << efficiency_line(summary) if summary.duration.positive?
16
17
  peak = summary.peak_memory_mb
17
18
  lines << peak_memory_line(peak) if peak
18
19
  append_survived(lines, summary)
19
20
  append_neutral(lines, summary)
20
21
  append_equivalent(lines, summary)
22
+ append_disabled(lines, summary)
21
23
  lines << ""
22
24
  lines << "[TRUNCATED] Stopped early due to --fail-fast" if summary.truncated?
23
25
  lines << result_line(summary)
@@ -51,6 +53,14 @@ class Evilution::Reporter::CLI
51
53
  summary.equivalent_results.each { |result| lines << format_neutral(result) }
52
54
  end
53
55
 
56
+ def append_disabled(lines, summary)
57
+ return unless summary.disabled_mutations.any?
58
+
59
+ lines << ""
60
+ lines << "Disabled mutations (skipped by # evilution:disable):"
61
+ summary.disabled_mutations.each { |mutation| lines << format_disabled(mutation) }
62
+ end
63
+
54
64
  def header
55
65
  "Evilution v#{Evilution::VERSION} — Mutation Testing Results"
56
66
  end
@@ -74,11 +84,17 @@ class Evilution::Reporter::CLI
74
84
  "Duration: #{format("%.2f", summary.duration)}s"
75
85
  end
76
86
 
87
+ def efficiency_line(summary)
88
+ pct = format("%.2f%%", summary.efficiency * 100)
89
+ rate = format("%.2f", summary.mutations_per_second)
90
+ "Efficiency: #{pct} killtime, #{rate} mutations/s"
91
+ end
92
+
77
93
  def format_survived(result)
78
94
  mutation = result.mutation
79
95
  location = "#{mutation.file_path}:#{mutation.line}"
80
96
  diff_lines = mutation.diff.split("\n").map { |l| " #{l}" }.join("\n")
81
- " #{mutation.operator_name}: #{location}\n#{diff_lines}"
97
+ " #{mutation.operator_name}: #{location} (#{mutation.subject.name})\n#{diff_lines}"
82
98
  end
83
99
 
84
100
  def format_neutral(result)
@@ -86,6 +102,10 @@ class Evilution::Reporter::CLI
86
102
  " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
87
103
  end
88
104
 
105
+ def format_disabled(mutation)
106
+ " #{mutation.operator_name}: #{mutation.file_path}:#{mutation.line}"
107
+ end
108
+
89
109
  def result_line(summary)
90
110
  min_score = 0.8
91
111
  pass_fail = summary.success?(min_score: min_score) ? "PASS" : "FAIL"
@@ -6,8 +6,10 @@ require_relative "suggestion"
6
6
  require_relative "../reporter"
7
7
 
8
8
  class Evilution::Reporter::HTML
9
- def initialize
9
+ def initialize(baseline: nil)
10
10
  @suggestion = Evilution::Reporter::Suggestion.new
11
+ @baseline = baseline
12
+ @baseline_keys = build_baseline_keys(baseline)
11
13
  end
12
14
 
13
15
  def call(summary)
@@ -40,6 +42,7 @@ class Evilution::Reporter::HTML
40
42
  <body>
41
43
  #{build_header(summary)}
42
44
  #{build_summary_cards(summary)}
45
+ #{build_baseline_comparison(summary)}
43
46
  #{build_truncation_notice(summary)}
44
47
  #{build_file_sections(files)}
45
48
  #{build_footer}
@@ -79,11 +82,21 @@ class Evilution::Reporter::HTML
79
82
  <div class="card"><span class="card-value">#{summary.equivalent}</span><span class="card-label">Equivalent</span></div>
80
83
  #{build_skipped_card(summary)}
81
84
  <div class="card"><span class="card-value">#{format("%.2f", summary.duration)}s</span><span class="card-label">Duration</span></div>
85
+ #{build_efficiency_cards(summary)}
82
86
  #{peak_html}
83
87
  </section>
84
88
  HTML
85
89
  end
86
90
 
91
+ def build_efficiency_cards(summary)
92
+ return "" unless summary.duration.positive?
93
+
94
+ pct = format("%.1f%%", summary.efficiency * 100)
95
+ rate = format("%.2f", summary.mutations_per_second)
96
+ %(<div class="card"><span class="card-value">#{pct}</span><span class="card-label">Efficiency</span></div>) +
97
+ %(<div class="card"><span class="card-value">#{rate}/s</span><span class="card-label">Rate</span></div>)
98
+ end
99
+
87
100
  def build_skipped_card(summary)
88
101
  return "" unless summary.skipped.positive?
89
102
 
@@ -156,10 +169,13 @@ class Evilution::Reporter::HTML
156
169
  mutation = result.mutation
157
170
  suggestion_text = @suggestion.suggestion_for(mutation)
158
171
  diff_html = format_diff(mutation.diff)
172
+ regression = regression?(mutation)
173
+ entry_class = regression ? "survived-entry regression" : "survived-entry"
174
+ regression_badge = regression ? ' <span class="regression-badge">NEW REGRESSION</span>' : ""
159
175
  <<~HTML
160
- <div class="survived-entry">
176
+ <div class="#{entry_class}">
161
177
  <div class="survived-header">
162
- <span class="operator">#{h(mutation.operator_name)}</span>
178
+ <span class="operator">#{h(mutation.operator_name)}#{regression_badge}</span>
163
179
  <span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
164
180
  </div>
165
181
  <pre class="diff">#{diff_html}</pre>
@@ -196,6 +212,48 @@ class Evilution::Reporter::HTML
196
212
  end
197
213
  end
198
214
 
215
+ def build_baseline_comparison(summary)
216
+ return "" unless @baseline
217
+
218
+ base_summary = @baseline["summary"] || {}
219
+ base_score = base_summary["score"] || 0.0
220
+ head_score = summary.score
221
+ delta = head_score - base_score
222
+ delta_str = format("%+.2f%%", delta * 100)
223
+ delta_class = if delta.positive?
224
+ "delta-positive"
225
+ elsif delta.negative?
226
+ "delta-negative"
227
+ else
228
+ "delta-neutral"
229
+ end
230
+
231
+ <<~HTML
232
+ <section class="baseline-comparison">
233
+ <h2>Baseline Comparison</h2>
234
+ <div class="comparison-scores">
235
+ <span>Baseline: #{format("%.2f%%", base_score * 100)}</span>
236
+ <span>Current: #{format("%.2f%%", head_score * 100)}</span>
237
+ <span class="#{delta_class}">Delta: #{delta_str}</span>
238
+ </div>
239
+ </section>
240
+ HTML
241
+ end
242
+
243
+ def regression?(mutation)
244
+ return false if @baseline_keys.nil?
245
+
246
+ key = [mutation.operator_name, mutation.file_path, mutation.line, mutation.subject.name]
247
+ !@baseline_keys.include?(key)
248
+ end
249
+
250
+ def build_baseline_keys(baseline)
251
+ return nil unless baseline
252
+
253
+ survived = baseline["survived"] || []
254
+ survived.to_set { |m| [m["operator"], m["file"], m["line"], m["subject"]] }
255
+ end
256
+
199
257
  def h(text)
200
258
  CGI.escapeHTML(text.to_s)
201
259
  end
@@ -250,6 +308,14 @@ class Evilution::Reporter::HTML
250
308
  .diff-added { color: #3fb950; display: block; }
251
309
  .suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
252
310
  .empty { color: #8b949e; text-align: center; padding: 2rem; }
311
+ .baseline-comparison { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
312
+ .baseline-comparison h2 { font-size: 1rem; color: #f0f6fc; margin-bottom: 0.75rem; }
313
+ .comparison-scores { display: flex; gap: 2rem; font-size: 0.9rem; }
314
+ .delta-positive { color: #3fb950; font-weight: bold; }
315
+ .delta-negative { color: #f85149; font-weight: bold; }
316
+ .delta-neutral { color: #8b949e; font-weight: bold; }
317
+ .survived-entry.regression { border-color: #f85149; background: #2a1010; }
318
+ .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; }
253
319
  footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
254
320
  </style>
255
321
  HTML
@@ -19,7 +19,7 @@ class Evilution::Reporter::JSON
19
19
 
20
20
  # rubocop:disable Metrics/PerceivedComplexity
21
21
  def build_report(summary)
22
- {
22
+ report = {
23
23
  version: Evilution::VERSION,
24
24
  timestamp: Time.now.iso8601,
25
25
  summary: build_summary(summary),
@@ -30,9 +30,17 @@ class Evilution::Reporter::JSON
30
30
  errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) },
31
31
  equivalent: summary.equivalent_results.map { |r| build_mutation_detail(r) }
32
32
  }
33
+ append_disabled_to_report(report, summary)
34
+ report
33
35
  end
34
36
  # rubocop:enable Metrics/PerceivedComplexity
35
37
 
38
+ def append_disabled_to_report(report, summary)
39
+ return unless summary.disabled_mutations.any?
40
+
41
+ report[:disabled] = summary.disabled_mutations.map { |m| build_disabled_detail(m) }
42
+ end
43
+
36
44
  def build_summary(summary)
37
45
  data = {
38
46
  total: summary.total,
@@ -43,7 +51,10 @@ class Evilution::Reporter::JSON
43
51
  neutral: summary.neutral,
44
52
  equivalent: summary.equivalent,
45
53
  score: summary.score.round(4),
46
- duration: summary.duration.round(4)
54
+ duration: summary.duration.round(4),
55
+ killtime: summary.killtime.round(4),
56
+ efficiency: summary.efficiency.round(4),
57
+ mutations_per_second: summary.mutations_per_second.round(2)
47
58
  }
48
59
  data[:truncated] = true if summary.truncated?
49
60
  data[:skipped] = summary.skipped if summary.skipped.positive?
@@ -64,8 +75,18 @@ class Evilution::Reporter::JSON
64
75
  }
65
76
  detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
66
77
  detail[:test_command] = result.test_command if result.test_command
78
+ detail[:parent_rss_kb] = result.parent_rss_kb if result.parent_rss_kb
67
79
  detail[:child_rss_kb] = result.child_rss_kb if result.child_rss_kb
68
80
  detail[:memory_delta_kb] = result.memory_delta_kb if result.memory_delta_kb
69
81
  detail
70
82
  end
83
+
84
+ def build_disabled_detail(mutation)
85
+ {
86
+ operator: mutation.operator_name,
87
+ file: mutation.file_path,
88
+ line: mutation.line,
89
+ diff: mutation.diff
90
+ }
91
+ end
71
92
  end
@@ -48,7 +48,9 @@ class Evilution::Reporter::Suggestion
48
48
  "index_assignment_removal" => "Add a test that verifies the []= assignment side effect is observable (the collection is modified)",
49
49
  "pattern_matching_guard" => "Add a test with input that matches the pattern but fails the guard to verify filtering",
50
50
  "pattern_matching_alternative" => "Add a test with input that matches only one specific alternative to verify each branch is reachable",
51
- "pattern_matching_array" => "Add a test that verifies each element position in the array pattern matches the expected type or value"
51
+ "pattern_matching_array" => "Add a test that verifies each element position in the array pattern matches the expected type or value",
52
+ "collection_return" => "Add a test that verifies the method returns a non-empty collection, not just any array or hash",
53
+ "scalar_return" => "Add a test that verifies the method returns a non-zero/non-empty scalar value, not just any type"
52
54
  }.freeze
53
55
 
54
56
  CONCRETE_TEMPLATES = {
@@ -614,6 +616,32 @@ class Evilution::Reporter::Suggestion
614
616
  end
615
617
  RSPEC
616
618
  },
619
+ "collection_return" => lambda { |mutation|
620
+ method_name = parse_method_name(mutation.subject.name)
621
+ original_line, mutated_line = extract_diff_lines(mutation.diff)
622
+ <<~RSPEC.strip
623
+ # Mutation: changed `#{original_line}` to `#{mutated_line}` in #{mutation.subject.name}
624
+ # #{mutation.file_path}:#{mutation.line}
625
+ it 'returns a non-empty collection from ##{method_name}' do
626
+ # Assert the collection has the expected elements, not just non-empty
627
+ result = subject.#{method_name}(input_value)
628
+ expect(result).to eq(expected)
629
+ end
630
+ RSPEC
631
+ },
632
+ "scalar_return" => lambda { |mutation|
633
+ method_name = parse_method_name(mutation.subject.name)
634
+ original_line, mutated_line = extract_diff_lines(mutation.diff)
635
+ <<~RSPEC.strip
636
+ # Mutation: changed `#{original_line}` to `#{mutated_line}` in #{mutation.subject.name}
637
+ # #{mutation.file_path}:#{mutation.line}
638
+ it 'returns a non-zero/non-empty value from ##{method_name}' do
639
+ # Assert the exact scalar value, not just presence or type
640
+ result = subject.#{method_name}(input_value)
641
+ expect(result).to eq(expected)
642
+ end
643
+ RSPEC
644
+ },
617
645
  "pattern_matching_array" => lambda { |mutation|
618
646
  method_name = parse_method_name(mutation.subject.name)
619
647
  original_line, mutated_line = extract_diff_lines(mutation.diff)
@@ -6,9 +6,11 @@ class Evilution::Result::MutationResult
6
6
  STATUSES = %i[killed survived timeout error neutral equivalent].freeze
7
7
 
8
8
  attr_reader :mutation, :status, :duration, :killing_test, :test_command,
9
- :child_rss_kb, :memory_delta_kb
9
+ :child_rss_kb, :memory_delta_kb, :parent_rss_kb
10
10
 
11
- def initialize(mutation:, status:, duration: 0.0, killing_test: nil, test_command: nil, child_rss_kb: nil, memory_delta_kb: nil)
11
+ def initialize(mutation:, status:, duration: 0.0, killing_test: nil,
12
+ test_command: nil, child_rss_kb: nil, memory_delta_kb: nil,
13
+ parent_rss_kb: nil)
12
14
  raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
13
15
 
14
16
  @mutation = mutation
@@ -18,6 +20,7 @@ class Evilution::Result::MutationResult
18
20
  @test_command = test_command
19
21
  @child_rss_kb = child_rss_kb
20
22
  @memory_delta_kb = memory_delta_kb
23
+ @parent_rss_kb = parent_rss_kb
21
24
  freeze
22
25
  end
23
26
 
@@ -3,13 +3,14 @@
3
3
  require_relative "../result"
4
4
 
5
5
  class Evilution::Result::Summary
6
- attr_reader :results, :duration, :skipped
6
+ attr_reader :results, :duration, :skipped, :disabled_mutations
7
7
 
8
- def initialize(results:, duration: 0.0, truncated: false, skipped: 0)
8
+ def initialize(results:, duration: 0.0, truncated: false, skipped: 0, disabled_mutations: [])
9
9
  @results = results
10
10
  @duration = duration
11
11
  @truncated = truncated
12
12
  @skipped = skipped
13
+ @disabled_mutations = disabled_mutations
13
14
  freeze
14
15
  end
15
16
 
@@ -72,6 +73,22 @@ class Evilution::Result::Summary
72
73
  results.select(&:equivalent?)
73
74
  end
74
75
 
76
+ def killtime
77
+ results.sum(0.0, &:duration)
78
+ end
79
+
80
+ def efficiency
81
+ return 0.0 if duration.zero?
82
+
83
+ killtime / duration
84
+ end
85
+
86
+ def mutations_per_second
87
+ return 0.0 if duration.zero?
88
+
89
+ total.to_f / duration
90
+ end
91
+
75
92
  def peak_memory_mb
76
93
  max_rss = nil
77
94
  results.each do |result|