evilution 0.12.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 +127 -0
  4. data/CHANGELOG.md +29 -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 +119 -0
  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 +18 -44
  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 -55
  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 -313
  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 +14 -0
  85. metadata +20 -2
@@ -18,416 +18,427 @@ require_relative "result/summary"
18
18
  require_relative "baseline"
19
19
  require_relative "cache"
20
20
  require_relative "parallel/pool"
21
+ require_relative "session/store"
22
+
23
+ class Evilution::Runner
24
+ attr_reader :config
25
+
26
+ def initialize(config: Evilution::Config.new, on_result: nil)
27
+ @config = config
28
+ @on_result = on_result
29
+ @parser = Evilution::AST::Parser.new
30
+ @registry = Evilution::Mutator::Registry.default
31
+ @isolator = build_isolator
32
+ @cache = config.incremental? ? Evilution::Cache.new : nil
33
+ end
21
34
 
22
- module Evilution
23
- class Runner
24
- attr_reader :config
35
+ def call
36
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
37
 
26
- def initialize(config: Config.new)
27
- @config = config
28
- @parser = AST::Parser.new
29
- @registry = Mutator::Registry.default
30
- @isolator = build_isolator
31
- @cache = config.incremental? ? Cache.new : nil
32
- end
38
+ subjects = parse_subjects
39
+ subjects = filter_by_target(subjects) if config.target?
40
+ subjects = filter_by_line_ranges(subjects) if config.line_ranges?
41
+ log_memory("after parse_subjects", "#{subjects.length} subjects")
33
42
 
34
- def call
35
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ baseline_result = run_baseline(subjects)
36
44
 
37
- subjects = parse_subjects
38
- subjects = filter_by_target(subjects) if config.target?
39
- subjects = filter_by_line_ranges(subjects) if config.line_ranges?
40
- log_memory("after parse_subjects", "#{subjects.length} subjects")
45
+ mutations = generate_mutations(subjects)
46
+ equivalent_mutations, mutations = filter_equivalent(mutations)
47
+ release_subject_nodes(subjects)
48
+ results, truncated = run_mutations(mutations, baseline_result)
49
+ results += equivalent_mutations.map do |m|
50
+ m.strip_sources!
51
+ equivalent_result(m)
52
+ end
53
+ log_memory("after run_mutations", "#{results.length} results")
41
54
 
42
- baseline_result = run_baseline(subjects)
55
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
43
56
 
44
- mutations = generate_mutations(subjects)
45
- equivalent_mutations, mutations = filter_equivalent(mutations)
46
- release_subject_nodes(subjects)
47
- results, truncated = run_mutations(mutations, baseline_result)
48
- results += equivalent_mutations.map do |m|
49
- m.strip_sources!
50
- equivalent_result(m)
51
- end
52
- log_memory("after run_mutations", "#{results.length} results")
57
+ summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: truncated)
58
+ output_report(summary)
59
+ save_session(summary)
53
60
 
54
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
61
+ summary
62
+ end
55
63
 
56
- summary = Result::Summary.new(results: results, duration: duration, truncated: truncated)
57
- output_report(summary)
64
+ private
58
65
 
59
- summary
60
- end
66
+ attr_reader :parser, :registry, :isolator, :cache, :on_result
61
67
 
62
- private
68
+ def parse_subjects
69
+ files = resolve_target_files
70
+ files.flat_map { |file| parser.call(file) }
71
+ end
63
72
 
64
- attr_reader :parser, :registry, :isolator, :cache
73
+ def resolve_target_files
74
+ return config.target_files unless config.target_files.empty?
65
75
 
66
- def parse_subjects
67
- files = resolve_target_files
68
- files.flat_map { |file| parser.call(file) }
69
- end
76
+ Evilution::Git::ChangedFiles.new.call
77
+ end
70
78
 
71
- def resolve_target_files
72
- return config.target_files unless config.target_files.empty?
79
+ def filter_by_target(subjects)
80
+ matched = if config.target.include?("#")
81
+ subjects.select { |s| s.name == config.target }
82
+ else
83
+ subjects.select { |s| s.name.start_with?("#{config.target}#") }
84
+ end
85
+ raise Evilution::Error, "no method found matching '#{config.target}'" if matched.empty?
73
86
 
74
- Git::ChangedFiles.new.call
75
- end
87
+ matched
88
+ end
76
89
 
