evilution 1.3.0 → 1.5.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. metadata +113 -4
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result"
4
+
5
+ # The kills one red spec file left uncounted -- mutations whose tests failed
6
+ # on nothing but examples that were already failing -- with why it was red.
7
+ #
8
+ # spec_file is nil when no single red file can be named, as in a run given
9
+ # its spec files explicitly; failures then holds all of them.
10
+ Evilution::Result::BaselineNeutralization = Data.define(:spec_file, :count, :failures)
@@ -9,8 +9,11 @@ class Evilution::Result::MutationResult
9
9
 
10
10
  attr_reader :mutation, :status, :duration, :killing_test, :test_command, :memory, :error, :neutral_reason
11
11
 
12
+ # known_failures_only: the tests failed, but every example that did was
13
+ # already failing before any mutation ran, so the failure says nothing
14
+ # about this one.
12
15
  def initialize(mutation:, status:, duration: 0.0, killing_test: nil,
13
- test_command: nil, memory: nil, error: nil, neutral_reason: nil)
16
+ test_command: nil, memory: nil, error: nil, neutral_reason: nil, known_failures_only: false)
14
17
  raise ArgumentError, "invalid status: #{status}" unless STATUSES.include?(status)
15
18
 
16
19
  @mutation = mutation
@@ -21,6 +24,7 @@ class Evilution::Result::MutationResult
21
24
  @memory = memory
22
25
  @error = error
23
26
  @neutral_reason = neutral_reason
27
+ @known_failures_only = known_failures_only == true
24
28
  freeze
25
29
  end
26
30
 
@@ -54,6 +58,10 @@ class Evilution::Result::MutationResult
54
58
  @error.is_a?(Evilution::Result::ErrorInfo) ? @error.backtrace : nil
55
59
  end
56
60
 
61
+ def known_failures_only?
62
+ @known_failures_only
63
+ end
64
+
57
65
  def killed?
58
66
  status == :killed
59
67
  end
@@ -3,13 +3,15 @@
3
3
  require_relative "../result"
4
4
  require_relative "coverage_gap_grouper"
5
5
  require_relative "subject_scorer"
6
+ require_relative "baseline_neutralization"
6
7
 
7
8
  class Evilution::Result::Summary
8
9
  attr_reader :results, :duration, :skipped, :disabled_mutations, :unresolved_target_files,
9
- :target_file_count, :infra_retried
10
+ :target_file_count, :infra_retried, :uncovered_code, :baseline_failures
10
11
 
11
12
  def initialize(results:, duration: 0.0, truncated: false, skipped: 0, disabled_mutations: [],
12
- unresolved_target_files: [], target_file_count: nil, infra_retried: 0)
13
+ unresolved_target_files: [], target_file_count: nil, infra_retried: 0, uncovered_code: [],
14
+ baseline_failures: [])
13
15
  @results = results
14
16
  @duration = duration
15
17
  @truncated = truncated
@@ -18,9 +20,27 @@ class Evilution::Result::Summary
18
20
  @unresolved_target_files = unresolved_target_files.freeze
19
21
  @target_file_count = target_file_count
20
22
  @infra_retried = infra_retried
23
+ @uncovered_code = uncovered_code.freeze
24
+ @baseline_failures = baseline_failures.freeze
21
25
  freeze
22
26
  end
23
27
 
28
+ # The kills not counted because only examples already failing in the
29
+ # baseline failed, gathered under the spec file that was red and paired with
30
+ # why it was. They are out of the score, so a red spec file would read as
31
+ # full marks unless the report says they are there.
32
+ def baseline_neutralizations
33
+ baseline_neutralized_results.group_by { |result| result.neutral_reason.detail }.map do |spec_file, results|
34
+ Evilution::Result::BaselineNeutralization.new(
35
+ spec_file: spec_file, count: results.length, failures: baseline_failures_for(spec_file)
36
+ )
37
+ end
38
+ end
39
+
40
+ def baseline_neutralized
41
+ baseline_neutralized_results.length
42
+ end
43
+
24
44
  # Neutral results gathered by the reason they were recorded, which is what the
25
45
  # report needs to say which kind of neutral a reader is looking at
26
46
  # (EV-5pob / GH #1606).
@@ -53,6 +73,12 @@ class Evilution::Result::Summary
53
73
  !unresolved_target_files.empty?
54
74
  end
55
75
 
