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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +8 -0
  3. data/CHANGELOG.md +28 -0
  4. data/README.md +37 -7
  5. data/lib/evilution/cli/command.rb +37 -0
  6. data/lib/evilution/cli/commands/environment_show.rb +20 -0
  7. data/lib/evilution/cli/commands/init.rb +24 -0
  8. data/lib/evilution/cli/commands/mcp.rb +19 -0
  9. data/lib/evilution/cli/commands/run.rb +68 -0
  10. data/lib/evilution/cli/commands/session_diff.rb +30 -0
  11. data/lib/evilution/cli/commands/session_gc.rb +46 -0
  12. data/lib/evilution/cli/commands/session_list.rb +51 -0
  13. data/lib/evilution/cli/commands/session_show.rb +27 -0
  14. data/lib/evilution/cli/commands/subjects.rb +50 -0
  15. data/lib/evilution/cli/commands/tests_list.rb +43 -0
  16. data/lib/evilution/cli/commands/util_mutation.rb +66 -0
  17. data/lib/evilution/cli/commands/version.rb +17 -0
  18. data/lib/evilution/cli/commands.rb +4 -0
  19. data/lib/evilution/cli/dispatcher.rb +23 -0
  20. data/lib/evilution/cli/parsed_args.rb +12 -0
  21. data/lib/evilution/cli/parser/command_extractor.rb +77 -0
  22. data/lib/evilution/cli/parser/file_args.rb +41 -0
  23. data/lib/evilution/cli/parser/options_builder.rb +103 -0
  24. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  25. data/lib/evilution/cli/parser.rb +88 -0
  26. data/lib/evilution/cli/printers/environment.rb +53 -0
  27. data/lib/evilution/cli/printers/session_detail.rb +76 -0
  28. data/lib/evilution/cli/printers/session_diff.rb +57 -0
  29. data/lib/evilution/cli/printers/session_list.rb +48 -0
  30. data/lib/evilution/cli/printers/subjects.rb +35 -0
  31. data/lib/evilution/cli/printers/tests_list.rb +45 -0
  32. data/lib/evilution/cli/printers/util_mutation.rb +35 -0
  33. data/lib/evilution/cli/printers.rb +4 -0
  34. data/lib/evilution/cli/result.rb +9 -0
  35. data/lib/evilution/cli.rb +30 -850
  36. data/lib/evilution/config.rb +31 -3
  37. data/lib/evilution/integration/base.rb +23 -55
  38. data/lib/evilution/integration/minitest.rb +22 -4
  39. data/lib/evilution/integration/rspec.rb +28 -8
  40. data/lib/evilution/isolation/fork.rb +11 -9
  41. data/lib/evilution/isolation/in_process.rb +11 -9
  42. data/lib/evilution/mcp/info_tool.rb +261 -0
  43. data/lib/evilution/mcp/mutate_tool.rb +112 -19
  44. data/lib/evilution/mcp/server.rb +3 -4
  45. data/lib/evilution/mcp/session_diff_tool.rb +5 -1
  46. data/lib/evilution/mcp/session_list_tool.rb +5 -1
  47. data/lib/evilution/mcp/session_show_tool.rb +5 -1
  48. data/lib/evilution/mcp/session_tool.rb +157 -0
  49. data/lib/evilution/reporter/cli.rb +2 -1
  50. data/lib/evilution/reporter/html/assets/style.css +68 -0
  51. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  52. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  53. data/lib/evilution/reporter/html/escape.rb +12 -0
  54. data/lib/evilution/reporter/html/namespace.rb +11 -0
  55. data/lib/evilution/reporter/html/report.rb +68 -0
  56. data/lib/evilution/reporter/html/section.rb +21 -0
  57. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  58. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  59. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  60. data/lib/evilution/reporter/html/sections/file_section.rb +47 -0
  61. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  62. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  63. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  64. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  65. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  66. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  67. data/lib/evilution/reporter/html/sections.rb +4 -0
  68. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  69. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  70. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  71. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  72. data/lib/evilution/reporter/html/templates/file_section.html.erb +9 -0
  73. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  74. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  75. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  76. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +23 -0
  77. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  78. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  79. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  80. data/lib/evilution/reporter/html.rb +11 -349
  81. data/lib/evilution/reporter/json.rb +12 -8
  82. data/lib/evilution/result/mutation_result.rb +5 -1
  83. data/lib/evilution/result/summary.rb +9 -1
  84. data/lib/evilution/runner/baseline_runner.rb +71 -0
  85. data/lib/evilution/runner/diagnostics.rb +105 -0
  86. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  87. data/lib/evilution/runner/mutation_executor.rb +255 -0
  88. data/lib/evilution/runner/mutation_planner.rb +126 -0
  89. data/lib/evilution/runner/report_publisher.rb +60 -0
  90. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  91. data/lib/evilution/runner.rb +57 -692
  92. data/lib/evilution/version.rb +1 -1
  93. 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 "../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
- 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.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