77
- def filter_by_target(subjects)
78
- matched = if config.target.include?("#")
79
- subjects.select { |s| s.name == config.target }
80
- else
81
- subjects.select { |s| s.name.start_with?("#{config.target}#") }
82
- end
83
- raise Error, "no method found matching '#{config.target}'" if matched.empty?
90
+ def filter_by_line_ranges(subjects)
91
+ subjects.select do |subject|
92
+ range = config.line_ranges[subject.file_path]
93
+ next true unless range
84
94
 
85
- matched
95
+ subject_start = subject.line_number
96
+ subject_end = subject_start + subject.source.count("\n")
97
+ subject_start <= range.last && subject_end >= range.first
86
98
  end
99
+ end
87
100
 
88
- def filter_by_line_ranges(subjects)
89
- subjects.select do |subject|
90
- range = config.line_ranges[subject.file_path]
91
- next true unless range
92
-
93
- subject_start = subject.line_number
94
- subject_end = subject_start + subject.source.count("\n")
95
- subject_start <= range.last && subject_end >= range.first
96
- end
101
+ def generate_mutations(subjects)
102
+ subjects.flat_map do |subject|
103
+ registry.mutations_for(subject)
97
104
  end
105
+ end
98
106
 
99
- def generate_mutations(subjects)
100
- subjects.flat_map do |subject|
101
- registry.mutations_for(subject)
102
- end
103
- end
107
+ def filter_equivalent(mutations)
108
+ Evilution::Equivalent::Detector.new.call(mutations)
109
+ end
104
110
 
105
- def filter_equivalent(mutations)
106
- Equivalent::Detector.new.call(mutations)
107
- end
111
+ def release_subject_nodes(subjects)
112
+ subjects.each(&:release_node!)
113
+ end
108
114
 
109
- def release_subject_nodes(subjects)
110
- subjects.each(&:release_node!)
111
- end
115
+ def equivalent_result(mutation)
116
+ Evilution::Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
117
+ end
112
118
 
113
- def equivalent_result(mutation)
114
- Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
115
- end
119
+ def run_baseline(subjects)
120
+ return nil unless config.baseline? && subjects.any?
116
121
 
117
- def run_baseline(subjects)
118
- return nil unless config.baseline? && subjects.any?
122
+ log_baseline_start
123
+ baseline = Evilution::Baseline.new(timeout: config.timeout)
124
+ result = baseline.call(subjects)
125
+ log_baseline_complete(result)
126
+ result
127
+ end
119
128
 
120
- log_baseline_start
121
- baseline = Baseline.new(timeout: config.timeout)
122
- result = baseline.call(subjects)
123
- log_baseline_complete(result)
124
- result
129
+ def run_mutations(mutations, baseline_result = nil)
130
+ if config.jobs > 1
131
+ run_mutations_parallel(mutations, baseline_result)
132
+ else
133
+ run_mutations_sequential(mutations, baseline_result)
125
134
  end
135
+ end
126
136
 
127
- def run_mutations(mutations, baseline_result = nil)
128
- if config.jobs > 1
129
- run_mutations_parallel(mutations, baseline_result)
130
- else
131
- run_mutations_sequential(mutations, baseline_result)
132
- end
133
- end
137
+ def run_mutations_sequential(mutations, baseline_result = nil)
138
+ integration = build_integration
139
+ spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
140
+ results = []
141
+ survived_count = 0
142
+ truncated = false
134
143
 
135
- def run_mutations_sequential(mutations, baseline_result = nil)
136
- integration = build_integration
137
- spec_resolver = baseline_result&.failed? ? SpecResolver.new : nil
138
- results = []
139
- survived_count = 0
140
- truncated = false
141
-
142
- mutations.each_with_index do |mutation, index|
143
- result = execute_or_fetch(mutation) do
144
- test_command = ->(m) { integration.call(m) }
145
- isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
146
- end
147
- mutation.strip_sources!
148
- result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
149
- results << result
150
- survived_count += 1 if result.survived?
151
- log_progress(index + 1, result.status)
152
- log_mutation_diagnostics(result)
153
-
154
- if config.fail_fast? && survived_count >= config.fail_fast
155
- truncated = true
156
- break
157
- end
144
+ mutations.each_with_index do |mutation, index|
145
+ result = execute_or_fetch(mutation) do
146
+ test_command = ->(m) { integration.call(m) }
147
+ isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
148
+ end
149
+ mutation.strip_sources!
150
+ result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
151
+ results << result
152
+ survived_count += 1 if result.survived?
153
+ on_result&.call(result)
154
+ log_progress(index + 1, result.status)
155
+ log_mutation_diagnostics(result)
156
+
157
+ if config.fail_fast? && survived_count >= config.fail_fast
158
+ truncated = true
159
+ break
158
160
  end
