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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Equivalent
4
+ end
@@ -2,53 +2,51 @@
2
2
 
3
3
  require "English"
4
4
 
5
- module Evilution
6
- module Git
7
- class ChangedFiles
8
- MAIN_BRANCHES = %w[main master origin/main origin/master].freeze
9
- SOURCE_PREFIXES = %w[lib/ app/].freeze
5
+ require_relative "../git"
10
6
 
11
- def call
12
- main_branch = detect_main_branch
13
- merge_base = run_git("merge-base", "HEAD", main_branch)
14
- diff_output = run_git("diff", "--name-only", "--diff-filter=ACMR", "#{merge_base}..HEAD")
7
+ class Evilution::Git::ChangedFiles
8
+ MAIN_BRANCHES = %w[main master origin/main origin/master].freeze
9
+ SOURCE_PREFIXES = %w[lib/ app/].freeze
15
10
 
16
- files = diff_output.split("\n").select { |f| ruby_source_file?(f) }
17
- raise Error, "no changed Ruby files found since merge base with #{main_branch}" if files.empty?
11
+ def call
12
+ main_branch = detect_main_branch
13
+ merge_base = run_git("merge-base", "HEAD", main_branch)
14
+ diff_output = run_git("diff", "--name-only", "--diff-filter=ACMR", "#{merge_base}..HEAD")
18
15
 
19
- files
20
- end
16
+ files = diff_output.split("\n").select { |f| ruby_source_file?(f) }
17
+ raise Evilution::Error, "no changed Ruby files found since merge base with #{main_branch}" if files.empty?
21
18
 
22
- private
19
+ files
20
+ end
23
21
 
24
- def detect_main_branch
25
- MAIN_BRANCHES.each do |branch|
26
- return branch if branch_exists?(branch)
27
- end
22
+ private
28
23
 
29
- raise Error, "could not detect main branch (tried #{MAIN_BRANCHES.join(", ")})"
30
- end
24
+ def detect_main_branch
25
+ MAIN_BRANCHES.each do |branch|
26
+ return branch if branch_exists?(branch)
27
+ end
31
28
 
32
- def branch_exists?(name)
33
- run_git("rev-parse", "--verify", name)
34
- true
35
- rescue Error => e
36
- raise if e.message.include?("not a git repository")
29
+ raise Evilution::Error, "could not detect main branch (tried #{MAIN_BRANCHES.join(", ")})"
30
+ end
37
31
 
38
- false
39
- end
32
+ def branch_exists?(name)
33
+ run_git("rev-parse", "--verify", name)
34
+ true
35
+ rescue Evilution::Error => e
36
+ raise if e.message.include?("not a git repository")
40
37
 
41
- def ruby_source_file?(path)
42
- path.end_with?(".rb") && SOURCE_PREFIXES.any? { |prefix| path.start_with?(prefix) }
43
- end
38
+ false
39
+ end
44
40
 
45
- def run_git(*args)
46
- output = `git #{args.join(" ")} 2>&1`.strip
47
- raise Error, "not a git repository" if output.include?("not a git repository")
48
- raise Error, "git command failed: git #{args.join(" ")}: #{output}" unless $CHILD_STATUS.success?
41
+ def ruby_source_file?(path)
42
+ path.end_with?(".rb") && SOURCE_PREFIXES.any? { |prefix| path.start_with?(prefix) }
43
+ end
49
44
 
50
- output
51
- end
52
- end
45
+ def run_git(*args)
46
+ output = `git #{args.join(" ")} 2>&1`.strip
47
+ raise Evilution::Error, "not a git repository" if output.include?("not a git repository")
48
+ raise Evilution::Error, "git command failed: git #{args.join(" ")}: #{output}" unless $CHILD_STATUS.success?
49
+
50
+ output
53
51
  end
54
52
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Git
4
+ end
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Integration
5
- class Base
6
- def call(mutation)
7
- raise NotImplementedError, "#{self.class}#call must be implemented"
8
- end
9
- end
3
+ require_relative "../integration"
4
+
5
+ class Evilution::Integration::Base
6
+ def call(mutation)
7
+ raise NotImplementedError, "#{self.class}#call must be implemented"
10
8
  end
11
9
  end
@@ -6,122 +6,120 @@ require "tmpdir"
6
6
  require_relative "base"
7
7
  require_relative "../spec_resolver"
8
8
 
