evilution 0.17.0 → 0.18.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.
@@ -0,0 +1,224 @@
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
+ WorkerStat = Struct.new(:pid, :items_completed, :busy_time, :wall_time) do
11
+ def idle_time
12
+ wall_time - busy_time
13
+ end
14
+
15
+ def utilization
16
+ return 0.0 if wall_time.nil? || wall_time.zero?
17
+
18
+ busy_time / wall_time
19
+ end
20
+ end
21
+
22
+ def initialize(size:, hooks: nil, prefetch: 1)
23
+ raise ArgumentError, "pool size must be a positive integer, got #{size.inspect}" unless size.is_a?(Integer) && size >= 1
24
+ raise ArgumentError, "prefetch must be a positive integer, got #{prefetch.inspect}" unless prefetch.is_a?(Integer) && prefetch >= 1
25
+
26
+ @size = size
27
+ @hooks = hooks
28
+ @prefetch = prefetch
29
+ @worker_stats = []
30
+ end
31
+
32
+ def map(items, &)
33
+ return [] if items.empty?
34
+
35
+ worker_count = [@size, items.length].min
36
+ workers = spawn_workers(worker_count, &)
37
+
38
+ begin
39
+ distribute_and_collect(items, workers)
40
+ ensure
41
+ shutdown_workers(workers)
42
+ @worker_stats = build_worker_stats(workers)
43
+ end
44
+ end
45
+
46
+ def worker_stats
47
+ @worker_stats.map { |stat| stat.dup.freeze }
48
+ end
49
+
50
+ private
51
+
52
+ def spawn_workers(count, &)
53
+ count.times.map do
54
+ cmd_read, cmd_write = IO.pipe
55
+ res_read, res_write = IO.pipe
56
+
57
+ pid = Process.fork do
58
+ cmd_write.close
59
+ res_read.close
60
+ worker_loop(cmd_read, res_write, &)
61
+ end
62
+
63
+ cmd_read.close
64
+ res_write.close
65
+
66
+ { pid: pid, cmd_write: cmd_write, res_read: res_read, items_completed: 0 }
67
+ end
68
+ end
69
+
70
+ def worker_loop(cmd_read, res_write, &block)
71
+ @hooks.fire(:worker_process_start) if @hooks
72
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ busy_time = 0.0
74
+
75
+ loop do
76
+ data = read_command(cmd_read)
77
+ break if data == SHUTDOWN
78
+
79
+ index, item = data
80
+ begin
81
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ result = block.call(item)
83
+ busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
84
+ write_message(res_write, [index, :ok, result])
85
+ rescue Exception => e # rubocop:disable Lint/RescueException
86
+ busy_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
87
+ write_message(res_write, [index, :error, e])
88
+ end
89
+ end
90
+
91
+ wall_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
92
+ write_message(res_write, [STATS, busy_time, wall_time])
93
+ ensure
94
+ cmd_read.close
95
+ res_write.close
96
+ exit!
97
+ end
98
+
99
+ def distribute_and_collect(items, workers)
100
+ state = CollectionState.new(items.length)
101
+ seed_workers(items, workers, state)
102
+ collect_results(items, workers, state)
103
+ raise state.first_error if state.first_error
104
+
105
+ state.results
106
+ end
107
+
108
+ def seed_workers(items, workers, state)
109
+ @prefetch.times do
110
+ workers.each do |worker|
111
+ break unless state.next_index < items.length
112
+
113
+ send_item(worker, items, state)
114
+ end
115
+ end
116
+ end
117
+
118
+ def collect_results(items, workers, state)
119
+ io_to_worker = workers.to_h { |w| [w[:res_read], w] }
120
+ result_ios = io_to_worker.keys
121
+
122
+ while state.in_flight.positive?
123
+ readable, = IO.select(result_ios)
124
+ readable.each { |io| handle_result(io, io_to_worker[io], items, state) }
125
+ end
126
+ end
127
+
128
+ def handle_result(io, worker, items, state)
129
+ message = read_result(io)
130
+
131
+ if message.nil?
132
+ state.first_error = Evilution::Error.new("worker process exited unexpectedly") if state.first_error.nil?
133
+ state.in_flight -= 1
134
+ return
135
+ end
136
+
137
+ index, status, value = message
138
+ state.first_error = value if status == :error && state.first_error.nil?
139
+ state.results[index] = value if status == :ok
140
+ state.in_flight -= 1
141
+ worker[:items_completed] += 1
142
+
143
+ send_item(worker, items, state) if state.next_index < items.length && state.first_error.nil?
144
+ end
145
+
146
+ def send_item(worker, items, state)
147
+ write_message(worker[:cmd_write], [state.next_index, items[state.next_index]])
148
+ state.next_index += 1
149
+ state.in_flight += 1
150
+ end
151
+
152
+ def build_worker_stats(workers)
153
+ workers.map do |worker|
154
+ WorkerStat.new(worker[:pid], worker[:items_completed], worker[:busy_time] || 0.0, worker[:wall_time] || 0.0)
155
+ end
156
+ end
157
+
158
+ def shutdown_workers(workers)
159
+ workers.each do |worker|
160
+ write_message(worker[:cmd_write], SHUTDOWN)
161
+ rescue Errno::EPIPE
162
+ # Worker already exited
163
+ end
164
+
165
+ collect_worker_timing(workers)
166
+
167
+ workers.each do |worker|
168
+ worker[:cmd_write].close unless worker[:cmd_write].closed?
169
+ worker[:res_read].close unless worker[:res_read].closed?
170
+ Process.wait(worker[:pid])
171
+ rescue Errno::ECHILD
172
+ # Already reaped
173
+ end
174
+ end
175
+
176
+ def collect_worker_timing(workers)
177
+ workers.each do |worker|
178
+ message = read_result(worker[:res_read])
179
+ next if message.nil?
180
+
181
+ tag, busy_time, wall_time = message
182
+ next unless tag == STATS
183
+
184
+ worker[:busy_time] = busy_time
185
+ worker[:wall_time] = wall_time
186
+ end
187
+ end
188
+
189
+ def write_message(io, data)
190
+ payload = Marshal.dump(data)
191
+ io.write([payload.bytesize].pack("N"))
192
+ io.write(payload)
193
+ io.flush
194
+ end
195
+
196
+ def read_command(io)
197
+ header = io.read(4)
198
+ return SHUTDOWN if header.nil? || header.bytesize < 4
199
+
200
+ length = header.unpack1("N")
201
+ payload = io.read(length)
202
+ return SHUTDOWN if payload.nil? || payload.bytesize < length
203
+
204
+ Marshal.load(payload) # rubocop:disable Security/MarshalLoad
205
+ end
206
+
207
+ def read_result(io)
208
+ header = io.read(4)
209
+ return nil if header.nil? || header.bytesize < 4
210
+
211
+ length = header.unpack1("N")
212
+ payload = io.read(length)
213
+ return nil if payload.nil? || payload.bytesize < length
214
+
215
+ Marshal.load(payload) # rubocop:disable Security/MarshalLoad
216
+ end
217
+
218
+ CollectionState = Struct.new(:results, :in_flight, :next_index, :first_error) do
219
+ def initialize(item_count)
220
+ super(Array.new(item_count), 0, 0, nil)
221
+ end
222
+ end
223
+ private_constant :CollectionState
224
+ 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?
@@ -68,4 +79,13 @@ class Evilution::Reporter::JSON
68
79
  detail[:memory_delta_kb] = result.memory_delta_kb if result.memory_delta_kb
69
80
  detail
70
81
  end
82
+
83
+ def build_disabled_detail(mutation)
84
+ {
85
+ operator: mutation.operator_name,
86
+ file: mutation.file_path,
87
+ line: mutation.line,
88
+ diff: mutation.diff
89
+ }
90
+ end
71
91
  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)
@@ -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|