mutation_tester 1.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +83 -0
  5. data/LICENSE.txt +19 -0
  6. data/Rakefile +57 -0
  7. data/docs/ci.md +158 -0
  8. data/docs/execution-runners.md +163 -0
  9. data/docs/json-schema.md +171 -0
  10. data/docs/mutation-types.md +207 -0
  11. data/examples/calculator.rb +35 -0
  12. data/examples/calculator_100_perc_spec.rb +121 -0
  13. data/examples/calculator_minitest.rb +53 -0
  14. data/examples/calculator_spec.rb +105 -0
  15. data/examples/github_actions/ai_mutation_gate.yml +75 -0
  16. data/examples/github_actions/mutation_test.yml +87 -0
  17. data/examples/hooks/pre-push +41 -0
  18. data/examples/run_example.rb +31 -0
  19. data/examples/run_example_minitest.rb +38 -0
  20. data/examples/run_example_parallel_minitest.rb +48 -0
  21. data/examples/run_example_parallel_rspec.rb +48 -0
  22. data/examples/run_example_rspec.rb +38 -0
  23. data/examples/spec_helper.rb +21 -0
  24. data/exe/mutation_test +345 -0
  25. data/lib/mutation_tester/batch_runner.rb +286 -0
  26. data/lib/mutation_tester/configuration.rb +105 -0
  27. data/lib/mutation_tester/core.rb +193 -0
  28. data/lib/mutation_tester/fork_runner/worker.rb +202 -0
  29. data/lib/mutation_tester/fork_runner.rb +382 -0
  30. data/lib/mutation_tester/framework_detector.rb +25 -0
  31. data/lib/mutation_tester/in_memory_loader.rb +25 -0
  32. data/lib/mutation_tester/mutation_runner.rb +644 -0
  33. data/lib/mutation_tester/mutator.rb +874 -0
  34. data/lib/mutation_tester/progress_display.rb +130 -0
  35. data/lib/mutation_tester/railtie.rb +7 -0
  36. data/lib/mutation_tester/rake_task.rb +18 -0
  37. data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
  38. data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
  39. data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
  40. data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
  41. data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
  42. data/lib/mutation_tester/test_command.rb +165 -0
  43. data/lib/mutation_tester/version.rb +3 -0
  44. data/lib/mutation_tester.rb +52 -0
  45. data/lib/tasks/mutation_tester.rake +80 -0
  46. data/readme.md +925 -0
  47. metadata +213 -0
