evilution 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +210 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +81 -4
  5. data/exe/evil +6 -0
  6. data/lib/evilution/ast/source_surgeon.rb +15 -1
  7. data/lib/evilution/cli/commands/compare.rb +68 -0
  8. data/lib/evilution/cli/parser/command_extractor.rb +78 -0
  9. data/lib/evilution/cli/parser/file_args.rb +41 -0
  10. data/lib/evilution/cli/parser/options_builder.rb +123 -0
  11. data/lib/evilution/cli/parser/stdin_reader.rb +28 -0
  12. data/lib/evilution/cli/parser.rb +27 -196
  13. data/lib/evilution/cli/printers/compare.rb +159 -0
  14. data/lib/evilution/cli.rb +1 -0
  15. data/lib/evilution/compare/categorizer.rb +109 -0
  16. data/lib/evilution/compare/detector.rb +21 -0
  17. data/lib/evilution/compare/fingerprint.rb +83 -0
  18. data/lib/evilution/compare/normalizer.rb +106 -0
  19. data/lib/evilution/compare/record.rb +16 -0
  20. data/lib/evilution/compare.rb +15 -0
  21. data/lib/evilution/config.rb +178 -3
  22. data/lib/evilution/example_filter.rb +143 -0
  23. data/lib/evilution/integration/base.rb +11 -57
  24. data/lib/evilution/integration/crash_detector.rb +5 -2
  25. data/lib/evilution/integration/minitest.rb +25 -7
  26. data/lib/evilution/integration/minitest_crash_detector.rb +5 -2
  27. data/lib/evilution/integration/rspec.rb +99 -12
  28. data/lib/evilution/isolation/fork.rb +26 -0
  29. data/lib/evilution/isolation/in_process.rb +1 -0
  30. data/lib/evilution/mcp/info_tool.rb +77 -5
  31. data/lib/evilution/mcp/mutate_tool/config_builder.rb +20 -0
  32. data/lib/evilution/mcp/mutate_tool/error_payload.rb +17 -0
  33. data/lib/evilution/mcp/mutate_tool/option_parser.rb +54 -0
  34. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +37 -0
  35. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +31 -0
  36. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +52 -0
  37. data/lib/evilution/mcp/mutate_tool.rb +34 -186
  38. data/lib/evilution/mutation.rb +43 -3
  39. data/lib/evilution/mutator/base.rb +39 -1
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +5 -1
  41. data/lib/evilution/mutator/operator/argument_removal.rb +5 -1
  42. data/lib/evilution/parallel/work_queue.rb +149 -31
  43. data/lib/evilution/parallel_db_warning.rb +68 -0
  44. data/lib/evilution/reporter/cli.rb +38 -11
  45. data/lib/evilution/reporter/html/assets/style.css +85 -0
  46. data/lib/evilution/reporter/html/baseline_keys.rb +28 -0
  47. data/lib/evilution/reporter/html/diff_formatter.rb +27 -0
  48. data/lib/evilution/reporter/html/escape.rb +12 -0
  49. data/lib/evilution/reporter/html/namespace.rb +11 -0
  50. data/lib/evilution/reporter/html/report.rb +68 -0
  51. data/lib/evilution/reporter/html/section.rb +21 -0
  52. data/lib/evilution/reporter/html/sections/baseline_comparison.rb +46 -0
  53. data/lib/evilution/reporter/html/sections/error_details.rb +30 -0
  54. data/lib/evilution/reporter/html/sections/error_entry.rb +22 -0
  55. data/lib/evilution/reporter/html/sections/file_section.rb +62 -0
  56. data/lib/evilution/reporter/html/sections/header.rb +29 -0
  57. data/lib/evilution/reporter/html/sections/mutation_map.rb +32 -0
  58. data/lib/evilution/reporter/html/sections/neutral_details.rb +25 -0
  59. data/lib/evilution/reporter/html/sections/summary_cards.rb +11 -0
  60. data/lib/evilution/reporter/html/sections/survived_details.rb +35 -0
  61. data/lib/evilution/reporter/html/sections/survived_entry.rb +36 -0
  62. data/lib/evilution/reporter/html/sections/truncation_notice.rb +17 -0
  63. data/lib/evilution/reporter/html/sections/unparseable_details.rb +25 -0
  64. data/lib/evilution/reporter/html/sections/unresolved_details.rb +25 -0
  65. data/lib/evilution/reporter/html/sections.rb +4 -0
  66. data/lib/evilution/reporter/html/stylesheet.rb +14 -0
  67. data/lib/evilution/reporter/html/templates/baseline_comparison.html.erb +8 -0
  68. data/lib/evilution/reporter/html/templates/error_details.html.erb +6 -0
  69. data/lib/evilution/reporter/html/templates/error_entry.html.erb +10 -0
  70. data/lib/evilution/reporter/html/templates/file_section.html.erb +12 -0
  71. data/lib/evilution/reporter/html/templates/header.html.erb +4 -0
  72. data/lib/evilution/reporter/html/templates/mutation_map.html.erb +6 -0
  73. data/lib/evilution/reporter/html/templates/neutral_details.html.erb +14 -0
  74. data/lib/evilution/reporter/html/templates/report.html.erb +17 -0
  75. data/lib/evilution/reporter/html/templates/summary_cards.html.erb +26 -0
  76. data/lib/evilution/reporter/html/templates/survived_details.html.erb +21 -0
  77. data/lib/evilution/reporter/html/templates/survived_entry.html.erb +8 -0
  78. data/lib/evilution/reporter/html/templates/truncation_notice.html.erb +1 -0
  79. data/lib/evilution/reporter/html/templates/unparseable_details.html.erb +11 -0
  80. data/lib/evilution/reporter/html/templates/unresolved_details.html.erb +11 -0
  81. data/lib/evilution/reporter/html.rb +11 -390
  82. data/lib/evilution/reporter/json.rb +19 -9
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +28 -0
  84. data/lib/evilution/reporter/suggestion/registry.rb +64 -0
  85. data/lib/evilution/reporter/suggestion/templates/generic.rb +55 -0
  86. data/lib/evilution/reporter/suggestion/templates/minitest.rb +659 -0
  87. data/lib/evilution/reporter/suggestion/templates/rspec.rb +613 -0
  88. data/lib/evilution/reporter/suggestion.rb +8 -1327
  89. data/lib/evilution/result/mutation_result.rb +9 -1
  90. data/lib/evilution/result/summary.rb +21 -1
  91. data/lib/evilution/runner/baseline_runner.rb +92 -0
  92. data/lib/evilution/runner/diagnostics.rb +105 -0
  93. data/lib/evilution/runner/isolation_resolver.rb +134 -0
  94. data/lib/evilution/runner/mutation_executor.rb +325 -0
  95. data/lib/evilution/runner/mutation_planner.rb +126 -0
  96. data/lib/evilution/runner/report_publisher.rb +60 -0
  97. data/lib/evilution/runner/subject_pipeline.rb +121 -0
  98. data/lib/evilution/runner.rb +61 -692
  99. data/lib/evilution/source_ast_cache.rb +39 -0
  100. data/lib/evilution/spec_ast_cache.rb +166 -0
  101. data/lib/evilution/spec_resolver.rb +6 -1
  102. data/lib/evilution/spec_selector.rb +39 -0
  103. data/lib/evilution/temp_dir_tracker.rb +23 -3
  104. data/lib/evilution/version.rb +1 -1
  105. data/script/memory_check +7 -5
  106. metadata +75 -2
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "ast/parser"
5
- require_relative "ast/inheritance_scanner"
6
5
  require_relative "memory"