159
-
160
- [results, truncated]
161
161
  end
162
162
 
163
- def run_mutations_parallel(mutations, baseline_result = nil)
164
- integration = build_integration
165
- pool = Parallel::Pool.new(size: config.jobs)
166
- worker_isolator = Isolation::InProcess.new
167
- spec_resolver = baseline_result&.failed? ? SpecResolver.new : nil
168
- state = { results: [], survived_count: 0, truncated: false, completed: 0 }
163
+ [results, truncated]
164
+ end
169
165
 
170
- mutations.each_slice(config.jobs) do |batch|
171
- break if state[:truncated]
166
+ def run_mutations_parallel(mutations, baseline_result = nil)
167
+ integration = build_integration
168
+ pool = Evilution::Parallel::Pool.new(size: config.jobs)
169
+ worker_isolator = Evilution::Isolation::InProcess.new
170
+ spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
171
+ state = { results: [], survived_count: 0, truncated: false, completed: 0 }
172
172
 
173
- batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
174
- process_batch(batch_results, baseline_result, spec_resolver, state)
175
- end
173
+ mutations.each_slice(config.jobs) do |batch|
174
+ break if state[:truncated]
176
175
 
177
- [state[:results], state[:truncated]]
176
+ batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
177
+ process_batch(batch_results, baseline_result, spec_resolver, state)
178
178
  end
179
179
 
180
- def run_parallel_batch(batch, pool, worker_isolator, integration)
181
- uncached_indices, cached_results = partition_cached(batch)
182
- worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
183
- compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
184
- batch.each(&:strip_sources!)
185
- batch_results = rebuild_results(batch, compact_results)
186
- batch_results.each { |r| store_cached_result(r.mutation, r) }
187
- batch_results
188
- end
180
+ [state[:results], state[:truncated]]
181
+ end
189
182
 
190
- def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
191
- return [] if uncached_indices.empty?
183
+ def run_parallel_batch(batch, pool, worker_isolator, integration)
184
+ uncached_indices, cached_results = partition_cached(batch)
185
+ worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
186
+ compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
187
+ batch.each(&:strip_sources!)
188
+ batch_results = rebuild_results(batch, compact_results)
189
+ batch_results.each { |r| store_cached_result(r.mutation, r) }
190
+ batch_results
191
+ end
192
192
 
193
- uncached = uncached_indices.map { |i| batch[i] }
194
- pool.map(uncached) do |mutation|
195
- test_command = ->(m) { integration.call(m) }
196
- result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
197
- compact_result(result)
198
- end
199
- end
193
+ def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
194
+ return [] if uncached_indices.empty?
200
195
 
201
- def process_batch(batch_results, baseline_result, spec_resolver, state)
202
- batch_results.each do |result|
203
- result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
204
- state[:results] << result
205
- state[:survived_count] += 1 if result.survived?
206
- state[:completed] += 1
207
- log_progress(state[:completed], result.status)
208
- log_mutation_diagnostics(result)
209
- end
196
+ uncached = uncached_indices.map { |i| batch[i] }
197
+ pool.map(uncached) do |mutation|
198
+ test_command = ->(m) { integration.call(m) }
199
+ result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
200
+ compact_result(result)
201
+ end
202
+ end
210
203
 
211
- log_memory("after batch", "#{state[:completed]} complete")
212
- state[:truncated] = true if should_truncate?(state[:survived_count])
204
+ def process_batch(batch_results, baseline_result, spec_resolver, state)
205
+ batch_results.each do |result|
206
+ result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
207
+ state[:results] << result
208
+ state[:survived_count] += 1 if result.survived?
209
+ state[:completed] += 1
210
+ on_result&.call(result)
211
+ log_progress(state[:completed], result.status)
212
+ log_mutation_diagnostics(result)
213
213
  end
214
214
 