76
+ # Targeted lines that hold code outside every subject, so no mutation was
77
+ # ever generated for them: `{ file:, lines: ["2-4", "9"] }` per file.
78
+ def uncovered_code?
79
+ !uncovered_code.empty?
80
+ end
81
+
56
82
  def truncated?
57
83
  @truncated
58
84
  end
@@ -167,4 +193,18 @@ class Evilution::Result::Summary
167
193
 
168
194
  max_rss && (max_rss / 1024.0)
169
195
  end
196
+
197
+ private
198
+
199
+ def baseline_neutralized_results
200
+ neutral_results.select { |result| result.neutral_reason && result.neutral_reason.kind == :baseline_failure }
201
+ end
202
+
203
+ # No spec file named means the run was given its spec files explicitly, and
204
+ # any of them being red neutralizes the survivor.
205
+ def baseline_failures_for(spec_file)
206
+ return baseline_failures if spec_file.nil?
207
+
208
+ baseline_failures.select { |failure| failure.spec_file == spec_file }
209
+ end
170
210
  end
@@ -12,6 +12,7 @@ require_relative "../integration/test_unit"
12
12
  require_relative "../example_filter"
13
13
  require_relative "../spec_ast_cache"
14
14
  require_relative "../source_ast_cache"
15
+ require_relative "../diagnostic"
15
16
 
16
17
  unless defined?(Evilution::Runner::INTEGRATIONS)
