evilution 0.20.0 → 0.22.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.gitignore +4 -0
  3. data/.beads/.migration-hint-ts +1 -1
  4. data/.beads/interactions.jsonl +12 -0
  5. data/.beads/issues.jsonl +22 -19
  6. data/CHANGELOG.md +35 -0
  7. data/README.md +17 -11
  8. data/comparison_results/baseline_2026-04-09.md +35 -0
  9. data/comparison_results/operator_classification.md +79 -0
  10. data/comparison_results/operator_prioritization.md +68 -0
  11. data/docs/mutation_density_benchmark.md +91 -0
  12. data/lib/evilution/ast/parser.rb +2 -1
  13. data/lib/evilution/baseline.rb +14 -11
  14. data/lib/evilution/cli.rb +13 -3
  15. data/lib/evilution/config.rb +27 -5
  16. data/lib/evilution/disable_comment.rb +2 -1
  17. data/lib/evilution/integration/base.rb +98 -1
  18. data/lib/evilution/integration/minitest.rb +145 -0
  19. data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
  20. data/lib/evilution/integration/rspec.rb +33 -92
  21. data/lib/evilution/isolation/fork.rb +3 -6
  22. data/lib/evilution/mcp/mutate_tool.rb +6 -6
  23. data/lib/evilution/mutator/base.rb +5 -1
  24. data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
  25. data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
  26. data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
  27. data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
  28. data/lib/evilution/mutator/operator/index_to_dig.rb +3 -3
  29. data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
  30. data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
  31. data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
  32. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
  33. data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
  34. data/lib/evilution/mutator/operator/string_literal.rb +18 -0
  35. data/lib/evilution/mutator/registry.rb +12 -2
  36. data/lib/evilution/reporter/html.rb +2 -2
  37. data/lib/evilution/reporter/json.rb +2 -2
  38. data/lib/evilution/reporter/suggestion.rb +659 -2
  39. data/lib/evilution/runner.rb +59 -13
  40. data/lib/evilution/spec_resolver.rb +24 -16
  41. data/lib/evilution/temp_dir_tracker.rb +39 -0
  42. data/lib/evilution/version.rb +1 -1
  43. data/lib/evilution.rb +4 -0
  44. data/scripts/benchmark_density +261 -0
  45. data/scripts/benchmark_density.yml +19 -0
  46. data/scripts/compare_mutations +404 -0
  47. data/scripts/compare_mutations.yml +24 -0
  48. data/scripts/mutant_json_adapter +224 -0
  49. metadata +17 -2
@@ -8,6 +8,7 @@ require_relative "mutator/registry"
8
8
  require_relative "isolation/fork"
9
9
  require_relative "isolation/in_process"
10
10
  require_relative "integration/rspec"
11
+ require_relative "integration/minitest"
11
12
  require_relative "reporter/json"
12
13
  require_relative "reporter/cli"
13
14
  require_relative "reporter/html"
@@ -21,10 +22,16 @@ require_relative "cache"
21
22
  require_relative "parallel/pool"
22
23
  require_relative "session/store"
23
24
  require_relative "ast/pattern/filter"
25
+ require_relative "temp_dir_tracker"
24
26
  require_relative "disable_comment"
25
27
  require_relative "ast/sorbet_sig_detector"
26
28
 
27
29
  class Evilution::Runner
30
+ INTEGRATIONS = {
31
+ rspec: Evilution::Integration::RSpec,
32
+ minitest: Evilution::Integration::Minitest
33
+ }.freeze
34
+
28
35
  attr_reader :config
29
36
 
30
37
  def initialize(config: Evilution::Config.new, on_result: nil, hooks: nil)
@@ -42,6 +49,7 @@ class Evilution::Runner
42
49
  end
43
50
 
44
51
  def call
52
+ install_signal_handlers
45
53
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
54
 
47
55
  subjects = parse_and_filter_subjects
@@ -180,8 +188,9 @@ class Evilution::Runner
180
188
 
