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.
- checksums.yaml +4 -4
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +28 -28
- data/CHANGELOG.md +31 -0
- data/README.md +143 -50
- data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
- data/lib/evilution/cli.rb +387 -23
- data/lib/evilution/config.rb +10 -2
- data/lib/evilution/disable_comment.rb +90 -0
- data/lib/evilution/mcp/session_diff_tool.rb +5 -35
- data/lib/evilution/mutator/operator/collection_return.rb +33 -0
- data/lib/evilution/mutator/operator/defined_check.rb +16 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
- data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
- data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
- data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
- data/lib/evilution/mutator/registry.rb +9 -1
- data/lib/evilution/parallel/pool.rb +6 -52
- data/lib/evilution/parallel/work_queue.rb +224 -0
- data/lib/evilution/reporter/cli.rb +21 -1
- data/lib/evilution/reporter/html.rb +69 -3
- data/lib/evilution/reporter/json.rb +22 -2
- data/lib/evilution/reporter/suggestion.rb +29 -1
- data/lib/evilution/result/summary.rb +19 -2
- data/lib/evilution/runner.rb +116 -8
- data/lib/evilution/session/diff.rb +85 -0
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +11 -0
- metadata +14 -2
|
@@ -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="
|
|
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|
|