7
6
  require_relative "mutator/registry"
8
7
  require_relative "isolation/fork"
@@ -13,7 +12,6 @@ require_relative "reporter/json"
13
12
  require_relative "reporter/cli"
14
13
  require_relative "reporter/html"
15
14
  require_relative "reporter/suggestion"
16
- require_relative "equivalent/detector"
17
15
  require_relative "git/changed_files"
18
16
  require_relative "result/mutation_result"
19
17
  require_relative "result/summary"
@@ -21,23 +19,18 @@ require_relative "baseline"
21
19
  require_relative "cache"
22
20
  require_relative "parallel/pool"
23
21
  require_relative "session/store"
24
- require_relative "ast/pattern/filter"
25
22
  require_relative "temp_dir_tracker"
26
- require_relative "disable_comment"
27
- require_relative "ast/sorbet_sig_detector"
28
23
  require_relative "rails_detector"
24
+ require_relative "parallel_db_warning"
25
+ require_relative "runner/subject_pipeline"
26
+ require_relative "runner/mutation_planner"
27
+ require_relative "runner/isolation_resolver"
28
+ require_relative "runner/baseline_runner"
29
+ require_relative "runner/diagnostics"
30
+ require_relative "runner/mutation_executor"
31
+ require_relative "runner/report_publisher"
29
32
 
30
33
  class Evilution::Runner
31
- INTEGRATIONS = {
32
- rspec: Evilution::Integration::RSpec,
33
- minitest: Evilution::Integration::Minitest
34
- }.freeze
35
-
36
- PRELOAD_CANDIDATES = [
37
- File.join("spec", "rails_helper.rb"),
38
- File.join("test", "test_helper.rb")
39
- ].freeze
40
-
41
34
  attr_reader :config
42
35
 
