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,130 @@
1
+ module MutationTester
2
+ class ProgressDisplay
3
+ attr_reader :total, :current, :current_mutation
4
+
5
+ def initialize(total, config, output_stream: $stdout.clone)
6
+ @output_stream = output_stream
7
+ @total = total
8
+ @current = 0
9
+ @current_mutation = nil
10
+ @config = config
11
+ @start_time = Time.now
12
+ @spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
13
+ @spinner_index = 0
14
+ @lock = Mutex.new
15
+ @spinner_thread = nil
16
+ @stop_spinner = false
17
+
18
+ start_spinner if @config.show_progress
19
+ end
20
+
21
+ def update(mutation, index)
22
+ @lock.synchronize do
23
+ @current = index
24
+ @current_mutation = mutation
25
+ end
26
+ end
27
+
28
+ def finish
29
+ @lock.synchronize do
30
+ @stop_spinner = true
31
+ if @config.show_progress
32
+ clear_line
33
+ elapsed = Time.now - @start_time
34
+ @output_stream.puts " #{Rainbow("Completed in #{format_duration(elapsed)}").green}"
35
+ end
36
+ end
37
+ stop_spinner
38
+ end
39
+
40
+ private
41
+
42
+ def render(already_locked = false)
43
+ return unless @config.show_progress
44
+
45
+ if already_locked
46
+ _render_internal
47
+ else
48
+ @lock.synchronize { _render_internal }
49
+ end
50
+ end
51
+
52
+ def _render_internal
53
+ clear_line
54
+
55
+ progress = (@current.to_f / @total * 100).round(1)
56
+ progress_bar = create_progress_bar(progress)
57
+ spinner = @spinner_frames[@spinner_index]
58
+
59
+ mutation_info = @current_mutation ? " | #{@current_mutation[:type]} | line #{@current_mutation[:line]}" : ''
60
+
61
+ line = "#{Rainbow(spinner).magenta} [#{progress_bar}] #{Rainbow(progress.to_s + "%").bright} | " \
62
+ "#{Rainbow("#{@current}/#{@total}").cyan} mutations processed#{mutation_info}"
63
+
64
+ @output_stream.print line
65
+ @output_stream.flush
66
+ end
67
+
68
+ def create_progress_bar(percentage)
69
+ width = 20
70
+ filled = (percentage / 100.0 * width).round
71
+ empty = width - filled
72
+
73
+ Rainbow('█' * filled).green + Rainbow('░' * empty).white
74
+ end
75
+
76
+ def clear_line
77
+ @output_stream.print "\r"
78
+ @output_stream.print ' ' * 100
79
+ @output_stream.print "\r"
80
+ end
81
+
82
+ def format_duration(seconds)
83
+ if seconds < 60
84
+ "#{seconds.round(1)}s"
85
+ elsif seconds < 3600
86
+ minutes = (seconds / 60).floor
87
+ secs = (seconds % 60).round
88
+ "#{minutes}m #{secs}s"
89
+ else
90
+ hours = (seconds / 3600).floor
91
+ minutes = ((seconds % 3600) / 60).floor
92
+ "#{hours}h #{minutes}m"
93
+ end
94
+ end
95
+
96
+ def start_spinner
97
+ return if @spinner_thread
98
+
99
+ @stop_spinner = false
100
+ @spinner_thread = Thread.new do
101
+ loop do
102
+ should_stop = @lock.synchronize do
103
+ if @stop_spinner
104
+ true
105
+ else
106
+ @spinner_index = (@spinner_index + 1) % @spinner_frames.length
107
+ render(true) if @config.show_progress
108
+ false
109
+ end
110
+ end
111
+
112
+ break if should_stop
113
+
114
+ sleep 0.1
115
+ end
116
+ end
117
+
118
+ @spinner_thread.abort_on_exception = false
119
+ end
120
+
121
+ def stop_spinner
122
+ return unless @spinner_thread
123
+
124
+ @stop_spinner = true
125
+ @spinner_thread.wakeup if @spinner_thread.status == 'sleep'
126
+ @spinner_thread.join
127
+ @spinner_thread = nil
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,7 @@
1
+ module MutationTester
2
+ class Railtie < Rails::Railtie
3
+ rake_tasks do
4
+ require_relative 'rake_task'
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ require 'rake'
2
+
3
+ module MutationTester
4
+ RAKE_TASKS_PATH = File.expand_path('../tasks/mutation_tester.rake', __dir__)
5
+ BENCH_TASKS_PATH = File.expand_path('../tasks/bench.rake', __dir__)
6
+
7
+ @rake_tasks_loaded = false
8
+
9
+ def self.load_rake_tasks
10
+ return if @rake_tasks_loaded
11
+
12
+ @rake_tasks_loaded = true
13
+ load RAKE_TASKS_PATH
14
+ load BENCH_TASKS_PATH if File.exist?(BENCH_TASKS_PATH)
15
+ end
16
+ end
17
+
18
+ MutationTester.load_rake_tasks
@@ -0,0 +1,154 @@
1
+ module MutationTester
2
+ module Reporters
3
+ class BaseReporter
4
+ attr_reader :results, :source_file, :spec_file, :config
5
+
6
+ def self.score(results)
7
+ effective_killed = results.count { |r| %i[killed timeout].include?(status_for(r)) }
8
+ scored = results.count { |r| %i[killed timeout survived].include?(status_for(r)) }
9
+ return 0.0 if scored.zero?
10
+
11
+ (effective_killed.to_f / scored * 100).round(2)
12
+ end
13
+
14
+ def self.status_for(result)
15
+ result[:status] || (result[:killed] ? :killed : :survived)
16
+ end
17
+
18
+ def initialize(results, source_file, spec_file, config, interrupted: false)
19
+ @results = results
20
+ @source_file = source_file
21
+ @spec_file = spec_file
22
+ @config = config
23
+ @interrupted = interrupted
24
+ end
25
+
26
+ def interrupted?
27
+ @interrupted
28
+ end
29
+
30
+ def generate
31
+ raise NotImplementedError, 'Subclasses must implement #generate'
32
+ end
33
+
34
+ protected
35
+
36
+ def status_of(result)
37
+ self.class.status_for(result)
38
+ end
39
+
40
+ def count_with_status(status)
41
+ @results.count { |r| status_of(r) == status }
42
+ end
43
+
44
+ def killed_count
45
+ count_with_status(:killed)
46
+ end
47
+
48
+ def survived_count
49
+ count_with_status(:survived)
50
+ end
51
+
52
+ def timeout_count
53
+ count_with_status(:timeout)
54
+ end
55
+
56
+ def stillborn_count
57
+ count_with_status(:stillborn)
58
+ end
59
+
60
+ def error_count
61
+ count_with_status(:error)
62
+ end
63
+
64
+ def effective_killed_count
65
+ killed_count + timeout_count
66
+ end
67
+
68
+ def total_count
69
+ @results.size
70
+ end
71
+
72
+ def mutation_score
73
+ self.class.score(@results)
74
+ end
75
+
76
+ def quality_rating
77
+ score = mutation_score
78
+ if score >= 90 then 'Excellent 🌟'
79
+ elsif score >= 75 then 'Good 👍'
80
+ elsif score >= 60 then 'Fair 😐'
81
+ elsif score >= 40 then 'Poor 😟'
82
+ else 'Critical ⚠️'
83
+ end
84
+ end
85
+
86
+ def survived_mutations
87
+ @results.select { |r| status_of(r) == :survived }
88
+ end
89
+
90
+ DIFF_CONTEXT_LINES = 2
91
+ DIFF_DETAIL_STATUSES = %i[survived timeout].freeze
92
+
93
+ def survivor_groups
94
+ survived_mutations
95
+ .group_by { |r| [r[:file_path], r[:line]] }
96
+ .map { |(file_path, line), mutations| { file_path: file_path, line: line, mutations: mutations } }
97
+ .sort_by { |group| group[:line].to_i }
98
+ end
99
+
100
+ def diff_lines(mutations)
101
+ primary = mutations.first
102
+ context = source_context_for(primary)
103
+ return diff_without_context(mutations) unless context
104
+
105
+ lines, first, last = context
106
+ hunk = "@@ -#{first},#{last - first + 1} +#{first},#{last - first + mutations.size} @@"
107
+ (first..last).each_with_object([[:hunk, hunk, nil]]) do |number, out|
108
+ if number == primary[:line]
109
+ out << [:removed, lines[number - 1], nil]
110
+ indent = lines[number - 1][/\A\s*/]
111
+ mutations.each { |m| out << [:added, indent + replacement_text(m), m] }
112
+ else
113
+ out << [:context, lines[number - 1], nil]
114
+ end
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def diff_without_context(mutations)
121
+ primary = mutations.first
122
+ removed = primary[:source_line] || primary[:original].to_s
123
+ [[:removed, removed, nil]] + mutations.map { |m| [:added, replacement_text(m), m] }
124
+ end
125
+
126
+ def replacement_text(mutation)
127
+ mutation[:mutated_line] || mutation[:mutated].to_s
128
+ end
129
+
130
+ def source_context_for(mutation)
131
+ line = mutation[:line]
132
+ return nil unless line.is_a?(Integer) && line >= 1 && mutation[:source_line]
133
+
134
+ lines = cached_source_lines(mutation[:file_path] || @source_file)
135
+ return nil unless lines && lines[line - 1] && lines[line - 1].strip == mutation[:source_line].strip
136
+
137
+ first = [line - DIFF_CONTEXT_LINES, 1].max
138
+ last = [line + DIFF_CONTEXT_LINES, lines.size].min
139
+ [lines, first, last]
140
+ end
141
+
142
+ def cached_source_lines(path)
143
+ @cached_source_lines ||= {}
144
+ return @cached_source_lines[path] if @cached_source_lines.key?(path)
145
+
146
+ @cached_source_lines[path] = begin
147
+ File.read(path).lines.map(&:chomp)
148
+ rescue StandardError
149
+ nil
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,72 @@
1
+ module MutationTester
2
+ module Reporters
3
+ class BatchJsonReporter
4
+ SCHEMA_VERSION = 1
5
+
6
+ def initialize(result, config, passed:)
7
+ @result = result
8
+ @config = config
9
+ @passed = passed
10
+ end
11
+
12
+ def render
13
+ JSON.pretty_generate(envelope)
14
+ end
15
+
16
+ private
17
+
18
+ def envelope
19
+ {
20
+ schema_version: SCHEMA_VERSION,
21
+ summary: {
22
+ files: @result.processed.size + @result.skipped.size,
23
+ processed: @result.processed.size,
24
+ skipped: skipped_entries,
25
+ score: aggregate_score,
26
+ passed: @passed,
27
+ interrupted: @result.interrupted?
28
+ },
29
+ survivors: survivor_entries,
30
+ files: file_reports
31
+ }
32
+ end
33
+
34
+ def skipped_entries
35
+ @result.skipped.map do |entry|
36
+ {
37
+ file: entry.source_file,
38
+ reason: BatchRunner::SKIP_REASONS.fetch(entry.reason || :no_spec)
39
+ }
40
+ end
41
+ end
42
+
43
+ def survivor_entries
44
+ @result.survivors.map do |survivor|
45
+ {
46
+ file: File.expand_path(survivor[:file]),
47
+ line: survivor[:line],
48
+ type: survivor[:type],
49
+ original: survivor[:original],
50
+ mutated: survivor[:mutated]
51
+ }
52
+ end
53
+ end
54
+
55
+ def aggregate_score
56
+ BaseReporter.score(@result.processed.flat_map { |entry| entry.results || [] })
57
+ end
58
+
59
+ def file_reports
60
+ @result.processed.map do |entry|
61
+ JsonReporter.new(
62
+ entry.results || [],
63
+ File.expand_path(entry.source_file),
64
+ File.expand_path(entry.spec_file),
65
+ @config,
66
+ interrupted: !!entry.interrupted
67
+ ).report_data
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,130 @@
1
+ module MutationTester
2
+ module Reporters
3
+ class ConsoleReporter < BaseReporter
4
+ def generate
5
+ puts "\n" + Rainbow('=' * 80).bright
6
+ puts Rainbow('🧬 MUTATION TESTING REPORT').bright.cyan
7
+ puts Rainbow('=' * 80).bright
8
+
9
+ print_summary
10
+ print_survived_mutations if survived_count > 0
11
+
12
+ puts "\n" + Rainbow('=' * 80).bright
13
+ end
14
+
15
+ private
16
+
17
+ def print_summary
18
+ puts "\n📊 Summary:"
19
+ puts " Total Mutations: #{total_count}"
20
+ puts " #{Rainbow("Killed: " + killed_count.to_s).green} ✅"
21
+ puts " #{Rainbow("Survived: " + survived_count.to_s).red} ❌"
22
+ puts " #{Rainbow("Timeout: " + timeout_count.to_s).yellow} ⏱️"
23
+ puts " #{Rainbow("Stillborn: " + stillborn_count.to_s).yellow} 🧬"
24
+ puts " #{Rainbow("Errors: " + error_count.to_s).yellow} 💥"
25
+ print_excluded_summary
26
+ print_selection_summary
27
+ puts " Mutation Score: #{Rainbow(mutation_score.to_s + "%").bright}"
28
+ puts " Quality: #{quality_rating}"
29
+ puts "\n " + progress_bar
30
+ end
31
+
32
+ def print_excluded_summary
33
+ count = excluded_line_count
34
+ return unless count.positive?
35
+
36
+ puts " #{Rainbow("Excluded: " + count.to_s + " line(s) (mutation_tester:disable)").blue} 🚫"
37
+ end
38
+
39
+ def print_selection_summary
40
+ return unless selection_stats_available?
41
+
42
+ puts " #{Rainbow("Selection: #{subset_kill_count} kill(s) by test subset, #{full_kill_count} by full file").cyan} ⚡"
43
+ end
44
+
45
+ def selection_stats_available?
46
+ return false unless @config.test_selection && FrameworkDetector.detect(@spec_file) == :rspec
47
+
48
+ @config.runner != :in_memory || @results.any? { |result| result.key?(:kill_phase) }
49
+ end
50
+
51
+ def subset_kill_count
52
+ kill_phase_count(:subset)
53
+ end
54
+
55
+ def full_kill_count
56
+ kill_phase_count(:full)
57
+ end
58
+
59
+ def kill_phase_count(phase)
60
+ @results.count { |r| r[:kill_phase] == phase }
61
+ end
62
+
63
+ def excluded_line_count
64
+ MutationTester::Mutator.disabled_lines(File.read(@source_file)).size
65
+ rescue StandardError
66
+ 0
67
+ end
68
+
69
+ def progress_bar
70
+ filled = (mutation_score / 5).to_i
71
+ empty = 20 - filled
72
+ bar = Rainbow('█' * filled).green + Rainbow('░' * empty).white
73
+ "[#{bar}] #{mutation_score}%"
74
+ end
75
+
76
+ def print_survived_mutations
77
+ puts "\n" + Rainbow('⚠️ Survived Mutations (Need Improvement):').yellow
78
+ puts Rainbow('-' * 80).yellow
79
+
80
+ survivor_groups.each { |group| print_survivor_group(group) }
81
+ end
82
+
83
+ def print_survivor_group(group)
84
+ mutations = group[:mutations]
85
+ puts "\n #{format_location(mutations.first)}#{variant_count_suffix(mutations)}"
86
+ mutations.each do |mutation|
87
+ puts " #{Rainbow("##{mutation[:id]}").bright} [#{mutation[:type]}] #{mutation[:description]}"
88
+ end
89
+ puts
90
+ diff_lines(mutations).each do |kind, text, mutation|
91
+ puts render_diff_line(kind, text, mutation, mutations.size)
92
+ end
93
+ puts " 💡 Suggestion: #{suggestion_for(mutations)}"
94
+ end
95
+
96
+ def variant_count_suffix(mutations)
97
+ mutations.size > 1 ? " (#{mutations.size} variants)" : ''
98
+ end
99
+
100
+ def suggestion_for(mutations)
101
+ if mutations.size == 1
102
+ "Add test to verify behavior when #{mutations.first[:description].downcase}"
103
+ else
104
+ "Add tests to verify behavior for each of the #{mutations.size} variants above"
105
+ end
106
+ end
107
+
108
+ def render_diff_line(kind, text, mutation, variant_count)
109
+ case kind
110
+ when :hunk then " #{Rainbow(text).cyan}"
111
+ when :removed then " #{Rainbow('-').red} #{text}"
112
+ when :added then " #{Rainbow('+').green} #{text}#{variant_marker(mutation, variant_count)}"
113
+ else " #{text}"
114
+ end
115
+ end
116
+
117
+ def variant_marker(mutation, variant_count)
118
+ variant_count > 1 ? " #{Rainbow("(##{mutation[:id]})").bright}" : ''
119
+ end
120
+
121
+ def format_location(mutation)
122
+ if @config.show_file_path
123
+ "Location: #{Rainbow("#{mutation[:file_path]}:#{mutation[:line]}").cyan}"
124
+ else
125
+ "Line: #{Rainbow(mutation[:line].to_s).cyan}"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end