215
- def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
216
- return result unless result.survived? && baseline_result && baseline_result.failed?
215
+ log_memory("after batch", "#{state[:completed]} complete")
216
+ state[:truncated] = true if should_truncate?(state[:survived_count])
217
+ end
217
218
 
218
- if config.spec_files.any?
219
- neutralize = true
220
- else
221
- spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
222
- neutralize = baseline_result.failed_spec_files.include?(spec_file)
223
- end
224
- return result unless neutralize
225
-
226
- Result::MutationResult.new(
227
- mutation: result.mutation,
228
- status: :neutral,
229
- duration: result.duration,
230
- test_command: result.test_command,
231
- child_rss_kb: result.child_rss_kb,
232
- memory_delta_kb: result.memory_delta_kb
219
+ def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
220
+ return result unless result.survived? && baseline_result && baseline_result.failed?
221
+
222
+ if config.spec_files.any?
223
+ neutralize = true
224
+ else
225
+ spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
226
+ neutralize = baseline_result.failed_spec_files.include?(spec_file)
227
+ end
228
+ return result unless neutralize
229
+
230
+ Evilution::Result::MutationResult.new(
231
+ mutation: result.mutation,
232
+ status: :neutral,
233
+ duration: result.duration,
234
+ test_command: result.test_command,
235
+ child_rss_kb: result.child_rss_kb,
236
+ memory_delta_kb: result.memory_delta_kb
237
+ )
238
+ end
239
+
240
+ def compact_result(result)
241
+ {
242
+ status: result.status,
243
+ duration: result.duration,
244
+ killing_test: result.killing_test,
245
+ test_command: result.test_command,
246
+ child_rss_kb: result.child_rss_kb,
247
+ memory_delta_kb: result.memory_delta_kb
248
+ }
249
+ end
250
+
251
+ def rebuild_results(batch, compact_results)
252
+ batch.zip(compact_results).map do |mutation, data|
253
+ Evilution::Result::MutationResult.new(
254
+ mutation: mutation,
255
+ status: data[:status],
256
+ duration: data[:duration],
257
+ killing_test: data[:killing_test],
258
+ test_command: data[:test_command],
259
+ child_rss_kb: data[:child_rss_kb],
260
+ memory_delta_kb: data[:memory_delta_kb]
233
261
  )
234
262
  end
263
+ end
235
264
 
236
- def compact_result(result)
237
- {
238
- status: result.status,
239
- duration: result.duration,
240
- killing_test: result.killing_test,
241
- test_command: result.test_command,
242
- child_rss_kb: result.child_rss_kb,
243
- memory_delta_kb: result.memory_delta_kb
244
- }
245
- end
265
+ def should_truncate?(survived_count)
266
+ config.fail_fast? && survived_count >= config.fail_fast
267
+ end
246
268
 
247
- def rebuild_results(batch, compact_results)
248
- batch.zip(compact_results).map do |mutation, data|
249
- Result::MutationResult.new(
250
- mutation: mutation,
251
- status: data[:status],
252
- duration: data[:duration],
253
- killing_test: data[:killing_test],
254
- test_command: data[:test_command],
255
- child_rss_kb: data[:child_rss_kb],
256
- memory_delta_kb: data[:memory_delta_kb]
257
- )
258
- end
269
+ def build_isolator
270
+ case resolve_isolation
271
+ when :fork then Evilution::Isolation::Fork.new
272
+ when :in_process then Evilution::Isolation::InProcess.new
259
273
  end
274
+ end
260
275
 
261
- def should_truncate?(survived_count)
262
- config.fail_fast? && survived_count >= config.fail_fast
263
- end
276
+ def resolve_isolation
277
+ return :fork if config.isolation == :fork
264
278
 
265
- def build_isolator
266
- case resolve_isolation
267
- when :fork then Isolation::Fork.new
268
- when :in_process then Isolation::InProcess.new
269
- end
279
+ :in_process
280
+ end
281
+
282
+ def build_integration
283
+ case config.integration
284
+ when :rspec
285
+ test_files = config.spec_files.empty? ? nil : config.spec_files
286
+ Evilution::Integration::RSpec.new(test_files: test_files)
287
+ else
288
+ raise Evilution::Error, "unknown integration: #{config.integration}"
270
289
  end
290
+ end
271
291
 
272
- def resolve_isolation
273
- return :fork if config.isolation == :fork
292
+ def output_report(summary)
293
+ reporter = build_reporter
294
+ return unless reporter
274
295
 
275
- :in_process
276
- end
296
+ output = reporter.call(summary)
297
+ return if config.quiet
277
298
 
278
- def build_integration
279
- case config.integration
280
- when :rspec
281
- test_files = config.spec_files.empty? ? nil : config.spec_files
282
- Integration::RSpec.new(test_files: test_files)
283
- else
284
- raise Error, "unknown integration: #{config.integration}"
285
- end
299
+ if config.html?
300
+ path = "evilution-report.html"
301
+ File.write(path, output)
302
+ warn "HTML report written to #{path}"
303
+ else
304
+ $stdout.puts(output)
286
305
  end
306
+ end
287
307
 
288
- def output_report(summary)
289
- reporter = build_reporter
290
- return unless reporter
308
+ def log_baseline_start
309
+ return if config.quiet || !config.text? || !$stderr.tty?
291
310
 
292
- output = reporter.call(summary)
293
- return if config.quiet
311
+ $stderr.write("Running baseline test suite...\n")
312
+ end
294
313
 
295
- if config.html?
296
- path = "evilution-report.html"
297
- File.write(path, output)
298
- warn "HTML report written to #{path}"
299
- else
300
- $stdout.puts(output)
301
- end
302
- end
314
+ def log_baseline_complete(result)
315
+ return if config.quiet || !config.text? || !$stderr.tty?
303
316
 
304
- def log_baseline_start
305
- return if config.quiet || !config.text? || !$stderr.tty?
317
+ count = result.failed_spec_files.size
318
+ $stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
319
+ end
306
320
 
307
- $stderr.write("Running baseline test suite...\n")
308
- end
321
+ def log_progress(current, status)
322
+ return if config.quiet || !config.text? || !$stderr.tty?
309
323
 
310
- def log_baseline_complete(result)
311
- return if config.quiet || !config.text? || !$stderr.tty?
324
+ $stderr.write("mutation #{current} #{status}\n")
325
+ end
312
326
 
313
- count = result.failed_spec_files.size
314
- $stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
315
- end
327
+ def log_memory(phase, context = nil)
328
+ return unless config.verbose && !config.quiet
316
329
 
317
- def log_progress(current, status)
318
- return if config.quiet || !config.text? || !$stderr.tty?
330
+ rss = Evilution::Memory.rss_mb
331
+ return unless rss
319
332
 
320
- $stderr.write("mutation #{current} #{status}\n")
321
- end
333
+ gc = gc_stats_string
334
+ msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
335
+ context = [context, gc].compact.join(", ")
336
+ msg += " (#{context})" unless context.empty?
337
+ $stderr.write("#{msg}\n")
338
+ end
322
339
 
323
- def log_memory(phase, context = nil)
324
- return unless config.verbose && !config.quiet
340
+ def log_mutation_diagnostics(result)
341
+ return unless config.verbose && !config.quiet
325
342
 
326
- rss = Memory.rss_mb
327
- return unless rss
343
+ parts = []
344
+ parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
328
345
 
329
- gc = gc_stats_string
330
- msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
331
- context = [context, gc].compact.join(", ")
332
- msg += " (#{context})" unless context.empty?
333
- $stderr.write("#{msg}\n")
346
+ if result.memory_delta_kb
347
+ sign = result.memory_delta_kb.negative? ? "" : "+"
348
+ parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
334
349
  end
335
350
 
336
- def log_mutation_diagnostics(result)
337
- return unless config.verbose && !config.quiet
351
+ parts << gc_stats_string
338
352
 
339
- parts = []
340
- parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
353
+ return if parts.empty?
341
354
 
342
- if result.memory_delta_kb
343
- sign = result.memory_delta_kb.negative? ? "" : "+"
344
- parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
345
- end
355
+ $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n")
356
+ end
346
357
 
347
- parts << gc_stats_string
358
+ def gc_stats_string
359
+ stats = GC.stat
360
+ format(
361
+ "heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
362
+ live: stats[:heap_live_slots],
363
+ alloc: stats[:total_allocated_objects],
364
+ freed: stats[:total_freed_objects]
365
+ )
366
+ end
348
367
 
349
- return if parts.empty?
368
+ def save_session(summary)
369
+ return unless config.save_session?
350
370
 
351
- $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n")
352
- end
371
+ Evilution::Session::Store.new.save(summary)
372
+ rescue StandardError => e
373
+ warn "[evilution] failed to save session: #{e.message}" unless config.quiet
374
+ end
353
375
 
354
- def gc_stats_string
355
- stats = GC.stat
356
- format(
357
- "heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
358
- live: stats[:heap_live_slots],
359
- alloc: stats[:total_allocated_objects],
360
- freed: stats[:total_freed_objects]
361
- )
376
+ def build_reporter
377
+ case config.format
378
+ when :json
379
+ Evilution::Reporter::JSON.new
380
+ when :text
381
+ Evilution::Reporter::CLI.new
382
+ when :html
383
+ Evilution::Reporter::HTML.new
362
384
  end
385
+ end
363
386
 
364
- def build_reporter
365
- case config.format
366
- when :json
367
- Reporter::JSON.new
368
- when :text
369
- Reporter::CLI.new
370
- when :html
371
- Reporter::HTML.new
372
- end
373
- end
387
+ def partition_cached(batch)
388
+ uncached_indices = []
389
+ cached_results = {}
374
390
 
375
- def partition_cached(batch)
376
- uncached_indices = []
377
- cached_results = {}
378
-
379
- batch.each_with_index do |mutation, i|
380
- cached = fetch_cached_result(mutation)
381
- if cached
382
- cached_results[i] = compact_result(cached)
383
- else
384
- uncached_indices << i
385
- end
391
+ batch.each_with_index do |mutation, i|
392
+ cached = fetch_cached_result(mutation)
393
+ if cached
394
+ cached_results[i] = compact_result(cached)
395
+ else
396
+ uncached_indices << i
386
397
  end
387
-
388
- [uncached_indices, cached_results]
389
398
  end
390
399
 
391
- def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
392
- result_map = cached_results.dup
393
- uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
394
- batch.each_index.map { |i| result_map[i] }
395
- end
400
+ [uncached_indices, cached_results]
401
+ end
396
402
 
397
- def execute_or_fetch(mutation)
398
- cached = fetch_cached_result(mutation)
399
- return cached if cached
403
+ def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
404
+ result_map = cached_results.dup
405
+ uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
406
+ batch.each_index.map { |i| result_map[i] }
407
+ end
400
408
 
401
- result = yield
402
- store_cached_result(mutation, result)
403
- result
404
- end
409
+ def execute_or_fetch(mutation)
410
+ cached = fetch_cached_result(mutation)
411
+ return cached if cached
405
412
 
406
- def fetch_cached_result(mutation)
407
- return nil unless cache
413
+ result = yield
414
+ store_cached_result(mutation, result)
415
+ result
416
+ end
408
417
 
409
- data = cache.fetch(mutation)
410
- return nil unless data
411
- return nil unless %i[killed timeout].include?(data[:status])
418
+ def fetch_cached_result(mutation)
419
+ return nil unless cache
412
420
 
413
- Result::MutationResult.new(
414
- mutation: mutation,
415
- status: data[:status],
416
- duration: data[:duration],
417
- killing_test: data[:killing_test],
418
- test_command: data[:test_command]
419
- )
420
- end
421
+ data = cache.fetch(mutation)
422
+ return nil unless data
423
+ return nil unless %i[killed timeout].include?(data[:status])
421
424
 
422
- def store_cached_result(mutation, result)
423
- return unless cache
424
- return unless result.killed? || result.timeout?
425
+ Evilution::Result::MutationResult.new(
426
+ mutation: mutation,
427
+ status: data[:status],
428
+ duration: data[:duration],
429
+ killing_test: data[:killing_test],
430
+ test_command: data[:test_command]
431
+ )
432
+ end
425
433
 
426
- cache.store(mutation,
427
- status: result.status,
428
- duration: result.duration,
429
- killing_test: result.killing_test,
430
- test_command: result.test_command)
431
- end
434
+ def store_cached_result(mutation, result)
435
+ return unless cache
436
+ return unless result.killed? || result.timeout?
437
+
438
+ cache.store(mutation,
439
+ status: result.status,
440
+ duration: result.duration,
441
+ killing_test: result.killing_test,
442
+ test_command: result.test_command)
432
443
  end
433
444
  end