evilution 0.13.0 → 0.15.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +17 -17
  4. data/CHANGELOG.md +39 -0
  5. data/lib/evilution/ast/inheritance_scanner.rb +70 -0
  6. data/lib/evilution/ast/parser.rb +73 -68
  7. data/lib/evilution/ast/source_surgeon.rb +7 -9
  8. data/lib/evilution/ast.rb +4 -0
  9. data/lib/evilution/baseline.rb +73 -75
  10. data/lib/evilution/cache.rb +75 -77
  11. data/lib/evilution/cli.rb +412 -173
  12. data/lib/evilution/config.rb +141 -136
  13. data/lib/evilution/equivalent/detector.rb +29 -27
  14. data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
  15. data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
  16. data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
  17. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  18. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  19. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  20. data/lib/evilution/equivalent/heuristic.rb +6 -0
  21. data/lib/evilution/equivalent.rb +4 -0
  22. data/lib/evilution/git/changed_files.rb +35 -37
  23. data/lib/evilution/git.rb +4 -0
  24. data/lib/evilution/integration/base.rb +5 -7
  25. data/lib/evilution/integration/rspec.rb +114 -116
  26. data/lib/evilution/integration.rb +4 -0
  27. data/lib/evilution/isolation/fork.rb +98 -100
  28. data/lib/evilution/isolation/in_process.rb +59 -61
  29. data/lib/evilution/isolation.rb +4 -0
  30. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  31. data/lib/evilution/mcp/server.rb +12 -11
  32. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  33. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  34. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  35. data/lib/evilution/mcp.rb +4 -0
  36. data/lib/evilution/memory/leak_check.rb +80 -84
  37. data/lib/evilution/memory.rb +34 -36
  38. data/lib/evilution/mutation.rb +40 -42
  39. data/lib/evilution/mutator/base.rb +62 -48
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  41. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  42. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  43. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  44. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  45. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  46. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  47. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  48. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  49. data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
  50. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  51. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  52. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  53. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  54. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  55. data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
  56. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  57. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  58. data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
  59. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  60. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  61. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  62. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  63. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  64. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  65. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  66. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  67. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  68. data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
  69. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  70. data/lib/evilution/mutator/operator.rb +6 -0
  71. data/lib/evilution/mutator/registry.rb +56 -56
  72. data/lib/evilution/mutator.rb +4 -0
  73. data/lib/evilution/parallel/pool.rb +56 -58
  74. data/lib/evilution/parallel.rb +4 -0
  75. data/lib/evilution/reporter/cli.rb +99 -101
  76. data/lib/evilution/reporter/html.rb +242 -244
  77. data/lib/evilution/reporter/json.rb +57 -59
  78. data/lib/evilution/reporter/suggestion.rb +354 -328
  79. data/lib/evilution/reporter.rb +4 -0
  80. data/lib/evilution/result/mutation_result.rb +43 -46
  81. data/lib/evilution/result/summary.rb +80 -81
  82. data/lib/evilution/result.rb +4 -0
  83. data/lib/evilution/runner.rb +401 -316
  84. data/lib/evilution/session/store.rb +147 -0
  85. data/lib/evilution/session.rb +4 -0
  86. data/lib/evilution/spec_resolver.rb +49 -47
  87. data/lib/evilution/subject.rb +14 -16
  88. data/lib/evilution/version.rb +1 -1
  89. data/lib/evilution.rb +16 -0
  90. metadata +24 -2
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "ast/parser"
5
+ require_relative "ast/inheritance_scanner"
5
6
  require_relative "memory"
6
7
  require_relative "mutator/registry"
7
8
  require_relative "isolation/fork"
@@ -18,416 +19,500 @@ require_relative "result/summary"
18
19
  require_relative "baseline"
19
20
  require_relative "cache"
20
21
  require_relative "parallel/pool"
22
+ require_relative "session/store"
23
+
24
+ class Evilution::Runner
25
+ attr_reader :config
26
+
27
+ def initialize(config: Evilution::Config.new, on_result: nil)
28
+ @config = config
29
+ @on_result = on_result
30
+ @parser = Evilution::AST::Parser.new
31
+ @registry = Evilution::Mutator::Registry.default
32
+ @isolator = build_isolator
33
+ @cache = config.incremental? ? Evilution::Cache.new : nil
34
+ end
35
+
36
+ def call
37
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
38
 
