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
@@ -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,9 +247,10 @@ 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
@@ -125,7 +125,7 @@ class Evilution::Config
125
125
  # Minimum mutation score to pass (0.0 to 1.0, default: 0.0)
126
126
  # min_score: 0.0
127
127
 
128
- # Test integration: rspec (default: rspec)
128
+ # Test integration: rspec, minitest (default: rspec)
129
129
  # integration: rspec
130
130
 
131
131
  # Number of parallel workers (default: 1)
@@ -134,7 +134,7 @@ class Evilution::Config
134
134
  # Stop after N surviving mutants (default: disabled)
135
135
  # fail_fast: 1
136
136
 
137
- # Generate concrete RSpec test code in suggestions (default: false)
137
+ # Generate concrete test code in suggestions, matching integration (default: false)
138
138
  # suggest_tests: false
139
139
 
140
140
  # Skip all string literal mutations inside heredocs (default: false)
@@ -172,7 +172,7 @@ class Evilution::Config
172
172
  @format = merged[:format].to_sym
173
173
  @target = merged[:target]
174
174
  @min_score = merged[:min_score].to_f
175
- @integration = merged[:integration].to_sym
175
+ @integration = validate_integration(merged[:integration])
176
176
  @verbose = merged[:verbose]
177
177
  @quiet = merged[:quiet]
178
178
  @jobs = validate_jobs(merged[:jobs])
@@ -192,6 +192,18 @@ class Evilution::Config
192
192
  @hooks = validate_hooks(merged[:hooks])
193
193
  end
194
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
+
195
207
  def validate_isolation(value)
196
208
  raise Evilution::ConfigError, "isolation must be auto, fork, or in_process, got nil" if value.nil?
197
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,136 @@
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
+ load_error = apply_mutation(mutation)
26
+ return load_error if load_error
27
+
28
+ fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
29
+ run_tests(mutation)
30
+ ensure
31
+ restore_original(mutation)
32
+ end
33
+
34
+ private
35
+
36
+ def ensure_framework_loaded
37
+ raise NotImplementedError, "#{self.class}#ensure_framework_loaded must be implemented"
38
+ end
39
+
40
+ def run_tests(_mutation)
41
+ raise NotImplementedError, "#{self.class}#run_tests must be implemented"
42
+ end
43
+
44
+ def build_args(_mutation)
45
+ raise NotImplementedError, "#{self.class}#build_args must be implemented"
46
+ end
47
+
48
+ def reset_state
49
+ raise NotImplementedError, "#{self.class}#reset_state must be implemented"
50
+ end
51
+
52
+ def fire_hook(event, **payload)
53
+ @hooks.fire(event, **payload) if @hooks
54
+ end
55
+
56
+ def apply_mutation(mutation)
57
+ @temp_dir = Dir.mktmpdir("evilution")
58
+ Evilution::TempDirTracker.register(@temp_dir)
59
+ @displaced_feature = nil
60
+ subpath = resolve_require_subpath(mutation.file_path)
61
+
62
+ if subpath
63
+ apply_via_require(mutation, subpath)
64
+ else
65
+ apply_via_load(mutation)
66
+ end
67
+ nil
68
+ rescue SyntaxError => e
69
+ {
70
+ passed: false,
71
+ error: "syntax error in mutated source: #{e.message}",
72
+ error_class: e.class.name,
73
+ error_backtrace: Array(e.backtrace).first(5)
74
+ }
75
+ rescue ScriptError, StandardError => e
76
+ {
77
+ passed: false,
78
+ error: "#{e.class}: #{e.message}",
79
+ error_class: e.class.name,
80
+ error_backtrace: Array(e.backtrace).first(5)
81
+ }
82
+ end
83
+
84
+ def apply_via_require(mutation, subpath)
85
+ dest = File.join(@temp_dir, subpath)
86
+ FileUtils.mkdir_p(File.dirname(dest))
87
+ File.write(dest, mutation.mutated_source)
88
+ $LOAD_PATH.unshift(@temp_dir)
89
+ displace_loaded_feature(mutation.file_path)
90
+ require(subpath.delete_suffix(".rb"))
91
+ end
92
+
93
+ def apply_via_load(mutation)
94
+ absolute = File.expand_path(mutation.file_path)
95
+ dest = File.join(@temp_dir, absolute)
96
+ FileUtils.mkdir_p(File.dirname(dest))
97
+ File.write(dest, mutation.mutated_source)
98
+ load(dest)
99
+ end
100
+
101
+ def restore_original(_mutation)
102
+ return unless @temp_dir
103
+
104
+ $LOAD_PATH.delete(@temp_dir)
105
+ $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
106
+ $LOADED_FEATURES << @displaced_feature if @displaced_feature && !$LOADED_FEATURES.include?(@displaced_feature)
107
+ @displaced_feature = nil
108
+ FileUtils.rm_rf(@temp_dir)
109
+ Evilution::TempDirTracker.unregister(@temp_dir)
110
+ @temp_dir = nil
111
+ end
112
+
113
+ def resolve_require_subpath(file_path)
114
+ absolute = File.expand_path(file_path)
115
+ best_subpath = nil
116
+
117
+ $LOAD_PATH.each do |entry|
118
+ dir = File.expand_path(entry)
119
+ prefix = dir.end_with?("/") ? dir : "#{dir}/"
120
+ next unless absolute.start_with?(prefix)
121
+
122
+ candidate = absolute.delete_prefix(prefix)
123
+ best_subpath = candidate if best_subpath.nil? || candidate.length < best_subpath.length
124
+ end
125
+
126
+ best_subpath
127
+ end
128
+
129
+ def displace_loaded_feature(file_path)
130
+ absolute = File.expand_path(file_path)
131
+ return unless $LOADED_FEATURES.include?(absolute)
132
+
133
+ @displaced_feature = absolute
134
+ $LOADED_FEATURES.delete(absolute)
12
135
  end
13
136
  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
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ class Evilution::Integration::MinitestCrashDetector
6
+ def initialize
7
+ reset
8
+ end
9
+
10
+ def start
11
+ # Required by Minitest reporter interface
12
+ end
13
+
14
+ def report
15
+ # Required by Minitest reporter interface
16
+ end
17
+
18
+ def passed?
19
+ @crashes.empty?
20
+ end
21
+
22
+ def reset
23
+ @assertion_failures = 0
24
+ @crashes = []
25
+ end
26
+
27
+ def record(result)
28
+ result.failures.each do |failure|
29
+ if failure.is_a?(::Minitest::UnexpectedError)
30
+ @crashes << failure.error
31
+ elsif failure.is_a?(::Minitest::Assertion)
32
+ @assertion_failures += 1
33
+ end
34
+ end
35
+ end
36
+
37
+ def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
38
+ @assertion_failures.positive?
39
+ end
40
+
41
+ def has_crash? # rubocop:disable Naming/PredicatePrefix
42
+ @crashes.any?
43
+ end
44
+
45
+ def only_crashes?
46
+ @crashes.any? && @assertion_failures.zero?
47
+ end
48
+
49
+ def crash_summary
50
+ return nil if @crashes.empty?
51
+
52
+ types = @crashes.map { |e| e.class.name }.uniq
53
+ "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
54
+ end
55
+ end