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.
- checksums.yaml +4 -4
- data/.beads/.gitignore +4 -0
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/interactions.jsonl +12 -0
- data/.beads/issues.jsonl +22 -19
- data/CHANGELOG.md +35 -0
- data/README.md +17 -11
- data/comparison_results/baseline_2026-04-09.md +35 -0
- data/comparison_results/operator_classification.md +79 -0
- data/comparison_results/operator_prioritization.md +68 -0
- data/docs/mutation_density_benchmark.md +91 -0
- data/lib/evilution/ast/parser.rb +2 -1
- data/lib/evilution/baseline.rb +14 -11
- data/lib/evilution/cli.rb +13 -3
- data/lib/evilution/config.rb +27 -5
- data/lib/evilution/disable_comment.rb +2 -1
- data/lib/evilution/integration/base.rb +98 -1
- data/lib/evilution/integration/minitest.rb +145 -0
- data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
- data/lib/evilution/integration/rspec.rb +33 -92
- data/lib/evilution/isolation/fork.rb +3 -6
- data/lib/evilution/mcp/mutate_tool.rb +6 -6
- data/lib/evilution/mutator/base.rb +5 -1
- data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
- data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
- data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
- data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +3 -3
- data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
- data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
- data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
- data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
- data/lib/evilution/mutator/operator/string_literal.rb +18 -0
- data/lib/evilution/mutator/registry.rb +12 -2
- data/lib/evilution/reporter/html.rb +2 -2
- data/lib/evilution/reporter/json.rb +2 -2
- data/lib/evilution/reporter/suggestion.rb +659 -2
- data/lib/evilution/runner.rb +59 -13
- data/lib/evilution/spec_resolver.rb +24 -16
- data/lib/evilution/temp_dir_tracker.rb +39 -0
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +4 -0
- data/scripts/benchmark_density +261 -0
- data/scripts/benchmark_density.yml +19 -0
- data/scripts/compare_mutations +404 -0
- data/scripts/compare_mutations.yml +24 -0
- data/scripts/mutant_json_adapter +224 -0
- 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.
|
data/lib/evilution/ast/parser.rb
CHANGED
|
@@ -70,7 +70,8 @@ module Evilution::AST
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
loc = node.location
|
|
73
|
-
method_source = @source
|
|
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,
|
data/lib/evilution/baseline.rb
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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!(
|
|
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
|
|
101
|
-
"Use --spec to specify the
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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")
|
data/lib/evilution/config.rb
CHANGED
|
@@ -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
|
|
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]
|
|
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
|
|
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
|
-
|
|
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
|