17
18
  Evilution::Runner::INTEGRATIONS = {
@@ -33,15 +34,19 @@ class Evilution::Runner::BaselineRunner
33
34
  end
34
35
  end
35
36
 
36
- def build_integration
37
+ # baseline_result: what the baseline found, when it ran. The tests it saw
38
+ # failing are handed to the integration, which can then tell a mutation run
39
+ # that fails on nothing else from one that kills.
40
+ def build_integration(baseline_result = nil)
37
41
  klass = integration_class
38
- kwargs = base_integration_kwargs
42
+ kwargs = base_integration_kwargs(baseline_result)
39
43
  kwargs.merge!(rspec_integration_kwargs) if klass == Evilution::Integration::RSpec
40
44
  klass.new(**kwargs)
41
45
  end
42
46
 
43
- def base_integration_kwargs
47
+ def base_integration_kwargs(baseline_result = nil)
44
48
  {
49
+ known_failures: baseline_result ? baseline_result.failed_example_ids : [],
45
50
  test_files: config.spec_files.empty? ? nil : config.spec_files,
46
51
  hooks: hooks,
47
52
  fallback_to_full_suite: config.fallback_to_full_suite?,
@@ -119,7 +124,7 @@ class Evilution::Runner::BaselineRunner
119
124
 
120
125
  Evilution::CoverageExampleFilter.new(map: map, lexical: lexical)
121
126
  rescue StandardError => e
122
- warn "evilution: coverage targeting unavailable (#{e.class}: #{e.message}); using lexical targeting"
127
+ Evilution::Diagnostic.warn("evilution: coverage targeting unavailable (#{e.class}: #{e.message}); using lexical targeting")
123
128
  lexical
124
129
  end
125
130
 
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require_relative "../runner"
7
7
  require_relative "../mutation"
8
8
  require_relative "../subject"
9
+ require_relative "canary_failure_message"
9
10
 
10
11
  # Runs one guaranteed-unobservable synthetic mutation through the configured
11
12
  # integration + isolation at session start. The synthetic spec never references
@@ -34,7 +35,7 @@ class Evilution::Runner::Canary
34
35
  test_command: ->(mutation) { build_integration(spec_path).call(mutation) },
35
36
  timeout: @config.timeout
36
37
  )
37
- raise Failed, failure_message(result) unless result.status == :survived
38
+ raise Failed, Evilution::Runner::CanaryFailureMessage.new(result).to_s unless result.status == :survived
38
39
 
39
40
  nil
40
41
  ensure
@@ -145,60 +146,4 @@ class Evilution::Runner::Canary
145
146
  def build_integration(spec_path)
146
147
  @integration_class.new(test_files: [spec_path], hooks: @hooks)
147
148
  end
148
-
149
- # When the child reported an error, that error IS the diagnosis -- naming it
150
- # beats guessing. The speculative list stays only for the cases that carry no
151
- # error at all (:killed, :timeout), where guesses are the only help there is.
152
- # Diagnosing GH #1581 meant rebuilding this canary by hand to read the field
153
- # this message used to drop, and none of the four guesses was the cause.
154
- # EV-65nf / GH #1586.
155
- def failure_message(result)
156
- "#{failure_preamble(result.status)} #{diagnosis(result)} " \
157
- "Re-run with --no-canary to bypass this check."
158
- end
159
-
160
- def failure_preamble(status)
161
- "evilution proof-of-life canary failed: a guaranteed-unobservable synthetic " \
162
- "mutation was scored #{status.inspect} instead of :survived. The mutation " \
163
- "pipeline is misreporting — every score this run would produce is unreliable."
164
- end
165
-
166
- def diagnosis(result)
167
- reported = reported_error(result)
168
- return speculative_causes if reported.nil?
169
-
170
- "The child reported: #{reported}"
171
- end
172
-
173
- def reported_error(result)
174
- message = result.error_message
175
- return nil if message.nil? || message.empty?
176
-
177
- described = with_class_prefix(message, result.error_class)
178
- frame = first_frame(result)
179
- frame.nil? ? described : "#{described} (at #{frame})"
180
- end
181
-
182
- # MutationApplier already prefixes the class onto the message it packs
183
- # ("#{e.class}: #{e.message}"), while other paths pack the bare message and
184
- # leave the class in its own field. Prefixing unconditionally would print
185
- # "NameError: NameError: ..." for the former.
186
- def with_class_prefix(message, klass)
187
- return message if klass.nil? || klass.empty? || message.start_with?("#{klass}:")
188
-
189
- "#{klass}: #{message}"
190
- end
191
-
192
- def first_frame(result)
193
- backtrace = result.error_backtrace
194
- return nil if backtrace.nil? || backtrace.empty?
195
-
196
- backtrace.first
197
- end
198
-
199
- def speculative_causes
200
- "Likely causes: Rails/Zeitwerk autoloading breaking child eval; an env-specific " \
201
- "RSpec config (e.g. fail_if_no_examples); a classify_status fallback defect; or " \
202
- "an isolation-mode defect."
203
- end
204
149
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../runner"
4
+
5
+ # Explains why the proof-of-life canary failed, from the result its synthetic
6
+ # mutation came back with. Kept apart from Canary, which only runs the probe:
7
+ # the wording changes for different reasons than the mechanics do.
8
+ class Evilution::Runner::CanaryFailureMessage
9
+ # Raised by RubyGems when it cannot load a gem at a version that satisfies
10
+ # every requirement already in play.
11
+ GEM_ACTIVATION_ERRORS = %w[
12
+ Gem::ConflictError Gem::LoadError Gem::MissingSpecError Gem::MissingSpecVersionError
13
+ ].freeze
14
+
15
+ def initialize(result)
16
+ @result = result
17
+ end
18
+
19
+ # When the child reported an error, that error IS the diagnosis -- naming it
20
+ # beats guessing. The speculative list stays only for the cases that carry no
21
+ # error at all (:killed, :timeout), where guesses are the only help there is.
22
+ # Diagnosing GH #1581 meant rebuilding this canary by hand to read the field
23
+ # this message used to drop, and none of the four guesses was the cause.
24
+ # EV-65nf / GH #1586.
25
+ def to_s
26
+ return gem_activation_message(@result) if gem_activation_failure?(@result)
27
+
28
+ "#{failure_preamble(@result.status)} #{diagnosis(@result)} " \
29
+ "Re-run with --no-canary to bypass this check."
30
+ end
31
+
32
+ private
33
+
34
+ # The usual cause is evilution running outside `bundle exec`: RubyGems then
35
+ # activates the newest installed copy of a shared dependency, which the test
36
+ # framework may not accept. Every mutation would fail the same way, so
37
+ # skipping the canary would not help.
38
+ def gem_activation_message(result)
39
+ "evilution proof-of-life canary failed: the test framework could not be loaded " \
40
+ "because of a gem activation conflict. This is an environment problem, not a " \
41
+ "mutation-pipeline defect, and every mutation would fail the same way. Run " \
42
+ "evilution under `bundle exec` so the project's Gemfile.lock decides gem versions " \
43
+ "(for the MCP server: command `bundle`, args [\"exec\", \"evilution\", \"mcp\"]). " \
44
+ "The child reported: #{reported_error(result)}"
45
+ end
46
+
47
+ # The class may arrive in its own field or only as the message prefix,
48
+ # depending on which path packed the error.
49
+ def gem_activation_failure?(result)
50
+ message = result.error_message.to_s
51
+
52
+ GEM_ACTIVATION_ERRORS.any? do |klass|
53
+ result.error_class == klass || message.start_with?("#{klass}:")
54
+ end
55
+ end
56
+
57
+ def failure_preamble(status)
58
+ "evilution proof-of-life canary failed: a guaranteed-unobservable synthetic " \
59
+ "mutation was scored #{status.inspect} instead of :survived. The mutation " \
60
+ "pipeline is misreporting — every score this run would produce is unreliable."
61
+ end
62
+
63
+ def diagnosis(result)
64
+ reported = reported_error(result)
65
+ return speculative_causes if reported.nil?
66
+
67
+ "The child reported: #{reported}"
68
+ end
69
+
70
+ def reported_error(result)
71
+ message = result.error_message
72
+ return nil if message.nil? || message.empty?
73
+
74
+ described = with_class_prefix(message, result.error_class)
75
+ frame = first_frame(result)
76
+ frame.nil? ? described : "#{described} (at #{frame})"
77
+ end
78
+
79
+ # MutationApplier already prefixes the class onto the message it packs
80
+ # ("#{e.class}: #{e.message}"), while other paths pack the bare message and
81
+ # leave the class in its own field. Prefixing unconditionally would print
82
+ # "NameError: NameError: ..." for the former.
83
+ def with_class_prefix(message, klass)
84
+ return message if klass.nil? || klass.empty? || message.start_with?("#{klass}:")
85
+
86
+ "#{klass}: #{message}"
87
+ end
88
+
89
+ def first_frame(result)
90
+ backtrace = result.error_backtrace
91
+ return nil if backtrace.nil? || backtrace.empty?
92
+
93
+ backtrace.first
94
+ end
95
+
96
+ def speculative_causes
97
+ "Likely causes: Rails/Zeitwerk autoloading breaking child eval; an env-specific " \
98
+ "RSpec config (e.g. fail_if_no_examples); a classify_status fallback defect; or " \
99
+ "an isolation-mode defect."
100
+ end
101
+ end
@@ -4,6 +4,17 @@ require_relative "../neutralizer"
4
4
  require_relative "../../../result/mutation_result"
5
5
  require_relative "../../../result/neutral_reason"
6
6
 
7
+ # Reclassifies a kill as :neutral when the tests failed on nothing but
8
+ # examples that were already failing before any mutation ran.
9
+ #
10
+ # Such an example fails whatever the mutation is, so every mutation it runs
11
+ # against would count as killed and a red spec file would score full marks.
12
+ # The failure says nothing about the mutation: it has no verdict.
13
+ #
14
+ # A survivor is left alone, whatever the baseline did. Its tests passed, the
15
+ # examples that were red in the baseline included, so the gap it reports is
16
+ # real -- and neutralizing it, as used to happen for every survivor a red spec
17
+ # file covered, hid exactly the mutations a reader needs to see.
7
18
  class Evilution::Runner::MutationExecutor::Neutralizer::BaselineFailed
8
19
  def initialize(config:, spec_resolver:, fallback_dir:)
9
20
  @config = config
@@ -12,19 +23,22 @@ class Evilution::Runner::MutationExecutor::Neutralizer::BaselineFailed
12
23
  end
13
24
 
14
25
  def call(result, baseline_result:)
15
- return result unless result.survived? && baseline_result && baseline_result.failed?
26
+ return result unless result.killed? && result.known_failures_only?
16
27
 
17
- return neutralize(result, nil) if @config.spec_files.any?
18
-
19
- failed = baseline_result.failed_spec_files
20
- spec_file = covering_specs(result.mutation.file_path).find { |spec| failed.include?(spec) }
21
- return result unless spec_file
22
-
23
- neutralize(result, spec_file)
28
+ neutralize(result, red_spec(result, baseline_result))
24
29
  end
25
30
 
26
31
  private
27
32
 
33
+ # The red spec file to name: one covering the mutated source. A run given
34
+ # its spec files explicitly has no single one to point at.
35
+ def red_spec(result, baseline_result)
36
+ return nil if @config.spec_files.any? || baseline_result.nil?
37
+
38
+ failed = baseline_result.failed_spec_files
39
+ covering_specs(result.mutation.file_path).find { |spec| failed.include?(spec) }
40
+ end
41
+
28
42
  # The spec files the baseline ran for this source: every file the resolver
29
43
  # returns (a spec_selector may map one source to several), or the fallback
30
44
  # directory when nothing resolves -- but only if the run falls back to the
@@ -34,6 +34,9 @@ class Evilution::Runner::MutationExecutor::ResultCache
34
34
  return unless @backend
35
35
  return unless result.killed? || result.timeout?
36
36
  return if infra_crash?(result)
37
+ # Nor is one where only already-failing examples failed: the baseline
38
+ # that says so belongs to this run, and the cache keeps no trace of it.
39
+ return if result.known_failures_only?
37
40
 
38
41
  @backend.store(mutation,
39
42
  status: result.status,
@@ -15,7 +15,8 @@ class Evilution::Runner::MutationExecutor::ResultPacker
15
15
  parent_rss_kb: result.parent_rss_kb,
16
16
  error_message: result.error_message,
17
17
  error_class: result.error_class,
18
- error_backtrace: result.error_backtrace
18
+ error_backtrace: result.error_backtrace,
19
+ known_failures_only: result.known_failures_only?
19
20
  }
20
21
  end
21
22
 
@@ -35,7 +36,8 @@ class Evilution::Runner::MutationExecutor::ResultPacker
35
36
  message: data[:error_message],
36
37
  klass: data[:error_class],
37
38
  backtrace: data[:error_backtrace]
38
- )
39
+ ),
40
+ known_failures_only: data[:known_failures_only]
39
41
  )
