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
@@ -0,0 +1,68 @@
1
+ # Operator Addition Prioritization
2
+
3
+ Date: 2026-04-09
4
+ Based on: EV-250 classification of 3,901 reference tool mutations across 10 files
5
+
6
+ ## Current Status
7
+
8
+ - Density ratio: **1.34x** (target: < 1.5x) — **already passing**
9
+ - Evilution: 68 operators covering 14/20 reference categories fully, 4 partially
10
+ - Effective signal gap after removing noise: ~1.0-1.1x (near parity)
11
+
12
+ ## Prioritized Operator Additions
13
+
14
+ Ranked by: (a) signal frequency, (b) implementation complexity, (c) equivalent mutant rate.
15
+
16
+ ### Priority 1: High signal, low complexity
17
+
18
+ | # | Operator | Signal Count | Complexity | Equiv. Rate | Notes |
19
+ |---|----------|-------------|-----------|-------------|-------|
20
+ | 1 | `[]` → `.at()` substitution | 60 | Low | Low (~10%) | Catches unchecked array/hash access. Single AST node transform. New operator needed. |
21
+
22
+ **Rationale:** Only uncovered category with real signal. `.at()` returns nil
23
+ instead of raising on out-of-bounds, exposing missing bounds checks. Simple to
24
+ implement — match `CallNode` with name `[]` on collection receivers, emit `.at()`
25
+ variant.
26
+
27
+ ### Priority 2: Improve existing coverage (partial gaps)
28
+
29
+ | # | Operator | Gap Area | Complexity | Equiv. Rate | Notes |
30
+ |---|----------|----------|-----------|-------------|-------|
31
+ | 2 | Regex simplification (EV-230, #514) | 27 | Medium | Low (~15%) | Quantifier removal, anchor removal, character class simplification. Already scoped. |
32
+ | 3 | Block pass removal (`&...`) | 5 | Low | Medium (~30%) | Remove `&` block pass arguments (`&:symbol`, `&method(:name)`, etc). Marginal count but trivial to add. |
33
+
34
+ **Rationale:** EV-230 is already scoped with a GH issue. Block pass removal is
35
+ minimal effort for minimal gain — include only if doing a batch of small operators.
36
+
37
+ ### Priority 3: Do not implement
38
+
39
+ | # | Category | Count | Reason |
40
+ |---|----------|------:|--------|
41
+ | — | Guard clause restructuring | 570 | Pure noise — syntactic reformatting, not semantic mutation |
42
+ | — | Receiver self-swap (remaining) | ~140 | Mostly equivalent — `self.method` vs `method` rarely matters |
43
+ | — | Complex compound mutations | ~288 | Noise portion of multi-statement changes; not decomposable into discrete operators |
44
+
45
+ ## Implementation Order
46
+
47
+ 1. **EV-230** (#514) — Regex simplification operators (already scoped, medium complexity, 27 signal mutations)
48
+ 2. **New: `IndexToAt`** — `[]` → `.at()` substitution (60 signal mutations, low complexity)
49
+ 3. **New: `BlockPassRemoval`** — `&:method` removal (5 signal mutations, trivial)
50
+
51
+ ## Impact Assessment
52
+
53
+ | Scenario | Estimated Ratio | Delta |
54
+ |----------|----------------|-------|
55
+ | Current | 1.34x | — |
56
+ | After adding all Priority 1+2 | ~1.31x | -0.03x |
57
+
58
+ The density gap is already within target. These additions improve **signal
59
+ coverage** (catching real bugs that reference tool catches and evilution misses)
60
+ rather than closing the headline ratio, which is already healthy.
61
+
62
+ ## Recommendation
63
+
64
+ The density gap research (EV-238) can be considered **successful** — the 1.5x
65
+ target is met at 1.34x. Remaining work should focus on signal quality (regex
66
+ mutations, bounds checking) rather than chasing the ratio lower. The reference
67
+ tool's ~15% noise inflation means its raw count is not a meaningful target for
68
+ exact parity.
@@ -0,0 +1,91 @@
1
+ # Mutation Density Benchmark Methodology
2
+
3
+ ## Goal
4
+
5
+ Track and close the mutation density gap between evilution and a reference
6
+ mutation testing tool.
7
+ Current gap: **1.8-2.6x** (evilution generates fewer mutations).
8
+ Target: **< 1.5x** across the benchmark corpus.
9
+
10
+ ## Metric
11
+
12
+ **Density ratio** = `reference_mutations / evilution_mutations` per file.
13
+
14
+ A ratio of 1.0 means parity. Values above 1.0 mean the reference tool generates
15
+ more. The aggregate ratio is computed from total mutations across all benchmark
16
+ files (not an average of per-file ratios, which would over-weight small files).
17
+
18
+ ## Measurement Protocol
19
+
20
+ ### Benchmark corpus
21
+
22
+ Select **10 files** from a real-world Rails project covering diverse patterns:
23
+
24
+ | Slot | Category | Example |
25
+ |------|----------------------|----------------------------------|
26
+ | 1 | Controller | `app/controllers/*_controller.rb`|
27
+ | 2 | Model (ActiveRecord) | `app/models/*.rb` |
28
+ | 3 | Service object | `app/services/*.rb` |
29
+ | 4 | Validator | `app/validators/*.rb` |
30
+ | 5 | Concern / mixin | `app/models/concerns/*.rb` |
31
+ | 6 | Helper | `app/helpers/*.rb` |
32
+ | 7 | Formatter / presenter| `app/presenters/*.rb` |
33
+ | 8 | Lib utility | `lib/*.rb` |
34
+ | 9 | Job / worker | `app/jobs/*.rb` |
35
+ | 10 | Configuration / DSL | `config/initializers/*.rb` |
36
+
37
+ Files should be **50-300 LOC** (enough mutations to be meaningful, small enough
38
+ to run quickly). The exact file list is stored in the benchmark config file
39
+ (`scripts/benchmark_density.yml`).
40
+
41
+ ### Tool configuration
42
+
43
+ Both tools must run with equivalent settings:
44
+
45
+ - **evilution**: default operators, no `--skip-heredoc-literals`, no ignore patterns
46
+ - **reference tool**: default operator set, no timeout (we only count, not run)
47
+
48
+ The benchmark counts **generated mutations**, not killed/survived. This isolates
49
+ operator coverage from test quality.
50
+
51
+ ### Running the benchmark
52
+
53
+ ```bash
54
+ # Count-only mode (fast, no test execution):
55
+ scripts/benchmark_density scripts/benchmark_density.yml
56
+
57
+ # Full output with per-file breakdown:
58
+ scripts/benchmark_density scripts/benchmark_density.yml --verbose
59
+ ```
60
+
61
+ ### Output
62
+
63
+ The script produces a table:
64
+
65
+ ```
66
+ File Evilution Reference Ratio
67
+ app/models/user.rb 42 78 1.86x
68
+ app/services/payment.rb 31 52 1.68x
69
+ ...
70
+ TOTAL 312 534 1.71x
71
+ ```
72
+
73
+ And a summary: `Density ratio: 1.71x (target: < 1.50x)`.
74
+
75
+ ## When to Run
76
+
77
+ - **Before each release** that adds new operators
78
+ - **After closing operator issues** from the gap analysis epic (GH #515)
79
+ - **On demand** when evaluating whether a proposed operator is worth adding
80
+
81
+ ## Interpreting Results
82
+
83
+ - **Ratio < 1.5x**: target met
84
+ - **Ratio 1.5-2.0x**: progress, but more operators needed
85
+ - **Ratio > 2.0x**: significant gap remains
86
+ - **Per-file outliers**: files with ratio > 3.0x likely expose a missing operator category
87
+
88
+ Not all extra mutations from the reference tool are valuable. Some produce
89
+ equivalent mutants (semantically identical code). The head-to-head comparison
90
+ (GH #523) classifies each extra mutation as signal vs noise. The density ratio
91
+ is a **coarse progress metric**, not a quality score.
@@ -70,7 +70,8 @@ module Evilution::AST
70
70
  end
71
71
 
72
72
  loc = node.location
73
- method_source = @source[loc.start_offset...loc.end_offset]
73
+ method_source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
74
+ .force_encoding(@source.encoding)
74
75
 
75
76
  @subjects << Evilution::Subject.new(
76
77
  name: name,
@@ -14,9 +14,11 @@ class Evilution::Baseline
14
14
  end
15
15
  end
16
16
 
17
- def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30)
17
+ def initialize(spec_resolver: Evilution::SpecResolver.new, timeout: 30, runner: nil, fallback_dir: "spec")
18
18
  @spec_resolver = spec_resolver
19
19
  @timeout = timeout
20
+ @runner = runner
21
+ @fallback_dir = fallback_dir
20
22
  end
21
23
 
22
24
  def call(subjects)
@@ -33,10 +35,14 @@ class Evilution::Baseline
33
35
  end
34
36
 
35
37
  def run_spec_file(spec_file)
38
+ raise Evilution::Error, "no baseline runner configured" unless @runner
39
+
36
40
  read_io, write_io = IO.pipe
37
41
  pid = fork_spec_runner(spec_file, read_io, write_io)
38
42
  write_io.close
39
43
  read_result(read_io, pid)
44
+ rescue Evilution::Error
45
+ raise
40
46
  rescue StandardError
41
47
  false
42
48
  ensure
@@ -45,19 +51,16 @@ class Evilution::Baseline
45
51
  end
46
52
 
47
53
  def fork_spec_runner(spec_file, read_io, write_io)
54
+ runner = @runner
48
55
  Process.fork do
49
56
  read_io.close
50
57
  $stdout.reopen(File::NULL, "w")
51
58
  $stderr.reopen(File::NULL, "w")
52
59
 
53
- require "rspec/core"
54
- RSpec.reset
55
- status = RSpec::Core::Runner.run(
56
- ["--format", "progress", "--no-color", "--order", "defined", spec_file]
57
- )
58
- Marshal.dump({ passed: status.zero? }, write_io)
60
+ passed = runner.call(spec_file)
61
+ Marshal.dump({ passed: passed }, write_io)
59
62
  write_io.close
60
- exit!(status.zero? ? 0 : 1)
63
+ exit!(passed ? 0 : 1)
61
64
  end
62
65
  end
63
66
 
@@ -97,10 +100,10 @@ class Evilution::Baseline
97
100
  subjects.map do |s|
98
101
  resolved = @spec_resolver.call(s.file_path)
99
102
  if resolved.nil? && warned.add?(s.file_path)
100
- warn "[evilution] No matching spec found for #{s.file_path}, running full suite. " \
101
- "Use --spec to specify the spec file."
103
+ warn "[evilution] No matching test found for #{s.file_path}, running full suite. " \
104
+ "Use --spec to specify the test file."
102
105
  end
103
- resolved || "spec"
106
+ resolved || @fallback_dir
104
107
  end.uniq
105
108
  end
106
109
  end
data/lib/evilution/cli.rb CHANGED
@@ -247,14 +247,16 @@ class Evilution::CLI
247
247
  "(default: disabled; if provided without N, uses 1; use --fail-fast=N)") { @options[:fail_fast] ||= 1 }
248
248
  opts.on("--no-baseline", "Skip baseline test suite check") { @options[:baseline] = false }
249
249
  opts.on("--incremental", "Cache killed/timeout results; skip re-running them on unchanged files") { @options[:incremental] = true }
250
+ opts.on("--integration NAME", "Test integration: rspec, minitest (default: rspec)") { |i| @options[:integration] = i }
250
251
  opts.on("--isolation STRATEGY", "Isolation: auto, fork, in_process (default: auto)") { |s| @options[:isolation] = s }
251
252
  opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
252
- opts.on("--suggest-tests", "Generate concrete RSpec test code in suggestions") { @options[:suggest_tests] = true }
253
+ opts.on("--suggest-tests", "Generate concrete test code in suggestions (RSpec or Minitest)") { @options[:suggest_tests] = true }
253
254
  opts.on("--no-progress", "Disable progress bar") { @options[:progress] = false }
254
255
  add_extra_flag_options(opts)
255
256
  end
256
257
 
257
258
  def add_extra_flag_options(opts)
259
+ opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
258
260
  opts.on("--show-disabled", "Report mutations skipped by # evilution:disable") { @options[:show_disabled] = true }
259
261
  opts.on("--baseline-session PATH", "Compare against a baseline session in HTML report") { |p| @options[:baseline_session] = p }
260
262
  opts.on("--save-session", "Save session results to .evilution/results/") { @options[:save_session] = true }
@@ -298,10 +300,11 @@ class Evilution::CLI
298
300
 
299
301
  registry = Evilution::Mutator::Registry.default
300
302
  filter = build_subject_filter(config)
303
+ operator_options = build_operator_options(config)
301
304
  total_mutations = 0
302
305
 
303
306
  subjects.each do |subj|
304
- count = registry.mutations_for(subj, filter: filter).length
307
+ count = registry.mutations_for(subj, filter: filter, operator_options: operator_options).length
305
308
  total_mutations += count
306
309
  label = count == 1 ? "1 mutation" : "#{count} mutations"
307
310
  $stdout.puts(" #{subj.name} #{subj.file_path}:#{subj.line_number} (#{label})")
@@ -317,6 +320,10 @@ class Evilution::CLI
317
320
  2
318
321
  end
319
322
 
323
+ def build_operator_options(config)
324
+ { skip_heredoc_literals: config.skip_heredoc_literals? }
325
+ end
326
+
320
327
  def build_subject_filter(config)
321
328
  return nil if config.ignore_patterns.empty?
322
329
 
@@ -425,6 +432,7 @@ class Evilution::CLI
425
432
  " suggest_tests: #{config.suggest_tests}",
426
433
  " save_session: #{config.save_session}",
427
434
  " target: #{config.target || "(all files)"}",
435
+ " skip_heredoc_literals: #{config.skip_heredoc_literals}",
428
436
  " ignore_patterns: #{config.ignore_patterns.empty? ? "(none)" : config.ignore_patterns.inspect}"
429
437
  ]
430
438
  end
@@ -481,8 +489,10 @@ class Evilution::CLI
481
489
  def run_util_mutation
482
490
  source, file_path = resolve_util_mutation_source
483
491
  subjects = parse_source_to_subjects(source, file_path)
492
+ config = Evilution::Config.new(**@options)
484
493
  registry = Evilution::Mutator::Registry.default
485
- mutations = subjects.flat_map { |s| registry.mutations_for(s) }
494
+ operator_options = build_operator_options(config)
495
+ mutations = subjects.flat_map { |s| registry.mutations_for(s, operator_options: operator_options) }
486
496
 
487
497
  if mutations.empty?
488
498
  $stdout.puts("No mutations generated")
@@ -25,14 +25,16 @@ class Evilution::Config
25
25
  spec_files: [],
26
26
  ignore_patterns: [],
27
27
  show_disabled: false,
28
- baseline_session: nil
28
+ baseline_session: nil,
29
+ skip_heredoc_literals: false
29
30
  }.freeze
30
31
 
31
32
  attr_reader :target_files, :timeout, :format,
32
33
  :target, :min_score, :integration, :verbose, :quiet,
33
34
  :jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
34
35
  :progress, :save_session, :line_ranges, :spec_files, :hooks,
35
- :ignore_patterns, :show_disabled, :baseline_session
36
+ :ignore_patterns, :show_disabled, :baseline_session,
37
+ :skip_heredoc_literals
36
38
 
37
39
  def initialize(**options)
38
40
  file_options = options.delete(:skip_config_file) ? {} : load_config_file
@@ -89,6 +91,10 @@ class Evilution::Config
89
91
  show_disabled
90
92
  end
91
93
 
94
+ def skip_heredoc_literals?
95
+ skip_heredoc_literals
96
+ end
97
+
92
98
  def self.file_options
93
99
  CONFIG_FILES.each do |path|
94
100
  next unless File.exist?(path)
@@ -119,7 +125,7 @@ class Evilution::Config
119
125
  # Minimum mutation score to pass (0.0 to 1.0, default: 0.0)
120
126
  # min_score: 0.0
121
127
 
122
- # Test integration: rspec (default: rspec)
128
+ # Test integration: rspec, minitest (default: rspec)
123
129
  # integration: rspec
124
130
 
125
131
  # Number of parallel workers (default: 1)
@@ -128,9 +134,12 @@ class Evilution::Config
128
134
  # Stop after N surviving mutants (default: disabled)
129
135
  # fail_fast: 1
130
136
 
131
- # Generate concrete RSpec test code in suggestions (default: false)
137
+ # Generate concrete test code in suggestions, matching integration (default: false)
132
138
  # suggest_tests: false
133
139
 
140
+ # Skip all string literal mutations inside heredocs (default: false)
141
+ # skip_heredoc_literals: false
142
+
134
143
  # Hooks: Ruby files returning a Proc, keyed by lifecycle event
135
144
  # hooks:
136
145
  # worker_process_start: config/evilution_hooks/worker_start.rb
@@ -163,7 +172,7 @@ class Evilution::Config
163
172
  @format = merged[:format].to_sym
164
173
  @target = merged[:target]
165
174
  @min_score = merged[:min_score].to_f
166
- @integration = merged[:integration].to_sym
175
+ @integration = validate_integration(merged[:integration])
167
176
  @verbose = merged[:verbose]
168
177
  @quiet = merged[:quiet]
169
178
  @jobs = validate_jobs(merged[:jobs])
@@ -179,9 +188,22 @@ class Evilution::Config
179
188
  @ignore_patterns = validate_ignore_patterns(merged[:ignore_patterns])
180
189
  @show_disabled = merged[:show_disabled]
181
190
  @baseline_session = merged[:baseline_session]
191
+ @skip_heredoc_literals = merged[:skip_heredoc_literals]
182
192
  @hooks = validate_hooks(merged[:hooks])
183
193
  end
184
194
 
195
+ def validate_integration(value)
196
+ raise Evilution::ConfigError, "integration must be rspec or minitest, got nil" if value.nil?
197
+
198
+ value = value.to_sym
199
+ unless %i[rspec minitest].include?(value)
200
+ raise Evilution::ConfigError,
201
+ "integration must be rspec or minitest, got #{value.inspect}"
202
+ end
203
+
204
+ value
205
+ end
206
+
185
207
  def validate_isolation(value)
186
208
  raise Evilution::ConfigError, "isolation must be auto, fork, or in_process, got nil" if value.nil?
187
209
 
@@ -22,7 +22,8 @@ class Evilution::DisableComment
22
22
  def classify_comments(parse_result, source)
23
23
  parse_result.comments.filter_map do |comment|
24
24
  loc = comment.location
25
- text = source[loc.start_offset...loc.end_offset]
25
+ text = source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
26
+ .force_encoding(source.encoding)
26
27
 
27
28
  if text.match?(DISABLE_MARKER)
28
29
  line = source.lines[loc.start_line - 1]
@@ -1,13 +1,110 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "tmpdir"
3
5
  require_relative "../integration"
6
+ require_relative "../temp_dir_tracker"
4
7
 
5
8
  class Evilution::Integration::Base
9
+ def self.baseline_runner
10
+ raise NotImplementedError, "#{name}.baseline_runner must be implemented"
11
+ end
12
+
13
+ def self.baseline_options
14
+ raise NotImplementedError, "#{name}.baseline_options must be implemented"
15
+ end
16
+
6
17
  def initialize(hooks: nil)
7
18
  @hooks = hooks
8
19
  end
9
20
 
10
21
  def call(mutation)
11
- raise NotImplementedError, "#{self.class}#call must be implemented"
22
+ @temp_dir = nil
23
+ ensure_framework_loaded
24
+ fire_hook(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path)
25
+ apply_mutation(mutation)
26
+ fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
27
+ run_tests(mutation)
28
+ ensure
29
+ restore_original(mutation)
30
+ end
31
+
32
+ private
33
+
34
+ def ensure_framework_loaded
35
+ raise NotImplementedError, "#{self.class}#ensure_framework_loaded must be implemented"
36
+ end
37
+
38
+ def run_tests(_mutation)
39
+ raise NotImplementedError, "#{self.class}#run_tests must be implemented"
40
+ end
41
+
42
+ def build_args(_mutation)
43
+ raise NotImplementedError, "#{self.class}#build_args must be implemented"
44
+ end
45
+
46
+ def reset_state
47
+ raise NotImplementedError, "#{self.class}#reset_state must be implemented"
48
+ end
49
+
50
+ def fire_hook(event, **payload)
51
+ @hooks.fire(event, **payload) if @hooks
52
+ end
53
+
54
+ def apply_mutation(mutation)
55
+ @temp_dir = Dir.mktmpdir("evilution")
56
+ Evilution::TempDirTracker.register(@temp_dir)
57
+ @displaced_feature = nil
58
+ subpath = resolve_require_subpath(mutation.file_path)
59
+
60
+ if subpath
61
+ dest = File.join(@temp_dir, subpath)
62
+ FileUtils.mkdir_p(File.dirname(dest))
63
+ File.write(dest, mutation.mutated_source)
64
+ $LOAD_PATH.unshift(@temp_dir)
65
+ displace_loaded_feature(mutation.file_path)
66
+ else
67
+ absolute = File.expand_path(mutation.file_path)
68
+ dest = File.join(@temp_dir, absolute)
69
+ FileUtils.mkdir_p(File.dirname(dest))
70
+ File.write(dest, mutation.mutated_source)
71
+ load(dest)
72
+ end
73
+ end
74
+
75
+ def restore_original(_mutation)
76
+ return unless @temp_dir
77
+
78
+ $LOAD_PATH.delete(@temp_dir)
79
+ $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
80
+ $LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
81
+ @displaced_feature = nil
82
+ FileUtils.rm_rf(@temp_dir)
83
+ Evilution::TempDirTracker.unregister(@temp_dir)
84
+ @temp_dir = nil
85
+ end
86
+
87
+ def resolve_require_subpath(file_path)
88
+ absolute = File.expand_path(file_path)
89
+ best_subpath = nil
90
+
91
+ $LOAD_PATH.each do |entry|
92
+ dir = File.expand_path(entry)
93
+ prefix = dir.end_with?("/") ? dir : "#{dir}/"
94
+ next unless absolute.start_with?(prefix)
95
+
96
+ candidate = absolute.delete_prefix(prefix)
97
+ best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
98
+ end
99
+
100
+ best_subpath
101
+ end
102
+
103
+ def displace_loaded_feature(file_path)
104
+ absolute = File.expand_path(file_path)
105
+ return unless $LOADED_FEATURES.include?(absolute)
106
+
107
+ @displaced_feature = absolute
108
+ $LOADED_FEATURES.delete(absolute)
12
109
  end
13
110
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "base"
5
+ require_relative "minitest_crash_detector"
6
+ require_relative "../spec_resolver"
7
+
8
+ require_relative "../integration"
9
+
10
+ class Evilution::Integration::Minitest < Evilution::Integration::Base
11
+ def self.baseline_runner
12
+ lambda { |test_file|
13
+ require "minitest"
14
+ require "stringio"
15
+ ::Minitest::Runnable.runnables.clear
16
+ files = File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
17
+ files.each { |f| load(File.expand_path(f)) }
18
+ out = StringIO.new
19
+ options = ::Minitest.process_args(["--seed", "0"])
20
+ options[:io] = out
21
+ reporter = ::Minitest::CompositeReporter.new
22
+ reporter << ::Minitest::SummaryReporter.new(out, options)
23
+ reporter.start
24
+ ::Minitest.__run(reporter, options)
25
+ reporter.report
26
+ reporter.passed?
27
+ }
28
+ end
29
+
30
+ def self.baseline_options
31
+ {
32
+ runner: baseline_runner,
33
+ spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration"),
34
+ fallback_dir: "test"
35
+ }
36
+ end
37
+
38
+ def initialize(test_files: nil, hooks: nil)
39
+ @test_files = test_files
40
+ @minitest_loaded = false
41
+ @spec_resolver = Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
42
+ @crash_detector = nil
43
+ @warned_files = Set.new
44
+ super(hooks: hooks)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :test_files
50
+
51
+ def ensure_framework_loaded
52
+ return if @minitest_loaded
53
+
54
+ fire_hook(:setup_integration_pre, integration: :minitest)
55
+ require "minitest"
56
+ @minitest_loaded = true
57
+ fire_hook(:setup_integration_post, integration: :minitest)
58
+ rescue LoadError => e
59
+ raise Evilution::Error, "minitest is required but not available: #{e.message}"
60
+ end
61
+
62
+ def run_tests(mutation)
63
+ reset_state
64
+ files = resolve_test_files(mutation)
65
+ command = "ruby -Itest #{files.join(" ")}"
66
+
67
+ files.each { |f| load(File.expand_path(f)) }
68
+
69
+ args = build_args(mutation)
70
+ detector = reset_crash_detector
71
+ passed = run_minitest(args, detector)
72
+
73
+ build_minitest_result(passed, command, detector)
74
+ rescue StandardError => e
75
+ { passed: false, error: e.message, test_command: command }
76
+ end
77
+
78
+ def build_args(_mutation)
79
+ ["--seed", "0"]
80
+ end
81
+
82
+ def reset_state
83
+ ::Minitest::Runnable.runnables.clear
84
+ end
85
+
86
+ def run_minitest(args, detector)
87
+ out = StringIO.new
88
+ options = ::Minitest.process_args(args)
89
+ options[:io] = out
90
+
91
+ reporter = ::Minitest::CompositeReporter.new
92
+ reporter << ::Minitest::SummaryReporter.new(out, options)
93
+ reporter << detector
94
+
95
+ reporter.start
96
+ ::Minitest.__run(reporter, options)
97
+ reporter.report
98
+
99
+ reporter.passed?
100
+ end
101
+
102
+ def reset_crash_detector
103
+ if @crash_detector
104
+ @crash_detector.reset
105
+ else
106
+ @crash_detector = Evilution::Integration::MinitestCrashDetector.new
107
+ end
108
+ @crash_detector
109
+ end
110
+
111
+ def build_minitest_result(passed, command, detector)
112
+ if passed
113
+ { passed: true, test_command: command }
114
+ elsif detector.only_crashes?
115
+ { passed: false, error: "test crashes: #{detector.crash_summary}", test_command: command }
116
+ else
117
+ { passed: false, test_command: command }
118
+ end
119
+ end
120
+
121
+ def resolve_test_files(mutation)
122
+ return test_files if test_files
123
+
124
+ resolved = @spec_resolver.call(mutation.file_path)
125
+ unless resolved
126
+ warn_unresolved_test(mutation.file_path)
127
+ return glob_test_files
128
+ end
129
+
130
+ [resolved]
131
+ end
132
+
133
+ def glob_test_files
134
+ files = Dir.glob("test/**/*_test.rb")
135
+ files.empty? ? ["test"] : files
136
+ end
137
+
138
+ def warn_unresolved_test(file_path)
139
+ return if @warned_files.include?(file_path)
140
+
141
+ @warned_files << file_path
142
+ warn "[evilution] No matching test found for #{file_path}, running full suite. " \
143
+ "Use --spec to specify the test file."
144
+ end
145
+ end