9
- module Evilution
10
- module Integration
11
- class RSpec < Base
12
- def initialize(test_files: nil)
13
- @test_files = test_files
14
- @rspec_loaded = false
15
- super()
16
- end
17
-
18
- def call(mutation)
19
- @original_content = nil
20
- @temp_dir = nil
21
- @lock_file = nil
22
- ensure_rspec_loaded
23
- apply_mutation(mutation)
24
- run_rspec(mutation)
25
- ensure
26
- restore_original(mutation)
27
- end
28
-
29
- private
30
-
31
- attr_reader :test_files
32
-
33
- def ensure_rspec_loaded
34
- return if @rspec_loaded
35
-
36
- require "rspec/core"
37
- @rspec_loaded = true
38
- rescue LoadError => e
39
- raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
40
- end
41
-
42
- def apply_mutation(mutation)
43
- subpath = resolve_require_subpath(mutation.file_path)
44
-
45
- if subpath
46
- @temp_dir = Dir.mktmpdir("evilution")
47
- dest = File.join(@temp_dir, subpath)
48
- FileUtils.mkdir_p(File.dirname(dest))
49
- File.write(dest, mutation.mutated_source)
50
- $LOAD_PATH.unshift(@temp_dir)
51
- else
52
- # Fallback: direct write when file isn't under any $LOAD_PATH entry.
53
- # Acquire an exclusive lock to prevent concurrent workers from corrupting the file.
54
- lock_path = File.join(Dir.tmpdir, "evilution-#{File.expand_path(mutation.file_path).hash.abs}.lock")
55
- @lock_file = File.open(lock_path, File::CREAT | File::RDWR) # rubocop:disable Style/FileOpen
56
- @lock_file.flock(File::LOCK_EX)
57
- @original_content = File.read(mutation.file_path)
58
- File.write(mutation.file_path, mutation.mutated_source)
59
- end
60
- end
61
-
62
- def restore_original(mutation)
63
- if @temp_dir
64
- $LOAD_PATH.delete(@temp_dir)
65
- $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
66
- FileUtils.rm_rf(@temp_dir)
67
- @temp_dir = nil
68
- elsif @original_content
69
- File.write(mutation.file_path, @original_content)
70
- @lock_file&.flock(File::LOCK_UN)
71
- @lock_file&.close
72
- @lock_file = nil
73
- end
74
- end
75
-
76
- def resolve_require_subpath(file_path)
77
- absolute = File.expand_path(file_path)
78
-
79
- $LOAD_PATH.each do |entry|
80
- dir = File.expand_path(entry)
81
- prefix = dir.end_with?("/") ? dir : "#{dir}/"
82
- next unless absolute.start_with?(prefix)
83
-
84
- return absolute.delete_prefix(prefix)
85
- end
86
-
87
- nil
88
- end
89
-
90
- def run_rspec(mutation)
91
- # When used via the Runner with Isolation::Fork, each mutation is executed
92
- # in its own forked child process, so RSpec state (loaded example groups,
93
- # world, configuration) cannot accumulate across mutation runs — the child
94
- # process exits after each run.
95
- #
96
- # This integration can also be invoked directly (e.g. in specs or alternative
97
- # runners) without fork isolation. RSpec.reset is called here as
98
- # defense-in-depth to clear RSpec state between mutation runs in those cases.
99
- ::RSpec.reset
100
-
101
- out = StringIO.new
102
- err = StringIO.new
103
- command = "rspec"
104
- args = build_args(mutation)
105
- command = "rspec #{args.join(" ")}"
106
-
107
- status = ::RSpec::Core::Runner.run(args, out, err)
108
-
109
- { passed: status.zero?, test_command: command }
110
- rescue StandardError => e
111
- { passed: false, error: e.message, test_command: command }
112
- end
113
-
114
- def build_args(mutation)
115
- files = resolve_test_files(mutation)
116
- ["--format", "progress", "--no-color", "--order", "defined", *files]
117
- end
118
-
119
- def resolve_test_files(mutation)
120
- return test_files if test_files
121
-
122
- resolved = SpecResolver.new.call(mutation.file_path)
123
- resolved ? [resolved] : ["spec"]
124
- end
9
+ require_relative "../integration"
10
+
11
+ class Evilution::Integration::RSpec < Evilution::Integration::Base
12
+ def initialize(test_files: nil)
13
+ @test_files = test_files
14
+ @rspec_loaded = false
15
+ super()
16
+ end
17
+
18
+ def call(mutation)
19
+ @original_content = nil
20
+ @temp_dir = nil
21
+ @lock_file = nil
22
+ ensure_rspec_loaded
23
+ apply_mutation(mutation)
24
+ run_rspec(mutation)
25
+ ensure
26
+ restore_original(mutation)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :test_files
32
+
33
+ def ensure_rspec_loaded
34
+ return if @rspec_loaded
35
+
36
+ require "rspec/core"
37
+ @rspec_loaded = true
38
+ rescue LoadError => e
39
+ raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
40
+ end
41
+
42
+ def apply_mutation(mutation)
43
+ subpath = resolve_require_subpath(mutation.file_path)
44
+
45
+ if subpath
46
+ @temp_dir = Dir.mktmpdir("evilution")
47
+ dest = File.join(@temp_dir, subpath)
48
+ FileUtils.mkdir_p(File.dirname(dest))
49
+ File.write(dest, mutation.mutated_source)
50
+ $LOAD_PATH.unshift(@temp_dir)
51
+ else
52
+ # Fallback: direct write when file isn't under any $LOAD_PATH entry.
53
+ # Acquire an exclusive lock to prevent concurrent workers from corrupting the file.
54
+ lock_path = File.join(Dir.tmpdir, "evilution-#{File.expand_path(mutation.file_path).hash.abs}.lock")
55
+ @lock_file = File.open(lock_path, File::CREAT | File::RDWR)
56
+ @lock_file.flock(File::LOCK_EX)
57
+ @original_content = File.read(mutation.file_path)
58
+ File.write(mutation.file_path, mutation.mutated_source)
59
+ end
60
+ end
61
+
62
+ def restore_original(mutation)
63
+ if @temp_dir
64
+ $LOAD_PATH.delete(@temp_dir)
65
+ $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
66
+ FileUtils.rm_rf(@temp_dir)
67
+ @temp_dir = nil
68
+ elsif @original_content
69
+ File.write(mutation.file_path, @original_content)
70
+ @lock_file&.flock(File::LOCK_UN)
71
+ @lock_file&.close
72
+ @lock_file = nil
125
73
  end