40
42
  end
41
43
  end
@@ -13,6 +13,15 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
13
13
  error_class: "Evilution::Error"
14
14
  }.freeze
15
15
 
16
+ # Compact result for an item whose worker sent a frame that was not its own:
17
+ # the worker was given up, and what it had to say about the item with it.
18
+ UNREADABLE_COMPACT = {
19
+ status: :error,
20
+ duration: 0.0,
21
+ error_message: "worker's result could not be read; the worker was replaced",
22
+ error_class: "Evilution::Error"
23
+ }.freeze
24
+
16
25
  def initialize(cache:, isolator:, packer:, pipeline:, notifier:, pool_factory:, config:, diagnostics: nil)
17
26
  @cache = cache
18
27
  @isolator = isolator
@@ -113,6 +122,7 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
113
122
  case result.reason
114
123
  when :timeout then timeout_compact
115
124
  when :died then DIED_COMPACT
125
+ when :unreadable then UNREADABLE_COMPACT
116
126
  end
117
127
  end
118
128
 
@@ -30,7 +30,7 @@ class Evilution::Runner::MutationExecutor
30
30
  end
31
31
 
32
32
  def call(mutations, baseline_result = nil)
33
- integration = @baseline_runner.build_integration
33
+ integration = @baseline_runner.build_integration(baseline_result)
34
34
  spec_resolver = baseline_failed?(baseline_result) ? @baseline_runner.neutralization_resolver : nil
