evilution 0.13.0 → 0.14.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +8 -8
  4. data/CHANGELOG.md +17 -0
  5. data/lib/evilution/ast/parser.rb +69 -68
  6. data/lib/evilution/ast/source_surgeon.rb +7 -9
  7. data/lib/evilution/ast.rb +4 -0
  8. data/lib/evilution/baseline.rb +73 -75
  9. data/lib/evilution/cache.rb +75 -77
  10. data/lib/evilution/cli.rb +408 -173
  11. data/lib/evilution/config.rb +141 -136
  12. data/lib/evilution/equivalent/detector.rb +25 -27
  13. data/lib/evilution/equivalent/heuristic/alias_swap.rb +29 -33
  14. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  15. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  16. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  17. data/lib/evilution/equivalent/heuristic.rb +6 -0
  18. data/lib/evilution/equivalent.rb +4 -0
  19. data/lib/evilution/git/changed_files.rb +35 -37
  20. data/lib/evilution/git.rb +4 -0
  21. data/lib/evilution/integration/base.rb +5 -7
  22. data/lib/evilution/integration/rspec.rb +114 -116
  23. data/lib/evilution/integration.rb +4 -0
  24. data/lib/evilution/isolation/fork.rb +98 -100
  25. data/lib/evilution/isolation/in_process.rb +59 -61
  26. data/lib/evilution/isolation.rb +4 -0
  27. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  28. data/lib/evilution/mcp/server.rb +12 -11
  29. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  30. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  31. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  32. data/lib/evilution/mcp.rb +4 -0
  33. data/lib/evilution/memory/leak_check.rb +80 -84
  34. data/lib/evilution/memory.rb +34 -36
  35. data/lib/evilution/mutation.rb +40 -42
  36. data/lib/evilution/mutator/base.rb +46 -48
  37. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  38. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  39. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  40. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  41. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  42. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  43. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  44. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  45. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  46. data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
  47. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  48. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  49. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  50. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  51. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  52. data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
  53. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  54. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  55. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  56. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  57. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  58. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  59. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  60. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  61. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  62. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  63. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  64. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  65. data/lib/evilution/mutator/operator.rb +6 -0
  66. data/lib/evilution/mutator/registry.rb +54 -56
  67. data/lib/evilution/mutator.rb +4 -0
  68. data/lib/evilution/parallel/pool.rb +56 -58
  69. data/lib/evilution/parallel.rb +4 -0
  70. data/lib/evilution/reporter/cli.rb +99 -101
  71. data/lib/evilution/reporter/html.rb +242 -244
  72. data/lib/evilution/reporter/json.rb +57 -59
  73. data/lib/evilution/reporter/suggestion.rb +326 -328
  74. data/lib/evilution/reporter.rb +4 -0
  75. data/lib/evilution/result/mutation_result.rb +43 -46
  76. data/lib/evilution/result/summary.rb +80 -81
  77. data/lib/evilution/result.rb +4 -0
  78. data/lib/evilution/runner.rb +334 -323
  79. data/lib/evilution/session/store.rb +147 -0
  80. data/lib/evilution/session.rb +4 -0
  81. data/lib/evilution/spec_resolver.rb +49 -47
  82. data/lib/evilution/subject.rb +14 -16
  83. data/lib/evilution/version.rb +1 -1
  84. data/lib/evilution.rb +13 -0
  85. metadata +19 -2
@@ -3,250 +3,248 @@
3
3
  require "cgi"
4
4
  require_relative "suggestion"
5
5
 
