evilution 0.22.7 → 0.24.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/interactions.jsonl +8 -0
- data/CHANGELOG.md +28 -0
- data/README.md +37 -7
- data/lib/evilution/cli/command.rb +37 -0
- data/lib/evilution/cli/commands/environment_show.rb +20 -0
- data/lib/evilution/cli/commands/init.rb +24 -0
- data/lib/evilution/cli/commands/mcp.rb +19 -0
- data/lib/evilution/cli/commands/run.rb +68 -0
- data/lib/evilution/cli/commands/session_diff.rb +30 -0
- data/lib/evilution/cli/commands/session_gc.rb +46 -0
- data/lib/evilution/cli/commands/session_list.rb +51 -0
- data/lib/evilution/cli/commands/session_show.rb +27 -0
- data/lib/evilution/cli/commands/subjects.rb +50 -0
- data/lib/evilution/cli/commands/tests_list.rb +43 -0
- data/lib/evilution/cli/commands/util_mutation.rb +66 -0
- data/lib/evilution/cli/commands/version.rb +17 -0
- data/lib/evilution/cli/commands.rb +4 -0
- data/lib/evilution/cli/dispatcher.rb +23 -0
- data/lib/evilution/cli/parsed_args.rb +12 -0
- data/lib/evilution/cli/parser/command_extractor.rb +77 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +103 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +88 -0
- data/lib/evilution/cli/printers/environment.rb +53 -0
- data/lib/evilution/cli/printers/session_detail.rb +76 -0
- data/lib/evilution/cli/printers/session_diff.rb +57 -0
- data/lib/evilution/cli/printers/session_list.rb +48 -0
- data/lib/evilution/cli/printers/subjects.rb +35 -0
- data/lib/evilution/cli/printers/tests_list.rb +45 -0
- data/lib/evilution/cli/printers/util_mutation.rb +35 -0
- data/lib/evilution/cli/printers.rb +4 -0
- data/lib/evilution/cli/result.rb +9 -0
- data/lib/evilution/cli.rb +30 -850
- data/lib/evilution/config.rb +31 -3
- data/lib/evilution/integration/base.rb +23 -55
- data/lib/evilution/integration/minitest.rb +22 -4
- data/lib/evilution/integration/rspec.rb +28 -8
- data/lib/evilution/isolation/fork.rb +11 -9
- data/lib/evilution/isolation/in_process.rb +11 -9
- data/lib/evilution/mcp/info_tool.rb +261 -0
- data/lib/evilution/mcp/mutate_tool.rb +112 -19
- data/lib/evilution/mcp/server.rb +3 -4
- data/lib/evilution/mcp/session_diff_tool.rb +5 -1
- data/lib/evilution/mcp/session_list_tool.rb +5 -1
- data/lib/evilution/mcp/session_show_tool.rb +5 -1
- data/lib/evilution/mcp/session_tool.rb +157 -0
- data/lib/evilution/reporter/cli.rb +2 -1
- data/lib/evilution/reporter/html/assets/style.css +68 -0
- data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
- data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
- data/lib/evilution/reporter/html/escape.rb +12 -0
- data/lib/evilution/reporter/html/namespace.rb +11 -0
- data/lib/evilution/reporter/html/report.rb +68 -0
- data/lib/evilution/reporter/html/section.rb +21 -0
- data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
- data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
- data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
- data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
- data/lib/evilution/reporter/html/sections/header.rb +29 -0
- data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
- data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
- data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
- data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
- data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
- data/lib/evilution/reporter/html/sections.rb +4 -0
- data/lib/evilution/reporter/html/stylesheet.rb +14 -0
- data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
- data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
- data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
- data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
- data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
- data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
- data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
- data/lib/evilution/reporter/html.rb +11 -349
- data/lib/evilution/reporter/json.rb +12 -8
- data/lib/evilution/result/mutation_result.rb +5 -1
- data/lib/evilution/result/summary.rb +9 -1
- data/lib/evilution/runner/baseline_runner.rb +71 -0
- data/lib/evilution/runner/diagnostics.rb +105 -0
- data/lib/evilution/runner/isolation_resolver.rb +134 -0
- data/lib/evilution/runner/mutation_executor.rb +255 -0
- data/lib/evilution/runner/mutation_planner.rb +126 -0
- data/lib/evilution/runner/report_publisher.rb +60 -0
- data/lib/evilution/runner/subject_pipeline.rb +121 -0
- data/lib/evilution/runner.rb +57 -692
- data/lib/evilution/version.rb +1 -1
- metadata +71 -2
|
@@ -1,362 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cgi"
|
|
4
|
-
require_relative "suggestion"
|
|
5
|
-
|
|
6
3
|
require_relative "../reporter"
|
|
7
|
-
require_relative "
|
|
4
|
+
require_relative "suggestion"
|
|
5
|
+
require_relative "html/escape"
|
|
6
|
+
require_relative "html/baseline_keys"
|
|
7
|
+
require_relative "html/report"
|
|
8
8
|
|
|
9
9
|
class Evilution::Reporter::HTML
|
|
10
10
|
def initialize(baseline: nil, integration: :rspec)
|
|
11
11
|
@suggestion = Evilution::Reporter::Suggestion.new(integration: integration)
|
|
12
12
|
@baseline = baseline
|
|
13
|
-
@baseline_keys =
|
|
13
|
+
@baseline_keys = BaselineKeys.new(baseline)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def call(summary)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def group_by_file(results)
|
|
24
|
-
grouped = {}
|
|
25
|
-
results.each do |result|
|
|
26
|
-
path = result.mutation.file_path
|
|
27
|
-
grouped[path] ||= []
|
|
28
|
-
grouped[path] << result
|
|
29
|
-
end
|
|
30
|
-
grouped.sort_by { |path, _| path }.to_h
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def build_html(summary, files)
|
|
34
|
-
<<~HTML
|
|
35
|
-
<!DOCTYPE html>
|
|
36
|
-
<html lang="en">
|
|
37
|
-
<head>
|
|
38
|
-
<meta charset="UTF-8">
|
|
39
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
40
|
-
<title>Evilution Mutation Report</title>
|
|
41
|
-
#{stylesheet}
|
|
42
|
-
</head>
|
|
43
|
-
<body>
|
|
44
|
-
#{build_header(summary)}
|
|
45
|
-
#{build_summary_cards(summary)}
|
|
46
|
-
#{build_baseline_comparison(summary)}
|
|
47
|
-
#{build_truncation_notice(summary)}
|
|
48
|
-
#{build_file_sections(files)}
|
|
49
|
-
#{build_footer}
|
|
50
|
-
</body>
|
|
51
|
-
</html>
|
|
52
|
-
HTML
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def build_header(summary)
|
|
56
|
-
score_pct = format("%.2f%%", summary.score * 100)
|
|
57
|
-
score_class = score_css_class(summary.score)
|
|
58
|
-
<<~HTML
|
|
59
|
-
<header>
|
|
60
|
-
<h1>Evilution <span class="version">v#{h(Evilution::VERSION)}</span></h1>
|
|
61
|
-
<div class="score-badge #{score_class}">#{score_pct}</div>
|
|
62
|
-
</header>
|
|
63
|
-
HTML
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def build_summary_cards(summary)
|
|
67
|
-
peak = summary.peak_memory_mb
|
|
68
|
-
peak_html = if peak
|
|
69
|
-
peak_val = format("%.1f", peak)
|
|
70
|
-
"<div class=\"card\"><span class=\"card-value\">#{peak_val} MB</span>" \
|
|
71
|
-
"<span class=\"card-label\">Peak Memory</span></div>"
|
|
72
|
-
else
|
|
73
|
-
""
|
|
74
|
-
end
|
|
75
|
-
<<~HTML
|
|
76
|
-
<section class="summary-cards">
|
|
77
|
-
<div class="card"><span class="card-value">#{summary.total}</span><span class="card-label">Total</span></div>
|
|
78
|
-
<div class="card card-killed"><span class="card-value">#{summary.killed}</span><span class="card-label">Killed</span></div>
|
|
79
|
-
<div class="card card-survived"><span class="card-value">#{summary.survived}</span><span class="card-label">Survived</span></div>
|
|
80
|
-
<div class="card"><span class="card-value">#{summary.timed_out}</span><span class="card-label">Timed Out</span></div>
|
|
81
|
-
<div class="card"><span class="card-value">#{summary.errors}</span><span class="card-label">Errors</span></div>
|
|
82
|
-
<div class="card"><span class="card-value">#{summary.neutral}</span><span class="card-label">Neutral</span></div>
|
|
83
|
-
<div class="card"><span class="card-value">#{summary.equivalent}</span><span class="card-label">Equivalent</span></div>
|
|
84
|
-
#{build_skipped_card(summary)}
|
|
85
|
-
<div class="card"><span class="card-value">#{format("%.2f", summary.duration)}s</span><span class="card-label">Duration</span></div>
|
|
86
|
-
#{build_efficiency_cards(summary)}
|
|
87
|
-
#{peak_html}
|
|
88
|
-
</section>
|
|
89
|
-
HTML
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def build_efficiency_cards(summary)
|
|
93
|
-
return "" unless summary.duration.positive?
|
|
94
|
-
|
|
95
|
-
pct = format("%.1f%%", summary.efficiency * 100)
|
|
96
|
-
rate = format("%.2f", summary.mutations_per_second)
|
|
97
|
-
%(<div class="card"><span class="card-value">#{pct}</span><span class="card-label">Efficiency</span></div>) +
|
|
98
|
-
%(<div class="card"><span class="card-value">#{rate}/s</span><span class="card-label">Rate</span></div>)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def build_skipped_card(summary)
|
|
102
|
-
return "" unless summary.skipped.positive?
|
|
103
|
-
|
|
104
|
-
%(<div class="card"><span class="card-value">#{summary.skipped}</span><span class="card-label">Skipped</span></div>)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def build_truncation_notice(summary)
|
|
108
|
-
return "" unless summary.truncated?
|
|
109
|
-
|
|
110
|
-
'<div class="truncation-notice">Truncated: Stopped early due to --fail-fast</div>'
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def build_file_sections(files)
|
|
114
|
-
return '<p class="empty">No mutations generated.</p>' if files.empty?
|
|
115
|
-
|
|
116
|
-
files.map { |path, results| build_file_section(path, results) }.join("\n")
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def build_file_section(path, results)
|
|
120
|
-
killed_count = results.count(&:killed?)
|
|
121
|
-
survived_count = results.count(&:survived?)
|
|
122
|
-
total = results.length
|
|
123
|
-
survived = results.select(&:survived?)
|
|
124
|
-
map_html = build_mutation_map(results)
|
|
125
|
-
|
|
126
|
-
<<~HTML
|
|
127
|
-
<section class="file-section">
|
|
128
|
-
<h2 class="file-header">
|
|
129
|
-
<span class="file-path">#{h(path)}</span>
|
|
130
|
-
<span class="file-stats">#{killed_count} killed / #{survived_count} survived / #{total} total</span>
|
|
131
|
-
</h2>
|
|
132
|
-
<div class="mutation-map">#{map_html}</div>
|
|
133
|
-
#{build_survived_details(survived)}
|
|
134
|
-
</section>
|
|
135
|
-
HTML
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def build_mutation_map(results)
|
|
139
|
-
results
|
|
140
|
-
.sort_by { |r| r.mutation.line }
|
|
141
|
-
.map { |r| build_map_entry(r) }
|
|
142
|
-
.join("\n")
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def build_map_entry(result)
|
|
146
|
-
mutation = result.mutation
|
|
147
|
-
status = result.status.to_s
|
|
148
|
-
title_text = normalize_title(result.error_message)
|
|
149
|
-
title_attr = title_text ? %( title="#{h(title_text)}") : ""
|
|
150
|
-
<<~HTML.chomp
|
|
151
|
-
<div class="map-line #{status}"#{title_attr}>
|
|
152
|
-
<span class="line-number">line #{mutation.line}</span>
|
|
153
|
-
<span class="operator">#{h(mutation.operator_name)}</span>
|
|
154
|
-
<span class="status-badge #{status}">#{status}</span>
|
|
155
|
-
</div>
|
|
156
|
-
HTML
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def normalize_title(message)
|
|
160
|
-
return nil if message.nil?
|
|
161
|
-
|
|
162
|
-
normalized = message.gsub(/\s+/, " ").strip
|
|
163
|
-
normalized.empty? ? nil : normalized
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def build_survived_details(survived)
|
|
167
|
-
return "" if survived.empty?
|
|
168
|
-
|
|
169
|
-
gaps = Evilution::Result::CoverageGapGrouper.new.call(survived)
|
|
170
|
-
entries = gaps.map { |gap| build_gap_entry(gap) }.join("\n")
|
|
171
|
-
<<~HTML
|
|
172
|
-
<div class="survived-details">
|
|
173
|
-
<h3>Coverage Gaps (#{gaps.length})</h3>
|
|
174
|
-
#{entries}
|
|
175
|
-
</div>
|
|
176
|
-
HTML
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def build_gap_entry(gap)
|
|
180
|
-
if gap.single?
|
|
181
|
-
build_survived_entry(gap.mutation_results.first)
|
|
182
|
-
else
|
|
183
|
-
build_grouped_gap_entry(gap)
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def build_grouped_gap_entry(gap)
|
|
188
|
-
operator_tags = gap.operator_names.map { |op| %(<span class="operator-tag">#{h(op)}</span>) }.join(" ")
|
|
189
|
-
entries_html = gap.mutation_results.map { |r| build_survived_entry(r) }.join("\n")
|
|
190
|
-
<<~HTML
|
|
191
|
-
<div class="coverage-gap">
|
|
192
|
-
<div class="gap-header">
|
|
193
|
-
<span class="location">#{h(gap.file_path)}:#{gap.line} (#{h(gap.subject_name)})</span>
|
|
194
|
-
<span class="gap-count">#{gap.count} mutations</span>
|
|
195
|
-
#{operator_tags}
|
|
196
|
-
</div>
|
|
197
|
-
#{entries_html}
|
|
198
|
-
</div>
|
|
199
|
-
HTML
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def build_survived_entry(result)
|
|
203
|
-
mutation = result.mutation
|
|
204
|
-
suggestion_text = @suggestion.suggestion_for(mutation)
|
|
205
|
-
diff_html = format_diff(mutation.diff)
|
|
206
|
-
regression = regression?(mutation)
|
|
207
|
-
entry_class = regression ? "survived-entry regression" : "survived-entry"
|
|
208
|
-
regression_badge = regression ? ' <span class="regression-badge">NEW REGRESSION</span>' : ""
|
|
209
|
-
<<~HTML
|
|
210
|
-
<div class="#{entry_class}">
|
|
211
|
-
<div class="survived-header">
|
|
212
|
-
<span class="operator">#{h(mutation.operator_name)}#{regression_badge}</span>
|
|
213
|
-
<span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
|
|
214
|
-
</div>
|
|
215
|
-
<pre class="diff">#{diff_html}</pre>
|
|
216
|
-
<div class="suggestion">#{h(suggestion_text)}</div>
|
|
217
|
-
</div>
|
|
218
|
-
HTML
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def format_diff(diff)
|
|
222
|
-
diff.split("\n").map do |line|
|
|
223
|
-
escaped = h(line)
|
|
224
|
-
css_class = if line.start_with?("- ")
|
|
225
|
-
"diff-removed"
|
|
226
|
-
elsif line.start_with?("+ ")
|
|
227
|
-
"diff-added"
|
|
228
|
-
else
|
|
229
|
-
""
|
|
230
|
-
end
|
|
231
|
-
%(<span class="#{css_class}">#{escaped}</span>)
|
|
232
|
-
end.join("\n")
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def build_footer
|
|
236
|
-
"<footer>Generated by Evilution v#{h(Evilution::VERSION)}</footer>"
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
def score_css_class(score)
|
|
240
|
-
if score >= 0.8
|
|
241
|
-
"score-high"
|
|
242
|
-
elsif score >= 0.5
|
|
243
|
-
"score-medium"
|
|
244
|
-
else
|
|
245
|
-
"score-low"
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def build_baseline_comparison(summary)
|
|
250
|
-
return "" unless @baseline
|
|
251
|
-
|
|
252
|
-
base_summary = @baseline["summary"] || {}
|
|
253
|
-
base_score = base_summary["score"] || 0.0
|
|
254
|
-
head_score = summary.score
|
|
255
|
-
delta = head_score - base_score
|
|
256
|
-
delta_str = format("%+.2f%%", delta * 100)
|
|
257
|
-
delta_class = if delta.positive?
|
|
258
|
-
"delta-positive"
|
|
259
|
-
elsif delta.negative?
|
|
260
|
-
"delta-negative"
|
|
261
|
-
else
|
|
262
|
-
"delta-neutral"
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
<<~HTML
|
|
266
|
-
<section class="baseline-comparison">
|
|
267
|
-
<h2>Baseline Comparison</h2>
|
|
268
|
-
<div class="comparison-scores">
|
|
269
|
-
<span>Baseline: #{format("%.2f%%", base_score * 100)}</span>
|
|
270
|
-
<span>Current: #{format("%.2f%%", head_score * 100)}</span>
|
|
271
|
-
<span class="#{delta_class}">Delta: #{delta_str}</span>
|
|
272
|
-
</div>
|
|
273
|
-
</section>
|
|
274
|
-
HTML
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def regression?(mutation)
|
|
278
|
-
return false if @baseline_keys.nil?
|
|
279
|
-
|
|
280
|
-
key = [mutation.operator_name, mutation.file_path, mutation.line, mutation.subject.name]
|
|
281
|
-
!@baseline_keys.include?(key)
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
def build_baseline_keys(baseline)
|
|
285
|
-
return nil unless baseline
|
|
286
|
-
|
|
287
|
-
survived = baseline["survived"] || []
|
|
288
|
-
survived.to_set { |m| [m["operator"], m["file"], m["line"], m["subject"]] }
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def h(text)
|
|
292
|
-
CGI.escapeHTML(text.to_s)
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def stylesheet
|
|
296
|
-
<<~HTML
|
|
297
|
-
<style>
|
|
298
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
299
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
|
|
300
|
-
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
|
301
|
-
h1 { font-size: 1.5rem; color: #f0f6fc; }
|
|
302
|
-
.version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
|
|
303
|
-
.score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
|
|
304
|
-
.score-high { background: #1a4731; color: #3fb950; }
|
|
305
|
-
.score-medium { background: #4a3a10; color: #d29922; }
|
|
306
|
-
.score-low { background: #4a1a1a; color: #f85149; }
|
|
307
|
-
.summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
|
|
308
|
-
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
|
|
309
|
-
.card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
|
|
310
|
-
.card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
|
|
311
|
-
.card-killed { border-color: #238636; }
|
|
312
|
-
.card-survived { border-color: #da3633; }
|
|
313
|
-
.truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
|
|
314
|
-
.file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
|
|
315
|
-
.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; }
|
|
316
|
-
.file-path { color: #58a6ff; font-family: monospace; }
|
|
317
|
-
.file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
|
|
318
|
-
.mutation-map { padding: 0.5rem 1rem; }
|
|
319
|
-
.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; }
|
|
320
|
-
.map-line.killed { color: #3fb950; }
|
|
321
|
-
.map-line.survived { color: #f85149; }
|
|
322
|
-
.map-line.timeout { color: #d29922; }
|
|
323
|
-
.map-line.error { color: #f85149; }
|
|
324
|
-
.map-line.neutral { color: #8b949e; }
|
|
325
|
-
.map-line.equivalent { color: #8b949e; }
|
|
326
|
-
.line-number { min-width: 60px; color: #8b949e; }
|
|
327
|
-
.operator { flex: 1; }
|
|
328
|
-
.status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
|
|
329
|
-
.status-badge.killed { background: #1a4731; }
|
|
330
|
-
.status-badge.survived { background: #4a1a1a; }
|
|
331
|
-
.status-badge.timeout { background: #4a3a10; }
|
|
332
|
-
.status-badge.neutral { background: #21262d; }
|
|
333
|
-
.status-badge.equivalent { background: #21262d; }
|
|
334
|
-
.survived-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
335
|
-
.survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
336
|
-
.survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
|
|
337
|
-
.survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
|
|
338
|
-
.survived-header .operator { color: #f85149; font-weight: bold; }
|
|
339
|
-
.survived-header .location { color: #8b949e; font-family: monospace; }
|
|
340
|
-
.diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
|
|
341
|
-
.diff-removed { color: #f85149; display: block; }
|
|
342
|
-
.diff-added { color: #3fb950; display: block; }
|
|
343
|
-
.suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
|
|
344
|
-
.coverage-gap { border: 1px solid #30363d; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; background: #161b22; }
|
|
345
|
-
.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; }
|
|
346
|
-
.gap-header .location { color: #58a6ff; font-family: monospace; }
|
|
347
|
-
.gap-count { background: #4a1a1a; color: #f85149; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: bold; }
|
|
348
|
-
.operator-tag { background: #21262d; color: #8b949e; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 4px; font-family: monospace; }
|
|
349
|
-
.empty { color: #8b949e; text-align: center; padding: 2rem; }
|
|
350
|
-
.baseline-comparison { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
|
|
351
|
-
.baseline-comparison h2 { font-size: 1rem; color: #f0f6fc; margin-bottom: 0.75rem; }
|
|
352
|
-
.comparison-scores { display: flex; gap: 2rem; font-size: 0.9rem; }
|
|
353
|
-
.delta-positive { color: #3fb950; font-weight: bold; }
|
|
354
|
-
.delta-negative { color: #f85149; font-weight: bold; }
|
|
355
|
-
.delta-neutral { color: #8b949e; font-weight: bold; }
|
|
356
|
-
.survived-entry.regression { border-color: #f85149; background: #2a1010; }
|
|
357
|
-
.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; }
|
|
358
|
-
footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
|
|
359
|
-
</style>
|
|
360
|
-
HTML
|
|
17
|
+
Report.new(
|
|
18
|
+
summary,
|
|
19
|
+
baseline: @baseline,
|
|
20
|
+
baseline_keys: @baseline_keys,
|
|
21
|
+
suggestion: @suggestion
|
|
22
|
+
).render
|
|
361
23
|
end
|
|
362
24
|
end
|
|
@@ -17,24 +17,27 @@ class Evilution::Reporter::JSON
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
|
21
20
|
def build_report(summary)
|
|
22
21
|
report = {
|
|
23
22
|
version: Evilution::VERSION,
|
|
24
23
|
timestamp: Time.now.iso8601,
|
|
25
24
|
summary: build_summary(summary),
|
|
26
|
-
survived: summary.survived_results
|
|
25
|
+
survived: map_details(summary.survived_results),
|
|
27
26
|
coverage_gaps: build_coverage_gaps(summary),
|
|
28
|
-
killed: summary.killed_results
|
|
29
|
-
neutral: summary.neutral_results
|
|
30
|
-
timed_out: summary.results.select(&:timeout?)
|
|
31
|
-
errors: summary.results.select(&:error?)
|
|
32
|
-
equivalent: summary.equivalent_results
|
|
27
|
+
killed: map_details(summary.killed_results),
|
|
28
|
+
neutral: map_details(summary.neutral_results),
|
|
29
|
+
timed_out: map_details(summary.results.select(&:timeout?)),
|
|
30
|
+
errors: map_details(summary.results.select(&:error?)),
|
|
31
|
+
equivalent: map_details(summary.equivalent_results),
|
|
32
|
+
unresolved: map_details(summary.unresolved_results)
|
|
33
33
|
}
|
|
34
34
|
append_disabled_to_report(report, summary)
|
|
35
35
|
report
|
|
36
36
|
end
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
def map_details(results)
|
|
39
|
+
results.map { |r| build_mutation_detail(r) }
|
|
40
|
+
end
|
|
38
41
|
|
|
39
42
|
def append_disabled_to_report(report, summary)
|
|
40
43
|
return unless summary.disabled_mutations.any?
|
|
@@ -51,6 +54,7 @@ class Evilution::Reporter::JSON
|
|
|
51
54
|
errors: summary.errors,
|
|
52
55
|
neutral: summary.neutral,
|
|
53
56
|
equivalent: summary.equivalent,
|
|
57
|
+
unresolved: summary.unresolved,
|
|
54
58
|
score: summary.score.round(4),
|
|
55
59
|
duration: summary.duration.round(4),
|
|
56
60
|
killtime: summary.killtime.round(4),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "../result"
|
|
4
4
|
|
|
5
5
|
class Evilution::Result::MutationResult
|
|
6
|
-
STATUSES = %i[killed survived timeout error neutral equivalent].freeze
|
|
6
|
+
STATUSES = %i[killed survived timeout error neutral equivalent unresolved].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :mutation, :status, :duration, :killing_test, :test_command,
|
|
9
9
|
:child_rss_kb, :memory_delta_kb, :parent_rss_kb,
|
|
@@ -54,4 +54,8 @@ class Evilution::Result::MutationResult
|
|
|
54
54
|
def equivalent?
|
|
55
55
|
status == :equivalent
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
def unresolved?
|
|
59
|
+
status == :unresolved
|
|
60
|
+
end
|
|
57
61
|
end
|
|
@@ -47,8 +47,12 @@ class Evilution::Result::Summary
|
|
|
47
47
|
results.count(&:equivalent?)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
def unresolved
|
|
51
|
+
results.count(&:unresolved?)
|
|
52
|
+
end
|
|
53
|
+
|
|
50
54
|
def score
|
|
51
|
-
denominator = total - errors - neutral - equivalent
|
|
55
|
+
denominator = total - errors - neutral - equivalent - unresolved
|
|
52
56
|
return 0.0 if denominator.zero?
|
|
53
57
|
|
|
54
58
|
killed.to_f / denominator
|
|
@@ -74,6 +78,10 @@ class Evilution::Result::Summary
|
|
|
74
78
|
results.select(&:equivalent?)
|
|
75
79
|
end
|
|
76
80
|
|
|
81
|
+
def unresolved_results
|
|
82
|
+
results.select(&:unresolved?)
|
|
83
|
+
end
|
|
84
|
+
|
|
77
85
|
def coverage_gaps
|
|
78
86
|
Evilution::Result::CoverageGapGrouper.new.call(survived_results)
|
|
79
87
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../baseline"
|
|
4
|
+
require_relative "../spec_resolver"
|
|
5
|
+
require_relative "../integration/rspec"
|
|
6
|
+
require_relative "../integration/minitest"
|
|
7
|
+
|
|
8
|
+
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
9
|
+
|
|
10
|
+
unless defined?(Evilution::Runner::INTEGRATIONS)
|
|
11
|
+
Evilution::Runner::INTEGRATIONS = {
|
|
12
|
+
rspec: Evilution::Integration::RSpec,
|
|
13
|
+
minitest: Evilution::Integration::Minitest
|
|
14
|
+
}.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Evilution::Runner::BaselineRunner
|
|
18
|
+
def initialize(config, hooks: nil)
|
|
19
|
+
@config = config
|
|
20
|
+
@hooks = hooks
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def integration_class
|
|
24
|
+
@integration_class ||= Evilution::Runner::INTEGRATIONS.fetch(config.integration) do
|
|
25
|
+
raise Evilution::Error, "unknown integration: #{config.integration}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_integration
|
|
30
|
+
klass = integration_class
|
|
31
|
+
test_files = config.spec_files.empty? ? nil : config.spec_files
|
|
32
|
+
kwargs = { test_files: test_files, hooks: hooks, fallback_to_full_suite: config.fallback_to_full_suite? }
|
|
33
|
+
kwargs[:related_specs_heuristic] = config.related_specs_heuristic? if klass == Evilution::Integration::RSpec
|
|
34
|
+
klass.new(**kwargs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(subjects)
|
|
38
|
+
return nil unless config.baseline? && subjects.any?
|
|
39
|
+
|
|
40
|
+
log_start
|
|
41
|
+
baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
|
|
42
|
+
result = baseline.call(subjects)
|
|
43
|
+
log_complete(result)
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def neutralization_resolver
|
|
48
|
+
integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def neutralization_fallback_dir
|
|
52
|
+
integration_class.baseline_options[:fallback_dir] || "spec"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :config, :hooks
|
|
58
|
+
|
|
59
|
+
def log_start
|
|
60
|
+
return if config.quiet || !config.text? || !$stderr.tty?
|
|
61
|
+
|
|
62
|
+
$stderr.write("Running baseline test suite...\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def log_complete(result)
|
|
66
|
+
return if config.quiet || !config.text? || !$stderr.tty?
|
|
67
|
+
|
|
68
|
+
count = result.failed_spec_files.size
|
|
69
|
+
$stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../memory"
|
|
4
|
+
require_relative "../parallel/pool"
|
|
5
|
+
|
|
6
|
+
class Evilution::Runner; end unless defined?(Evilution::Runner) # rubocop:disable Lint/EmptyClass
|
|
7
|
+
|
|
8
|
+
class Evilution::Runner::Diagnostics
|
|
9
|
+
def initialize(config, stderr: $stderr)
|
|
10
|
+
@config = config
|
|
11
|
+
@stderr = stderr
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def log_memory(phase, context = nil)
|
|
15
|
+
return unless verbose?
|
|
16
|
+
|
|
17
|
+
rss = Evilution::Memory.rss_mb
|
|
18
|
+
return unless rss
|
|
19
|
+
|
|
20
|
+
gc = gc_stats_string
|
|
21
|
+
msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
|
|
22
|
+
ctx = [context, gc].compact.join(", ")
|
|
23
|
+
msg += " (#{ctx})" unless ctx.empty?
|
|
24
|
+
stderr.write("#{msg}\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def log_progress(current, status)
|
|
28
|
+
return unless text_tty?
|
|
29
|
+
|
|
30
|
+
stderr.write("mutation #{current} #{status}\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def log_mutation_diagnostics(result)
|
|
34
|
+
return unless verbose?
|
|
35
|
+
|
|
36
|
+
parts = []
|
|
37
|
+
parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
|
|
38
|
+
|
|
39
|
+
if result.memory_delta_kb
|
|
40
|
+
sign = result.memory_delta_kb.negative? ? "" : "+"
|
|
41
|
+
parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
parts << gc_stats_string
|
|
45
|
+
|
|
46
|
+
stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n") unless parts.empty?
|
|
47
|
+
|
|
48
|
+
log_mutation_error(result) if result.error?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def log_worker_stats(stats)
|
|
52
|
+
return unless verbose? && stats.any?
|
|
53
|
+
|
|
54
|
+
stats.each do |stat|
|
|
55
|
+
pct = format("%.1f", stat.utilization * 100)
|
|
56
|
+
stderr.write("[verbose] worker #{stat.pid}: #{stat.items_completed} items, utilization #{pct}%\n")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def aggregate_worker_stats(stats)
|
|
61
|
+
return stats if stats.empty?
|
|
62
|
+
|
|
63
|
+
stats.group_by(&:pid).map do |pid, entries|
|
|
64
|
+
Evilution::Parallel::WorkQueue::WorkerStat.new(
|
|
65
|
+
pid,
|
|
66
|
+
entries.sum(&:items_completed),
|
|
67
|
+
entries.sum(&:busy_time),
|
|
68
|
+
entries.sum(&:wall_time)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
attr_reader :config, :stderr
|
|
76
|
+
|
|
77
|
+
def verbose?
|
|
78
|
+
config.verbose && !config.quiet
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def text_tty?
|
|
82
|
+
!config.quiet && config.text? && stderr.tty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def log_mutation_error(result)
|
|
86
|
+
header = "[verbose] #{result.mutation}: error"
|
|
87
|
+
header += " #{result.error_class}" if result.error_class
|
|
88
|
+
header += ": #{result.error_message}" if result.error_message
|
|
89
|
+
stderr.write("#{header}\n")
|
|
90
|
+
|
|
91
|
+
Array(result.error_backtrace).first(5).each do |line|
|
|
92
|
+
stderr.write("[verbose] #{line}\n")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def gc_stats_string
|
|
97
|
+
stats = GC.stat
|
|
98
|
+
format(
|
|
99
|
+
"heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
|
|
100
|
+
live: stats[:heap_live_slots],
|
|
101
|
+
alloc: stats[:total_allocated_objects],
|
|
102
|
+
freed: stats[:total_freed_objects]
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|