35
35
  notifier = build_notifier
36
36
  pipeline = build_pipeline(spec_resolver)
@@ -7,7 +7,7 @@ require_relative "../ast/pattern/filter"
7
7
  require_relative "../equivalent/detector"
8
8
 
9
9
  class Evilution::Runner::MutationPlanner
10
- Plan = Struct.new(:enabled, :equivalent, :skipped_count, :disabled_mutations, keyword_init: true)
10
+ Plan = Struct.new(:enabled, :equivalent, :skipped_count, :disabled_mutations, :mutated_lines, keyword_init: true)
11
11
 
12
12
  GenerationResult = Data.define(:mutations, :skipped)
13
13
  DisabledFilterResult = Data.define(:enabled, :disabled)
@@ -33,13 +33,22 @@ class Evilution::Runner::MutationPlanner
33
33
  sig_filter = filter_sig_blocks(disabled_filter.enabled)
34
34
  equivalent_filter = filter_equivalent(sig_filter.enabled)
35
35
 
36
- build_plan(equivalent_filter, disabled_mutations, total_skipped(generation, disabled_filter, sig_filter))
36
+ plan = build_plan(equivalent_filter, disabled_mutations, total_skipped(generation, disabled_filter, sig_filter))
37
+ plan.mutated_lines = mutated_lines(deduped)
38
+ plan
37
39
  end
38
40
 
39
41
  private
40
42
 
41
43
  attr_reader :config, :registry
42
44
 
