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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. 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 "../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,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.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
+ unparseable: map_details(summary.unparseable_results)
33
34
  }
34
35
  append_disabled_to_report(report, summary)
35
36
  report
36
37
  end
37
- # rubocop:enable Metrics/PerceivedComplexity
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
- detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
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