22
- module Evilution
23
- class Runner
24
- attr_reader :config
39
+ subjects = parse_and_filter_subjects
40
+ log_memory("after parse_subjects", "#{subjects.length} subjects")
25
41
 
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
42
+ baseline_result = run_baseline(subjects)
43
+
44
+ mutations = generate_mutations(subjects)
45
+ equivalent_mutations, mutations = filter_equivalent(mutations)
46
+ release_subject_nodes(subjects)
47
+ clear_operator_caches
48
+ results, truncated = run_mutations(mutations, baseline_result)
49
+ results += equivalent_mutations.map do |m|
50
+ m.strip_sources!
51
+ equivalent_result(m)
32
52
  end
53
+ log_memory("after run_mutations", "#{results.length} results")
33
54
 
34
- def call
35
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
36
56
 
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")
57
+ summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: truncated)
58
+ output_report(summary)
59
+ save_session(summary)
41
60
 
42
- baseline_result = run_baseline(subjects)
61
+ summary
62
+ end
43
63
 
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")
64
+ private
53
65
 
54
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
66
+ attr_reader :parser, :registry, :isolator, :cache, :on_result
55
67
 
56
- summary = Result::Summary.new(results: results, duration: duration, truncated: truncated)
57
- output_report(summary)
68
+ def parse_and_filter_subjects
69
+ subjects = parse_subjects
70
+ subjects = filter_by_descendants(subjects) if descendants_target?
71
+ subjects = filter_by_target(subjects) if method_target?
72
+ subjects = filter_by_line_ranges(subjects) if config.line_ranges?
73
+ subjects
74
+ end
58
75
 
59
- summary
60
- end
76
+ def parse_subjects
77
+ files = resolve_target_files
78
+ files.flat_map { |file| parser.call(file) }
79
+ end
61
80
 
62
- private
81
+ def resolve_target_files
82
+ return resolve_source_glob if source_glob_target?
83
+ return config.target_files unless config.target_files.empty?
63
84
 
64
- attr_reader :parser, :registry, :isolator, :cache
85
+ Evilution::Git::ChangedFiles.new.call
86
+ end
65
87
 
66
- def parse_subjects
67
- files = resolve_target_files
68
- files.flat_map { |file| parser.call(file) }
69
- end
88
+ def source_glob_target?
89
+ config.target&.start_with?("source:")
90
+ end
70
91
 
71
- def resolve_target_files
72
- return config.target_files unless config.target_files.empty?
92
+ def descendants_target?
93
+ config.target&.start_with?("descendants:")
94
+ end
73
95
 
74
- Git::ChangedFiles.new.call
75
- end
96
+ def method_target?
97
+ config.target? && !source_glob_target? && !descendants_target?
98
+ end
76
99
 
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?
100
+ def resolve_source_glob
101
+ pattern = config.target.delete_prefix("source:")
102
+ files = Dir.glob(pattern)
103
+ raise Evilution::Error, "no files found matching '#{pattern}'" if files.empty?
84
104
 
