evilution 0.28.0 → 0.30.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +106 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +49 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/constant_names.rb +28 -11
  8. data/lib/evilution/ast/heredoc_span.rb +99 -0
  9. data/lib/evilution/ast/pattern/parser.rb +29 -17
  10. data/lib/evilution/baseline.rb +15 -2
  11. data/lib/evilution/cli/commands/compare.rb +13 -0
  12. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  13. data/lib/evilution/cli/commands/subjects.rb +6 -3
  14. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  15. data/lib/evilution/cli/parser/command_extractor.rb +12 -12
  16. data/lib/evilution/cli/parser/file_args.rb +3 -1
  17. data/lib/evilution/cli/parser/options_builder.rb +31 -3
  18. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  19. data/lib/evilution/cli/parser.rb +18 -20
  20. data/lib/evilution/cli/printers/environment.rb +19 -19
  21. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  22. data/lib/evilution/compare/normalizer.rb +10 -5
  23. data/lib/evilution/config/file_loader.rb +40 -1
  24. data/lib/evilution/config.rb +21 -11
  25. data/lib/evilution/disable_comment.rb +21 -12
  26. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  27. data/lib/evilution/feedback/setup_warning.rb +79 -0
  28. data/lib/evilution/gem_detector.rb +132 -0
  29. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  30. data/lib/evilution/integration/loading/mutation_applier.rb +35 -15
  31. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  32. data/lib/evilution/integration/minitest.rb +60 -16
  33. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  34. data/lib/evilution/integration/rspec.rb +20 -1
  35. data/lib/evilution/isolation/fork.rb +104 -27
  36. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  37. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  38. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  39. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  40. data/lib/evilution/mcp/info_tool.rb +10 -2
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
  42. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +49 -17
  45. data/lib/evilution/mcp/session_tool.rb +34 -22
  46. data/lib/evilution/mcp.rb +6 -0
  47. data/lib/evilution/mutation.rb +26 -16
  48. data/lib/evilution/mutator/base.rb +66 -16
  49. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  50. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  51. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  52. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  53. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  54. data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
  55. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  56. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  57. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  58. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  59. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
  60. data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
  61. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  62. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  63. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  64. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  65. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  66. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  67. data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
  68. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  69. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  70. data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
  71. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  72. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  73. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  74. data/lib/evilution/mutator/registry.rb +2 -0
  75. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  76. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  77. data/lib/evilution/parallel/work_queue.rb +35 -18
  78. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  79. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  80. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  81. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  82. data/lib/evilution/reporter/json.rb +54 -18
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  84. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  85. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  86. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  87. data/lib/evilution/result/mutation_result.rb +12 -6
  88. data/lib/evilution/runner/baseline_runner.rb +20 -9
  89. data/lib/evilution/runner/diagnostics.rb +13 -9
  90. data/lib/evilution/runner/isolation_resolver.rb +75 -12
  91. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  92. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  93. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  94. data/lib/evilution/runner/mutation_executor.rb +2 -0
  95. data/lib/evilution/runner/mutation_planner.rb +53 -16
  96. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  97. data/lib/evilution/runner.rb +3 -3
  98. data/lib/evilution/session/diff.rb +15 -6
  99. data/lib/evilution/session/schema.rb +44 -0
  100. data/lib/evilution/session/store.rb +5 -1
  101. data/lib/evilution/spec_ast_cache.rb +26 -12
  102. data/lib/evilution/version.rb +1 -1
  103. data/lib/evilution.rb +2 -0
  104. data/schema/evilution.config.schema.json +205 -0
  105. data/script/build_runtime_snapshot +88 -0
  106. data/script/memory_check +11 -5
  107. data/script/run_self_baseline +79 -0
  108. data/script/run_self_validation +54 -0
  109. data/scripts/benchmark_density +10 -9
  110. data/scripts/compare_mutations +38 -21
  111. data/scripts/mutant_json_adapter +7 -4
  112. metadata +16 -2
@@ -5,14 +5,19 @@ require_relative "../item_formatters"
5
5
  class Evilution::Reporter::CLI::ItemFormatters::CoverageGap
6
6
  def format(gap)
7
7
  location = "#{gap.file_path}:#{gap.line}"