43
36
  def initialize(config: Evilution::Config.new, on_result: nil, hooks: nil)
@@ -47,17 +40,14 @@ class Evilution::Runner
47
40
  @parser = Evilution::AST::Parser.new
48
41
  @registry = Evilution::Mutator::Registry.default
49
42
  @cache = config.incremental? ? Evilution::Cache.new : nil
50
- @disable_detector = Evilution::DisableComment.new
51
- @disabled_ranges_cache = {}
52
- @sig_detector = Evilution::AST::SorbetSigDetector.new
53
- @sig_ranges_cache = {}
54
43
  end
55
44
 
56
45
  def call
57
46
  install_signal_handlers
47
+ emit_parallel_db_warning
58
48
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
49
 
60
- subjects = parse_and_filter_subjects
50
+ subjects = subject_pipeline.call
61
51
  log_memory("after parse_subjects", "#{subjects.length} subjects")
62
52
 
63
53
  perform_preload
@@ -65,22 +55,18 @@ class Evilution::Runner
65
55
 
66
56
  baseline_result = run_baseline(subjects)
67
57
 
68
- mutations, skipped_count, disabled_mutations = generate_mutations(subjects)
69
- equivalent_mutations, mutations = filter_equivalent(mutations)
58
+ plan = mutation_planner.call(subjects)
70
59
  release_subject_nodes(subjects)
71
60
  clear_operator_caches
72
- results, truncated = run_mutations(mutations, baseline_result)
73
- results += equivalent_mutations.map do |m|
74
- m.strip_sources!
75
- equivalent_result(m)
76
- end
61
+ results, truncated = run_mutations(plan.enabled, baseline_result)
62
+ results += equivalent_results(plan.equivalent)
77
63
  log_memory("after run_mutations", "#{results.length} results")
78
64
 
79
65
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
80
66
 
81
67
  summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: truncated,
82
- skipped: skipped_count,
83
- disabled_mutations: disabled_mutations)
68
+ skipped: plan.skipped_count,
69
+ disabled_mutations: plan.disabled_mutations)
84
70
  output_report(summary)
85
71
  save_session(summary)
86
72
 
@@ -88,207 +74,39 @@ class Evilution::Runner
88
74
  end
89
75
 
90
76
  def parse_and_filter_subjects
91
- subjects = parse_subjects
92
- subjects = filter_by_descendants(subjects) if descendants_target?
93
- subjects = filter_by_target(subjects) if method_target?
94
- subjects = filter_by_line_ranges(subjects) if config.line_ranges?
95
- subjects
77
+ subject_pipeline.call
96
78
  end
97
79
 
98
80
  private
99
81
 
