evilution 0.23.0 → 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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +5 -0
  3. data/CHANGELOG.md +16 -0
  4. data/README.md +1 -0
  5. data/lib/evilution/cli/parser/command_extractor.rb +77 -0
  6. data/lib/evilution/cli/parser/file_args.rb +41 -0
  7. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  8. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  9. data/lib/evilution/cli/parser.rb +27 -196
  10. data/lib/evilution/config.rb +14 -1
  11. data/lib/evilution/integration/base.rb +11 -57
  12. data/lib/evilution/integration/minitest.rb +16 -3
  13. data/lib/evilution/integration/rspec.rb +19 -7
  14. data/lib/evilution/isolation/fork.rb +1 -0
  15. data/lib/evilution/isolation/in_process.rb +1 -0
  16. data/lib/evilution/reporter/cli.rb +2 -1
  17. data/lib/evilution/reporter/html/assets/style.css +68 -0
  18. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  19. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  20. data/lib/evilution/reporter/html/escape.rb +12 -0
  21. data/lib/evilution/reporter/html/namespace.rb +11 -0
  22. data/lib/evilution/reporter/html/report.rb +68 -0
  23. data/lib/evilution/reporter/html/section.rb +21 -0
  24. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  25. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  26. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  27. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  28. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  29. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  30. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  31. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  32. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  33. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  34. data/lib/evilution/reporter/html/sections.rb +4 -0
  35. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  36. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  37. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  38. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  39. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  40. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  41. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  42. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  43. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  44. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  45. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  46. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  47. data/lib/evilution/reporter/html.rb +11 -390
  48. data/lib/evilution/reporter/json.rb +12 -8
  49. data/lib/evilution/result/mutation_result.rb +5 -1
  50. data/lib/evilution/result/summary.rb +9 -1
  51. data/lib/evilution/runner/baseline_runner.rb +71 -0
  52. data/lib/evilution/runner/diagnostics.rb +105 -0
  53. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  54. data/lib/evilution/runner/mutation_executor.rb +255 -0
  55. data/lib/evilution/runner/mutation_planner.rb +126 -0
  56. data/lib/evilution/runner/report_publisher.rb +60 -0
  57. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  58. data/lib/evilution/runner.rb +57 -694
  59. data/lib/evilution/version.rb +1 -1
  60. metadata +42 -1
@@ -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 "../result/coverage_gap_grouper"
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 = build_baseline_keys(baseline)
13
+ @baseline_keys = BaselineKeys.new(baseline)
14
14
  end
15
15
 
16
16
  def call(summary)
17
- files = group_by_file(summary.results)
18
- build_html(summary, files)
19
- end
20
-
21
- private
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,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.map { |r| build_mutation_detail(r) },
25
+ survived: map_details(summary.survived_results),
27
26
  coverage_gaps: build_coverage_gaps(summary),
28
- killed: summary.killed_results.map { |r| build_mutation_detail(r) },
29
- neutral: summary.neutral_results.map { |r| build_mutation_detail(r) },
30
- timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
31
- errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) },
32
- equivalent: summary.equivalent_results.map { |r| build_mutation_detail(r) }
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
- # rubocop:enable Metrics/PerceivedComplexity
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