126
74
  end
75
+
76
+ def resolve_require_subpath(file_path)
77
+ absolute = File.expand_path(file_path)
78
+
79
+ $LOAD_PATH.each do |entry|
80
+ dir = File.expand_path(entry)
81
+ prefix = dir.end_with?("/") ? dir : "#{dir}/"
82
+ next unless absolute.start_with?(prefix)
83
+
84
+ return absolute.delete_prefix(prefix)
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ def run_rspec(mutation)
91
+ # When used via the Runner with Isolation::Fork, each mutation is executed
92
+ # in its own forked child process, so RSpec state (loaded example groups,
93
+ # world, configuration) cannot accumulate across mutation runs — the child
94
+ # process exits after each run.
95
+ #
96
+ # This integration can also be invoked directly (e.g. in specs or alternative
97
+ # runners) without fork isolation. RSpec.reset is called here as
98
+ # defense-in-depth to clear RSpec state between mutation runs in those cases.
99
+ ::RSpec.reset
100
+
101
+ out = StringIO.new
102
+ err = StringIO.new
103
+ command = "rspec"
104
+ args = build_args(mutation)
105
+ command = "rspec #{args.join(" ")}"
106
+
107
+ status = ::RSpec::Core::Runner.run(args, out, err)
108
+
109
+ { passed: status.zero?, test_command: command }
110
+ rescue StandardError => e
111
+ { passed: false, error: e.message, test_command: command }
112
+ end
113
+
114
+ def build_args(mutation)
115
+ files = resolve_test_files(mutation)
116
+ ["--format", "progress", "--no-color", "--order", "defined", *files]
117
+ end
118
+
119
+ def resolve_test_files(mutation)
120
+ return test_files if test_files
121
+
122
+ resolved = Evilution::SpecResolver.new.call(mutation.file_path)
123
+ resolved ? [resolved] : ["spec"]
124
+ end
127
125
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::Integration
4
+ end
@@ -4,106 +4,104 @@ require "fileutils"
4
4
  require "tmpdir"
5
5
  require_relative "../memory"
6
6
 