181
189
  def generate_mutations(subjects)
182
190
  filter = build_ignore_filter
191
+ operator_options = build_operator_options
183
192
  mutations = subjects.flat_map do |subject|
184
- registry.mutations_for(subject, filter: filter)
193
+ registry.mutations_for(subject, filter: filter, operator_options: operator_options)
185
194
  end
186
195
  skipped_count = filter ? filter.skipped_count : 0
187
196
 
@@ -252,6 +261,10 @@ class Evilution::Runner
252
261
  end
253
262
  end
254
263
 
264
+ def build_operator_options
265
+ { skip_heredoc_literals: config.skip_heredoc_literals? }
266
+ end
267
+
255
268
  def build_ignore_filter
256
269
  patterns = config.ignore_patterns
257
270
  return nil if patterns.nil? || patterns.empty?
@@ -279,7 +292,8 @@ class Evilution::Runner
279
292
  return nil unless config.baseline? && subjects.any?
280
293
 
281
294
  log_baseline_start
282
- baseline = Evilution::Baseline.new(timeout: config.timeout)
295
+ integration_class = resolve_integration_class
296
+ baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
283
297
  result = baseline.call(subjects)
284
298
  log_baseline_complete(result)
285
299
  result
@@ -298,7 +312,7 @@ class Evilution::Runner
298
312
 
299
313
  def run_mutations_sequential(mutations, baseline_result = nil)
300
314
  integration = build_integration
301
- spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
315
+ spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
302
316
  results = []
303
317
  survived_count = 0
304
318
  truncated = false
@@ -327,7 +341,7 @@ class Evilution::Runner
327
341
  integration = build_integration
328
342
  pool = Evilution::Parallel::Pool.new(size: config.jobs, hooks: @hooks, item_timeout: config.timeout ? config.timeout * 2 : nil)
329
343
  worker_isolator = Evilution::Isolation::InProcess.new
330
- spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
344
+ spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
331
345
  state = { results: [], survived_count: 0, truncated: false, completed: 0 }
332
346
 
333
347
  all_worker_stats = []
@@ -385,7 +399,7 @@ class Evilution::Runner
385
399
  if config.spec_files.any?
386
400
  neutralize = true
387
401
  else
388
- spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
402
+ spec_file = spec_resolver.call(result.mutation.file_path) || neutralization_fallback_dir
389
403
  neutralize = baseline_result.failed_spec_files.include?(spec_file)
390
404
  end
391
405
  return result unless neutralize
@@ -432,6 +446,26 @@ class Evilution::Runner
432
446
  config.fail_fast? && survived_count >= config.fail_fast
433
447
  end
434
448
 
449
+ def install_signal_handlers
450
+ %w[INT TERM].each { |sig| install_signal_handler(sig) }
451
+ end
452
+
453
+ def install_signal_handler(sig)
454
+ prev_handler = Signal.trap(sig) do
455
+ Evilution::TempDirTracker.cleanup_all
456
+
457
+ case prev_handler
458
+ when Proc, Method
459
+ prev_handler.call
460
+ when "IGNORE"
461
+ # Do nothing — signal is ignored
462
+ else
463
+ Signal.trap(sig, "DEFAULT")
464
+ Process.kill(sig, Process.pid)
465
+ end
466
+ end
467
+ end
468
+
435
469
  def build_isolator
436
470
  case resolve_isolation
437
471
  when :fork then Evilution::Isolation::Fork.new(hooks: @hooks)
@@ -445,16 +479,28 @@ class Evilution::Runner
445
479
  :in_process
446
480
  end
447
481
 
448
- def build_integration
449
- case config.integration
450
- when :rspec
451
- test_files = config.spec_files.empty? ? nil : config.spec_files
452
- Evilution::Integration::RSpec.new(test_files: test_files, hooks: @hooks)
453
- else
482
+ def resolve_integration_class
483
+ INTEGRATIONS.fetch(config.integration) do
454
484
  raise Evilution::Error, "unknown integration: #{config.integration}"
