evilution 0.21.0 → 0.22.1

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.gitignore +4 -0
  3. data/.beads/interactions.jsonl +16 -0
  4. data/.beads/issues.jsonl +9 -6
  5. data/.claude/settings.json +5 -0
  6. data/CHANGELOG.md +35 -0
  7. data/README.md +28 -13
  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 +2 -1
  15. data/lib/evilution/config.rb +15 -3
  16. data/lib/evilution/disable_comment.rb +2 -1
  17. data/lib/evilution/integration/base.rb +124 -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 -100
  21. data/lib/evilution/isolation/fork.rb +11 -3
  22. data/lib/evilution/isolation/in_process.rb +12 -3
  23. data/lib/evilution/mcp/mutate_tool.rb +6 -6
  24. data/lib/evilution/mutator/base.rb +4 -0
  25. data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
  26. data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
  27. data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
  28. data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
  29. data/lib/evilution/mutator/operator/index_to_dig.rb +2 -2
  30. data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
  31. data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
  32. data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
  33. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
  34. data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
  35. data/lib/evilution/mutator/operator/symbol_literal.rb +9 -0
  36. data/lib/evilution/mutator/registry.rb +3 -0
  37. data/lib/evilution/reporter/cli.rb +19 -0
  38. data/lib/evilution/reporter/html.rb +12 -3
  39. data/lib/evilution/reporter/json.rb +14 -3
  40. data/lib/evilution/reporter/suggestion.rb +659 -2
  41. data/lib/evilution/result/mutation_result.rb +9 -2
  42. data/lib/evilution/runner.rb +56 -17
  43. data/lib/evilution/spec_resolver.rb +24 -16
  44. data/lib/evilution/version.rb +1 -1
  45. data/lib/evilution.rb +4 -0
  46. data/script/memory_check +5 -5
  47. data/scripts/benchmark_density +261 -0
  48. data/scripts/benchmark_density.yml +19 -0
  49. data/scripts/compare_mutations +404 -0
  50. data/scripts/compare_mutations.yml +24 -0
  51. data/scripts/mutant_json_adapter +224 -0
  52. metadata +17 -2
@@ -6,11 +6,15 @@ class Evilution::Result::MutationResult
6
6
  STATUSES = %i[killed survived timeout error neutral equivalent].freeze
7
7
 
8
8
  attr_reader :mutation, :status, :duration, :killing_test, :test_command,
9
- :child_rss_kb, :memory_delta_kb, :parent_rss_kb
9
+ :child_rss_kb, :memory_delta_kb, :parent_rss_kb,
10
+ :error_message, :error_class, :error_backtrace
10
11
 
12
+ # rubocop:disable Metrics/ParameterLists
11
13
  def initialize(mutation:, status:, duration: 0.0, killing_test: nil,
12
14
  test_command: nil, child_rss_kb: nil, memory_delta_kb: nil,
13
- parent_rss_kb: nil)
15
+ parent_rss_kb: nil, error_message: nil, error_class: nil,
16
+ error_backtrace: nil)
17
+ # rubocop:enable Metrics/ParameterLists
14
18
  raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
15
19
 
16
20
  @mutation = mutation
@@ -21,6 +25,9 @@ class Evilution::Result::MutationResult
21
25
  @child_rss_kb = child_rss_kb
22
26
  @memory_delta_kb = memory_delta_kb
23
27
  @parent_rss_kb = parent_rss_kb
28
+ @error_message = error_message
29
+ @error_class = error_class
30
+ @error_backtrace = error_backtrace.nil? ? nil : error_backtrace.dup.freeze
24
31
  freeze
25
32
  end
26
33
 
@@ -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"
@@ -26,6 +27,11 @@ require_relative "disable_comment"
26
27
  require_relative "ast/sorbet_sig_detector"
27
28
 
28
29
  class Evilution::Runner
30
+ INTEGRATIONS = {
31
+ rspec: Evilution::Integration::RSpec,
32
+ minitest: Evilution::Integration::Minitest
33
+ }.freeze
34
+
29
35
  attr_reader :config
30
36
 
31
37
  def initialize(config: Evilution::Config.new, on_result: nil, hooks: nil)
@@ -286,7 +292,8 @@ class Evilution::Runner
286
292
  return nil unless config.baseline? && subjects.any?
287
293
 
288
294
  log_baseline_start
289
- 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)
290
297
  result = baseline.call(subjects)
291
298
  log_baseline_complete(result)
292
299
  result
@@ -305,7 +312,7 @@ class Evilution::Runner
305
312
 
306
313
  def run_mutations_sequential(mutations, baseline_result = nil)
307
314
  integration = build_integration
308
- spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
315
+ spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
309
316
  results = []
310
317
  survived_count = 0
311
318
  truncated = false
@@ -334,7 +341,7 @@ class Evilution::Runner
334
341
  integration = build_integration
335
342
  pool = Evilution::Parallel::Pool.new(size: config.jobs, hooks: @hooks, item_timeout: config.timeout ? config.timeout * 2 : nil)
336
343
  worker_isolator = Evilution::Isolation::InProcess.new
337
- spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
344
+ spec_resolver = baseline_result&.failed? ? build_neutralization_resolver : nil
338
345
  state = { results: [], survived_count: 0, truncated: false, completed: 0 }
339
346
 
340
347
  all_worker_stats = []
@@ -392,7 +399,7 @@ class Evilution::Runner
392
399
  if config.spec_files.any?
393
400
  neutralize = true
394
401
  else
395
- spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
402
+ spec_file = spec_resolver.call(result.mutation.file_path) || neutralization_fallback_dir
396
403
  neutralize = baseline_result.failed_spec_files.include?(spec_file)