45
+ # The lines mutations were generated for, per file. Taken before any filter:
46
+ # a line whose mutations were all disabled or judged equivalent was still
47
+ # reached by an operator.
48
+ def mutated_lines(mutations)
49
+ mutations.group_by(&:file_path).transform_values { |group| group.map(&:line).uniq.sort }
50
+ end
51
+
43
52
  def compute_disabled_mutations(disabled_filter)
44
53
  return [] unless config.show_disabled?
45
54
 
@@ -5,6 +5,7 @@ require_relative "../reporter/json"
5
5
  require_relative "../reporter/cli"
6
6
  require_relative "../reporter/html"
7
7
  require_relative "../session/store"
8
+ require_relative "../diagnostic"
8
9
 
9
10
  class Evilution::Runner::ReportPublisher
10
11
  def initialize(config)
@@ -46,7 +47,7 @@ class Evilution::Runner::ReportPublisher
46
47
  def publish_html(output)
47
48
  path = "evilution-report.html"
48
49
  File.write(path, output)
49
- warn "HTML report written to #{path}"
50
+ Evilution::Diagnostic.warn("HTML report written to #{path}")
50
51
  end
51
52
 
52
53
  def save_session(summary)
@@ -54,7 +55,7 @@ class Evilution::Runner::ReportPublisher
54
55
 
55
56
  Evilution::Session::Store.new.save(summary)
56
57
  rescue StandardError => e
57
- warn "[evilution] failed to save session: #{e.message}" unless config.quiet
58
+ Evilution::Diagnostic.warn("[evilution] failed to save session: #{e.message}") unless config.quiet
58
59
  end
59
60
 
60
61
  private
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../subject_pipeline"
4
+
5
+ # What `--target` asks for, read once from the text the user gave:
6
+ #
7
+ # source:lib/**/*.rb the files of a glob
8
+ # descendants:Base a class and everything inheriting from it
9
+ # User#adult? a method; `User`, `User#`, `Billing::*` for several
10
+ #
11
+ # NONE stands in when no target was given: it is no kind of target and
12
+ # selects every subject, so the pipeline never has to ask whether there is
13
+ # one.
14
+ class Evilution::Runner::SubjectPipeline::Target
15
+ PREFIXES = { "source:" => :source_glob, "descendants:" => :descendants }.freeze
16
+ private_constant :PREFIXES
17
+
18
+ def self.parse(text)
19
+ return NONE if text.nil?
20
+
21
+ prefix, kind = PREFIXES.find { |candidate, _| text.start_with?(candidate) }
22
+ return new(kind: :method, text: text, value: text) if prefix.nil?
23
+
24
+ new(kind: kind, text: text, value: text.delete_prefix(prefix))
25
+ end
26
+
27
+ # The part after the prefix: the glob, the base class name, or the method
28
+ # name as written.
29
+ attr_reader :value
30
+
31
+ def initialize(kind:, text:, value:)
32
+ @kind = kind
33
+ @text = text
34
+ @value = value
35
+ end
36
+
37
+ def source_glob?
38
+ @kind == :source_glob
39
+ end
40
+
41
+ def descendants?
42
+ @kind == :descendants
43
+ end
44
+
45
+ def method?
46
+ @kind == :method
47
+ end
48
+
49
+ # The target as the user wrote it, for messages.
50
+ def to_s
51
+ @text.to_s
52
+ end
53
+
54
+ # Only a method target narrows subjects by name.
55
+ def selects?(subject)
56
+ !method? || matcher.call(subject.name)
57
+ end
58
+
59
+ NONE = new(kind: :none, text: nil, value: nil)
60
+
61
+ private
62
+
63
+ def matcher
64
+ @matcher ||= build_matcher
65
+ end
66
+
67
+ def build_matcher
68
+ return wildcard_matcher(value.chomp("*")) if value.end_with?("*")
69
+ return prefix_matcher(value) if value.end_with?("#", ".")
70
+
71
+ name_matcher(value)
72
+ end
73
+
74
+ def wildcard_matcher(prefix)
75
+ ->(name) { name.split(/[#.]/).first.start_with?(prefix) }
76
+ end
77
+
78
+ def prefix_matcher(prefix)
79
+ ->(name) { name.start_with?(prefix) }
80
+ end
81
+
82
+ # A full method name matches itself; a class name, each of its methods.
83
+ def name_matcher(target)
84
+ ->(name) { name == target || name.start_with?("#{target}#") || name.start_with?("#{target}.") }
85
+ end
86
+ end