455
485
  end
456
486
  end
457
487
 
488
+ def build_integration
489
+ klass = resolve_integration_class
490
+ test_files = config.spec_files.empty? ? nil : config.spec_files
491
+ klass.new(test_files: test_files, hooks: @hooks)
492
+ end
493
+
494
+ def build_neutralization_resolver
495
+ options = resolve_integration_class.baseline_options
496
+ options[:spec_resolver] || Evilution::SpecResolver.new
497
+ end
498
+
499
+ def neutralization_fallback_dir
500
+ options = resolve_integration_class.baseline_options
501
+ options[:fallback_dir] || "spec"
502
+ end
503
+
458
504
  def output_report(summary)
459
505
  reporter = build_reporter
460
506
  return unless reporter
@@ -577,11 +623,11 @@ class Evilution::Runner
577
623
  def build_reporter
578
624
  case config.format
579
625
  when :json
580
- Evilution::Reporter::JSON.new
626
+ Evilution::Reporter::JSON.new(integration: config.integration)
581
627
  when :text
582
628
  Evilution::Reporter::CLI.new
583
629
  when :html
584
- Evilution::Reporter::HTML.new(baseline: load_baseline_session)
630
+ Evilution::Reporter::HTML.new(baseline: load_baseline_session, integration: config.integration)
585
631
  end
586
632
  end
587
633
 
@@ -4,11 +4,17 @@ class Evilution::SpecResolver
4
4
  STRIPPABLE_PREFIXES = %w[lib/ app/].freeze
5
5
  CONTROLLER_PREFIX = "controllers/"
6
6
 
7
+ def initialize(test_dir: "spec", test_suffix: "_spec.rb", request_dir: "requests")
8
+ @test_dir = test_dir
9
+ @test_suffix = test_suffix
10
+ @request_dir = request_dir
11
+ end
12
+
7
13
  def call(source_path)
8
14
  return nil if source_path.nil? || source_path.empty?
9
15
 
10
16
  normalized = normalize_path(source_path)
11
- candidates = candidate_spec_paths(normalized)
17
+ candidates = candidate_test_paths(normalized)
12
18
  candidates.find { |path| File.exist?(path) }
13
19
  end
14
20
 
@@ -24,16 +30,16 @@ class Evilution::SpecResolver
24
30
  path
25
31
  end
26
32
 
27
- def candidate_spec_paths(source_path)
28
- base = source_path.sub(/\.rb\z/, "_spec.rb")
33
+ def candidate_test_paths(source_path)
34
+ base = source_path.sub(/\.rb\z/, @test_suffix)
29
35
  prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
30
36
 
31
37
  candidates = if prefix
32
38
  stripped = base.delete_prefix(prefix)
33
- request_spec = controller_to_request_spec(stripped)
34
- [request_spec, "spec/#{stripped}", "spec/#{base}"].compact
39
+ request_test = controller_to_request_test(stripped)
40
+ [request_test, "#{@test_dir}/#{stripped}", "#{@test_dir}/#{base}"].compact
35
41
  else
36
- ["spec/#{base}"]
42
+ ["#{@test_dir}/#{base}"]
37
43
  end
38
44
 
39
45
  fallbacks = candidates.flat_map { |c| parent_fallback_candidates(c) }.uniq
@@ -41,33 +47,35 @@ class Evilution::SpecResolver
41
47
  candidates + fallbacks
42
48
  end
43
49
 
44
- def controller_to_request_spec(stripped_path)
50
+ def controller_to_request_test(stripped_path)
45
51
  return nil unless stripped_path.start_with?(CONTROLLER_PREFIX)
46
- return nil unless stripped_path.end_with?("_controller_spec.rb")
52
+
53
+ controller_suffix = "_controller#{@test_suffix}"
54
+ return nil unless stripped_path.end_with?(controller_suffix)
47
55
 