100
- attr_reader :parser, :registry, :cache, :on_result, :hooks, :disable_detector, :sig_detector
101
-
102
- def isolator
103
- @isolator ||= build_isolator
104
- end
105
-
106
- def parse_subjects
107
- files = resolve_target_files
108
- files.flat_map { |file| parser.call(file) }
109
- end
110
-
111
- def resolve_target_files
112
- @resolve_target_files ||= if source_glob_target?
113
- resolve_source_glob
114
- elsif !config.target_files.empty?
115
- config.target_files
116
- else
117
- Evilution::Git::ChangedFiles.new.call
118
- end
119
- end
120
-
121
- def source_glob_target?
122
- config.target&.start_with?("source:")
123
- end
124
-
125
- def descendants_target?
126
- config.target&.start_with?("descendants:")
127
- end
128
-
129
- def method_target?
130
- config.target? && !source_glob_target? && !descendants_target?
131
- end
132
-
133
- def resolve_source_glob
134
- pattern = config.target.delete_prefix("source:")
135
- files = Dir.glob(pattern)
136
- raise Evilution::Error, "no files found matching '#{pattern}'" if files.empty?
137
-
138
- files.sort
139
- end
140
-
141
- def filter_by_descendants(subjects)
142
- base_name = config.target.delete_prefix("descendants:")
143
- files = resolve_target_files
144
- inheritance = Evilution::AST::InheritanceScanner.call(files)
145
- class_names = resolve_descendant_set(base_name, inheritance)
146
- raise Evilution::Error, "no classes found matching '#{config.target}'" if class_names.empty?
147
-
148
- subjects.select { |s| class_names.include?(s.name.split(/[#.]/).first) }
149
- end
150
-
151
- def resolve_descendant_set(base_name, inheritance)
152
- descendants = Set.new
153
- known = inheritance.key?(base_name) || inheritance.value?(base_name)
154
- return descendants unless known
155
-
156
- descendants.add(base_name)
157
- changed = true
158
- while changed
159
- changed = false
160
- inheritance.each do |child, parent|
161
- next unless descendants.include?(parent)
162
- next if descendants.include?(child)
163
-
164
- descendants.add(child)
165
- changed = true
166
- end
167
- end
168
- descendants
169
- end
170
-
171
- def filter_by_target(subjects)
172
- matched = subjects.select(&target_matcher)
173
- raise Evilution::Error, "no method found matching '#{config.target}'" if matched.empty?
174
-
175
- matched
176
- end
177
-
178
- def target_matcher
179
- target = config.target
180
- if target.end_with?("*")
181
- prefix = target.chomp("*")
182
- ->(s) { s.name.split(/[#.]/).first.start_with?(prefix) }
183
- elsif target.end_with?("#", ".")
184
- prefix = target
185
- ->(s) { s.name.start_with?(prefix) }
186
- elsif target.include?("#") || target.include?(".")
187
- ->(s) { s.name == target }
188
- else
189
- ->(s) { s.name.start_with?("#{target}#") || s.name.start_with?("#{target}.") }
190
- end
191
- end
192
-
193
- def filter_by_line_ranges(subjects)
194
- subjects.select do |subject|
195
- range = config.line_ranges[subject.file_path]
196
- next true unless range
197
-
198
- subject_start = subject.line_number
199
- subject_end = subject_start + subject.source.count("\n")
200
- subject_start <= range.last && subject_end >= range.first
201
- end
202
- end
203
-
204
- def generate_mutations(subjects)
205
- filter = build_ignore_filter
206
- operator_options = build_operator_options
207
- mutations = subjects.flat_map do |subject|
208
- registry.mutations_for(subject, filter: filter, operator_options: operator_options)
209
- end
210
- skipped_count = filter ? filter.skipped_count : 0
211
-
212
- mutations, disabled = filter_disabled(mutations)
213
- disabled.each(&:strip_sources!) if config.show_disabled?
214
- disabled_mutations = config.show_disabled? ? disabled : []
215
-
216
- mutations, sig_skipped = filter_sig_blocks(mutations)
217
-
218
- [mutations, skipped_count + disabled.length + sig_skipped, disabled_mutations]
219
- end
220
-
221
- def filter_disabled(mutations)
222
- enabled = []
223
- disabled = []
224
-
225
- mutations.each do |mutation|
226
- if mutation_disabled?(mutation)
227
- disabled << mutation
228
- else
229
- enabled << mutation
230
- end
231
- end
232
-
233
- [enabled, disabled]
234
- end
235
-
236
- def mutation_disabled?(mutation)
237
- ranges = disabled_ranges_for(mutation.file_path)
238
- ranges.any? { |range| range.cover?(mutation.line) }
239
- end
240
-
241
- def disabled_ranges_for(file_path)
242
- @disabled_ranges_cache[file_path] ||= begin
243
- source = File.read(file_path)
244
- @disable_detector.call(source)
245
- rescue SystemCallError
246
- []
247
- end
248
- end
249
-
250
- def filter_sig_blocks(mutations)
251
- enabled = []
252
- skipped = 0
82
+ attr_reader :parser, :registry, :cache, :on_result, :hooks
253
83
 
254
- mutations.each do |mutation|
255
- if mutation_in_sig_block?(mutation)
256
- skipped += 1
257
- else
258
- enabled << mutation
259
- end
260
- end
261
-
262
- [enabled, skipped]
84
+ def subject_pipeline
85
+ @subject_pipeline ||= Evilution::Runner::SubjectPipeline.new(config, parser: parser)
263
86
  end
264
87
 
265
- def mutation_in_sig_block?(mutation)
266
- ranges = sig_line_ranges_for(mutation.file_path)
267
- ranges.any? { |range| range.cover?(mutation.line) }
88
+ def mutation_planner
89
+ @mutation_planner ||= Evilution::Runner::MutationPlanner.new(config, registry: registry)
268
90
  end
269
91
 
270
- def sig_line_ranges_for(file_path)
271
- @sig_ranges_cache[file_path] ||= begin
272
- source = File.read(file_path)
273
- @sig_detector.line_ranges(source)
274
- rescue SystemCallError
275
- []
276
- end
92
+ def isolation_resolver
93
+ @isolation_resolver ||= Evilution::Runner::IsolationResolver.new(
94
+ config,
95
+ target_files: -> { subject_pipeline.target_files },
96
+ hooks: @hooks
97
+ )
277
98
  end
278
99
 
279
- def build_operator_options
280
- { skip_heredoc_literals: config.skip_heredoc_literals? }
100
+ def isolator
101
+ isolation_resolver.isolator
281
102
  end
282
103
 
283
- def build_ignore_filter
284
- patterns = config.ignore_patterns
285
- return nil if patterns.nil? || patterns.empty?
286
-
287
- Evilution::AST::Pattern::Filter.new(patterns)
104
+ def rails_root_detected?
105
+ isolation_resolver.rails_root_detected?
288
106
  end
289
107
 
290
- def filter_equivalent(mutations)
291
- Evilution::Equivalent::Detector.new.call(mutations)
108
+ def perform_preload
109
+ isolation_resolver.perform_preload
292
110
  end
293
111
 
294
112
  def release_subject_nodes(subjects)
@@ -299,175 +117,43 @@ class Evilution::Runner
299
117
  Evilution::Mutator::Base.clear_parse_cache!
300
118
  end
301
119
 
302
- def equivalent_result(mutation)
303
- Evilution::Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
304
- end
305
-
306
- def run_baseline(subjects)
307
- return nil unless config.baseline? && subjects.any?
308
-
309
- log_baseline_start
310
- integration_class = resolve_integration_class
311
- baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
312
- result = baseline.call(subjects)
313
- log_baseline_complete(result)
314
- result
315
- end
316
-
317
- def run_mutations(mutations, baseline_result = nil)
318
- @progress_bar = build_progress_bar(mutations.length)
319
- result = if config.jobs > 1
320
- run_mutations_parallel(mutations, baseline_result)
321
- else
322
- run_mutations_sequential(mutations, baseline_result)
323
- end
324
- @progress_bar&.finish
325
- result
326
- end
327
-
328
- def run_mutations_sequential(mutations, baseline_result = nil)
329
- integration = build_integration
330
- spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
331
- results = []
332
- survived_count = 0
333
- truncated = false
334
-
335
- mutations.each_with_index do |mutation, index|
336
- result = execute_or_fetch(mutation) do
337
- test_command = ->(m) { integration.call(m) }
338
- isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
339
- end
120
+ def equivalent_results(mutations)
121
+ mutations.map do |mutation|
340
122
  mutation.strip_sources!
341
- result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
342
- results << result
343
- survived_count += 1 if result.survived?
344
- notify_result(result, index + 1)
345
-
346
- if config.fail_fast? && survived_count >= config.fail_fast
347
- truncated = true
348
- break
349
- end
123
+ Evilution::Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
350
124
  end
351
-
352
- [results, truncated]
353
125
  end
354
126
 
355
- def run_mutations_parallel(mutations, baseline_result = nil)
356
- integration = build_integration
357
- pool = Evilution::Parallel::Pool.new(size: config.jobs, hooks: @hooks, item_timeout: config.timeout ? config.timeout * 2 : nil)
358
- worker_isolator = build_isolator
359
- spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
360
- state = { results: [], survived_count: 0, truncated: false, completed: 0 }
361
-
362
- all_worker_stats = []
363
-
364
- mutations.each_slice(config.jobs) do |batch|
365
- break if state[:truncated]
366
-
367
- batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
368
- all_worker_stats.concat(pool.worker_stats)
369
- process_batch(batch_results, baseline_result, spec_resolver, state)
370
- end
371
-
372
- log_worker_stats(aggregate_worker_stats(all_worker_stats))
373
-
374
- [state[:results], state[:truncated]]
375
- end
376
-
377
- def run_parallel_batch(batch, pool, worker_isolator, integration)
378
- uncached_indices, cached_results = partition_cached(batch)
379
- worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
380
- compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
381
- batch.each(&:strip_sources!)
382
- batch_results = rebuild_results(batch, compact_results)
383
- batch_results.each { |r| store_cached_result(r.mutation, r) }
384
- batch_results
127
+ def baseline_runner
128
+ @baseline_runner ||= Evilution::Runner::BaselineRunner.new(config, hooks: @hooks)
385
129
  end
386
130
 
387
- def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
388
- return [] if uncached_indices.empty?
389
-
390
- uncached = uncached_indices.map { |i| batch[i] }
391
- pool.map(uncached) do |mutation|
392
- test_command = ->(m) { integration.call(m) }
393
- result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
394
- compact_result(result)
395
- end
396
- end
397
-
398
- def process_batch(batch_results, baseline_result, spec_resolver, state)
399
- batch_results.each do |result|
400
- result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
401
- state[:results] << result
402
- state[:survived_count] += 1 if result.survived?
403
- state[:completed] += 1
404
- notify_result(result, state[:completed])
405
- end
406
-
407
- log_memory("after batch", "#{state[:completed]} complete")
408
- state[:truncated] = true if should_truncate?(state[:survived_count])
131
+ def diagnostics
132
+ @diagnostics ||= Evilution::Runner::Diagnostics.new(config)
409
133
  end
410
134
 
411
- def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
412
- return result unless result.survived? && baseline_result && baseline_result.failed?
413
-
414
- if config.spec_files.any?
415
- neutralize = true
416
- else
417
- spec_file = spec_resolver.call(result.mutation.file_path) || neutralization_fallback_dir
418
- neutralize = baseline_result.failed_spec_files.include?(spec_file)
419
- end
420
- return result unless neutralize
421
-
422
- Evilution::Result::MutationResult.new(
423
- mutation: result.mutation,
424
- status: :neutral,
425
- duration: result.duration,
426
- test_command: result.test_command,
427
- child_rss_kb: result.child_rss_kb,
428
- memory_delta_kb: result.memory_delta_kb,
429
- parent_rss_kb: result.parent_rss_kb,
430
- error_message: result.error_message,
431
- error_class: result.error_class,
432
- error_backtrace: result.error_backtrace
135
+ def mutation_executor
136
+ @mutation_executor ||= Evilution::Runner::MutationExecutor.new(
137
+ config,
138
+ isolator: isolator,
139
+ baseline_runner: baseline_runner,
140
+ cache: cache,
141
+ hooks: @hooks,
142
+ diagnostics: diagnostics,
143
+ on_result: on_result
433
144
  )
434
145
  end
435
146
 
436
- def compact_result(result)
437
- {
438
- status: result.status,
439
- duration: result.duration,
440
- killing_test: result.killing_test,
441
- test_command: result.test_command,
442
- child_rss_kb: result.child_rss_kb,
443
- memory_delta_kb: result.memory_delta_kb,
444
- parent_rss_kb: result.parent_rss_kb,
445
- error_message: result.error_message,
446
- error_class: result.error_class,
447
- error_backtrace: result.error_backtrace
448
- }
147
+ def run_baseline(subjects)
148
+ baseline_runner.call(subjects)
449
149
  end
450
150
 
451
- def rebuild_results(batch, compact_results)
452
- batch.zip(compact_results).map do |mutation, data|
453
- Evilution::Result::MutationResult.new(
454
- mutation: mutation,
455
- status: data[:status],
456
- duration: data[:duration],
457
- killing_test: data[:killing_test],
458
- test_command: data[:test_command],
459
- child_rss_kb: data[:child_rss_kb],
460
- memory_delta_kb: data[:memory_delta_kb],
461
- parent_rss_kb: data[:parent_rss_kb],
462
- error_message: data[:error_message],
463
- error_class: data[:error_class],
464
- error_backtrace: data[:error_backtrace]
465
- )
466
- end
151
+ def run_mutations(mutations, baseline_result = nil)
152
+ mutation_executor.call(mutations, baseline_result)
467
153
  end
468
154
 
469
- def should_truncate?(survived_count)
470
- config.fail_fast? && survived_count >= config.fail_fast
155
+ def emit_parallel_db_warning
156
+ Evilution::ParallelDbWarning.warn_if_sqlite_parallel(config)
471
157
  end
472
158
 
473
159
  def install_signal_handlers
@@ -490,336 +176,19 @@ class Evilution::Runner
490
176
  end
491
177
  end
492
178
 
493
- def build_isolator
494
- case resolve_isolation
495
- when :fork then Evilution::Isolation::Fork.new(hooks: @hooks)
496
- when :in_process then Evilution::Isolation::InProcess.new
497
- end
498
- end
499
-
500
- def resolve_isolation
501
- case config.isolation
502
- when :fork
503
- :fork
504
- when :in_process
505
- warn_in_process_under_rails if rails_root_detected?
506
- :in_process
507
- else # :auto
508
- rails_root_detected? ? :fork : :in_process
509
- end
510
- end
511
-
512
- def rails_root_detected?
513
- return @rails_root_detected if defined?(@rails_root_detected)
514
-
515
- @rails_root_detected = !detected_rails_root.nil?
516
- end
517
-
518
- def detected_rails_root
519
- return @detected_rails_root if defined?(@detected_rails_root)
520
-
521
- @detected_rails_root = Evilution::RailsDetector.rails_root_for_any(resolve_target_files)
522
- end
523
-
524
- def perform_preload
525
- return if config.preload == false
526
- return unless resolve_isolation == :fork
527
-
528
- path = resolve_preload_path
529
- return unless path
530
-
531
- prepare_load_path_for_preload
532
- require File.expand_path(path)
533
- rescue ScriptError, StandardError => e
534
- raise Evilution::ConfigError.new(
535
- "failed to preload #{path.inspect}: #{e.class}: #{e.message}",
536
- file: path
537
- )
538
- end
539
-
540
- # Preload files (e.g. spec/rails_helper.rb) typically `require 'spec_helper'`
541
- # which needs spec/ on $LOAD_PATH, and use `RSpec.configure` which needs
542
- # rspec/core loaded. The RSpec CLI normally sets this up, but evilution
543
- # calls Runner.run directly.
544
- def prepare_load_path_for_preload
545
- spec_dir = File.expand_path(resolve_spec_dir)
546
- $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
547
- require "rspec/core" if config.integration == :rspec
548
- end
549
-
550
- def resolve_spec_dir
551
- root = detected_rails_root
552
- return File.join(root, "spec") if root
553
-
554
- "spec"
555
- end
556
-
557
- def resolve_preload_path
558
- if config.preload.is_a?(String)
559
- unless File.file?(config.preload)
560
- raise Evilution::ConfigError.new(
561
- "preload file not found: #{config.preload.inspect}",
562
- file: config.preload
563
- )
564
- end
565
- return config.preload
566
- end
567
-
568
- root = detected_rails_root
569
- return nil unless root
570
-
571
- PRELOAD_CANDIDATES.each do |rel|
572
- abs = File.join(root, rel)
573
- return abs if File.file?(abs)
574
- end
575
- nil
576
- end
577
-
578
- # When the user explicitly requests InProcess on a Rails project, warn once
579
- # per run. Rails wraps ActiveRecord transactions in
580
- # Thread.handle_interrupt(Exception => :never), which defers Timeout's
581
- # Thread#raise indefinitely — making InProcess unable to kill runaway mutants.
582
- def warn_in_process_under_rails
583
- return if config.quiet
584
- return if @warned_in_process_under_rails
585
-
586
- @warned_in_process_under_rails = true
587
- $stderr.write(
588
- "[evilution] warning: --isolation in_process is unsafe on Rails projects. " \
589
- "ActiveRecord wraps transactions in Thread.handle_interrupt(Exception => :never), " \
590
- "which swallows Timeout.timeout and can cause evilution to hang indefinitely on " \
591
- "mutants that introduce infinite loops. Use --isolation fork for reliable interruption.\n"
592
- )
593
- end
594
-
595
- def resolve_integration_class
596
- INTEGRATIONS.fetch(config.integration) do
597
- raise Evilution::Error, "unknown integration: #{config.integration}"
598
- end
599
- end
600
-
601
- def build_integration
602
- klass = resolve_integration_class
603
- test_files = config.spec_files.empty? ? nil : config.spec_files
604
- kwargs = { test_files: test_files, hooks: @hooks }
605
- kwargs[:related_specs_heuristic] = config.related_specs_heuristic? if klass == Evilution::Integration::RSpec
606
- klass.new(**kwargs)
607
- end
608
-
609
- def build_neutralization_resolver
610
- options = resolve_integration_class.baseline_options
611
- options[:spec_resolver] || Evilution::SpecResolver.new
612
- end
613
-
614
- def neutralization_fallback_dir
615
- options = resolve_integration_class.baseline_options
616
- options[:fallback_dir] || "spec"
179
+ def report_publisher
180
+ @report_publisher ||= Evilution::Runner::ReportPublisher.new(config)
617
181
  end
618
182
 
619
183
  def output_report(summary)
620
- reporter = build_reporter
621
- return unless reporter
622
-
623
- output = reporter.call(summary)
624
- return if config.quiet
625
-
626
- if config.html?
627
- path = "evilution-report.html"
628
- File.write(path, output)
629
- warn "HTML report written to #{path}"
630
- else
631
- $stdout.puts(output)
632
- end
633
- end
634
-
635
- def log_baseline_start
636
- return if config.quiet || !config.text? || !$stderr.tty?
637
-
638
- $stderr.write("Running baseline test suite...\n")
639
- end
640
-
641
- def log_baseline_complete(result)
642
- return if config.quiet || !config.text? || !$stderr.tty?
643
-
644
- count = result.failed_spec_files.size
645
- $stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
646
- end
647
-
648
- def log_progress(current, status)
649
- return if config.quiet || !config.text? || !$stderr.tty?
650
-
651
- $stderr.write("mutation #{current} #{status}\n")
652
- end
653
-
654
- def log_memory(phase, context = nil)
655
- return unless config.verbose && !config.quiet
656
-
657
- rss = Evilution::Memory.rss_mb
658
- return unless rss
659
-
660
- gc = gc_stats_string
661
- msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
662
- context = [context, gc].compact.join(", ")
663
- msg += " (#{context})" unless context.empty?
664
- $stderr.write("#{msg}\n")
665
- end
666
-
667
- def log_mutation_diagnostics(result)
668
- return unless config.verbose && !config.quiet
669
-
670
- parts = []
671
- parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
672
-
673
- if result.memory_delta_kb
674
- sign = result.memory_delta_kb.negative? ? "" : "+"
675
- parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
676
- end
677
-
678
- parts << gc_stats_string
679
-
680
- $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n") unless parts.empty?
681
-
682
- log_mutation_error(result) if result.error?
683
- end
684
-
685
- def log_mutation_error(result)
686
- header = "[verbose] #{result.mutation}: error"
687
- header += " #{result.error_class}" if result.error_class
688
- header += ": #{result.error_message}" if result.error_message
689
- $stderr.write("#{header}\n")
690
-
691
- Array(result.error_backtrace).first(5).each do |line|
692
- $stderr.write("[verbose] #{line}\n")
693
- end
694
- end
695
-
696
- def gc_stats_string
697
- stats = GC.stat
698
- format(
699
- "heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
700
- live: stats[:heap_live_slots],
701
- alloc: stats[:total_allocated_objects],
702
- freed: stats[:total_freed_objects]
703
- )
184
+ report_publisher.publish(summary)
704
185
  end
705
186
 
706
187
  def save_session(summary)
707
- return unless config.save_session?
708
-
709
- Evilution::Session::Store.new.save(summary)
710
- rescue StandardError => e
711
- warn "[evilution] failed to save session: #{e.message}" unless config.quiet
712
- end
713
-
714
- def log_worker_stats(stats)
715
- return unless config.verbose && !config.quiet && stats.any?
716
-
717
- stats.each do |stat|
718
- pct = format("%.1f", stat.utilization * 100)
719
- $stderr.write("[verbose] worker #{stat.pid}: #{stat.items_completed} items, utilization #{pct}%\n")
720
- end
721
- end
722
-
723
- def aggregate_worker_stats(stats)
724
- return stats if stats.empty?
725
-
726
- stats.group_by(&:pid).map do |pid, entries|
727
- Evilution::Parallel::WorkQueue::WorkerStat.new(
728
- pid,
729
- entries.sum(&:items_completed),
730
- entries.sum(&:busy_time),
731
- entries.sum(&:wall_time)
732
- )
733
- end
734
- end
735
-
736
- def notify_result(result, index)
737
- on_result&.call(result)
738
- @progress_bar&.tick(status: result.status)
739
- log_progress(index, result.status)
740
- log_mutation_diagnostics(result)
741
- end
742
-
743
- def build_progress_bar(total)
744
- return nil if !config.progress? || config.quiet || config.verbose || !config.text? || !$stderr.tty?
745
-
746
- Evilution::Reporter::ProgressBar.new(total: total, output: $stderr)
188
+ report_publisher.save_session(summary)
747
189
  end
748
190
 
749
- def build_reporter
750
- case config.format
751
- when :json
752
- Evilution::Reporter::JSON.new(integration: config.integration)
753
- when :text
754
- Evilution::Reporter::CLI.new
755
- when :html
756
- Evilution::Reporter::HTML.new(baseline: load_baseline_session, integration: config.integration)
757
- end
758
- end
759
-
760
- def load_baseline_session
761
- path = config.baseline_session
762
- return nil unless path
763
-
764
- store = Evilution::Session::Store.new
765
- store.load(path)
766
- end
767
-
768
- def partition_cached(batch)
769
- uncached_indices = []
770
- cached_results = {}
771
-
772
- batch.each_with_index do |mutation, i|
773
- cached = fetch_cached_result(mutation)
774
- if cached
775
- cached_results[i] = compact_result(cached)
776
- else
777
- uncached_indices << i
778
- end
779
- end
780
-
781
- [uncached_indices, cached_results]
782
- end
783
-
784
- def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
785
- result_map = cached_results.dup
786
- uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
787
- batch.each_index.map { |i| result_map[i] }
788
- end
789
-
790
- def execute_or_fetch(mutation)
791
- cached = fetch_cached_result(mutation)
792
- return cached if cached
793
-
794
- result = yield
795
- store_cached_result(mutation, result)
796
- result
797
- end
798
-
799
- def fetch_cached_result(mutation)
800
- return nil unless cache
801
-
802
- data = cache.fetch(mutation)
803
- return nil unless data
804
- return nil unless %i[killed timeout].include?(data[:status])
805
-
806
- Evilution::Result::MutationResult.new(
807
- mutation: mutation,
808
- status: data[:status],
809
- duration: data[:duration],
810
- killing_test: data[:killing_test],
811
- test_command: data[:test_command]
812
- )
813
- end
814
-
815
- def store_cached_result(mutation, result)
816
- return unless cache
817
- return unless result.killed? || result.timeout?
818
-
819
- cache.store(mutation,
820
- status: result.status,
821
- duration: result.duration,
822
- killing_test: result.killing_test,
823
- test_command: result.test_command)
191
+ def log_memory(phase, context = nil)
192
+ diagnostics.log_memory(phase, context)
824
193
  end
825
194
  end