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,105 @@
1
+ require 'etc'
2
+
3
+ module MutationTester
4
+ class Configuration
5
+ RUNNER_MODES = %i[auto fork spawn in_memory].freeze
6
+ AUTO_PARALLEL_CAP = 8
7
+
8
+ def self.auto_parallel_processes
9
+ [[Etc.nprocessors, AUTO_PARALLEL_CAP].min, 1].max
10
+ end
11
+
12
+ def self.worker_env_value(index)
13
+ number = index.to_i
14
+ number <= 0 ? '' : (number + 1).to_s
15
+ end
16
+
17
+ attr_reader :parallel_processes, :runner, :worker_env_var
18
+
19
+ attr_accessor :timeout,
20
+ :baseline_timeout,
21
+ :mutation_types,
22
+ :reporters,
23
+ :output_dir,
24
+ :minimum_score,
25
+ :fail_on_threshold,
26
+ :verbose,
27
+ :show_file_path,
28
+ :show_progress,
29
+ :test_selection,
30
+ :fail_fast
31
+
32
+ def initialize
33
+ self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
34
+ self.runner = ENV['MUTATION_TESTER_RUNNER'] || :auto
35
+ self.worker_env_var = ENV['MUTATION_TESTER_WORKER_ENV']
36
+ @timeout = 30
37
+ @baseline_timeout = 300
38
+ @mutation_types = {
39
+ arithmetic: true,
40
+ comparison: true,
41
+ logical: true,
42
+ boolean: true,
43
+ number: true,
44
+ string: true,
45
+ conditional: true,
46
+ call_removal: true,
47
+ nil_injection: true,
48
+ argument: true,
49
+ strict_equality: false
50
+ }
51
+ @reporters = %i[console html json]
52
+ @output_dir = 'tmp/mutation_reports'
53
+ @minimum_score = 80.0
54
+ @fail_on_threshold = true
55
+ @verbose = false
56
+ @show_file_path = true
57
+ @show_progress = true
58
+ @test_selection = true
59
+ @fail_fast = false
60
+ end
61
+
62
+ def merge(options)
63
+ config = dup
64
+ options.each do |key, value|
65
+ config.public_send("#{key}=", value) if config.respond_to?("#{key}=")
66
+ end
67
+ config
68
+ end
69
+
70
+ def initialize_copy(source)
71
+ super
72
+ @mutation_types = source.mutation_types.dup
73
+ @reporters = source.reporters.dup
74
+ end
75
+
76
+ def parallel_processes=(value)
77
+ count = value.to_i
78
+ if count < 1
79
+ warn "[MutationTester] parallel_processes must be >= 1; got #{value.inspect}, falling back to 1 (serial execution)."
80
+ count = 1
81
+ end
82
+ @parallel_processes = count
83
+ end
84
+
85
+ def runner=(value)
86
+ mode = value.to_s.strip.downcase.to_sym
87
+ unless RUNNER_MODES.include?(mode)
88
+ warn "[MutationTester] runner must be one of #{RUNNER_MODES.join(", ")}; got #{value.inspect}, falling back to auto."
89
+ mode = :auto
90
+ end
91
+ @runner = mode
92
+ end
93
+
94
+ def worker_env_var=(value)
95
+ normalized = value.to_s.strip
96
+ @worker_env_var = normalized.empty? ? nil : normalized
97
+ end
98
+
99
+ def worker_env_assignment(index)
100
+ return nil unless @worker_env_var
101
+
102
+ { @worker_env_var => self.class.worker_env_value(index) }
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,193 @@
1
+ require_relative 'progress_display'
2
+
3
+ module MutationTester
4
+ class Core
5
+ attr_reader :source_file, :spec_file, :mutations, :results, :config
6
+
7
+ def initialize(source_file, spec_file, config = MutationTester.configuration)
8
+ @source_file = File.expand_path(source_file)
9
+ @spec_file = File.expand_path(spec_file)
10
+ @config = config
11
+ @mutations = []
12
+ @results = []
13
+ @parse_failed = false
14
+ MutationRunner.recover_in_place_backup(@source_file)
15
+ @original_content = File.read(@source_file)
16
+ end
17
+
18
+ def run
19
+ print_header
20
+ return false unless run_original_tests
21
+
22
+ generate_mutations
23
+ return false if @parse_failed
24
+
25
+ if @mutations.empty?
26
+ puts Rainbow("\n✓ No mutations were generated for this file; nothing to test.").green
27
+ return true
28
+ end
29
+
30
+ return false unless shadow_environment_reliable?
31
+
32
+ run_mutations
33
+ generate_reports
34
+ return report_interruption if interrupted?
35
+
36
+ check_threshold
37
+ ensure
38
+ @mutation_runner&.cleanup_shadow_workspaces
39
+ ForkRunner.shutdown_all
40
+ end
41
+
42
+ def interrupted?
43
+ @config.fail_fast &&
44
+ @results.size < @mutations.size &&
45
+ @results.any? { |result| result[:status] == :survived }
46
+ end
47
+
48
+ def mutation_score
49
+ Reporters::BaseReporter.score(@results)
50
+ end
51
+
52
+ def threshold_met?
53
+ return true unless @config.fail_on_threshold
54
+
55
+ mutation_score >= @config.minimum_score
56
+ end
57
+
58
+ private
59
+
60
+ def print_header
61
+ puts Rainbow('=' * 80).bright
62
+ puts Rainbow("🧬 MutationTester v#{MutationTester::VERSION}").bright.cyan
63
+ puts Rainbow('=' * 80).bright
64
+ puts "Source: #{@source_file}"
65
+ puts "Spec: #{@spec_file}"
66
+ puts Rainbow('=' * 80).bright
67
+ end
68
+
69
+ def run_original_tests
70
+ puts Rainbow("\n🧪 Running original tests...").yellow
71
+ command = test_command
72
+ result = command.run(timeout: @config.baseline_timeout, capture: true)
73
+
74
+ if result.timed_out?
75
+ puts Rainbow("❌ Original tests exceeded the baseline deadline of #{@config.baseline_timeout}s and were terminated.").red
76
+ puts Rainbow(' Speed them up or raise config.baseline_timeout (nil disables the deadline).').red
77
+ return false
78
+ end
79
+
80
+ unless result.passed?
81
+ replay_baseline_output(result.output)
82
+ puts Rainbow('❌ Original tests are failing. Fix them first!').red
83
+ puts Rainbow("\nDebug: The following command failed:").yellow
84
+ puts Rainbow(" #{command.command}").cyan
85
+ return false
86
+ end
87
+ puts Rainbow('✓ Original tests passed').green
88
+ true
89
+ end
90
+
91
+ def replay_baseline_output(output)
92
+ return if output.nil? || output.strip.empty?
93
+
94
+ puts Rainbow("\n🔴 Original test output:").red
95
+ puts output
96
+ end
97
+
98
+ def generate_mutations
99
+ puts Rainbow("\n📝 Generating mutations...").yellow
100
+
101
+ ast = Parser::CurrentRuby.parse(@original_content)
102
+ mutator = Mutator.new(@source_file, @config)
103
+ @mutations = mutator.generate_mutations(ast)
104
+
105
+ puts Rainbow("✓ Generated #{@mutations.size} mutations").green
106
+ rescue Parser::SyntaxError => e
107
+ puts Rainbow("❌ Failed to parse source file: #{e.message}").red
108
+ @parse_failed = true
109
+ @mutations = []
110
+ end
111
+
112
+ def shadow_environment_reliable?
113
+ return true unless @config.parallel_processes > 1
114
+ return true if mutation_runner.in_memory_first?
115
+
116
+ puts Rainbow("\n🩺 Verifying the shadow workspace with the unmutated source...").yellow
117
+ if mutation_runner.shadow_baseline_passes?
118
+ puts Rainbow('✓ Shadow workspace verified with the unmutated source').green
119
+ return true
120
+ end
121
+
122
+ puts Rainbow('❌ The unmutated source fails inside the shadow workspace; the shadow environment is unreliable.').red
123
+ puts Rainbow(' Every mutant would falsely die there, so the run is aborted instead of reporting a misleading score.').red
124
+ false
125
+ end
126
+
127
+ def run_mutations
128
+ return if @mutations.empty?
129
+
130
+ puts Rainbow("\n🔬 Running mutations in #{@config.parallel_processes} parallel job#{"s" if @config.parallel_processes > 1}...").yellow
131
+
132
+ progress_display = ProgressDisplay.new(@mutations.size, @config)
133
+
134
+ @results = mutation_runner.run(@mutations) do |mutation, index|
135
+ progress_display.update(mutation, index)
136
+ end
137
+
138
+ progress_display.finish
139
+ puts Rainbow("✓ Completed #{@results.size} mutations").green
140
+ end
141
+
142
+ def mutation_runner
143
+ @mutation_runner ||= MutationRunner.new(@source_file, @spec_file, @original_content, @config)
144
+ end
145
+
146
+ def generate_reports
147
+ puts Rainbow("\n📊 Generating reports...").yellow
148
+ FileUtils.mkdir_p(@config.output_dir)
149
+
150
+ @config.reporters.each do |reporter_type|
151
+ reporter = create_reporter(reporter_type)
152
+ reporter.generate if reporter
153
+ end
154
+ end
155
+
156
+ def create_reporter(type)
157
+ case type
158
+ when :console
159
+ Reporters::ConsoleReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
160
+ when :html
161
+ Reporters::HtmlReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
162
+ when :json
163
+ Reporters::JsonReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
164
+ end
165
+ end
166
+
167
+ def test_command
168
+ TestCommand.new(
169
+ @spec_file,
170
+ use_bundle_exec: TestCommand.use_bundle_exec?(@source_file),
171
+ runner: @config.runner
172
+ )
173
+ end
174
+
175
+ def report_interruption
176
+ puts Rainbow("\n🛑 Run interrupted by --fail-fast: a mutant survived after #{@results.size} of #{@mutations.size} mutations.").red
177
+ puts Rainbow(' Reports contain the results obtained up to the interruption.').red
178
+ false
179
+ end
180
+
181
+ def check_threshold
182
+ score = mutation_score
183
+
184
+ unless threshold_met?
185
+ puts Rainbow("\n❌ Mutation score #{score}% is below threshold #{@config.minimum_score}%").red
186
+ return false
187
+ end
188
+
189
+ puts Rainbow("\n✓ Mutation score: #{score}%").green
190
+ true
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rspec/core'
5
+ require_relative '../in_memory_loader'
6
+
7
+ control = $stdout.dup
8
+ control.sync = true
9
+ STDOUT.reopen(File::NULL)
10
+
11
+ kill_group = lambda do |pid|
12
+ begin
13
+ Process.kill('KILL', -pid)
14
+ rescue Errno::ESRCH, Errno::EPERM
15
+ end
16
+ end
17
+
18
+ monotonic = lambda { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
19
+
20
+ current_child = nil
21
+ preloaded = nil
22
+
23
+ %w[TERM INT].each do |signal|
24
+ trap(signal) do
25
+ kill_group.call(current_child) if current_child
26
+ trap(signal, 'DEFAULT')
27
+ Process.kill(signal, Process.pid)
28
+ end
29
+ end
30
+
31
+ supervise_child = lambda do |job, out, child_body|
32
+ result_reader, result_writer = IO.pipe
33
+
34
+ child = fork do
35
+ trap('TERM', 'DEFAULT')
36
+ trap('INT', 'DEFAULT')
37
+ out.close
38
+ result_reader.close
39
+ begin
40
+ Process.setpgid(0, 0)
41
+ rescue Errno::EACCES, Errno::EPERM
42
+ end
43
+ Dir.chdir(job['chdir']) if job['chdir']
44
+ sink = File.open(job['log'] || File::NULL, 'w')
45
+ sink.sync = true
46
+ STDOUT.reopen(sink)
47
+ STDERR.reopen(sink)
48
+ result_writer.puts(child_body.call)
49
+ result_writer.close
50
+ end
51
+
52
+ result_writer.close
53
+ begin
54
+ Process.setpgid(child, child)
55
+ rescue Errno::EACCES, Errno::EPERM, Errno::ESRCH
56
+ end
57
+ current_child = child
58
+ out.puts(JSON.generate('event' => 'started', 'pid' => child))
59
+
60
+ deadline = job['timeout'] ? monotonic.call + job['timeout'] : nil
61
+ reaped = nil
62
+ timed_out = false
63
+ payload_ready = false
64
+
65
+ loop do
66
+ _pid, reaped = Process.waitpid2(child, Process::WNOHANG)
67
+ break if reaped
68
+
69
+ if deadline && monotonic.call >= deadline
70
+ timed_out = true
71
+ kill_group.call(child)
72
+ begin
73
+ Process.waitpid(child)
74
+ rescue Errno::ECHILD
75
+ end
76
+ break
77
+ end
78
+
79
+ if payload_ready
80
+ sleep(0.002)
81
+ else
82
+ remaining = deadline ? deadline - monotonic.call : nil
83
+ wait = remaining ? remaining.clamp(0, 0.05) : 0.05
84
+ payload_ready = !IO.select([result_reader], nil, nil, wait).nil?
85
+ end
86
+ end
87
+
88
+ current_child = nil
89
+ payload = result_reader.read
90
+ result_reader.close
91
+ [timed_out, payload, reaped]
92
+ end
93
+
94
+ run_job = lambda do |job, out|
95
+ timed_out, payload, reaped = supervise_child.call(job, out, lambda do
96
+ RSpec::Core::Runner.run([job['spec'], *Array(job['args'])], STDERR, STDOUT).to_i
97
+ end)
98
+
99
+ status =
100
+ if timed_out
101
+ 'timeout'
102
+ elsif payload[/-?\d+/] == '0' && reaped.success?
103
+ 'pass'
104
+ else
105
+ 'fail'
106
+ end
107
+
108
+ out.puts(JSON.generate('event' => 'result', 'status' => status))
109
+ end
110
+
111
+ preload_specs = lambda do |request, out|
112
+ begin
113
+ Dir.chdir(request['chdir']) if request['chdir']
114
+ sink = File.open(File::NULL, 'w')
115
+ runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new([request['spec']]))
116
+ runner.setup(sink, sink)
117
+ preloaded = runner
118
+ out.puts(JSON.generate('event' => 'preloaded', 'status' => 'ok'))
119
+ rescue ScriptError, StandardError => e
120
+ out.puts(JSON.generate('event' => 'preloaded', 'status' => 'error', 'message' => "#{e.class}: #{e.message}"))
121
+ end
122
+ end
123
+
124
+ run_in_memory_job = lambda do |job, out|
125
+ request = job['in_memory']
126
+
127
+ unless preloaded
128
+ out.puts(JSON.generate('event' => 'result', 'status' => 'error', 'message' => 'the worker has no preloaded spec environment'))
129
+ next
130
+ end
131
+
132
+ timed_out, payload, reaped = supervise_child.call(job, out, lambda do
133
+ begin
134
+ MutationTester::InMemoryLoader.apply(request['source'], request['path'])
135
+ JSON.generate('code' => preloaded.run_specs(RSpec.world.ordered_example_groups).to_i)
136
+ rescue ScriptError, StandardError => e
137
+ JSON.generate('error' => "#{e.class}: #{e.message}")
138
+ end
139
+ end)
140
+
141
+ outcome = begin
142
+ JSON.parse(payload)
143
+ rescue JSON::ParserError
144
+ nil
145
+ end
146
+
147
+ event =
148
+ if timed_out
149
+ { 'event' => 'result', 'status' => 'timeout' }
150
+ elsif outcome.is_a?(Hash) && outcome['error']
151
+ { 'event' => 'result', 'status' => 'error', 'message' => outcome['error'] }
152
+ elsif outcome.is_a?(Hash) && outcome['code'] == 0 && reaped.success?
153
+ { 'event' => 'result', 'status' => 'pass' }
154
+ else
155
+ { 'event' => 'result', 'status' => 'fail' }
156
+ end
157
+
158
+ out.puts(JSON.generate(event))
159
+ end
160
+
161
+ serve = nil
162
+
163
+ spawn_clone = lambda do |request, out|
164
+ child = fork do
165
+ guard = Thread.new do
166
+ sleep 15
167
+ Process.kill('KILL', Process.pid)
168
+ end
169
+ begin
170
+ Process.setpgid(0, 0)
171
+ rescue Errno::EACCES, Errno::EPERM
172
+ end
173
+ input = File.open(request['job'], 'r')
174
+ clone_out = File.open(request['events'], 'w')
175
+ guard.kill
176
+ clone_out.sync = true
177
+ out.close
178
+ STDIN.reopen(File::NULL)
179
+ request['env']&.each { |name, value| ENV[name] = value }
180
+ serve.call(input, clone_out)
181
+ end
182
+ Process.detach(child)
183
+ out.puts(JSON.generate('event' => 'cloned', 'pid' => child))
184
+ end
185
+
186
+ serve = lambda do |input, out|
187
+ out.puts(JSON.generate('event' => 'ready'))
188
+ input.each_line do |line|
189
+ job = JSON.parse(line)
190
+ if job['clone']
191
+ spawn_clone.call(job['clone'], out)
192
+ elsif job['preload']
193
+ preload_specs.call(job['preload'], out)
194
+ elsif job['in_memory']
195
+ run_in_memory_job.call(job, out)
196
+ else
197
+ run_job.call(job, out)
198
+ end
199
+ end
200
+ end
201
+
202
+ serve.call($stdin, control)