8
- header = if gap.single?
9
- " #{gap.primary_operator}: #{location} (#{gap.subject_name})"
10
- else
11
- operators = gap.operator_names.join(", ")
12
- " #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{operators}]"
13
- end
8
+ "#{format_header(gap, location)}\n#{format_body(gap)}"
9
+ end
10
+
11
+ private
12
+
13
+ def format_header(gap, location)
14
+ return " #{gap.primary_operator}: #{location} (#{gap.subject_name})" if gap.single?
15
+
16
+ " #{location} (#{gap.subject_name}) [#{gap.count} mutations: #{gap.operator_names.join(", ")}]"
17
+ end
18
+
19
+ def format_body(gap)
14
20
  body = gap.mutation_results.first.mutation.unified_diff || gap.primary_diff
15
- indented = body.split("\n").map { |l| " #{l}" }.join("\n")
16
- "#{header}\n#{indented}"
21
+ body.split("\n").map { |l| " #{l}" }.join("\n")
17
22
  end
18
23
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ # EV-nrgw / GH #1168: Score is `killed / score_denominator` where
6
+ # `score_denominator = total - errors - neutral - equivalent - unresolved -
7
+ # unparseable` — errors are excluded from the denominator entirely. A run
8
+ # can read PASS at 100% while 16 of 19 mutations silently errored. This
9
+ # formatter surfaces a warning right under the metrics block when the error
10
+ # rate crosses the threshold so the silent failure mode becomes loud.
11
+ class Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning
12
+ DEFAULT_THRESHOLD = 0.25
13
+
14
+ def initialize(threshold: DEFAULT_THRESHOLD)
15
+ @threshold = threshold
16
+ end
17
+
18
+ def format(summary)
19
+ return nil if summary.total.zero?
20
+ return nil if summary.errors.zero?
21
+
22
+ rate = summary.errors.to_f / summary.total
23
+ return nil if rate <= @threshold
24
+
25
+ pct = (rate * 100).round(1)
26
+ "! High error rate: #{summary.errors}/#{summary.total} (#{pct}%) mutations errored — " \
27
+ "score may be unreliable. See the \"Errored mutations:\" section for the underlying cause."
28
+ end
29
+ end
@@ -3,14 +3,23 @@
3
3
  require_relative "../line_formatters"
4
4
 
5
5
  class Evilution::Reporter::CLI::LineFormatters::Mutations
6
+ OPTIONAL_FIELDS = %i[neutral equivalent unresolved unparseable skipped].freeze
7
+
6
8
  def format(summary)
7
- parts = "Mutations: #{summary.total} total, #{summary.killed} killed, " \
8
- "#{summary.survived} survived, #{summary.timed_out} timed out"
9
- parts += ", #{summary.neutral} neutral" if summary.neutral.positive?
10
- parts += ", #{summary.equivalent} equivalent" if summary.equivalent.positive?
11
- parts += ", #{summary.unresolved} unresolved" if summary.unresolved.positive?
12
- parts += ", #{summary.unparseable} unparseable" if summary.unparseable.positive?
13
- parts += ", #{summary.skipped} skipped" if summary.skipped.positive?
14
- parts
9
+ base_line(summary) + optional_sections(summary)
10
+ end
11
+
12
+ private
13
+
14
+ def base_line(summary)
15
+ "Mutations: #{summary.total} total, #{summary.killed} killed, " \
16
+ "#{summary.survived} survived, #{summary.timed_out} timed out"
17
+ end
18
+
19
+ def optional_sections(summary)
20
+ OPTIONAL_FIELDS.filter_map do |field|
21
+ count = summary.public_send(field)
22
+ ", #{count} #{field}" if count.positive?
23
+ end.join
15
24
  end
16
25
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../cli"
4
4
  require_relative "line_formatters/mutations"
5
5
  require_relative "line_formatters/score"
6
+ require_relative "line_formatters/error_rate_warning"
6
7
  require_relative "line_formatters/duration"
7
8
  require_relative "line_formatters/efficiency"
8
9
  require_relative "line_formatters/peak_memory"
