evilution 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +210 -0
- data/CHANGELOG.md +51 -0
- data/README.md +81 -4
- data/exe/evil +6 -0
- data/lib/evilution/ast/source_surgeon.rb +15 -1
- data/lib/evilution/cli/commands/compare.rb +68 -0
- data/lib/evilution/cli/parser/command_extractor.rb +78 -0
- data/lib/evilution/cli/parser/file_args.rb +41 -0
- data/lib/evilution/cli/parser/options_builder.rb +123 -0
- data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
- data/lib/evilution/cli/parser.rb +27 -196
- data/lib/evilution/cli/printers/compare.rb +159 -0
- data/lib/evilution/cli.rb +1 -0
- data/lib/evilution/compare/categorizer.rb +109 -0
- data/lib/evilution/compare/detector.rb +21 -0
- data/lib/evilution/compare/fingerprint.rb +83 -0
- data/lib/evilution/compare/normalizer.rb +106 -0
- data/lib/evilution/compare/record.rb +16 -0
- data/lib/evilution/compare.rb +15 -0
- data/lib/evilution/config.rb +178 -3
- data/lib/evilution/example_filter.rb +143 -0
- data/lib/evilution/integration/base.rb +11 -57
- data/lib/evilution/integration/crash_detector.rb +5 -2
- data/lib/evilution/integration/minitest.rb +25 -7
- data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
- data/lib/evilution/integration/rspec.rb +99 -12
- data/lib/evilution/isolation/fork.rb +26 -0
- data/lib/evilution/isolation/in_process.rb +1 -0
- data/lib/evilution/mcp/info_tool.rb +77 -5
- data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
- data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
- data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
- data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
- data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
- data/lib/evilution/mcp/mutate_tool.rb +34 -186
- data/lib/evilution/mutation.rb +43 -3
- data/lib/evilution/mutator/base.rb +39 -1
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
- data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
- data/lib/evilution/parallel/work_queue.rb +149 -31
- data/lib/evilution/parallel_db_warning.rb +68 -0
- data/lib/evilution/reporter/cli.rb +38 -11
- data/lib/evilution/reporter/html/assets/style.css +85 -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 +62 -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/neutral_details.rb +25 -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/unparseable_details.rb +25 -0
- data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -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 +12 -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/neutral_details.html.erb +14 -0
- data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
- data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -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/templates/unparseable_details.html.erb +11 -0
- data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
- data/lib/evilution/reporter/html.rb +11 -390
- data/lib/evilution/reporter/json.rb +19 -9
- data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
- data/lib/evilution/reporter/suggestion/registry.rb +64 -0
- data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
- data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
- data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
- data/lib/evilution/reporter/suggestion.rb +8 -1327
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +21 -1
- data/lib/evilution/runner/baseline_runner.rb +92 -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 +325 -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 +61 -692
- data/lib/evilution/source_ast_cache.rb +39 -0
- data/lib/evilution/spec_ast_cache.rb +166 -0
- data/lib/evilution/spec_resolver.rb +6 -1
- data/lib/evilution/spec_selector.rb +39 -0
- data/lib/evilution/temp_dir_tracker.rb +23 -3
- data/lib/evilution/version.rb +1 -1
- data/script/memory_check +7 -5
- metadata +75 -2
|
@@ -1,403 +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
|
-
errored = results.select(&:error?)
|
|
125
|
-
map_html = build_mutation_map(results)
|
|
126
|
-
|
|
127
|
-
<<~HTML
|
|
128
|
-
<section class="file-section">
|
|
129
|
-
<h2 class="file-header">
|
|
130
|
-
<span class="file-path">#{h(path)}</span>
|
|
131
|
-
<span class="file-stats">#{killed_count} killed / #{survived_count} survived / #{total} total</span>
|
|
132
|
-
</h2>
|
|
133
|
-
<div class="mutation-map">#{map_html}</div>
|
|
134
|
-
#{build_survived_details(survived)}
|
|
135
|
-
#{build_error_details(errored)}
|
|
136
|
-
</section>
|
|
137
|
-
HTML
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def build_mutation_map(results)
|
|
141
|
-
results
|
|
142
|
-
.sort_by { |r| r.mutation.line }
|
|
143
|
-
.map { |r| build_map_entry(r) }
|
|
144
|
-
.join("\n")
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def build_map_entry(result)
|
|
148
|
-
mutation = result.mutation
|
|
149
|
-
status = result.status.to_s
|
|
150
|
-
title_text = normalize_title(result.error_message)
|
|
151
|
-
title_attr = title_text ? %( title="#{h(title_text)}") : ""
|
|
152
|
-
<<~HTML.chomp
|
|
153
|
-
<div class="map-line #{status}"#{title_attr}>
|
|
154
|
-
<span class="line-number">line #{mutation.line}</span>
|
|
155
|
-
<span class="operator">#{h(mutation.operator_name)}</span>
|
|
156
|
-
<span class="status-badge #{status}">#{status}</span>
|
|
157
|
-
</div>
|
|
158
|
-
HTML
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def normalize_title(message)
|
|
162
|
-
return nil if message.nil?
|
|
163
|
-
|
|
164
|
-
normalized = message.gsub(/\s+/, " ").strip
|
|
165
|
-
normalized.empty? ? nil : normalized
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def build_survived_details(survived)
|
|
169
|
-
return "" if survived.empty?
|
|
170
|
-
|
|
171
|
-
gaps = Evilution::Result::CoverageGapGrouper.new.call(survived)
|
|
172
|
-
entries = gaps.map { |gap| build_gap_entry(gap) }.join("\n")
|
|
173
|
-
<<~HTML
|
|
174
|
-
<div class="survived-details">
|
|
175
|
-
<h3>Coverage Gaps (#{gaps.length})</h3>
|
|
176
|
-
#{entries}
|
|
177
|
-
</div>
|
|
178
|
-
HTML
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def build_gap_entry(gap)
|
|
182
|
-
if gap.single?
|
|
183
|
-
build_survived_entry(gap.mutation_results.first)
|
|
184
|
-
else
|
|
185
|
-
build_grouped_gap_entry(gap)
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def build_grouped_gap_entry(gap)
|
|
190
|
-
operator_tags = gap.operator_names.map { |op| %(<span class="operator-tag">#{h(op)}</span>) }.join(" ")
|
|
191
|
-
entries_html = gap.mutation_results.map { |r| build_survived_entry(r) }.join("\n")
|
|
192
|
-
<<~HTML
|
|
193
|
-
<div class="coverage-gap">
|
|
194
|
-
<div class="gap-header">
|
|
195
|
-
<span class="location">#{h(gap.file_path)}:#{gap.line} (#{h(gap.subject_name)})</span>
|
|
196
|
-
<span class="gap-count">#{gap.count} mutations</span>
|
|
197
|
-
#{operator_tags}
|
|
198
|
-
</div>
|
|
199
|
-
#{entries_html}
|
|
200
|
-
</div>
|
|
201
|
-
HTML
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def build_survived_entry(result)
|
|
205
|
-
mutation = result.mutation
|
|
206
|
-
suggestion_text = @suggestion.suggestion_for(mutation)
|
|
207
|
-
diff_html = format_diff(mutation.diff)
|
|
208
|
-
regression = regression?(mutation)
|
|
209
|
-
entry_class = regression ? "survived-entry regression" : "survived-entry"
|
|
210
|
-
regression_badge = regression ? ' <span class="regression-badge">NEW REGRESSION</span>' : ""
|
|
211
|
-
<<~HTML
|
|
212
|
-
<div class="#{entry_class}">
|
|
213
|
-
<div class="survived-header">
|
|
214
|
-
<span class="operator">#{h(mutation.operator_name)}#{regression_badge}</span>
|
|
215
|
-
<span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
|
|
216
|
-
</div>
|
|
217
|
-
<pre class="diff">#{diff_html}</pre>
|
|
218
|
-
<div class="suggestion">#{h(suggestion_text)}</div>
|
|
219
|
-
</div>
|
|
220
|
-
HTML
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def build_error_details(errored)
|
|
224
|
-
return "" if errored.empty?
|
|
225
|
-
|
|
226
|
-
entries = errored
|
|
227
|
-
.sort_by { |r| [r.mutation.operator_name, r.mutation.line] }
|
|
228
|
-
.map { |r| build_error_entry(r) }
|
|
229
|
-
.join("\n")
|
|
230
|
-
<<~HTML
|
|
231
|
-
<div class="error-details">
|
|
232
|
-
<h3>Errors (#{errored.length})</h3>
|
|
233
|
-
#{entries}
|
|
234
|
-
</div>
|
|
235
|
-
HTML
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def build_error_entry(result)
|
|
239
|
-
mutation = result.mutation
|
|
240
|
-
message = result.error_message.to_s
|
|
241
|
-
message_html = message.empty? ? "" : %(<pre class="error-message">#{h(message)}</pre>)
|
|
242
|
-
diff_html = format_diff(mutation.diff)
|
|
243
|
-
<<~HTML
|
|
244
|
-
<div class="error-entry">
|
|
245
|
-
<div class="error-header">
|
|
246
|
-
<span class="operator">#{h(mutation.operator_name)}</span>
|
|
247
|
-
<span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
|
|
248
|
-
</div>
|
|
249
|
-
<pre class="diff">#{diff_html}</pre>
|
|
250
|
-
#{message_html}
|
|
251
|
-
</div>
|
|
252
|
-
HTML
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def format_diff(diff)
|
|
256
|
-
diff.split("\n").map do |line|
|
|
257
|
-
escaped = h(line)
|
|
258
|
-
css_class = if line.start_with?("- ")
|
|
259
|
-
"diff-removed"
|
|
260
|
-
elsif line.start_with?("+ ")
|
|
261
|
-
"diff-added"
|
|
262
|
-
else
|
|
263
|
-
""
|
|
264
|
-
end
|
|
265
|
-
%(<span class="#{css_class}">#{escaped}</span>)
|
|
266
|
-
end.join("\n")
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
def build_footer
|
|
270
|
-
"<footer>Generated by Evilution v#{h(Evilution::VERSION)}</footer>"
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
def score_css_class(score)
|
|
274
|
-
if score >= 0.8
|
|
275
|
-
"score-high"
|
|
276
|
-
elsif score >= 0.5
|
|
277
|
-
"score-medium"
|
|
278
|
-
else
|
|
279
|
-
"score-low"
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def build_baseline_comparison(summary)
|
|
284
|
-
return "" unless @baseline
|
|
285
|
-
|
|
286
|
-
base_summary = @baseline["summary"] || {}
|
|
287
|
-
base_score = base_summary["score"] || 0.0
|
|
288
|
-
head_score = summary.score
|
|
289
|
-
delta = head_score - base_score
|
|
290
|
-
delta_str = format("%+.2f%%", delta * 100)
|
|
291
|
-
delta_class = if delta.positive?
|
|
292
|
-
"delta-positive"
|
|
293
|
-
elsif delta.negative?
|
|
294
|
-
"delta-negative"
|
|
295
|
-
else
|
|
296
|
-
"delta-neutral"
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
<<~HTML
|
|
300
|
-
<section class="baseline-comparison">
|
|
301
|
-
<h2>Baseline Comparison</h2>
|
|
302
|
-
<div class="comparison-scores">
|
|
303
|
-
<span>Baseline: #{format("%.2f%%", base_score * 100)}</span>
|
|
304
|
-
<span>Current: #{format("%.2f%%", head_score * 100)}</span>
|
|
305
|
-
<span class="#{delta_class}">Delta: #{delta_str}</span>
|
|
306
|
-
</div>
|
|
307
|
-
</section>
|
|
308
|
-
HTML
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
def regression?(mutation)
|
|
312
|
-
return false if @baseline_keys.nil?
|
|
313
|
-
|
|
314
|
-
key = [mutation.operator_name, mutation.file_path, mutation.line, mutation.subject.name]
|
|
315
|
-
!@baseline_keys.include?(key)
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
def build_baseline_keys(baseline)
|
|
319
|
-
return nil unless baseline
|
|
320
|
-
|
|
321
|
-
survived = baseline["survived"] || []
|
|
322
|
-
survived.to_set { |m| [m["operator"], m["file"], m["line"], m["subject"]] }
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def h(text)
|
|
326
|
-
CGI.escapeHTML(text.to_s)
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def stylesheet
|
|
330
|
-
<<~HTML
|
|
331
|
-
<style>
|
|
332
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
333
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
|
|
334
|
-
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
|
335
|
-
h1 { font-size: 1.5rem; color: #f0f6fc; }
|
|
336
|
-
.version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
|
|
337
|
-
.score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
|
|
338
|
-
.score-high { background: #1a4731; color: #3fb950; }
|
|
339
|
-
.score-medium { background: #4a3a10; color: #d29922; }
|
|
340
|
-
.score-low { background: #4a1a1a; color: #f85149; }
|
|
341
|
-
.summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
|
|
342
|
-
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
|
|
343
|
-
.card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
|
|
344
|
-
.card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
|
|
345
|
-
.card-killed { border-color: #238636; }
|
|
346
|
-
.card-survived { border-color: #da3633; }
|
|
347
|
-
.truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
|
|
348
|
-
.file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
|
|
349
|
-
.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; }
|
|
350
|
-
.file-path { color: #58a6ff; font-family: monospace; }
|
|
351
|
-
.file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
|
|
352
|
-
.mutation-map { padding: 0.5rem 1rem; }
|
|
353
|
-
.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; }
|
|
354
|
-
.map-line.killed { color: #3fb950; }
|
|
355
|
-
.map-line.survived { color: #f85149; }
|
|
356
|
-
.map-line.timeout { color: #d29922; }
|
|
357
|
-
.map-line.error { color: #f85149; }
|
|
358
|
-
.map-line.neutral { color: #8b949e; }
|
|
359
|
-
.map-line.equivalent { color: #8b949e; }
|
|
360
|
-
.line-number { min-width: 60px; color: #8b949e; }
|
|
361
|
-
.operator { flex: 1; }
|
|
362
|
-
.status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
|
|
363
|
-
.status-badge.killed { background: #1a4731; }
|
|
364
|
-
.status-badge.survived { background: #4a1a1a; }
|
|
365
|
-
.status-badge.timeout { background: #4a3a10; }
|
|
366
|
-
.status-badge.neutral { background: #21262d; }
|
|
367
|
-
.status-badge.equivalent { background: #21262d; }
|
|
368
|
-
.survived-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
369
|
-
.survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
370
|
-
.survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
|
|
371
|
-
.survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
|
|
372
|
-
.survived-header .operator { color: #f85149; font-weight: bold; }
|
|
373
|
-
.survived-header .location { color: #8b949e; font-family: monospace; }
|
|
374
|
-
.diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
|
|
375
|
-
.diff-removed { color: #f85149; display: block; }
|
|
376
|
-
.diff-added { color: #3fb950; display: block; }
|
|
377
|
-
.suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
|
|
378
|
-
.coverage-gap { border: 1px solid #30363d; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; background: #161b22; }
|
|
379
|
-
.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; }
|
|
380
|
-
.gap-header .location { color: #58a6ff; font-family: monospace; }
|
|
381
|
-
.gap-count { background: #4a1a1a; color: #f85149; font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; font-weight: bold; }
|
|
382
|
-
.operator-tag { background: #21262d; color: #8b949e; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 4px; font-family: monospace; }
|
|
383
|
-
.empty { color: #8b949e; text-align: center; padding: 2rem; }
|
|
384
|
-
.baseline-comparison { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
|
|
385
|
-
.baseline-comparison h2 { font-size: 1rem; color: #f0f6fc; margin-bottom: 0.75rem; }
|
|
386
|
-
.comparison-scores { display: flex; gap: 2rem; font-size: 0.9rem; }
|
|
387
|
-
.delta-positive { color: #3fb950; font-weight: bold; }
|
|
388
|
-
.delta-negative { color: #f85149; font-weight: bold; }
|
|
389
|
-
.delta-neutral { color: #8b949e; font-weight: bold; }
|
|
390
|
-
.error-details { border-top: 1px solid #30363d; padding: 1rem; }
|
|
391
|
-
.error-details h3 { color: #d29922; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
|
392
|
-
.error-entry { background: #1c1a10; border: 1px solid #4a3a10; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
|
|
393
|
-
.error-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
|
|
394
|
-
.error-header .operator { color: #d29922; font-weight: bold; }
|
|
395
|
-
.error-header .location { color: #8b949e; font-family: monospace; }
|
|
396
|
-
.error-message { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; color: #f85149; margin-top: 0.5rem; white-space: pre-wrap; overflow-x: auto; }
|
|
397
|
-
.survived-entry.regression { border-color: #f85149; background: #2a1010; }
|
|
398
|
-
.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; }
|
|
399
|
-
footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
|
|
400
|
-
</style>
|
|
401
|
-
HTML
|
|
17
|
+
Report.new(
|
|
18
|
+
summary,
|
|
19
|
+
baseline: @baseline,
|
|
20
|
+
baseline_keys: @baseline_keys,
|
|
21
|
+
suggestion: @suggestion
|
|
22
|
+
).render
|
|
402
23
|
end
|
|
403
24
|
end
|
|
@@ -17,24 +17,28 @@ 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
|
+
unparseable: map_details(summary.unparseable_results)
|
|
33
34
|
}
|
|
34
35
|
append_disabled_to_report(report, summary)
|
|
35
36
|
report
|
|
36
37
|
end
|
|
37
|
-
|
|
38
|
+
|
|
39
|
+
def map_details(results)
|
|
40
|
+
results.map { |r| build_mutation_detail(r) }
|
|
41
|
+
end
|
|
38
42
|
|
|
39
43
|
def append_disabled_to_report(report, summary)
|
|
40
44
|
return unless summary.disabled_mutations.any?
|
|
@@ -51,6 +55,8 @@ class Evilution::Reporter::JSON
|
|
|
51
55
|
errors: summary.errors,
|
|
52
56
|
neutral: summary.neutral,
|
|
53
57
|
equivalent: summary.equivalent,
|
|
58
|
+
unresolved: summary.unresolved,
|
|
59
|
+
unparseable: summary.unparseable,
|
|
54
60
|
score: summary.score.round(4),
|
|
55
61
|
duration: summary.duration.round(4),
|
|
56
62
|
killtime: summary.killtime.round(4),
|
|
@@ -74,7 +80,11 @@ class Evilution::Reporter::JSON
|
|
|
74
80
|
duration: result.duration.round(4),
|
|
75
81
|
diff: mutation.diff
|
|
76
82
|
}
|
|
77
|
-
|
|
83
|
+
if result.status == :survived
|
|
84
|
+
detail[:suggestion] = @suggestion.suggestion_for(mutation)
|
|
85
|
+
unified = mutation.unified_diff
|
|
86
|
+
detail[:unified_diff] = unified if unified
|
|
87
|
+
end
|
|
78
88
|
detail[:test_command] = result.test_command if result.test_command
|
|
79
89
|
append_memory_fields(detail, result)
|
|
80
90
|
append_error_fields(detail, result)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../suggestion"
|
|
4
|
+
|
|
5
|
+
module Evilution::Reporter::Suggestion::DiffHelpers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse_method_name(subject_name)
|
|
9
|
+
subject_name.split(/[#.]/).last
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def sanitize_method_name(name)
|
|
13
|
+
name.gsub(/[^a-zA-Z0-9_]/, "_").gsub(/_+/, "_").gsub(/\A_|_\z/, "")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def extract_diff_lines(diff)
|
|
17
|
+
lines = diff.split("\n")
|
|
18
|
+
original = lines.find { |l| l.start_with?("- ") }
|
|
19
|
+
mutated = lines.find { |l| l.start_with?("+ ") }
|
|
20
|
+
[clean_diff_line(original, "- "), clean_diff_line(mutated, "+ ")]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clean_diff_line(line, prefix)
|
|
24
|
+
return nil if line.nil?
|
|
25
|
+
|
|
26
|
+
line.sub(/^#{Regexp.escape(prefix)}/, "").strip
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../suggestion"
|
|
4
|
+
|
|
5
|
+
# rubocop:disable Style/OneClassPerFile
|
|
6
|
+
module Evilution::Reporter::Suggestion::Templates
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Evilution::Reporter::Suggestion::Registry
|
|
10
|
+
# rubocop:enable Style/OneClassPerFile
|
|
11
|
+
def self.default
|
|
12
|
+
return @default if @default
|
|
13
|
+
|
|
14
|
+
require_relative "templates/generic"
|
|
15
|
+
require_relative "templates/rspec"
|
|
16
|
+
require_relative "templates/minitest"
|
|
17
|
+
|
|
18
|
+
registry = new
|
|
19
|
+
Evilution::Reporter::Suggestion::Templates::Generic::GENERIC_ENTRIES.each do |op, text|
|
|
20
|
+
registry.register_generic(op, text)
|
|
21
|
+
end
|
|
22
|
+
Evilution::Reporter::Suggestion::Templates::Rspec::RSPEC_ENTRIES.each do |op, blk|
|
|
23
|
+
registry.register_concrete(op, integration: :rspec, block: blk)
|
|
24
|
+
end
|
|
25
|
+
Evilution::Reporter::Suggestion::Templates::Minitest::MINITEST_ENTRIES.each do |op, blk|
|
|
26
|
+
registry.register_concrete(op, integration: :minitest, block: blk)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@default = registry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.reset!
|
|
33
|
+
@default = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@generic = {}
|
|
38
|
+
@concrete = Hash.new { |h, k| h[k] = {} }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def register_generic(operator_name, text)
|
|
42
|
+
@generic[operator_name] = text
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def register_concrete(operator_name, integration:, block:)
|
|
47
|
+
@concrete[integration][operator_name] = block
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def generic(operator_name)
|
|
52
|
+
@generic[operator_name]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def concrete(operator_name, integration:)
|
|
56
|
+
@concrete.fetch(integration, {})[operator_name]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def each_generic_operator(&)
|
|
60
|
+
return @generic.each_key unless block_given?
|
|
61
|
+
|
|
62
|
+
@generic.each_key(&)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../registry"
|
|
4
|
+
|
|
5
|
+
module Evilution::Reporter::Suggestion::Templates::Generic
|
|
6
|
+
GENERIC_ENTRIES = {
|
|
7
|
+
"comparison_replacement" => "Add a test for the boundary condition where the comparison operand equals the threshold exactly",
|
|
8
|
+
"arithmetic_replacement" => "Add a test that verifies the arithmetic result, not just truthiness of the outcome",
|
|
9
|
+
"boolean_operator_replacement" => "Add a test where only one of the boolean conditions is true to distinguish && from ||",
|
|
10
|
+
"boolean_literal_replacement" => "Add a test that exercises the false/true branch explicitly",
|
|
11
|
+
"nil_replacement" => "Add a test that asserts the return value is not nil",
|
|
12
|
+
"integer_literal" => "Add a test that checks the exact numeric value, not just > 0 or truthy",
|
|
13
|
+
"float_literal" => "Add a test that checks the exact floating-point value returned",
|
|
14
|
+
"string_literal" => "Add a test that asserts the string content, not just its presence",
|
|
15
|
+
"array_literal" => "Add a test that verifies the array contents or length",
|
|
16
|
+
"hash_literal" => "Add a test that verifies the hash keys and values",
|
|
17
|
+
"symbol_literal" => "Add a test that checks the exact symbol returned",
|
|
18
|
+
"conditional_negation" => "Add tests for both the true and false branches of this conditional",
|
|
19
|
+
"conditional_branch" => "Add a test that exercises the removed branch of this conditional",
|
|
20
|
+
"statement_deletion" => "Add a test that depends on the side effect of this statement",
|
|
21
|
+
"method_body_replacement" => "Add a test that checks the method's return value or side effects",
|
|
22
|
+
"negation_insertion" => "Add a test where the predicate result matters (not just truthiness)",
|
|
23
|
+
"return_value_removal" => "Add a test that uses the return value of this method",
|
|
24
|
+
"collection_replacement" => "Add a test that checks the return value of the collection operation, not just side effects",
|
|
25
|
+
"method_call_removal" => "Add a test that depends on the return value or side effect of this method call",
|
|
26
|
+
"argument_removal" => "Add a test that verifies the correct arguments are passed to this method call",
|
|
27
|
+
"compound_assignment" => "Add a test that verifies the side effect of this compound assignment (the accumulated value matters)",
|
|
28
|
+
"superclass_removal" => "Add a test that exercises inherited behavior from the superclass",
|
|
29
|
+
"mixin_removal" => "Add a test that exercises behavior provided by the included/extended module",
|
|
30
|
+
"local_variable_assignment" => "Add a test that depends on the assigned variable being stored, not just the value expression",
|
|
31
|
+
"instance_variable_write" => "Add a test that verifies the instance variable is set correctly, not just the return value",
|
|
32
|
+
"class_variable_write" => "Add a test that verifies the class variable is set correctly and affects shared state",
|
|
33
|
+
"global_variable_write" => "Add a test that verifies the global variable is set correctly, not just the value expression",
|
|
34
|
+
"rescue_removal" => "Add a test that triggers the rescued exception and verifies the rescue handler behavior",
|
|
35
|
+
"rescue_body_replacement" => "Add a test that triggers the rescued exception and verifies the rescue body produces the correct result",
|
|
36
|
+
"inline_rescue" => "Add a test that triggers the inline rescue and verifies the fallback value is used correctly",
|
|
37
|
+
"ensure_removal" => "Add a test that verifies the ensure cleanup code runs and its side effects are observable",
|
|
38
|
+
"break_statement" => "Add a test that verifies the break condition and the value returned when the loop exits early",
|
|
39
|
+
"next_statement" => "Add a test that verifies the next condition and the value yielded when the iteration skips",
|
|
40
|
+
"redo_statement" => "Add a test that verifies the redo restarts the iteration and the retry logic is necessary",
|
|
41
|
+
"bang_method" => "Add a test that distinguishes in-place mutation from copy semantics (bang vs non-bang)",
|
|
42
|
+
"bitwise_replacement" => "Add a test that checks the exact bitwise result to distinguish &, |, and ^ operators",
|
|
43
|
+
"bitwise_complement" => "Add a test that verifies the bitwise complement (~) result, not just the sign or magnitude",
|
|
44
|
+
"zsuper_removal" => "Add a test that verifies inherited behavior from super is needed, not just the subclass logic",
|
|
45
|
+
"explicit_super_mutation" => "Add a test that verifies the correct arguments are passed to super and the inherited result matters",
|
|
46
|
+
"index_to_fetch" => "Add a test that distinguishes [] (returns nil for missing keys) from .fetch (raises KeyError)",
|
|
47
|
+
"index_to_dig" => "Add a test that verifies chained [] access returns the correct nested value",
|
|
48
|
+
"index_assignment_removal" => "Add a test that verifies the []= assignment side effect is observable (the collection is modified)",
|
|
49
|
+
"pattern_matching_guard" => "Add a test with input that matches the pattern but fails the guard to verify filtering",
|
|
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",
|
|
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"
|
|
54
|
+
}.freeze
|
|
55
|
+
end
|