397
404
  end
398
405
  return result unless neutralize
@@ -404,7 +411,10 @@ class Evilution::Runner
404
411
  test_command: result.test_command,
405
412
  child_rss_kb: result.child_rss_kb,
406
413
  memory_delta_kb: result.memory_delta_kb,
407
- parent_rss_kb: result.parent_rss_kb
414
+ parent_rss_kb: result.parent_rss_kb,
415
+ error_message: result.error_message,
416
+ error_class: result.error_class,
417
+ error_backtrace: result.error_backtrace
408
418
  )
409
419
  end
410
420
 
@@ -416,7 +426,10 @@ class Evilution::Runner
416
426
  test_command: result.test_command,
417
427
  child_rss_kb: result.child_rss_kb,
418
428
  memory_delta_kb: result.memory_delta_kb,
419
- parent_rss_kb: result.parent_rss_kb
429
+ parent_rss_kb: result.parent_rss_kb,
430
+ error_message: result.error_message,
431
+ error_class: result.error_class,
432
+ error_backtrace: result.error_backtrace
420
433
  }
421
434
  end
422
435
 
@@ -430,7 +443,10 @@ class Evilution::Runner
430
443
  test_command: data[:test_command],
431
444
  child_rss_kb: data[:child_rss_kb],
432
445
  memory_delta_kb: data[:memory_delta_kb],
433
- parent_rss_kb: data[:parent_rss_kb]
446
+ parent_rss_kb: data[:parent_rss_kb],
447
+ error_message: data[:error_message],
448
+ error_class: data[:error_class],
449
+ error_backtrace: data[:error_backtrace]
434
450
  )
435
451
  end
436
452
  end
@@ -472,16 +488,28 @@ class Evilution::Runner
472
488
  :in_process
473
489
  end
474
490
 
475
- def build_integration
476
- case config.integration
477
- when :rspec
478
- test_files = config.spec_files.empty? ? nil : config.spec_files
479
- Evilution::Integration::RSpec.new(test_files: test_files, hooks: @hooks)
480
- else
491
+ def resolve_integration_class
492
+ INTEGRATIONS.fetch(config.integration) do
481
493
  raise Evilution::Error, "unknown integration: #{config.integration}"
482
494
  end
483
495
  end
484
496
 
497
+ def build_integration
498
+ klass = resolve_integration_class
499
+ test_files = config.spec_files.empty? ? nil : config.spec_files
500
+ klass.new(test_files: test_files, hooks: @hooks)
501
+ end
502
+
503
+ def build_neutralization_resolver
504
+ options = resolve_integration_class.baseline_options
505
+ options[:spec_resolver] || Evilution::SpecResolver.new
506
+ end
507
+
508
+ def neutralization_fallback_dir
509
+ options = resolve_integration_class.baseline_options
510
+ options[:fallback_dir] || "spec"
511
+ end
512
+
485
513
  def output_report(summary)
486
514
  reporter = build_reporter
487
515
  return unless reporter
@@ -543,9 +571,20 @@ class Evilution::Runner
543
571
 
544
572
  parts << gc_stats_string
545
573
 
546
- return if parts.empty?
574
+ $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n") unless parts.empty?
547
575
 
548
- $stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n")
576
+ log_mutation_error(result) if result.error?
577
+ end
578
+
579
+ def log_mutation_error(result)
580
+ header = "[verbose] #{result.mutation}: error"
581
+ header += " #{result.error_class}" if result.error_class
582
+ header += ": #{result.error_message}" if result.error_message
583
+ $stderr.write("#{header}\n")
584
+
585
+ Array(result.error_backtrace).first(5).each do |line|
586
+ $stderr.write("[verbose] #{line}\n")
587
+ end
549
588
  end
550
589
 
551
590
  def gc_stats_string
@@ -604,11 +643,11 @@ class Evilution::Runner
604
643
  def build_reporter
605
644
  case config.format
606
645
  when :json
607
- Evilution::Reporter::JSON.new
646
+ Evilution::Reporter::JSON.new(integration: config.integration)
608
647
  when :text
609
648
  Evilution::Reporter::CLI.new
610
649
  when :html
611
- Evilution::Reporter::HTML.new(baseline: load_baseline_session)
650
+ Evilution::Reporter::HTML.new(baseline: load_baseline_session, integration: config.integration)
612
651
  end
613
652
  end
614
653
 
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.21.0"
4
+ VERSION = "0.22.1"
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"
data/script/memory_check CHANGED
@@ -104,12 +104,12 @@ complex_mutations = complex_subjects.flat_map { |s| complex_registry.mutations_f
104
104
 
105
105
  integration = Evilution::Integration::RSpec.new(test_files: [COMPLEX_FIXTURE_SPEC])
106
106
 
107
- all_passed &= run_check("RSpec integration per-mutation (Config)", iterations: 20, max_growth_kb: 20_480) do
107
+ # Budget is generous: per-mutation require() adds the file to $LOADED_FEATURES and
108
+ # accumulates constant-redefinition warnings. Local runs land around 20-25 MB; CI
109
+ # varies up to ~30 MB depending on Ruby build and GC pressure, so we leave headroom.
110
+ all_passed &= run_check("RSpec integration per-mutation (Config)", iterations: 20, max_growth_kb: 40_960) do
108
111
  mutation = complex_mutations.sample
109
- result = integration.call(mutation)
110
- raise "RSpec integration memory check failed: #{result[:error]}" if result[:error]
111
-
112
- result
112
+ integration.call(mutation)
113
113
  end
114
114
 
115
115
  puts all_passed ? "All memory checks passed." : "Some memory checks failed!"
@@ -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: []