48
56
  request_path = stripped_path
49
57
  .delete_prefix(CONTROLLER_PREFIX)
50
- .sub(/_controller_spec\.rb\z/, "_spec.rb")
51
- "spec/requests/#{request_path}"
58
+ .sub(/#{Regexp.escape(controller_suffix)}\z/, @test_suffix)
59
+ "#{@test_dir}/#{@request_dir}/#{request_path}"
52
60
  end
53
61
 
54
- def parent_fallback_candidates(spec_path)
55
- parts = spec_path.split("/")
62
+ def parent_fallback_candidates(test_path)
63
+ parts = test_path.split("/")
56
64
  # parts: ["spec", "foo", "bar_spec.rb"] — need at least 3 parts for fallback
57
65
  return [] if parts.length < 3
58
66
 
59
67
  candidates = []
60
68
  # Remove filename, then progressively remove directories
61
- dir_parts = parts[1..-2] # ["models", "game"]
69
+ dir_parts = parts[1..-2]
62
70
 
63
71
  (dir_parts.length - 1).downto(0) do |i|
64
- file = "#{dir_parts[i]}_spec.rb"
72
+ file = "#{dir_parts[i]}#{@test_suffix}"
65
73
 
66
74
  if i.zero?
67
- candidates << "spec/#{file}"
75
+ candidates << "#{@test_dir}/#{file}"
68
76
  else
69
77
  parent = dir_parts[0...i].join("/")
70
- candidates << "spec/#{parent}/#{file}"
78
+ candidates << "#{@test_dir}/#{parent}/#{file}"
71
79
  end
72
80
  end
73
81
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "monitor"
5
+ require_relative "version"
6
+
7
+ module Evilution::TempDirTracker
8
+ @dirs = Set.new
9
+ @monitor = Monitor.new
10
+ @at_exit_registered = false
11
+
12
+ def self.register(dir)
13
+ @monitor.synchronize do
14
+ @dirs << dir
15
+ register_at_exit unless @at_exit_registered
16
+ end
17
+ end
18
+
19
+ def self.unregister(dir)
20
+ @monitor.synchronize { @dirs.delete(dir) }
21
+ end
22
+
23
+ def self.cleanup_all
24
+ @monitor.synchronize do
25
+ @dirs.each { |d| FileUtils.rm_rf(d) }
26
+ @dirs.clear
27
+ end
28
+ end
29
+
30
+ def self.tracked_dirs
31
+ @monitor.synchronize { @dirs.dup }
32
+ end
33
+
34
+ def self.register_at_exit
35
+ at_exit { cleanup_all }
36
+ @at_exit_registered = true
37
+ end
38
+ private_class_method :register_at_exit
39
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.20.0"
4
+ VERSION = "0.22.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -42,9 +42,11 @@ require_relative "evilution/mutator/operator/collection_replacement"
42
42
  require_relative "evilution/mutator/operator/method_call_removal"
43
43
  require_relative "evilution/mutator/operator/argument_removal"
44
44
  require_relative "evilution/mutator/operator/block_removal"
45
+ require_relative "evilution/mutator/operator/block_pass_removal"
45
46
  require_relative "evilution/mutator/operator/conditional_flip"
46
47
  require_relative "evilution/mutator/operator/range_replacement"
47
48
  require_relative "evilution/mutator/operator/regexp_mutation"
49
+ require_relative "evilution/mutator/operator/regex_simplification"
48
50
  require_relative "evilution/mutator/operator/receiver_replacement"
49
51
  require_relative "evilution/mutator/operator/send_mutation"
50
52
  require_relative "evilution/mutator/operator/argument_nil_substitution"
@@ -69,6 +71,7 @@ require_relative "evilution/mutator/operator/bitwise_replacement"
69
71
  require_relative "evilution/mutator/operator/bitwise_complement"
70
72
  require_relative "evilution/mutator/operator/zsuper_removal"
71
73
  require_relative "evilution/mutator/operator/explicit_super_mutation"
74
+ require_relative "evilution/mutator/operator/index_to_at"
72
75
  require_relative "evilution/mutator/operator/index_to_fetch"
73
76
  require_relative "evilution/mutator/operator/index_to_dig"
74
77
  require_relative "evilution/mutator/operator/index_assignment_removal"
@@ -106,6 +109,7 @@ require_relative "evilution/git/changed_files"
106
109
  require_relative "evilution/integration"
107
110
  require_relative "evilution/integration/base"
108
111
  require_relative "evilution/integration/rspec"
112
+ require_relative "evilution/integration/minitest"
109
113
  require_relative "evilution/result/mutation_result"
110
114
  require_relative "evilution/result/summary"
111
115
  require_relative "evilution/reporter"
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Mutation Density Benchmark
5
+ #
6
+ # Compares mutation counts between evilution and a reference mutation testing
7
+ # tool on a corpus of files.
8
+ # See docs/mutation_density_benchmark.md for methodology.
9
+ #
10
+ # Usage:
11
+ # scripts/benchmark_density CONFIG_FILE [--verbose]
12
+ #
13
+ # The config file is a YAML file listing the benchmark corpus.
14
+ # See scripts/benchmark_density.yml for the format.
15
+
16
+ require "yaml"
17
+ require "open3"
18
+ require "optparse"
19
+
20
+ module BenchmarkDensity
21
+ class ConfigError < StandardError; end
22
+
23
+ class Config
24
+ attr_reader :project_root, :target_ratio, :reference_cmd, :files
25
+
26
+ def initialize(path)
27
+ data = YAML.safe_load_file(path, symbolize_names: true, permitted_classes: [Symbol])
28
+ raise ConfigError, "config file must contain a YAML mapping" unless data.is_a?(Hash)
29
+
30
+ @project_root = data[:project_root]
31
+ @target_ratio = data.key?(:target_ratio) ? data[:target_ratio] : 1.5
32
+ @reference_cmd = data.key?(:reference_cmd) ? data[:reference_cmd] : []
33
+ @files = data.key?(:files) ? data[:files] : []
34
+
35
+ validate!
36
+ rescue Psych::Exception => e
37
+ raise ConfigError, "failed to parse config file #{path}: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def validate!
43
+ raise ConfigError, "project_root is required in config" if @project_root.nil?
44
+ raise ConfigError, "project_root does not exist: #{@project_root}" unless Dir.exist?(@project_root)
45
+ raise ConfigError, "reference_cmd is required in config" if @reference_cmd.empty?
46
+ raise ConfigError, "files list is empty — add benchmark files to the config" if @files.empty?
47
+
48
+ @files.each_with_index { |entry, index| validate_file_entry!(entry, index) }
49
+ end
50
+
51
+ def validate_file_entry!(entry, index)
52
+ raise ConfigError, "files[#{index}] must be a mapping" unless entry.is_a?(Hash)
53
+
54
+ path = entry[:path]
55
+ target = entry[:reference_target]
56
+ raise ConfigError, "files[#{index}] is missing required path" if path.nil? || path.to_s.strip.empty?
57
+ raise ConfigError, "files[#{index}] is missing required reference_target" if target.nil? || target.to_s.strip.empty?
58
+ end
59
+ end
60
+
61
+ class EvilutionCounter
62
+ def initialize(project_root)
63
+ @project_root = project_root
64
+ end
65
+
66
+ def count_mutations(file_path)
67
+ full_path = File.join(@project_root, file_path)
68
+ cmd = ["bundle", "exec", "evilution", "subjects", full_path]
69
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: @project_root)
70
+
71
+ unless status.success?
72
+ warn " evilution failed for #{file_path}: #{stderr.strip}"
73
+ return nil
74
+ end
75
+
76
+ count = parse_subject_count(stdout)
77
+ if count.nil?
78
+ warn " evilution produced unparseable output for #{file_path}: #{stdout.lines.first&.strip}"
79
+ return nil
80
+ end
81
+ count
82
+ end
83
+
84
+ private
85
+
86
+ def parse_subject_count(output)
87
+ match = output.match(/(\d+)\s+subjects,\s+(\d+)\s+mutations/)
88
+ match ? match[2].to_i : nil
89
+ end
90
+ end
91
+
92
+ class ReferenceCounter
93
+ def initialize(project_root, reference_cmd)
94
+ @project_root = project_root
95
+ @reference_cmd = reference_cmd
96
+ end
97
+
98
+ def count_mutations(reference_target)
99
+ cmd = @reference_cmd + [reference_target]
100
+ stdout, stderr, _status = Open3.capture3(*cmd, chdir: @project_root)
101
+
102
+ # Reference tool may exit non-zero when mutants survive, which is expected.
103
+ # Only treat it as failure if there's no parseable output.
104
+ count = parse_mutation_count(stdout)
105
+ if count.nil?
106
+ warn " reference tool failed for #{reference_target}: #{stderr.lines.first&.strip}"
107
+ return nil
108
+ end
109
+ count
110
+ end
111
+
112
+ private
113
+
114
+ def parse_mutation_count(output)
115
+ match = output.match(/mutations:\s*(\d+)/i)
116
+ match ? match[1].to_i : nil
117
+ end
118
+ end
119
+
120
+ class Runner
121
+ HEADER_FMT = "%-45<file>s %10<evilution>s %10<reference>s %8<ratio>s"
122
+ ROW_FMT = "%-45<file>s %10<evilution>s %10<reference>s %8<ratio>s"
123
+ TOTAL_FMT = "%-45<file>s %10<evilution>d %10<reference>d %7.2<ratio>fx"
124
+
125
+ def initialize(config:, verbose: false)
126
+ @config = config
127
+ @verbose = verbose
128
+ @evilution = EvilutionCounter.new(config.project_root)
129
+ @reference = ReferenceCounter.new(config.project_root, config.reference_cmd)
130
+ end
131
+
132
+ def call
133
+ results = collect_results
134
+ print_table(results)
135
+ print_summary(results)
136
+ passing?(results) ? 0 : 1
137
+ end
138
+
139
+ private
140
+
141
+ def collect_results
142
+ @config.files.map do |entry|
143
+ path = entry[:path]
144
+ reference_target = entry[:reference_target]
145
+
146
+ warn " Measuring #{path}..." if @verbose
147
+ ev_count = @evilution.count_mutations(path)
148
+ ref_count = @reference.count_mutations(reference_target)
149
+ warn " done" if @verbose
150
+
151
+ { path: path, evilution: ev_count, reference: ref_count }
152
+ end
153
+ end
154
+
155
+ def print_table(results)
156
+ puts format(HEADER_FMT, file: "File", evilution: "Evilution", reference: "Reference", ratio: "Ratio")
157
+ puts "-" * 75
158
+
159
+ results.each do |r|
160
+ ratio = compute_ratio(r[:evilution], r[:reference])
161
+ ratio_str = ratio ? format("%.2fx", ratio) : "N/A"
162
+ ev_str = r[:evilution]&.to_s || "ERR"
163
+ ref_str = r[:reference]&.to_s || "ERR"
164
+ puts format(ROW_FMT, file: r[:path], evilution: ev_str, reference: ref_str, ratio: ratio_str)
165
+ end
166
+
167
+ puts "-" * 75
168
+ end
169
+
170
+ def print_summary(results)
171
+ totals = compute_totals(results)
172
+ print_total_line(totals)
173
+ puts ""
174
+ print_status_line(totals)
175
+ print_error_warning(totals[:error_count])
176
+ end
177
+
178
+ def print_total_line(totals)
179
+ if totals[:invalid]
180
+ puts format(ROW_FMT,
181
+ file: "TOTAL", evilution: totals[:ev].to_s, reference: totals[:ref].to_s, ratio: "N/A")
182
+ else
183
+ puts format(TOTAL_FMT,
184
+ file: "TOTAL", evilution: totals[:ev], reference: totals[:ref], ratio: totals[:display_ratio])
185
+ end
186
+ end
187
+
188
+ def compute_totals(results)
189
+ valid, errored = partition_results(results)
190
+ ev_total = valid.sum { |r| r[:evilution] }
191
+ ref_total = valid.sum { |r| r[:reference] }
192
+ invalid = errored.any? || ev_total.zero?
193
+ ratio = ev_total.positive? ? ref_total.to_f / ev_total : 0.0
194
+
195
+ { ev: ev_total, ref: ref_total, ratio: ratio, display_ratio: ratio.round(2),
196
+ invalid: invalid, error_count: errored.length }
197
+ end
198
+
199
+ def partition_results(results)
200
+ valid = results.select { |r| r[:evilution] && r[:reference] }
201
+ errored = results - valid
202
+ [valid, errored]
203
+ end
204
+
205
+ def print_status_line(totals)
206
+ target = @config.target_ratio
207
+ if totals[:invalid]
208
+ puts "Density ratio: INVALID (target: < #{target}x) — FAIL"
209
+ return
210
+ end
211
+
212
+ status = totals[:ratio] < target ? "PASS" : "FAIL"
213
+ puts "Density ratio: #{totals[:display_ratio]}x (target: < #{target}x) — #{status}"
214
+ end
215
+
216
+ def print_error_warning(error_count)
217
+ puts "WARNING: #{error_count} file(s) had errors — benchmark marked invalid" if error_count.positive?
218
+ end
219
+
220
+ def passing?(results)
221
+ totals = compute_totals(results)
222
+ return false if totals[:invalid]
223
+
224
+ totals[:ratio] < @config.target_ratio
225
+ end
226
+
227
+ def compute_ratio(evilution, reference)
228
+ return nil if evilution.nil? || reference.nil? || evilution.zero?
229
+
230
+ (reference.to_f / evilution).round(2)
231
+ end
232
+ end
233
+ end
234
+
235
+ if __FILE__ == $PROGRAM_NAME
236
+ verbose = false
237
+ parser = OptionParser.new do |opts|
238
+ opts.banner = "Usage: #{$PROGRAM_NAME} CONFIG_FILE [--verbose]"
239
+ opts.on("-v", "--verbose", "Show progress") { verbose = true }
240
+ opts.on("-h", "--help", "Show help") do
241
+ puts opts
242
+ exit 0
243
+ end
244
+ end
245
+ parser.parse!
246
+
247
+ config_path = ARGV.first
248
+ unless config_path
249
+ warn parser.banner
250
+ exit 2
251
+ end
252
+
253
+ begin
254
+ config = BenchmarkDensity::Config.new(config_path)
255
+ runner = BenchmarkDensity::Runner.new(config: config, verbose: verbose)
256
+ exit runner.call
257
+ rescue BenchmarkDensity::ConfigError => e
258
+ warn "Config error: #{e.message}"
259
+ exit 2
260
+ end
261
+ end
@@ -0,0 +1,19 @@
1
+ # Benchmark corpus configuration
2
+ #
3
+ # See docs/mutation_density_benchmark.md for selection criteria and methodology.
4
+ #
5
+ # Example:
6
+ #
7
+ # project_root: /path/to/rails/app
8
+ # target_ratio: 1.5
9
+ # reference_cmd: ["bundle", "exec", "mutant", "run", "--integration", "rspec", "--fail-fast"]
10
+ # files:
11
+ # - path: app/models/user.rb
12
+ # reference_target: "User"
13
+ # - path: app/services/payment_processor.rb
14
+ # reference_target: "PaymentProcessor"
15
+
16
+ project_root: null # Set to the absolute path of the target project
17
+ target_ratio: 1.5
18
+ reference_cmd: [] # Command prefix for the reference tool
19
+ files: []