85
- matched
86
- end
105
+ files.sort
106
+ end
87
107
 
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
108
+ def filter_by_descendants(subjects)
109
+ base_name = config.target.delete_prefix("descendants:")
110
+ files = resolve_target_files
111
+ inheritance = Evilution::AST::InheritanceScanner.call(files)
112
+ class_names = resolve_descendant_set(base_name, inheritance)
113
+ raise Evilution::Error, "no classes found matching '#{config.target}'" if class_names.empty?
92
114
 
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
97
- end
115
+ subjects.select { |s| class_names.include?(s.name.split(/[#.]/).first) }
116
+ end
98
117
 
99
- def generate_mutations(subjects)
100
- subjects.flat_map do |subject|
101
- registry.mutations_for(subject)
118
+ def resolve_descendant_set(base_name, inheritance)
119
+ descendants = Set.new
120
+ known = inheritance.key?(base_name) || inheritance.value?(base_name)
121
+ return descendants unless known
122
+
123
+ descendants.add(base_name)
124
+ changed = true
125
+ while changed
126
+ changed = false
127
+ inheritance.each do |child, parent|
128
+ next unless descendants.include?(parent)
129
+ next if descendants.include?(child)
130
+
131
+ descendants.add(child)
132
+ changed = true
102
133
  end
103
134
  end
135
+ descendants
136
+ end
104
137
 
105
- def filter_equivalent(mutations)
106
- Equivalent::Detector.new.call(mutations)
107
- end
138
+ def filter_by_target(subjects)
139
+ matched = subjects.select(&target_matcher)
140
+ raise Evilution::Error, "no method found matching '#{config.target}'" if matched.empty?
108
141
 
109
- def release_subject_nodes(subjects)
110
- subjects.each(&:release_node!)
111
- end
142
+ matched
143
+ end
112
144
 
113
- def equivalent_result(mutation)
114
- Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
145
+ def target_matcher
146
+ target = config.target
147
+ if target.end_with?("*")
148
+ prefix = target.chomp("*")
149
+ ->(s) { s.name.split(/[#.]/).first.start_with?(prefix) }
150
+ elsif target.end_with?("#", ".")
151
+ prefix = target
152
+ ->(s) { s.name.start_with?(prefix) }
153
+ elsif target.include?("#") || target.include?(".")
154
+ ->(s) { s.name == target }
155
+ else
156
+ ->(s) { s.name.start_with?("#{target}#") || s.name.start_with?("#{target}.") }
115
157
  end
158
+ end
116
159
 
117
- def run_baseline(subjects)
118
- return nil unless config.baseline? && subjects.any?
160
+ def filter_by_line_ranges(subjects)
161
+ subjects.select do |subject|
162
+ range = config.line_ranges[subject.file_path]
163
+ next true unless range
119
164
 
120
- log_baseline_start
121
- baseline = Baseline.new(timeout: config.timeout)
122
- result = baseline.call(subjects)
123
- log_baseline_complete(result)
124
- result
165
+ subject_start = subject.line_number
166
+ subject_end = subject_start + subject.source.count("\n")
167
+ subject_start <= range.last && subject_end >= range.first
125
168
  end
169
+ end
126
170
 
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
171
+ def generate_mutations(subjects)
172
+ subjects.flat_map do |subject|
173
+ registry.mutations_for(subject)
133
174
  end
175
+ end
134
176
 
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
158
- end
177
+ def filter_equivalent(mutations)
178
+ Evilution::Equivalent::Detector.new.call(mutations)
179
+ end
159
180
 
160
- [results, truncated]
161
- end
181
+ def release_subject_nodes(subjects)
182
+ subjects.each(&:release_node!)
183
+ end
162
184
 
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 }
185
+ def clear_operator_caches
186
+ Evilution::Mutator::Base.clear_parse_cache!
187
+ end
169
188
 
170
- mutations.each_slice(config.jobs) do |batch|
171
- break if state[:truncated]
189
+ def equivalent_result(mutation)
190
+ Evilution::Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
191
+ end
172
192
 
173
- batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
174
- process_batch(batch_results, baseline_result, spec_resolver, state)
175
- end
193
+ def run_baseline(subjects)
194
+ return nil unless config.baseline? && subjects.any?
176
195
 
177
- [state[:results], state[:truncated]]
178
- end
196
+ log_baseline_start
197
+ baseline = Evilution::Baseline.new(timeout: config.timeout)
198
+ result = baseline.call(subjects)
199
+ log_baseline_complete(result)
200
+ result
201
+ end
179
202
 
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
203
+ def run_mutations(mutations, baseline_result = nil)
204
+ if config.jobs > 1
205
+ run_mutations_parallel(mutations, baseline_result)
206
+ else
207
+ run_mutations_sequential(mutations, baseline_result)
188
208
  end
209
+ end
189
210
 
190
- def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
191
- return [] if uncached_indices.empty?
211
+ def run_mutations_sequential(mutations, baseline_result = nil)
212
+ integration = build_integration
213
+ spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
214
+ results = []
215
+ survived_count = 0
216
+ truncated = false
192
217
 
193
- uncached = uncached_indices.map { |i| batch[i] }
194
- pool.map(uncached) do |mutation|
218
+ mutations.each_with_index do |mutation, index|
219
+ result = execute_or_fetch(mutation) do
195
220
  test_command = ->(m) { integration.call(m) }
196
- result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
197
- compact_result(result)
221
+ isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
222
+ end
223
+ mutation.strip_sources!
224
+ result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
225
+ results << result
226
+ survived_count += 1 if result.survived?
227
+ on_result&.call(result)
228
+ log_progress(index + 1, result.status)
229
+ log_mutation_diagnostics(result)
230
+
231
+ if config.fail_fast? && survived_count >= config.fail_fast
232
+ truncated = true
233
+ break
198
234
  end
199
235
  end
200
236
 
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
237
+ [results, truncated]
238
+ end
210
239
 
211
- log_memory("after batch", "#{state[:completed]} complete")
212
- state[:truncated] = true if should_truncate?(state[:survived_count])
213
- end
240
+ def run_mutations_parallel(mutations, baseline_result = nil)
241
+ integration = build_integration
242
+ pool = Evilution::Parallel::Pool.new(size: config.jobs)
243
+ worker_isolator = Evilution::Isolation::InProcess.new
244
+ spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
245
+ state = { results: [], survived_count: 0, truncated: false, completed: 0 }
214
246
 
215
- def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
216
- return result unless result.survived? && baseline_result && baseline_result.failed?
247
+ mutations.each_slice(config.jobs) do |batch|
248
+ break if state[:truncated]
217
249
 
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
233
- )
250
+ batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
251
+ process_batch(batch_results, baseline_result, spec_resolver, state)
234
252
  end
235
253
 
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
254
+ [state[:results], state[:truncated]]
255
+ end
246
256
 
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
259
- end
257
+ def run_parallel_batch(batch, pool, worker_isolator, integration)
258
+ uncached_indices, cached_results = partition_cached(batch)
259
+ worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
260
+ compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
261
+ batch.each(&:strip_sources!)
262
+ batch_results = rebuild_results(batch, compact_results)
263
+ batch_results.each { |r| store_cached_result(r.mutation, r) }
264
+ batch_results
265
+ end
260
266
 
261
- def should_truncate?(survived_count)
262
- config.fail_fast? && survived_count >= config.fail_fast
267
+ def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
268
+ return [] if uncached_indices.empty?
269
+
270
+ uncached = uncached_indices.map { |i| batch[i] }
271
+ pool.map(uncached) do |mutation|
272
+ test_command = ->(m) { integration.call(m) }
273
+ result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
274
+ compact_result(result)
263
275
  end
276
+ end
264
277
 
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
278
+ def process_batch(batch_results, baseline_result, spec_resolver, state)
279
+ batch_results.each do |result|
280
+ result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
281
+ state[:results] << result
282
+ state[:survived_count] += 1 if result.survived?
283
+ state[:completed] += 1
284
+ on_result&.call(result)
285
+ log_progress(state[:completed], result.status)
286
+ log_mutation_diagnostics(result)
270
287
  end
271
288
 
272
- def resolve_isolation
273
- return :fork if config.isolation == :fork
289
+ log_memory("after batch", "#{state[:completed]} complete")
290
+ state[:truncated] = true if should_truncate?(state[:survived_count])
291
+ end
274
292
 
275
- :in_process
293
+ def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
294
+ return result unless result.survived? && baseline_result && baseline_result.failed?
295
+
296
+ if config.spec_files.any?
297
+ neutralize = true
298
+ else
299
+ spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
300
+ neutralize = baseline_result.failed_spec_files.include?(spec_file)
301
+ end
302
+ return result unless neutralize
303
+
304
+ Evilution::Result::MutationResult.new(
305
+ mutation: result.mutation,
306
+ status: :neutral,
307
+ duration: result.duration,
308
+ test_command: result.test_command,
309
+ child_rss_kb: result.child_rss_kb,
310
+ memory_delta_kb: result.memory_delta_kb
311
+ )
312
+ end
313
+
314
+ def compact_result(result)
315
+ {
316
+ status: result.status,
317
+ duration: result.duration,
318
+ killing_test: result.killing_test,
319
+ test_command: result.test_command,
320
+ child_rss_kb: result.child_rss_kb,
321
+ memory_delta_kb: result.memory_delta_kb
322
+ }
323
+ end
324
+
325
+ def rebuild_results(batch, compact_results)
326
+ batch.zip(compact_results).map do |mutation, data|
327
+ Evilution::Result::MutationResult.new(
328
+ mutation: mutation,
329
+ status: data[:status],
330
+ duration: data[:duration],
331
+ killing_test: data[:killing_test],
332
+ test_command: data[:test_command],
333
+ child_rss_kb: data[:child_rss_kb],
334
+ memory_delta_kb: data[:memory_delta_kb]
335
+ )
276
336
  end
337
+ end
277
338
 
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
339
+ def should_truncate?(survived_count)
340
+ config.fail_fast? && survived_count >= config.fail_fast
341
+ end
342
+
343
+ def build_isolator
344
+ case resolve_isolation
345
+ when :fork then Evilution::Isolation::Fork.new
346
+ when :in_process then Evilution::Isolation::InProcess.new
286
347
  end
348
+ end
287
349
 
288
- def output_report(summary)
289
- reporter = build_reporter
290
- return unless reporter
350
+ def resolve_isolation
351
+ return :fork if config.isolation == :fork
291
352
 
292
- output = reporter.call(summary)
293
- return if config.quiet
353
+ :in_process
354
+ end
294
355
 
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
356
+ def build_integration
357
+ case config.integration
358
+ when :rspec
359
+ test_files = config.spec_files.empty? ? nil : config.spec_files
360
+ Evilution::Integration::RSpec.new(test_files: test_files)
361
+ else
362
+ raise Evilution::Error, "unknown integration: #{config.integration}"
302
363
  end
364
+ end
303
365
 
304
- def log_baseline_start
305
- return if config.quiet || !config.text? || !$stderr.tty?
366
+ def output_report(summary)
367
+ reporter = build_reporter
368
+ return unless reporter
306
369
 
307
- $stderr.write("Running baseline test suite...\n")
370
+ output = reporter.call(summary)
371
+ return if config.quiet
372
+
373
+ if config.html?
374
+ path = "evilution-report.html"
375
+ File.write(path, output)
376
+ warn "HTML report written to #{path}"
377
+ else
378
+ $stdout.puts(output)
308
379
  end
380
+ end
309
381
 
310
- def log_baseline_complete(result)
311
- return if config.quiet || !config.text? || !$stderr.tty?
382
+ def log_baseline_start
383
+ return if config.quiet || !config.text? || !$stderr.tty?
312
384
 
313
- count = result.failed_spec_files.size
314
- $stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
315
- end
385
+ $stderr.write("Running baseline test suite...\n")
386
+ end
316
387
 
317
- def log_progress(current, status)
318
- return if config.quiet || !config.text? || !$stderr.tty?
388
+ def log_baseline_complete(result)
389
+ return if config.quiet || !config.text? || !$stderr.tty?
319
390
 
320
- $stderr.write("mutation #{current} #{status}\n")
321
- end
391
+ count = result.failed_spec_files.size
392
+ $stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
393
+ end
394
+
395
+ def log_progress(current, status)
396
+ return if config.quiet || !config.text? || !$stderr.tty?
397
+
398
+ $stderr.write("mutation #{current} #{status}\n")
399
+ end
400
+
401
+ def log_memory(phase, context = nil)
402
+ return unless config.verbose && !config.quiet
403
+
404
+ rss = Evilution::Memory.rss_mb
405
+ return unless rss
406
+
407
+ gc = gc_stats_string
408
+ msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
409
+ context = [context, gc].compact.join(", ")
410
+ msg += " (#{context})" unless context.empty?
411
+ $stderr.write("#{msg}\n")
412
+ end
322
413
 
323
- def log_memory(phase, context = nil)
324
- return unless config.verbose && !config.quiet
414
+ def log_mutation_diagnostics(result)
415
+ return unless config.verbose && !config.quiet
325
416
 
326
- rss = Memory.rss_mb
327
- return unless rss
417
+ parts = []
418
+ parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
328
419
 
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")
420
+ if result.memory_delta_kb
421
+ sign = result.memory_delta_kb.negative? ? "" : "+"
422
+ parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
334
423
  end
335
424
 
336
- def log_mutation_diagnostics(result)
337
- return unless config.verbose && !config.quiet
425
+ parts << gc_stats_string
338
426
 
339
- parts = []
340
- parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
427
+ return if parts.empty?
341
428
 
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
429
+ $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n")
430
+ end
346
431
 
347
- parts << gc_stats_string
432
+ def gc_stats_string
433
+ stats = GC.stat
434
+ format(
435
+ "heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
436
+ live: stats[:heap_live_slots],
437
+ alloc: stats[:total_allocated_objects],
438
+ freed: stats[:total_freed_objects]
439
+ )
440
+ end
348
441
 
349
- return if parts.empty?
442
+ def save_session(summary)
443
+ return unless config.save_session?
350
444
 
351
- $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n")
352
- end
445
+ Evilution::Session::Store.new.save(summary)
446
+ rescue StandardError => e
447
+ warn "[evilution] failed to save session: #{e.message}" unless config.quiet
448
+ end
353
449
 
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
- )
450
+ def build_reporter
451
+ case config.format
452
+ when :json
453
+ Evilution::Reporter::JSON.new
454
+ when :text
455
+ Evilution::Reporter::CLI.new
456
+ when :html
457
+ Evilution::Reporter::HTML.new
362
458
  end
459
+ end
363
460
 
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
461
+ def partition_cached(batch)
462
+ uncached_indices = []
463
+ cached_results = {}
374
464
 
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
465
+ batch.each_with_index do |mutation, i|
466
+ cached = fetch_cached_result(mutation)
467
+ if cached
468
+ cached_results[i] = compact_result(cached)
469
+ else
470
+ uncached_indices << i
386
471
  end
387
-
388
- [uncached_indices, cached_results]
389
472
  end
390
473
 
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
474
+ [uncached_indices, cached_results]
475
+ end
396
476
 
397
- def execute_or_fetch(mutation)
398
- cached = fetch_cached_result(mutation)
399
- return cached if cached
477
+ def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
478
+ result_map = cached_results.dup
479
+ uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
480
+ batch.each_index.map { |i| result_map[i] }
481
+ end
400
482
 
401
- result = yield
402
- store_cached_result(mutation, result)
403
- result
404
- end
483
+ def execute_or_fetch(mutation)
484
+ cached = fetch_cached_result(mutation)
485
+ return cached if cached
405
486
 
406
- def fetch_cached_result(mutation)
407
- return nil unless cache
487
+ result = yield
488
+ store_cached_result(mutation, result)
489
+ result
490
+ end
408
491
 
409
- data = cache.fetch(mutation)
410
- return nil unless data
411
- return nil unless %i[killed timeout].include?(data[:status])
492
+ def fetch_cached_result(mutation)
493
+ return nil unless cache
412
494
 
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
495
+ data = cache.fetch(mutation)
496
+ return nil unless data
497
+ return nil unless %i[killed timeout].include?(data[:status])
421
498
 
422
- def store_cached_result(mutation, result)
423
- return unless cache
424
- return unless result.killed? || result.timeout?
499
+ Evilution::Result::MutationResult.new(
500
+ mutation: mutation,
501
+ status: data[:status],
502
+ duration: data[:duration],
503
+ killing_test: data[:killing_test],
504
+ test_command: data[:test_command]
505
+ )
506
+ end
425
507
 
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
508
+ def store_cached_result(mutation, result)
509
+ return unless cache
510
+ return unless result.killed? || result.timeout?
511
+
512
+ cache.store(mutation,
513
+ status: result.status,
514
+ duration: result.duration,
515
+ killing_test: result.killing_test,
516
+ test_command: result.test_command)
432
517
  end
433
518
  end