@@ -0,0 +1,644 @@
1
+ require 'tmpdir'
2
+ require 'pathname'
3
+ require 'securerandom'
4
+
5
+ module MutationTester
6
+ class MutationRunner
7
+ PARALLEL_INTERRUPT_LINE = "Parallel execution interrupted, exiting ...\n"
8
+
9
+ class ParallelInterruptFilter
10
+ def initialize(target)
11
+ @target = target
12
+ end
13
+
14
+ def write(*args)
15
+ return args.first.bytesize if args.length == 1 && args.first == PARALLEL_INTERRUPT_LINE
16
+
17
+ @target.write(*args)
18
+ end
19
+
20
+ def respond_to_missing?(name, include_private = false)
21
+ @target.respond_to?(name, include_private)
22
+ end
23
+
24
+ def method_missing(name, *args, &block)
25
+ @target.send(name, *args, &block)
26
+ end
27
+ end
28
+ private_constant :PARALLEL_INTERRUPT_LINE, :ParallelInterruptFilter
29
+
30
+ def initialize(source_file, spec_file, original_content, config)
31
+ @source_file = File.expand_path(source_file)
32
+ @spec_file = File.expand_path(spec_file)
33
+ @original_content = original_content
34
+ @config = config
35
+ @use_bundle_exec = TestCommand.use_bundle_exec?(@source_file)
36
+ end
37
+
38
+ def self.backup_path_for(source_file)
39
+ "#{File.expand_path(source_file)}.mutation_backup"
40
+ end
41
+
42
+ def self.recover_in_place_backup(source_file)
43
+ backup = backup_path_for(source_file)
44
+ return false unless File.exist?(backup)
45
+
46
+ File.write(File.expand_path(source_file), File.read(backup))
47
+ File.delete(backup)
48
+ true
49
+ end
50
+
51
+ def run(mutations, &progress_callback)
52
+ announce_worker_env_in_memory_opt_out
53
+ if in_memory_first? && @config.parallel_processes > 1
54
+ run_in_memory_parallel(mutations, &progress_callback)
55
+ elsif in_memory_first?
56
+ run_in_memory_series(mutations, &progress_callback)
57
+ elsif @config.parallel_processes == 1
58
+ run_in_place_series(mutations, &progress_callback)
59
+ else
60
+ run_in_shadow_parallel(mutations, &progress_callback)
61
+ end
62
+ end
63
+
64
+ def in_memory_first?
65
+ return false if @config.worker_env_var
66
+
67
+ %i[in_memory auto].include?(@config.runner)
68
+ end
69
+
70
+ def run_in_memory_series(mutations, &progress_callback)
71
+ blocker = prepare_in_memory_execution
72
+ return fall_back_to_file_based(blocker, mutations, &progress_callback) if blocker
73
+
74
+ warn '[MutationTester] In-memory execution selected (serial, zero file writes per mutant).'
75
+ announce_load_time_routing(mutations)
76
+ results = []
77
+ begin
78
+ mutations.each_with_index do |mutation, index|
79
+ unless @in_memory_runner&.ready?
80
+ return results + fall_back_to_file_based(
81
+ 'the in-memory worker terminated unexpectedly',
82
+ mutations.drop(index), completed: index, &progress_callback
83
+ )
84
+ end
85
+
86
+ result = run_single_mutation(mutation, :in_memory)
87
+ progress_callback.call(mutation, index + 1) if progress_callback
88
+ results << result
89
+ break if stop_early?(result)
90
+ end
91
+ ensure
92
+ shutdown_in_memory_worker
93
+ cleanup_shadow_workspaces
94
+ end
95
+ results
96
+ end
97
+
98
+ def run_in_memory_parallel(mutations, &progress_callback)
99
+ blocker = prepare_in_memory_execution
100
+ return fall_back_to_parallel_file_based(blocker, mutations, &progress_callback) if blocker
101
+
102
+ pool = ForkRunner.prepare_in_memory_pool([@config.parallel_processes, mutations.size].min, @in_memory_runner)
103
+ if pool.compact.empty?
104
+ return fall_back_to_parallel_file_based('the preloaded worker pool could not be cloned', mutations, &progress_callback)
105
+ end
106
+
107
+ warn "[MutationTester] In-memory execution selected (parallel, #{pool.size} preloaded workers, zero file writes per mutant)."
108
+ announce_load_time_routing(mutations)
109
+ total = mutations.size
110
+ completed_count = 0
111
+ collected = []
112
+ project_root = discoverable_project_root
113
+ reserve_fallback_shadow_root
114
+
115
+ report_progress = lambda do |_item, _index, result|
116
+ completed_count += 1
117
+ collected << result
118
+ if progress_callback && (completed_count.even? || completed_count == total)
119
+ progress_callback.call(nil, completed_count)
120
+ end
121
+ raise Parallel::Break if stop_early?(result)
122
+ end
123
+
124
+ mapped = with_parallel_interrupt_silenced do
125
+ Parallel.map(mutations, in_processes: pool.size, finish: report_progress) do |mutation|
126
+ run_single_mutation(mutation, :in_memory, project_root)
127
+ end
128
+ end
129
+ mapped || collected
130
+ ensure
131
+ ForkRunner.shutdown_in_memory_pool
132
+ shutdown_in_memory_worker
133
+ cleanup_shadow_workspaces
134
+ end
135
+
136
+ def run_in_shadow_parallel(mutations, &progress_callback)
137
+ total = mutations.size
138
+ completed_count = 0
139
+ collected = []
140
+ project_root = find_project_root
141
+ shadow_run_root
142
+ prepare_worker_preloads(total)
143
+
144
+ report_progress = lambda do |_item, _index, result|
145
+ completed_count += 1
146
+ collected << result
147
+ if progress_callback && (completed_count.even? || completed_count == total)
148
+ progress_callback.call(nil, completed_count)
149
+ end
150
+ raise Parallel::Break if stop_early?(result)
151
+ end
152
+
153
+ mapped = with_parallel_interrupt_silenced do
154
+ Parallel.map(mutations, in_processes: @config.parallel_processes, finish: report_progress) do |mutation|
155
+ run_single_mutation(mutation, :shadow, project_root)
156
+ end
157
+ end
158
+ mapped || collected
159
+ ensure
160
+ cleanup_shadow_workspaces
161
+ end
162
+
163
+ def run_in_place_series(mutations, &progress_callback)
164
+ write_in_place_backup
165
+ results = []
166
+ mutations.each_with_index do |mutation, index|
167
+ result = run_single_mutation(mutation, :in_place)
168
+ progress_callback.call(mutation, index + 1) if progress_callback
169
+ results << result
170
+ break if stop_early?(result)
171
+ end
172
+ results
173
+ ensure
174
+ restore_and_clear_in_place_backup
175
+ end
176
+
177
+ def run_single_mutation(mutation, strategy, project_root = nil)
178
+ {
179
+ id: mutation[:id],
180
+ type: mutation[:type],
181
+ line: mutation[:line],
182
+ file_path: @source_file,
183
+ original: mutation[:original],
184
+ mutated: mutation[:mutated],
185
+ source_line: mutation[:source_line],
186
+ mutated_line: mutation[:mutated_line],
187
+ killed: false,
188
+ timeout: false,
189
+ status: :survived,
190
+ description: mutation[:description]
191
+ }.tap do |result|
192
+ if unparseable?(mutation[:code])
193
+ mark_stillborn(result)
194
+ elsif strategy == :in_memory && mutation[:in_memory_safe] == false
195
+ run_mutation_load_time(mutation, result, project_root)
196
+ elsif strategy == :in_memory
197
+ run_mutation_in_memory(mutation, result, project_root)
198
+ elsif strategy == :in_place
199
+ run_mutation_in_place(mutation, result)
200
+ else
201
+ run_mutation_in_shadow(mutation, result, project_root)
202
+ end
203
+ end
204
+ end
205
+
206
+ def run_mutation_in_shadow(mutation, result, project_root)
207
+ shadow_root = worker_shadow_root(project_root)
208
+
209
+ relative_source = Pathname.new(@source_file).relative_path_from(Pathname.new(project_root)).to_s
210
+ shadow_source = File.join(shadow_root, relative_source)
211
+ relative_spec = Pathname.new(@spec_file).relative_path_from(Pathname.new(project_root)).to_s
212
+ shadow_spec = File.join(shadow_root, relative_spec)
213
+
214
+ begin
215
+ File.unlink(shadow_source) if File.symlink?(shadow_source)
216
+ File.write(shadow_source, mutation[:code])
217
+
218
+ outcome, phase = run_two_phase(mutation) do |example_filters|
219
+ run_specs_in_shadow(shadow_spec, shadow_root, example_filters: example_filters)
220
+ end
221
+ apply_outcome(result, outcome, phase)
222
+ ensure
223
+ restore_shadow_source(shadow_source, result)
224
+ end
225
+ rescue => e
226
+ mark_error(result, e)
227
+ end
228
+
229
+ def cleanup_shadow_workspaces
230
+ root = @shadow_run_root
231
+ return unless root
232
+
233
+ begin
234
+ FileUtils.remove_entry(root) if File.directory?(root)
235
+ rescue SystemCallError
236
+ nil
237
+ end
238
+ @shadow_run_root = nil
239
+ @worker_shadow_root = nil
240
+ end
241
+
242
+ def shadow_baseline_passes?
243
+ project_root = find_project_root
244
+
245
+ Dir.mktmpdir do |temp_dir|
246
+ shadow_root = File.join(temp_dir, 'shadow')
247
+ FileUtils.mkdir_p(shadow_root)
248
+ shadow_copy_project(project_root, shadow_root)
249
+
250
+ relative_source = Pathname.new(@source_file).relative_path_from(Pathname.new(project_root)).to_s
251
+ shadow_source = File.join(shadow_root, relative_source)
252
+ relative_spec = Pathname.new(@spec_file).relative_path_from(Pathname.new(project_root)).to_s
253
+ shadow_spec = File.join(shadow_root, relative_spec)
254
+
255
+ File.unlink(shadow_source)
256
+ File.write(shadow_source, @original_content)
257
+
258
+ run_specs_in_shadow(shadow_spec, shadow_root).passed?
259
+ end
260
+ rescue => e
261
+ warn("[MutationTester] Shadow sanity check could not prepare the shadow workspace: #{e.message}")
262
+ false
263
+ end
264
+
265
+ def shadow_copy_project(source, dest)
266
+ if source == '/' || source.match?(%r{^/(usr|bin|sbin|etc|var|opt)$})
267
+ raise MutationTester::Error, "Refusing to shadow copy system directory: #{source}"
268
+ end
269
+
270
+ Dir.glob("#{source}/*", File::FNM_DOTMATCH).each do |path|
271
+ next if ['.', '..', '.git', 'tmp', 'log', 'coverage', 'node_modules'].include?(File.basename(path))
272
+
273
+ basename = File.basename(path)
274
+ target = File.join(dest, basename)
275
+
276
+ if File.directory?(path) && !File.symlink?(path)
277
+ FileUtils.mkdir_p(target)
278
+ shadow_copy_project(path, target)
279
+ elsif File.extname(path) == '.rb'
280
+ FileUtils.copy_file(path, target)
281
+ else
282
+ File.symlink(path, target)
283
+ end
284
+ end
285
+ end
286
+
287
+ def run_specs_in_shadow(spec_file, working_dir, example_filters: [])
288
+ test_command(spec_file, example_filters: example_filters).run(timeout: @config.timeout, chdir: working_dir)
289
+ end
290
+
291
+ def discoverable_project_root
292
+ find_project_root
293
+ rescue MutationTester::Error
294
+ nil
295
+ end
296
+
297
+ def find_project_root
298
+ current = File.dirname(File.expand_path(@source_file))
299
+ loop do
300
+ return current if File.exist?(File.join(current, 'Gemfile')) || File.exist?(File.join(current, '.git'))
301
+
302
+ parent = File.dirname(current)
303
+
304
+ if parent == current || parent == Dir.home || parent == '/'
305
+ raise MutationTester::Error, "Could not find project root (looking for Gemfile or .git). Stopped at #{parent}"
306
+ end
307
+
308
+ current = parent
309
+ end
310
+ end
311
+
312
+ def run_mutation_in_place(mutation, result)
313
+
314
+ File.write(@source_file, mutation[:code])
315
+
316
+ outcome, phase = run_two_phase(mutation) do |example_filters|
317
+ run_specs_in_place(@spec_file, example_filters: example_filters)
318
+ end
319
+ apply_outcome(result, outcome, phase)
320
+ rescue StandardError, Interrupt => e
321
+ mark_error(result, e)
322
+ raise e if e.is_a?(Interrupt)
323
+ ensure
324
+ File.write(@source_file, @original_content)
325
+ end
326
+
327
+ def run_specs_in_place(spec_file, example_filters: [])
328
+ test_command(spec_file, example_filters: example_filters).run(timeout: @config.timeout)
329
+ end
330
+
331
+ def run_mutation_in_memory(mutation, result, project_root = nil)
332
+ runner = active_in_memory_runner
333
+ return in_memory_worker_fallback(mutation, result, project_root, 'the in-memory worker is unavailable') unless runner&.ready?
334
+
335
+ outcome = runner.execute_in_memory(
336
+ source: mutation[:code],
337
+ path: @source_file,
338
+ timeout: @config.timeout,
339
+ chdir: Dir.pwd
340
+ )
341
+
342
+ if outcome.status == 'error'
343
+ in_memory_apply_fallback(mutation, result, project_root, outcome.message || 'in-memory application failed')
344
+ else
345
+ apply_outcome(result, TestCommand::Result.new(outcome.status == 'pass', outcome.status == 'timeout'))
346
+ end
347
+ rescue MutationTester::Error => e
348
+ return in_memory_worker_fallback(mutation, result, project_root, e.message) if project_root
349
+
350
+ mark_error(result, e)
351
+ rescue StandardError => e
352
+ mark_error(result, e)
353
+ end
354
+
355
+ def run_two_phase(mutation)
356
+ filters = subset_filters(mutation)
357
+ unless filters.empty?
358
+ subset_outcome = yield(filters)
359
+ return [subset_outcome, :subset] unless subset_outcome.passed?
360
+ end
361
+
362
+ [yield([]), :full]
363
+ end
364
+
365
+ def subset_filters(mutation)
366
+ return [] unless @config.test_selection
367
+ return [] unless detect_test_framework(@spec_file) == :rspec
368
+
369
+ method_name = mutation[:method_name].to_s
370
+ return [] if method_name.empty?
371
+
372
+ content = spec_content
373
+ ["##{method_name}", ".#{method_name}"].select do |token|
374
+ content.match?(/['"]#{Regexp.escape(token)}/)
375
+ end
376
+ end
377
+
378
+ def detect_test_framework(spec_path)
379
+ FrameworkDetector.detect(spec_path)
380
+ end
381
+
382
+ private
383
+
384
+ def with_parallel_interrupt_silenced
385
+ previous_stderr = $stderr
386
+ $stderr = ParallelInterruptFilter.new(previous_stderr)
387
+ yield
388
+ ensure
389
+ $stderr = previous_stderr
390
+ end
391
+
392
+ def prepare_in_memory_execution
393
+ return 'the in-memory runner supports RSpec suites only' unless detect_test_framework(@spec_file) == :rspec
394
+ return 'Process.fork is not supported on this platform' unless ForkRunner.available?
395
+ if InMemoryLoader.load_time_defined_guard?(@original_content)
396
+ return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
397
+ end
398
+
399
+ runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec)
400
+ unless runner.ready?
401
+ runner.shutdown
402
+ return 'the in-memory worker failed to preload the environment'
403
+ end
404
+
405
+ preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd)
406
+ unless preloaded
407
+ runner.shutdown
408
+ return "the spec file could not be preloaded (#{message})"
409
+ end
410
+
411
+ probe = probe_in_memory_application(runner)
412
+ return probe if probe
413
+
414
+ @in_memory_runner = runner
415
+ nil
416
+ end
417
+
418
+ def probe_in_memory_application(runner)
419
+ outcome = runner.execute_in_memory(
420
+ source: @original_content,
421
+ path: @source_file,
422
+ timeout: @config.timeout,
423
+ chdir: Dir.pwd
424
+ )
425
+ return nil if outcome.status == 'pass'
426
+
427
+ runner.shutdown
428
+ case outcome.status
429
+ when 'error'
430
+ "re-applying the unmutated source in memory failed (#{outcome.message})"
431
+ when 'timeout'
432
+ 're-applying the unmutated source in memory exceeded the mutant deadline'
433
+ else
434
+ 'the test suite fails after the unmutated source is re-applied in memory (load-time side effects?)'
435
+ end
436
+ rescue MutationTester::Error => e
437
+ e.message
438
+ end
439
+
440
+ def fall_back_to_file_based(reason, mutations, completed: 0, &progress_callback)
441
+ announce_file_based_fallback(reason)
442
+ offset_callback = progress_callback && lambda do |mutation, index|
443
+ progress_callback.call(mutation, completed + index)
444
+ end
445
+ run_in_place_series(mutations, &offset_callback)
446
+ end
447
+
448
+ def fall_back_to_parallel_file_based(reason, mutations, &progress_callback)
449
+ announce_file_based_fallback(reason)
450
+ return run_in_shadow_parallel(mutations, &progress_callback) if shadow_baseline_passes?
451
+
452
+ warn '[MutationTester] The unmutated source fails inside the shadow workspace; finishing serially in place instead.'
453
+ run_in_place_series(mutations, &progress_callback)
454
+ end
455
+
456
+ def announce_file_based_fallback(reason)
457
+ label = test_command(@spec_file).fork_execution? ? 'fork' : 'spawn'
458
+ warn "[MutationTester] In-memory execution is unavailable: #{reason}. Falling back to file-based execution (#{label})."
459
+ end
460
+
461
+ def announce_worker_env_in_memory_opt_out
462
+ return unless @config.worker_env_var
463
+ return unless %i[in_memory auto].include?(@config.runner)
464
+
465
+ warn "[MutationTester] --worker-env #{@config.worker_env_var} is set, so the in-memory runner is skipped (its clones share one preloaded database connection); using the fork runner for per-worker database isolation."
466
+ end
467
+
468
+ def run_mutation_load_time(mutation, result, project_root)
469
+ root = project_root || discoverable_project_root
470
+ return mark_error(result, MutationTester::Error.new('a load-time mutant needs a project root for file-based execution')) unless root
471
+
472
+ run_mutation_in_shadow(mutation, result, root)
473
+ end
474
+
475
+ def announce_load_time_routing(mutations)
476
+ return unless mutations.any? { |mutation| mutation[:in_memory_safe] == false }
477
+
478
+ warn '[MutationTester] Some mutants affect load-time code (constants, class macros, included do); those run file-based so the in-memory score matches a full fork run.'
479
+ end
480
+
481
+ def in_memory_apply_fallback(mutation, result, project_root, reason)
482
+ root = project_root || discoverable_project_root
483
+ return mark_error(result, MutationTester::Error.new(reason)) unless root
484
+
485
+ unless @in_memory_apply_fallback_announced
486
+ @in_memory_apply_fallback_announced = true
487
+ warn "[MutationTester] A mutant could not be applied in memory (#{reason}); deciding each such mutant file-based."
488
+ end
489
+ run_mutation_in_shadow(mutation, result, root)
490
+ end
491
+
492
+ def in_memory_worker_fallback(mutation, result, project_root, reason)
493
+ return mark_error(result, MutationTester::Error.new(reason)) unless project_root
494
+
495
+ unless @in_memory_fallback_announced
496
+ @in_memory_fallback_announced = true
497
+ warn "[MutationTester] An in-memory worker became unavailable (#{reason}); finishing its share of mutants file-based."
498
+ end
499
+ run_mutation_in_shadow(mutation, result, project_root)
500
+ end
501
+
502
+ def active_in_memory_runner
503
+ return @in_memory_runner unless ForkRunner.in_memory_pool_prepared?
504
+
505
+ ForkRunner.checkout_in_memory
506
+ end
507
+
508
+ def reserve_fallback_shadow_root
509
+ @shadow_run_root ||= File.join(Dir.tmpdir, "mutation_tester_shadow-#{Process.pid}-#{SecureRandom.hex(8)}")
510
+ end
511
+
512
+ def shutdown_in_memory_worker
513
+ runner = @in_memory_runner
514
+ @in_memory_runner = nil
515
+ runner&.shutdown
516
+ end
517
+
518
+ def prepare_worker_preloads(total)
519
+ return unless test_command(@spec_file).fork_execution?
520
+
521
+ ForkRunner.prepare_pool(
522
+ [@config.parallel_processes, total].min,
523
+ use_bundle_exec: @use_bundle_exec,
524
+ env_for: worker_env_for
525
+ )
526
+ end
527
+
528
+ def worker_env_for
529
+ return nil unless @config.worker_env_var
530
+
531
+ ->(index) { @config.worker_env_assignment(index) }
532
+ end
533
+
534
+ def shadow_run_root
535
+ @shadow_run_root ||= Dir.mktmpdir('mutation_tester_shadow')
536
+ end
537
+
538
+ def worker_shadow_root(project_root)
539
+ if @worker_shadow_pid != Process.pid
540
+ @worker_shadow_pid = Process.pid
541
+ @worker_shadow_root = nil
542
+ end
543
+
544
+ @worker_shadow_root ||= build_worker_workspace(project_root)
545
+ end
546
+
547
+ def build_worker_workspace(project_root)
548
+ @worker_workspace_serial = @worker_workspace_serial.to_i + 1
549
+ root = File.join(shadow_run_root, "worker-#{Process.pid}-#{@worker_workspace_serial}")
550
+ FileUtils.mkdir_p(root)
551
+ shadow_copy_project(project_root, root)
552
+ root
553
+ end
554
+
555
+ def restore_shadow_source(shadow_source, result)
556
+ File.write(shadow_source, @original_content)
557
+ rescue StandardError => e
558
+ discard_worker_workspace
559
+ mark_error(result, e)
560
+ end
561
+
562
+ def discard_worker_workspace
563
+ root = @worker_shadow_root
564
+ @worker_shadow_root = nil
565
+ return unless root && File.directory?(root)
566
+
567
+ FileUtils.remove_entry(root)
568
+ rescue SystemCallError
569
+ nil
570
+ end
571
+
572
+ def stop_early?(result)
573
+ @config.fail_fast && result[:status] == :survived
574
+ end
575
+
576
+ def backup_path
577
+ self.class.backup_path_for(@source_file)
578
+ end
579
+
580
+ def write_in_place_backup
581
+ File.write(backup_path, @original_content)
582
+ end
583
+
584
+ def restore_and_clear_in_place_backup
585
+ return unless File.exist?(backup_path)
586
+
587
+ File.write(@source_file, File.read(backup_path))
588
+ File.delete(backup_path)
589
+ end
590
+
591
+ def apply_outcome(result, outcome, phase = nil)
592
+ if outcome.timed_out?
593
+ result[:killed] = true
594
+ result[:timeout] = true
595
+ result[:status] = :timeout
596
+ elsif outcome.passed?
597
+ result[:status] = :survived
598
+ else
599
+ result[:killed] = true
600
+ result[:status] = :killed
601
+ end
602
+ result[:kill_phase] = phase if result[:killed] && phase
603
+ end
604
+
605
+ def mark_stillborn(result)
606
+ result[:status] = :stillborn
607
+ end
608
+
609
+ def mark_error(result, error)
610
+ result[:killed] = false
611
+ result[:timeout] = false
612
+ result[:status] = :error
613
+ result[:description] = "Error: #{error.message}"
614
+ end
615
+
616
+ def unparseable?(code)
617
+ return false if code.nil?
618
+
619
+ buffer = Parser::Source::Buffer.new('(mutant)')
620
+ buffer.source = code
621
+ parser = Parser::CurrentRuby.new
622
+ parser.diagnostics.all_errors_are_fatal = true
623
+ parser.diagnostics.ignore_warnings = true
624
+ parser.parse(buffer)
625
+ false
626
+ rescue Parser::SyntaxError
627
+ true
628
+ end
629
+
630
+ def spec_content
631
+ @spec_content ||= File.exist?(@spec_file) ? File.read(@spec_file) : ''
632
+ end
633
+
634
+ def test_command(spec_file, example_filters: [])
635
+ TestCommand.new(
636
+ File.expand_path(spec_file),
637
+ use_bundle_exec: @use_bundle_exec,
638
+ runner: @config.runner,
639
+ example_filters: example_filters,
640
+ worker_env_var: @config.worker_env_var
641
+ )
642
+ end
643
+ end
644
+ end