6
- module Evilution
7
- module Reporter
8
- class HTML
9
- def initialize
10
- @suggestion = Suggestion.new
11
- end
12
-
13
- def call(summary)
14
- files = group_by_file(summary.results)
15
- build_html(summary, files)
16
- end
17
-
18
- private
19
-
20
- def group_by_file(results)
21
- grouped = {}
22
- results.each do |result|
23
- path = result.mutation.file_path
24
- grouped[path] ||= []
25
- grouped[path] << result
26
- end
27
- grouped.sort_by { |path, _| path }.to_h
28
- end
29
-
30
- def build_html(summary, files)
31
- <<~HTML
32
- <!DOCTYPE html>
33
- <html lang="en">
34
- <head>
35
- <meta charset="UTF-8">
36
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
- <title>Evilution Mutation Report</title>
38
- #{stylesheet}
39
- </head>
40
- <body>
41
- #{build_header(summary)}
42
- #{build_summary_cards(summary)}
43
- #{build_truncation_notice(summary)}
44
- #{build_file_sections(files)}
45
- #{build_footer}
46
- </body>
47
- </html>
48
- HTML
49
- end
50
-
51
- def build_header(summary)
52
- score_pct = format("%.2f%%", summary.score * 100)
53
- score_class = score_css_class(summary.score)
54
- <<~HTML
55
- <header>
56
- <h1>Evilution <span class="version">v#{h(Evilution::VERSION)}</span></h1>
57
- <div class="score-badge #{score_class}">#{score_pct}</div>
58
- </header>
59
- HTML
60
- end
61
-
62
- def build_summary_cards(summary)
63
- peak = summary.peak_memory_mb
64
- peak_html = if peak
65
- peak_val = format("%.1f", peak)
66
- "<div class=\"card\"><span class=\"card-value\">#{peak_val} MB</span>" \
67
- "<span class=\"card-label\">Peak Memory</span></div>"
68
- else
69
- ""
70
- end
71
- <<~HTML
72
- <section class="summary-cards">
73
- <div class="card"><span class="card-value">#{summary.total}</span><span class="card-label">Total</span></div>
74
- <div class="card card-killed"><span class="card-value">#{summary.killed}</span><span class="card-label">Killed</span></div>
75
- <div class="card card-survived"><span class="card-value">#{summary.survived}</span><span class="card-label">Survived</span></div>
76
- <div class="card"><span class="card-value">#{summary.timed_out}</span><span class="card-label">Timed Out</span></div>
77
- <div class="card"><span class="card-value">#{summary.errors}</span><span class="card-label">Errors</span></div>
78
- <div class="card"><span class="card-value">#{summary.neutral}</span><span class="card-label">Neutral</span></div>
79
- <div class="card"><span class="card-value">#{summary.equivalent}</span><span class="card-label">Equivalent</span></div>
80
- <div class="card"><span class="card-value">#{format("%.2f", summary.duration)}s</span><span class="card-label">Duration</span></div>
81
- #{peak_html}
82
- </section>
83
- HTML
84
- end
85
-
86
- def build_truncation_notice(summary)
87
- return "" unless summary.truncated?
88
-
89
- '<div class="truncation-notice">Truncated: Stopped early due to --fail-fast</div>'
90
- end
91
-
92
- def build_file_sections(files)
93
- return '<p class="empty">No mutations generated.</p>' if files.empty?
94
-
95
- files.map { |path, results| build_file_section(path, results) }.join("\n")
96
- end
97
-
98
- def build_file_section(path, results)
99
- killed_count = results.count(&:killed?)
100
- survived_count = results.count(&:survived?)
101
- total = results.length
102
- survived = results.select(&:survived?)
103
- map_html = build_mutation_map(results)
104
-
105
- <<~HTML
106
- <section class="file-section">
107
- <h2 class="file-header">
108
- <span class="file-path">#{h(path)}</span>
109
- <span class="file-stats">#{killed_count} killed / #{survived_count} survived / #{total} total</span>
110
- </h2>
111
- <div class="mutation-map">#{map_html}</div>
112
- #{build_survived_details(survived)}
113
- </section>
114
- HTML
115
- end
116
-
117
- def build_mutation_map(results)
118
- results
119
- .sort_by { |r| r.mutation.line }
120
- .map { |r| build_map_entry(r) }
121
- .join("\n")
122
- end
123
-
124
- def build_map_entry(result)
125
- mutation = result.mutation
126
- status = result.status.to_s
127
- <<~HTML.chomp
128
- <div class="map-line #{status}">
129
- <span class="line-number">line #{mutation.line}</span>
130
- <span class="operator">#{h(mutation.operator_name)}</span>
131
- <span class="status-badge #{status}">#{status}</span>
132
- </div>
133
- HTML
134
- end
135
-
136
- def build_survived_details(survived)
137
- return "" if survived.empty?
138
-
139
- entries = survived.map { |r| build_survived_entry(r) }.join("\n")
140
- <<~HTML
141
- <div class="survived-details">
142
- <h3>Survived Mutations</h3>
143
- #{entries}
144
- </div>
145
- HTML
146
- end
147
-
148
- def build_survived_entry(result)
149
- mutation = result.mutation
150
- suggestion_text = @suggestion.suggestion_for(mutation)
151
- diff_html = format_diff(mutation.diff)
152
- <<~HTML
153
- <div class="survived-entry">
154
- <div class="survived-header">
155
- <span class="operator">#{h(mutation.operator_name)}</span>
156
- <span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
157
- </div>
158
- <pre class="diff">#{diff_html}</pre>
159
- <div class="suggestion">#{h(suggestion_text)}</div>
160
- </div>
161
- HTML
162
- end
163
-
164
- def format_diff(diff)
165
- diff.split("\n").map do |line|
166
- escaped = h(line)
167
- css_class = if line.start_with?("- ")
168
- "diff-removed"
169
- elsif line.start_with?("+ ")
170
- "diff-added"
171
- else
172
- ""
173
- end
174
- %(<span class="#{css_class}">#{escaped}</span>)
175
- end.join("\n")
176
- end
177
-
178
- def build_footer
179
- "<footer>Generated by Evilution v#{h(Evilution::VERSION)}</footer>"
180
- end
181
-
182
- def score_css_class(score)
183
- if score >= 0.8
184
- "score-high"
185
- elsif score >= 0.5
186
- "score-medium"
187
- else
188
- "score-low"
189
- end
190
- end
191
-
192
- def h(text)
193
- CGI.escapeHTML(text.to_s)
194
- end
195
-
196
- def stylesheet
197
- <<~HTML
198
- <style>
199
- * { margin: 0; padding: 0; box-sizing: border-box; }
200
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
201
- header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
202
- h1 { font-size: 1.5rem; color: #f0f6fc; }
203
- .version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
204
- .score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
205
- .score-high { background: #1a4731; color: #3fb950; }
206
- .score-medium { background: #4a3a10; color: #d29922; }
207
- .score-low { background: #4a1a1a; color: #f85149; }
208
- .summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
209
- .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
210
- .card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
211
- .card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
212
- .card-killed { border-color: #238636; }
213
- .card-survived { border-color: #da3633; }
214
- .truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
215
- .file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
216
- .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; }
217
- .file-path { color: #58a6ff; font-family: monospace; }
218
- .file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
219
- .mutation-map { padding: 0.5rem 1rem; }
220
- .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; }
221
- .map-line.killed { color: #3fb950; }
222
- .map-line.survived { color: #f85149; }
223
- .map-line.timeout { color: #d29922; }
224
- .map-line.error { color: #f85149; }
225
- .map-line.neutral { color: #8b949e; }
226
- .map-line.equivalent { color: #8b949e; }
227
- .line-number { min-width: 60px; color: #8b949e; }
228
- .operator { flex: 1; }
229
- .status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
230
- .status-badge.killed { background: #1a4731; }
231
- .status-badge.survived { background: #4a1a1a; }
232
- .status-badge.timeout { background: #4a3a10; }
233
- .status-badge.neutral { background: #21262d; }
234
- .status-badge.equivalent { background: #21262d; }
235
- .survived-details { border-top: 1px solid #30363d; padding: 1rem; }
236
- .survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
237
- .survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
238
- .survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
239
- .survived-header .operator { color: #f85149; font-weight: bold; }
240
- .survived-header .location { color: #8b949e; font-family: monospace; }
241
- .diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
242
- .diff-removed { color: #f85149; display: block; }
243
- .diff-added { color: #3fb950; display: block; }
244
- .suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
245
- .empty { color: #8b949e; text-align: center; padding: 2rem; }
246
- footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
247
- </style>
248
- HTML
249
- end
6
+ require_relative "../reporter"
7
+
8
+ class Evilution::Reporter::HTML
9
+ def initialize
10
+ @suggestion = Evilution::Reporter::Suggestion.new
11
+ end
12
+
13
+ def call(summary)
14
+ files = group_by_file(summary.results)
15
+ build_html(summary, files)
16
+ end
17
+
18
+ private
19
+
20
+ def group_by_file(results)
21
+ grouped = {}
22
+ results.each do |result|
23
+ path = result.mutation.file_path
24
+ grouped[path] ||= []
25
+ grouped[path] << result
26
+ end
27
+ grouped.sort_by { |path, _| path }.to_h
28
+ end
29
+
30
+ def build_html(summary, files)
31
+ <<~HTML
32
+ <!DOCTYPE html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="UTF-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
+ <title>Evilution Mutation Report</title>
38
+ #{stylesheet}
39
+ </head>
40
+ <body>
41
+ #{build_header(summary)}
42
+ #{build_summary_cards(summary)}
43
+ #{build_truncation_notice(summary)}
44
+ #{build_file_sections(files)}
45
+ #{build_footer}
46
+ </body>
47
+ </html>
48
+ HTML
49
+ end
50
+
51
+ def build_header(summary)
52
+ score_pct = format("%.2f%%", summary.score * 100)
53
+ score_class = score_css_class(summary.score)
54
+ <<~HTML
55
+ <header>
56
+ <h1>Evilution <span class="version">v#{h(Evilution::VERSION)}</span></h1>
57
+ <div class="score-badge #{score_class}">#{score_pct}</div>
58
+ </header>
59
+ HTML
60
+ end
61
+
62
+ def build_summary_cards(summary)
63
+ peak = summary.peak_memory_mb
64
+ peak_html = if peak
65
+ peak_val = format("%.1f", peak)
66
+ "<div class=\"card\"><span class=\"card-value\">#{peak_val} MB</span>" \
67
+ "<span class=\"card-label\">Peak Memory</span></div>"
68
+ else
69
+ ""
70
+ end
71
+ <<~HTML
72
+ <section class="summary-cards">
73
+ <div class="card"><span class="card-value">#{summary.total}</span><span class="card-label">Total</span></div>
74
+ <div class="card card-killed"><span class="card-value">#{summary.killed}</span><span class="card-label">Killed</span></div>
75
+ <div class="card card-survived"><span class="card-value">#{summary.survived}</span><span class="card-label">Survived</span></div>
76
+ <div class="card"><span class="card-value">#{summary.timed_out}</span><span class="card-label">Timed Out</span></div>
77
+ <div class="card"><span class="card-value">#{summary.errors}</span><span class="card-label">Errors</span></div>
78
+ <div class="card"><span class="card-value">#{summary.neutral}</span><span class="card-label">Neutral</span></div>
79
+ <div class="card"><span class="card-value">#{summary.equivalent}</span><span class="card-label">Equivalent</span></div>
80
+ <div class="card"><span class="card-value">#{format("%.2f", summary.duration)}s</span><span class="card-label">Duration</span></div>
81
+ #{peak_html}
82
+ </section>
83
+ HTML
84
+ end
85
+
86
+ def build_truncation_notice(summary)
87
+ return "" unless summary.truncated?
88
+
89
+ '<div class="truncation-notice">Truncated: Stopped early due to --fail-fast</div>'
90
+ end
91
+
92
+ def build_file_sections(files)
93
+ return '<p class="empty">No mutations generated.</p>' if files.empty?
94
+
95
+ files.map { |path, results| build_file_section(path, results) }.join("\n")
96
+ end
97
+
98
+ def build_file_section(path, results)
99
+ killed_count = results.count(&:killed?)
100
+ survived_count = results.count(&:survived?)
101
+ total = results.length
102
+ survived = results.select(&:survived?)
103
+ map_html = build_mutation_map(results)
104
+
105
+ <<~HTML
106
+ <section class="file-section">
107
+ <h2 class="file-header">
108
+ <span class="file-path">#{h(path)}</span>
109
+ <span class="file-stats">#{killed_count} killed / #{survived_count} survived / #{total} total</span>
110
+ </h2>
111
+ <div class="mutation-map">#{map_html}</div>
112
+ #{build_survived_details(survived)}
113
+ </section>
114
+ HTML
115
+ end
116
+
117
+ def build_mutation_map(results)
118
+ results
119
+ .sort_by { |r| r.mutation.line }
120
+ .map { |r| build_map_entry(r) }
121
+ .join("\n")
122
+ end
123
+
124
+ def build_map_entry(result)
125
+ mutation = result.mutation
126
+ status = result.status.to_s
127
+ <<~HTML.chomp
128
+ <div class="map-line #{status}">
129
+ <span class="line-number">line #{mutation.line}</span>
130
+ <span class="operator">#{h(mutation.operator_name)}</span>
131
+ <span class="status-badge #{status}">#{status}</span>
132
+ </div>
133
+ HTML
134
+ end
135
+
136
+ def build_survived_details(survived)
137
+ return "" if survived.empty?
138
+
139
+ entries = survived.map { |r| build_survived_entry(r) }.join("\n")
140
+ <<~HTML
141
+ <div class="survived-details">
142
+ <h3>Survived Mutations</h3>
143
+ #{entries}
144
+ </div>
145
+ HTML
146
+ end
147
+
148
+ def build_survived_entry(result)
149
+ mutation = result.mutation
150
+ suggestion_text = @suggestion.suggestion_for(mutation)
151
+ diff_html = format_diff(mutation.diff)
152
+ <<~HTML
153
+ <div class="survived-entry">
154
+ <div class="survived-header">
155
+ <span class="operator">#{h(mutation.operator_name)}</span>
156
+ <span class="location">#{h(mutation.file_path)}:#{mutation.line}</span>
157
+ </div>
158
+ <pre class="diff">#{diff_html}</pre>
159
+ <div class="suggestion">#{h(suggestion_text)}</div>
160
+ </div>
161
+ HTML
162
+ end
163
+
164
+ def format_diff(diff)
165
+ diff.split("\n").map do |line|
166
+ escaped = h(line)
167
+ css_class = if line.start_with?("- ")
168
+ "diff-removed"
169
+ elsif line.start_with?("+ ")
170
+ "diff-added"
171
+ else
172
+ ""
173
+ end
174
+ %(<span class="#{css_class}">#{escaped}</span>)
175
+ end.join("\n")
176
+ end
177
+
178
+ def build_footer
179
+ "<footer>Generated by Evilution v#{h(Evilution::VERSION)}</footer>"
180
+ end
181
+
182
+ def score_css_class(score)
183
+ if score >= 0.8
184
+ "score-high"
185
+ elsif score >= 0.5
186
+ "score-medium"
187
+ else
188
+ "score-low"
250
189
  end
251
190
  end
191
+
192
+ def h(text)
193
+ CGI.escapeHTML(text.to_s)
194
+ end
195
+
196
+ def stylesheet
197
+ <<~HTML
198
+ <style>
199
+ * { margin: 0; padding: 0; box-sizing: border-box; }
200
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 1200px; margin: 0 auto; }
201
+ header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
202
+ h1 { font-size: 1.5rem; color: #f0f6fc; }
203
+ .version { color: #8b949e; font-weight: normal; font-size: 0.9rem; }
204
+ .score-badge { font-size: 1.8rem; font-weight: bold; padding: 0.3rem 1rem; border-radius: 8px; }
205
+ .score-high { background: #1a4731; color: #3fb950; }
206
+ .score-medium { background: #4a3a10; color: #d29922; }
207
+ .score-low { background: #4a1a1a; color: #f85149; }
208
+ .summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
209
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem 1.2rem; text-align: center; min-width: 100px; }
210
+ .card-value { display: block; font-size: 1.4rem; font-weight: bold; color: #f0f6fc; }
211
+ .card-label { display: block; font-size: 0.75rem; color: #8b949e; margin-top: 0.2rem; text-transform: uppercase; }
212
+ .card-killed { border-color: #238636; }
213
+ .card-survived { border-color: #da3633; }
214
+ .truncation-notice { background: #4a3a10; border: 1px solid #d29922; color: #d29922; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 2rem; }
215
+ .file-section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; }
216
+ .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; }
217
+ .file-path { color: #58a6ff; font-family: monospace; }
218
+ .file-stats { color: #8b949e; font-size: 0.8rem; font-weight: normal; }
219
+ .mutation-map { padding: 0.5rem 1rem; }
220
+ .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; }
221
+ .map-line.killed { color: #3fb950; }
222
+ .map-line.survived { color: #f85149; }
223
+ .map-line.timeout { color: #d29922; }
224
+ .map-line.error { color: #f85149; }
225
+ .map-line.neutral { color: #8b949e; }
226
+ .map-line.equivalent { color: #8b949e; }
227
+ .line-number { min-width: 60px; color: #8b949e; }
228
+ .operator { flex: 1; }
229
+ .status-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 10px; text-transform: uppercase; font-weight: bold; }
230
+ .status-badge.killed { background: #1a4731; }
231
+ .status-badge.survived { background: #4a1a1a; }
232
+ .status-badge.timeout { background: #4a3a10; }
233
+ .status-badge.neutral { background: #21262d; }
234
+ .status-badge.equivalent { background: #21262d; }
235
+ .survived-details { border-top: 1px solid #30363d; padding: 1rem; }
236
+ .survived-details h3 { color: #f85149; font-size: 0.9rem; margin-bottom: 0.75rem; }
237
+ .survived-entry { background: #1c1a1a; border: 1px solid #4a1a1a; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
238
+ .survived-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.85rem; }
239
+ .survived-header .operator { color: #f85149; font-weight: bold; }
240
+ .survived-header .location { color: #8b949e; font-family: monospace; }
241
+ .diff { background: #0d1117; border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.8rem; overflow-x: auto; line-height: 1.5; }
242
+ .diff-removed { color: #f85149; display: block; }
243
+ .diff-added { color: #3fb950; display: block; }
244
+ .suggestion { color: #d29922; font-size: 0.8rem; margin-top: 0.5rem; font-style: italic; }
245
+ .empty { color: #8b949e; text-align: center; padding: 2rem; }
246
+ footer { text-align: center; color: #484f58; font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; }
247
+ </style>
248
+ HTML
249
+ end
252
250
  end
@@ -4,69 +4,67 @@ require "json"
4
4
  require "time"
5
5
  require_relative "suggestion"
6
6
 
7
- module Evilution
8
- module Reporter
9
- class JSON
10
- def initialize
11
- @suggestion = Suggestion.new
12
- end
7
+ require_relative "../reporter"
13
8
 
14
- def call(summary)
15
- ::JSON.generate(build_report(summary))
16
- end
9
+ class Evilution::Reporter::JSON
10
+ def initialize(suggest_tests: false)
11
+ @suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: suggest_tests)
12
+ end
17
13
 
18
- private
14
+ def call(summary)
15
+ ::JSON.generate(build_report(summary))
16
+ end
19
17
 
20
- # rubocop:disable Metrics/PerceivedComplexity
21
- def build_report(summary)
22
- {
23
- version: Evilution::VERSION,
24
- timestamp: Time.now.iso8601,
25
- summary: build_summary(summary),
26
- survived: summary.survived_results.map { |r| build_mutation_detail(r) },
27
- killed: summary.killed_results.map { |r| build_mutation_detail(r) },
28
- neutral: summary.neutral_results.map { |r| build_mutation_detail(r) },
29
- timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
30
- errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) },
31
- equivalent: summary.equivalent_results.map { |r| build_mutation_detail(r) }
32
- }
33
- end
34
- # rubocop:enable Metrics/PerceivedComplexity
18
+ private
35
19
 
36
- def build_summary(summary)
37
- data = {
38
- total: summary.total,
39
- killed: summary.killed,
40
- survived: summary.survived,
41
- timed_out: summary.timed_out,
42
- errors: summary.errors,
43
- neutral: summary.neutral,
44
- equivalent: summary.equivalent,
45
- score: summary.score.round(4),
46
- duration: summary.duration.round(4)
47
- }
48
- data[:truncated] = true if summary.truncated?
49
- peak = summary.peak_memory_mb
50
- data[:peak_memory_mb] = peak.round(1) if peak
51
- data
52
- end
20
+ # rubocop:disable Metrics/PerceivedComplexity
21
+ def build_report(summary)
22
+ {
23
+ version: Evilution::VERSION,
24
+ timestamp: Time.now.iso8601,
25
+ summary: build_summary(summary),
26
+ survived: summary.survived_results.map { |r| build_mutation_detail(r) },
27
+ killed: summary.killed_results.map { |r| build_mutation_detail(r) },
28
+ neutral: summary.neutral_results.map { |r| build_mutation_detail(r) },
29
+ timed_out: summary.results.select(&:timeout?).map { |r| build_mutation_detail(r) },
30
+ errors: summary.results.select(&:error?).map { |r| build_mutation_detail(r) },
31
+ equivalent: summary.equivalent_results.map { |r| build_mutation_detail(r) }
32
+ }
33
+ end
34
+ # rubocop:enable Metrics/PerceivedComplexity
35
+
36
+ def build_summary(summary)
37
+ data = {
38
+ total: summary.total,
39
+ killed: summary.killed,
40
+ survived: summary.survived,
41
+ timed_out: summary.timed_out,
42
+ errors: summary.errors,
43
+ neutral: summary.neutral,
44
+ equivalent: summary.equivalent,
45
+ score: summary.score.round(4),
46
+ duration: summary.duration.round(4)
47
+ }
48
+ data[:truncated] = true if summary.truncated?
49
+ peak = summary.peak_memory_mb
50
+ data[:peak_memory_mb] = peak.round(1) if peak
51
+ data
52
+ end
53
53
 
54
- def build_mutation_detail(result)
55
- mutation = result.mutation
56
- detail = {
57
- operator: mutation.operator_name,
58
- file: mutation.file_path,
59
- line: mutation.line,
60
- status: result.status.to_s,
61
- duration: result.duration.round(4),
62
- diff: mutation.diff
63
- }
64
- detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
65
- detail[:test_command] = result.test_command if result.test_command
66
- detail[:child_rss_kb] = result.child_rss_kb if result.child_rss_kb
67
- detail[:memory_delta_kb] = result.memory_delta_kb if result.memory_delta_kb
68
- detail
69
- end
70
- end
54
+ def build_mutation_detail(result)
55
+ mutation = result.mutation
56
+ detail = {
57
+ operator: mutation.operator_name,
58
+ file: mutation.file_path,
59
+ line: mutation.line,
60
+ status: result.status.to_s,
61
+ duration: result.duration.round(4),
62
+ diff: mutation.diff
63
+ }
64
+ detail[:suggestion] = @suggestion.suggestion_for(mutation) if result.status == :survived
65
+ detail[:test_command] = result.test_command if result.test_command
66
+ detail[:child_rss_kb] = result.child_rss_kb if result.child_rss_kb
67
+ detail[:memory_delta_kb] = result.memory_delta_kb if result.memory_delta_kb
68
+ detail
71
69
  end
72
70
  end