@@ -11,6 +12,7 @@ class Evilution::Reporter::CLI::MetricsBlock
11
12
  DEFAULT_LINES = [
12
13
  Evilution::Reporter::CLI::LineFormatters::Mutations.new,
13
14
  Evilution::Reporter::CLI::LineFormatters::Score.new,
15
+ Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning.new,
14
16
  Evilution::Reporter::CLI::LineFormatters::Duration.new,
15
17
  Evilution::Reporter::CLI::LineFormatters::Efficiency.new,
16
18
  Evilution::Reporter::CLI::LineFormatters::PeakMemory.new
@@ -5,6 +5,7 @@ require "time"
5
5
  require_relative "suggestion"
6
6
 
7
7
  require_relative "../reporter"
8
+ require_relative "../session/schema"
8
9
 
9
10
  class Evilution::Reporter::JSON
10
11
  def initialize(suggest_tests: false, integration: :rspec)
@@ -19,21 +20,37 @@ class Evilution::Reporter::JSON
19
20
 
20
21
  def build_report(summary)
21
22
  report = {
23
+ schema_version: Evilution::Session::Schema::CURRENT_VERSION,
22
24
  version: Evilution::VERSION,
23
25
  timestamp: Time.now.iso8601,
24
26
  summary: build_summary(summary),
25
- survived: map_details(summary.survived_results),
26
27
  coverage_gaps: build_coverage_gaps(summary),
28
+ **result_categories(summary)
29
+ }
30
+ append_disabled_to_report(report, summary)
31
+ report
32
+ end
33
+
34
+ def result_categories(summary)
35
+ direct_categories(summary).merge(derived_categories(summary))
36
+ end
37
+
38
+ def direct_categories(summary)
39
+ {
40
+ survived: map_details(summary.survived_results),
27
41
  killed: map_details(summary.killed_results),
28
42
  neutral: map_details(summary.neutral_results),
29
- timed_out: map_details(summary.results.select(&:timeout?)),
30
- errors: map_details(summary.results.select(&:error?)),
31
43
  equivalent: map_details(summary.equivalent_results),
32
44
  unresolved: map_details(summary.unresolved_results),
33
45
  unparseable: map_details(summary.unparseable_results)
34
46
  }
35
- append_disabled_to_report(report, summary)
36
- report
47
+ end
48
+
49
+ def derived_categories(summary)
50
+ {
51
+ timed_out: map_details(summary.results.select(&:timeout?)),
52
+ errors: map_details(summary.results.select(&:error?))
53
+ }
37
54
  end
38
55
 
39
56
  def map_details(results)
@@ -47,7 +64,13 @@ class Evilution::Reporter::JSON
47
64
  end
48
65
 
49
66
  def build_summary(summary)
50
- data = {
67
+ data = build_core_summary(summary).merge(build_metrics_summary(summary))
68
+ append_optional_summary_fields(data, summary)
69
+ data
70
+ end
71
+
72
+ def build_core_summary(summary)
73
+ {
51
74
  total: summary.total,
52
75
  killed: summary.killed,
53
76
  survived: summary.survived,
@@ -56,23 +79,39 @@ class Evilution::Reporter::JSON
56
79
  neutral: summary.neutral,
57
80
  equivalent: summary.equivalent,
58
81
  unresolved: summary.unresolved,
59
- unparseable: summary.unparseable,
82
+ unparseable: summary.unparseable
83
+ }
84
+ end
85
+
86
+ def build_metrics_summary(summary)
87
+ {
60
88
  score: summary.score.round(4),
61
89
  duration: summary.duration.round(4),
62
90
  killtime: summary.killtime.round(4),
63
91
  efficiency: summary.efficiency.round(4),
64
92
  mutations_per_second: summary.mutations_per_second.round(2)
65
93
  }
94
+ end
95
+
96
+ def append_optional_summary_fields(data, summary)
66
97
  data[:truncated] = true if summary.truncated?
67
98
  data[:skipped] = summary.skipped if summary.skipped.positive?
68
99
  peak = summary.peak_memory_mb
69
100
  data[:peak_memory_mb] = peak.round(1) if peak
70
- data
71
101
  end
72
102
 
73
103
  def build_mutation_detail(result)
74
104
  mutation = result.mutation
75
- detail = {
105
+ detail = base_mutation_fields(mutation, result)
106
+ append_survived_fields(detail, mutation) if result.status == :survived
107
+ detail[:test_command] = result.test_command if result.test_command
108
+ append_memory_fields(detail, result)
109
+ append_error_fields(detail, result)
110
+ detail
111
+ end
112
+
113
+ def base_mutation_fields(mutation, result)
114
+ {
76
115
  operator: mutation.operator_name,
77
116
  file: mutation.file_path,
78
117
  line: mutation.line,
@@ -80,15 +119,12 @@ class Evilution::Reporter::JSON
80
119
  duration: result.duration.round(4),
81
120
  diff: mutation.diff
82
121
  }
83
- if result.status == :survived
84
- detail[:suggestion] = @suggestion.suggestion_for(mutation)
85
- unified = mutation.unified_diff
86
- detail[:unified_diff] = unified if unified
87
- end
88
- detail[:test_command] = result.test_command if result.test_command
89
- append_memory_fields(detail, result)
90
- append_error_fields(detail, result)
91
- detail
122
+ end
123
+
124
+ def append_survived_fields(detail, mutation)
125
+ detail[:suggestion] = @suggestion.suggestion_for(mutation)
126
+ unified = mutation.unified_diff
127
+ detail[:unified_diff] = unified if unified
92
128
  end
93
129
 
94
130
  def append_memory_fields(detail, result)
@@ -12,17 +12,4 @@ module Evilution::Reporter::Suggestion::DiffHelpers
12
12
  def sanitize_method_name(name)
13
13
  name.gsub(/[^a-zA-Z0-9_]/, "_").gsub(/_+/, "_").gsub(/\A_|_\z/, "")
14
14
  end
15
-
16
- def extract_diff_lines(diff)
17
- lines = diff.split("\n")
18
- original = lines.find { |l| l.start_with?("- ") }
19
- mutated = lines.find { |l| l.start_with?("+ ") }
20
- [clean_diff_line(original, "- "), clean_diff_line(mutated, "+ ")]
21
- end
22
-
23
- def clean_diff_line(line, prefix)
24
- return nil if line.nil?
25
-
26
- line.sub(/^#{Regexp.escape(prefix)}/, "").strip
27
- end
28
15
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../suggestion"
4
+
5
+ class Evilution::Reporter::Suggestion::DiffLines
6
+ def self.from_diff(raw_diff)
7
+ lines = raw_diff.split("\n")
8
+ new(
9
+ original: clean(lines.find { |l| l.start_with?("- ") }, "- "),
10
+ mutated: clean(lines.find { |l| l.start_with?("+ ") }, "+ ")
11
+ )
12
+ end
13
+
14
+ def self.clean(line, prefix)
15
+ return nil if line.nil?
16
+
17
+ line.sub(/^#{Regexp.escape(prefix)}/, "").strip
18
+ end
19
+ private_class_method :clean
20
+
21
+ attr_reader :original, :mutated
22
+
23
+ def initialize(original:, mutated:)
24
+ @original = original
25
+ @mutated = mutated
26
+ freeze
27
+ end
28
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../templates"
4
4
  require_relative "../diff_helpers"
5
+ require_relative "../diff_lines"
5
6
 
6
7
  module Evilution::Reporter::Suggestion::Templates::Minitest
7
8
  H = Evilution::Reporter::Suggestion::DiffHelpers
@@ -17,21 +18,26 @@ module Evilution::Reporter::Suggestion::Templates::Minitest
17
18
  end
18
19
 
19
20
  def self.build(test_name:, action: :changed, &body_block)
20
- lambda do |mutation|
21
- method_name = H.parse_method_name(mutation.subject.name)
22
- safe_name = H.sanitize_method_name(method_name)
23
- original_line, mutated_line = H.extract_diff_lines(mutation.diff)
24
- body = body_block.call(method_name)
25
- indented = body.lines.map { |l| " #{l}" }.join.chomp
21
+ ->(mutation) { render(test_name, action, body_block, mutation) }
22
+ end
26
23
 
27
- <<~MINITEST.strip
28
- # Mutation: #{format_header(action, original_line, mutated_line, mutation.subject.name)}
29
- # #{mutation.file_path}:#{mutation.line}
30
- def test_#{test_name}_#{safe_name}
31
- #{indented}
32
- end
33
- MINITEST
34
- end
24
+ def self.render(test_name, action, body_block, mutation)
25
+ method_name = H.parse_method_name(mutation.subject.name)
26
+ safe_name = H.sanitize_method_name(method_name)
27
+ diff_lines = Evilution::Reporter::Suggestion::DiffLines.from_diff(mutation.diff)
28
+ indented = indent_body(body_block.call(method_name))
29
+
30
+ <<~MINITEST.strip
31
+ # Mutation: #{format_header(action, diff_lines.original, diff_lines.mutated, mutation.subject.name)}
32
+ # #{mutation.file_path}:#{mutation.line}
33
+ def test_#{test_name}_#{safe_name}
34
+ #{indented}
35
+ end
36
+ MINITEST
37
+ end
38
+
39
+ def self.indent_body(body)
40
+ body.lines.map { |l| " #{l}" }.join.chomp
35
41
  end
36
42
 
37
43
  MINITEST_ENTRIES = {
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../templates"
4
4
  require_relative "../diff_helpers"
5
+ require_relative "../diff_lines"
5
6
 
6
7
  module Evilution::Reporter::Suggestion::Templates::Rspec
7
8
  H = Evilution::Reporter::Suggestion::DiffHelpers
@@ -17,20 +18,25 @@ module Evilution::Reporter::Suggestion::Templates::Rspec
17
18
  end
18
19
 
19
20
  def self.build(it_desc:, action: :changed, &body_block)
20
- lambda do |mutation|
21
- method_name = H.parse_method_name(mutation.subject.name)
22
- original_line, mutated_line = H.extract_diff_lines(mutation.diff)
23
- body = body_block.call(method_name)
24
- indented = body.lines.map { |l| " #{l}" }.join.chomp
21
+ ->(mutation) { render(it_desc, action, body_block, mutation) }
22
+ end
25
23
 
26
- <<~RSPEC.strip
27
- # Mutation: #{format_header(action, original_line, mutated_line, mutation.subject.name)}
28
- # #{mutation.file_path}:#{mutation.line}
29
- it '#{it_desc} ##{method_name}' do
30
- #{indented}
31
- end
32
- RSPEC
33
- end
24
+ def self.render(it_desc, action, body_block, mutation)
25
+ method_name = H.parse_method_name(mutation.subject.name)
26
+ diff_lines = Evilution::Reporter::Suggestion::DiffLines.from_diff(mutation.diff)
27
+ indented = indent_body(body_block.call(method_name))
28
+
29
+ <<~RSPEC.strip
30
+ # Mutation: #{format_header(action, diff_lines.original, diff_lines.mutated, mutation.subject.name)}
31
+ # #{mutation.file_path}:#{mutation.line}
32
+ it '#{it_desc} ##{method_name}' do
33
+ #{indented}
34
+ end
35
+ RSPEC
36
+ end
37
+
38
+ def self.indent_body(body)
39
+ body.lines.map { |l| " #{l}" }.join.chomp
34
40
  end
35
41
 
36
42
  RSPEC_ENTRIES = {
@@ -23,28 +23,34 @@ class Evilution::Result::MutationResult
23
23
  freeze
24
24
  end
25
25
 
26
+ # Positive type checks, not nil checks. EV-s5br / GH #1174: the
27
+ # nil_replacement mutator can swap a nil default into `false` (or some other
28
+ # non-typed value), and `nil?` then returns false, sending a missing method
29
+ # to the wrong receiver and crashing the parent worker. Asking the field
30
+ # explicitly whether it is the expected struct keeps the parent process
31
+ # alive and lets the mutation count as a measured (errored) result.
26
32
  def child_rss_kb
27
- @memory.nil? ? nil : @memory.child_rss_kb
33
+ @memory.is_a?(Evilution::Result::MemoryStats) ? @memory.child_rss_kb : nil
28
34
  end
29
35
 
30
36
  def memory_delta_kb
31
- @memory.nil? ? nil : @memory.memory_delta_kb
37
+ @memory.is_a?(Evilution::Result::MemoryStats) ? @memory.memory_delta_kb : nil
32
38
  end
33
39
 
34
40
  def parent_rss_kb
35
- @memory.nil? ? nil : @memory.parent_rss_kb
41
+ @memory.is_a?(Evilution::Result::MemoryStats) ? @memory.parent_rss_kb : nil
36
42
  end
37
43
 
38
44
  def error_message
39
- @error.nil? ? nil : @error.message
45
+ @error.is_a?(Evilution::Result::ErrorInfo) ? @error.message : nil
40
46
  end
41
47
 
42
48
  def error_class
43
- @error.nil? ? nil : @error.klass
49
+ @error.is_a?(Evilution::Result::ErrorInfo) ? @error.klass : nil
44
50
  end
45
51
 
46
52
  def error_backtrace
47
- @error.nil? ? nil : @error.backtrace
53
+ @error.is_a?(Evilution::Result::ErrorInfo) ? @error.backtrace : nil
48
54
  end
49
55
 
50
56
  def killed?
@@ -30,25 +30,36 @@ class Evilution::Runner::BaselineRunner
30
30
 
31
31
  def build_integration
32
32
  klass = integration_class
33
- test_files = config.spec_files.empty? ? nil : config.spec_files
34
- kwargs = {
35
- test_files: test_files,
33
+ kwargs = base_integration_kwargs
34
+ kwargs.merge!(rspec_integration_kwargs) if klass == Evilution::Integration::RSpec
35
+ klass.new(**kwargs)
36
+ end
37
+
38
+ def base_integration_kwargs
39
+ {
40
+ test_files: config.spec_files.empty? ? nil : config.spec_files,
36
41
  hooks: hooks,
37
42
  fallback_to_full_suite: config.fallback_to_full_suite?,
38
43
  spec_selector: config.spec_selector
39
44
  }
40
- if klass == Evilution::Integration::RSpec
41
- kwargs[:related_specs_heuristic] = config.related_specs_heuristic?
42
- kwargs[:example_filter] = build_example_filter
43
- end
44
- klass.new(**kwargs)
45
+ end
46
+
47
+ def rspec_integration_kwargs
48
+ {
49
+ related_specs_heuristic: config.related_specs_heuristic?,
50
+ example_filter: build_example_filter
51
+ }
45
52
  end
46
53
 
47
54
  def call(subjects)
48
55
  return nil unless config.baseline? && subjects.any?
49
56
 
50
57
  log_start
51
- baseline = Evilution::Baseline.new(timeout: config.timeout, **integration_class.baseline_options)
58
+ baseline = Evilution::Baseline.new(
59
+ timeout: config.timeout,
60
+ test_files: config.spec_files.empty? ? nil : config.spec_files,
61
+ **integration_class.baseline_options
62
+ )
52
63
  result = baseline.call(subjects)
53
64
  log_complete(result)
54
65
  result
@@ -32,19 +32,23 @@ class Evilution::Runner::Diagnostics
32
32
  def log_mutation_diagnostics(result)
33
33
  return unless verbose?
34
34
 
35
- parts = []
36
- parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
35
+ parts = mutation_metric_parts(result)
36
+ stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n") unless parts.empty?
37
37
 
38
- if result.memory_delta_kb
39
- sign = result.memory_delta_kb.negative? ? "" : "+"
40
- parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
41
- end
38
+ log_mutation_error(result) if result.error?
39
+ end
42
40
 
41
+ def mutation_metric_parts(result)
42
+ parts = []
43
+ parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
44
+ parts << format_memory_delta(result.memory_delta_kb) if result.memory_delta_kb
43
45
  parts << gc_stats_string
46
+ parts
47
+ end
44
48
 
45
- stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n") unless parts.empty?
46
-
47
- log_mutation_error(result) if result.error?
49
+ def format_memory_delta(delta_kb)
50
+ sign = delta_kb.negative? ? "" : "+"
51
+ format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: delta_kb / 1024.0)
48
52
  end
49
53
 
50
54
  def log_worker_stats(stats)
@@ -4,6 +4,7 @@ require_relative "../runner"
4
4
  require_relative "../isolation/fork"
5
5
  require_relative "../isolation/in_process"
6
6
  require_relative "../rails_detector"
7
+ require_relative "../gem_detector"
7
8
 
8
9
  class Evilution::Runner::IsolationResolver
9
10
  PRELOAD_CANDIDATES = [
@@ -108,16 +109,52 @@ class Evilution::Runner::IsolationResolver
108
109
  end
109
110
 
110
111
  def resolve_preload_path
111
- if config.preload.is_a?(String)
112
- unless File.file?(config.preload)
113
- raise Evilution::ConfigError.new(
114
- "preload file not found: #{config.preload.inspect}",
115
- file: config.preload
116
- )
117
- end
118
- return config.preload
112
+ return resolve_explicit_with_fallback(config.preload) if config.preload.is_a?(String)
113
+
114
+ resolve_autodetected_preload
115
+ end
116
+
117
+ # Explicit preload path resolution with auto-detect fallthrough under :fork.
118
+ # When the user-configured path is missing, surface a stderr warning naming
119
+ # the missing path and try the auto-detect chain so a stale .evilution.yml
120
+ # entry doesn't silently disable preloading. Fallthrough requires both
121
+ # :fork isolation AND a detected Rails root (otherwise the chain has nowhere
122
+ # to look and the explicit-missing error is raised directly).
123
+ def resolve_explicit_with_fallback(explicit)
124
+ return explicit if File.file?(explicit)
125
+
126
+ raise_explicit_preload_missing(explicit) unless can_fallthrough_to_autodetect?
127
+
128
+ warn_missing_explicit_preload(explicit)
129
+ fallback = find_first_existing_candidate
130
+ return fallback if fallback
131
+
132
+ raise build_combined_missing_error(explicit)
133
+ end
134
+
135
+ def can_fallthrough_to_autodetect?
136
+ resolve_isolation == :fork && !detected_rails_root.nil?
137
+ end
138
+
139
+ def resolve_autodetected_preload
140
+ if detected_rails_root
141
+ fallback = find_first_existing_candidate
142
+ return fallback if fallback
143
+
144
+ raise Evilution::ConfigError, autodetect_missing_message
119
145
  end
120
146
 
147
+ detected_gem_entry
148
+ end
149
+
150
+ def detected_gem_entry
151
+ return @detected_gem_entry if defined?(@detected_gem_entry)
152
+
153
+ root = Evilution::GemDetector.gem_root_for_any(target_files)
154
+ @detected_gem_entry = root && Evilution::GemDetector.gem_entry_for(root, target_paths: target_files)
155
+ end
156
+
157
+ def find_first_existing_candidate
121
158
  root = detected_rails_root
122
159
  return nil unless root
123
160
 
@@ -125,11 +162,37 @@ class Evilution::Runner::IsolationResolver
125
162
  abs = File.join(root, rel)
126
163
  return abs if File.file?(abs)
127
164
  end
165
+ nil
166
+ end
167
+
168
+ def autodetect_missing_message
169
+ "Preload file not found. Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
170
+ "Pass --preload <file> or set preload: in .evilution.yml. " \
171
+ "Use --no-preload (or preload: false) to disable preloading entirely."
172
+ end
173
+
174
+ def build_combined_missing_error(explicit)
175
+ Evilution::ConfigError.new(
176
+ "Preload file not found. Configured preload #{explicit.inspect} does not exist, " \
177
+ "and none of the auto-detect candidates exist either. " \
178
+ "Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
179
+ "Pass --preload <file> or set preload: in .evilution.yml. " \
180
+ "Use --no-preload (or preload: false) to disable preloading entirely.",
181
+ file: explicit
182
+ )
183
+ end
184
+
185
+ def raise_explicit_preload_missing(path)
186
+ raise Evilution::ConfigError.new("preload file not found: #{path.inspect}", file: path)
187
+ end
128
188
 
129
- raise Evilution::ConfigError,
130
- "Preload file not found. Tried: [#{PRELOAD_CANDIDATES.join(", ")}]. " \
131
- "Pass --preload <file> or set preload: in .evilution.yml. " \
132
- "Use --no-preload (or preload: false) to disable preloading entirely."
189
+ def warn_missing_explicit_preload(path)
190
+ return if config.quiet
191
+
192
+ $stderr.write(
193
+ "[evilution] warning: configured preload #{path.inspect} not found; " \
194
+ "falling through to auto-detect chain.\n"
195
+ )
133
196
  end
134
197
 
135
198
  # When the user explicitly requests InProcess on a Rails project, warn once
@@ -7,6 +7,8 @@ class Evilution::Runner::MutationExecutor::ResultCache
7
7
  CACHEABLE_STATUSES = %i[killed timeout].freeze
8
8
  private_constant :CACHEABLE_STATUSES
9
9
 
10
+ Partition = Data.define(:uncached_indices, :cached_results)
11
+
10
12
  def initialize(backend)
11
13
  @backend = backend
12
14
  end
@@ -56,7 +58,7 @@ class Evilution::Runner::MutationExecutor::ResultCache
56
58
  end
57
59
  end
58
60
 
59
- [uncached_indices, cached_results]
61
+ Partition.new(uncached_indices: uncached_indices, cached_results: cached_results)
60
62
  end
61
63
 
62
64
  private