7
- module Evilution
8
- module Isolation
9
- class Fork
10
- GRACE_PERIOD = 2
11
-
12
- def call(mutation:, test_command:, timeout:)
13
- sandbox_dir = Dir.mktmpdir("evilution-run")
14
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
- read_io, write_io = IO.pipe
16
-
17
- pid = ::Process.fork do
18
- ENV["TMPDIR"] = sandbox_dir
19
- read_io.close
20
- suppress_child_output
21
- result = execute_in_child(mutation, test_command)
22
- Marshal.dump(result, write_io)
23
- write_io.close
24
- exit!(result[:passed] ? 0 : 1)
25
- end
26
-
27
- write_io.close
28
- result = wait_for_result(pid, read_io, timeout)
29
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
30
-
31
- build_mutation_result(mutation, result, duration)
32
- ensure
33
- read_io&.close
34
- write_io&.close
35
- restore_original_source(mutation)
36
- FileUtils.rm_rf(sandbox_dir) if sandbox_dir
37
- end
38
-
39
- private
40
-
41
- def restore_original_source(mutation)
42
- return if File.read(mutation.file_path) == mutation.original_source
43
-
44
- File.write(mutation.file_path, mutation.original_source)
45
- rescue StandardError => e
46
- warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
47
- end
48
-
49
- def suppress_child_output
50
- $stdout.reopen(File::NULL, "w")
51
- $stderr.reopen(File::NULL, "w")
52
- end
53
-
54
- def execute_in_child(mutation, test_command)
55
- result = test_command.call(mutation)
56
- { child_rss_kb: Memory.rss_kb }.merge(result)
57
- rescue StandardError => e
58
- { passed: false, error: e.message }
59
- end
60
-
61
- def wait_for_result(pid, read_io, timeout)
62
- if read_io.wait_readable(timeout)
63
- data = read_io.read
64
- ::Process.wait(pid)
65
- return { timeout: false }.merge(Marshal.load(data)) unless data.empty? # rubocop:disable Security/MarshalLoad
66
-
67
- ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
68
- { timeout: false, passed: false, error: "empty result from child" }
69
- else
70
- terminate_child(pid)
71
- { timeout: true }
72
- end
73
- end
74
-
75
- def terminate_child(pid)
76
- ::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
77
- _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
78
- return if status
79
-
80
- sleep(GRACE_PERIOD)
81
- _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
82
- return if status
83
-
84
- ::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
85
- ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
86
- end
87
-
88
- def build_mutation_result(mutation, result, duration)
89
- status = if result[:timeout]
90
- :timeout
91
- elsif result[:error]
92
- :error
93
- elsif result[:passed]
94
- :survived
95
- else
96
- :killed
97
- end
98
-
99
- Result::MutationResult.new(
100
- mutation: mutation,
101
- status: status,
102
- duration: duration,
103
- test_command: result[:test_command],
104
- child_rss_kb: result[:child_rss_kb]
105
- )
106
- end
7
+ require_relative "../isolation"
8
+
9
+ class Evilution::Isolation::Fork
10
+ GRACE_PERIOD = 2
11
+
12
+ def call(mutation:, test_command:, timeout:)
13
+ sandbox_dir = Dir.mktmpdir("evilution-run")
14
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ read_io, write_io = IO.pipe
16
+
17
+ pid = ::Process.fork do
18
+ ENV["TMPDIR"] = sandbox_dir
19
+ read_io.close
20
+ suppress_child_output
21
+ result = execute_in_child(mutation, test_command)
22
+ Marshal.dump(result, write_io)
23
+ write_io.close
24
+ exit!(result[:passed] ? 0 : 1)
107
25
  end
26
+
27
+ write_io.close
28
+ result = wait_for_result(pid, read_io, timeout)
29
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
30
+
31
+ build_mutation_result(mutation, result, duration)
32
+ ensure
33
+ read_io&.close
34
+ write_io&.close
35
+ restore_original_source(mutation)
36
+ FileUtils.rm_rf(sandbox_dir) if sandbox_dir
37
+ end
38
+
39
+ private
40
+
41
+ def restore_original_source(mutation)
42
+ return if File.read(mutation.file_path) == mutation.original_source
43
+
44
+ File.write(mutation.file_path, mutation.original_source)
45
+ rescue StandardError => e
46
+ warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
47
+ end
48
+
49
+ def suppress_child_output
50
+ $stdout.reopen(File::NULL, "w")
51
+ $stderr.reopen(File::NULL, "w")
52
+ end
53
+
54
+ def execute_in_child(mutation, test_command)
55
+ result = test_command.call(mutation)
56
+ { child_rss_kb: Evilution::Memory.rss_kb }.merge(result)
57
+ rescue StandardError => e
58
+ { passed: false, error: e.message }
59
+ end
60
+
61
+ def wait_for_result(pid, read_io, timeout)
62
+ if read_io.wait_readable(timeout)
63
+ data = read_io.read
64
+ ::Process.wait(pid)
65
+ return { timeout: false }.merge(Marshal.load(data)) unless data.empty? # rubocop:disable Security/MarshalLoad
66
+
67
+ ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
68
+ { timeout: false, passed: false, error: "empty result from child" }
69
+ else
70
+ terminate_child(pid)
71
+ { timeout: true }
72
+ end
73
+ end
74
+
75
+ def terminate_child(pid)
76
+ ::Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
77
+ _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
78
+ return if status
79
+
80
+ sleep(GRACE_PERIOD)
81
+ _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
82
+ return if status
83
+
84
+ ::Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
85
+ ::Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
86
+ end
87
+
88
+ def build_mutation_result(mutation, result, duration)
89
+ status = if result[:timeout]
90
+ :timeout
91
+ elsif result[:error]
92
+ :error
93
+ elsif result[:passed]
94
+ :survived
95
+ else
96
+ :killed
97
+ end
98
+
99
+ Evilution::Result::MutationResult.new(
100
+ mutation: mutation,
101
+ status: status,
102
+ duration: duration,
103
+ test_command: result[:test_command],
104
+ child_rss_kb: result[:child_rss_kb]
105
+ )
108
106
  end
109
107
  end