evilution 1.2.0 → 1.4.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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -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/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../result"
3
4
  require_relative "coverage_gap"
4
5
 
5
6
  class Evilution::Result::CoverageGapGrouper
@@ -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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../result"
3
4
  require_relative "subject_score"
4
5
 
5
6
  # Groups mutation results by the subject they belong to and scores each one.
@@ -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?,
@@ -60,18 +65,15 @@ class Evilution::Runner::BaselineRunner
60
65
  return nil unless config.baseline? && subjects.any?
61
66
 
62
67
  log_start
63
- baseline = Evilution::Baseline.new(
64
- timeout: config.timeout,
65
- test_files: config.spec_files.empty? ? nil : config.spec_files,
66
- **integration_class.baseline_options
67
- )
68
- result = baseline.call(subjects)
68
+ result = build_baseline.call(subjects)
69
69
  log_complete(result)
70
70
  result
71
71
  end
72
72
 
73
+ # The same selector the baseline resolves with, so a surviving mutation is
74
+ # matched against exactly the spec files the baseline ran.
73
75
  def neutralization_resolver
74
- integration_class.baseline_options[:spec_resolver] || Evilution::SpecResolver.new
76
+ config.spec_selector
75
77
  end
76
78
 
77
79
  def neutralization_fallback_dir
@@ -82,6 +84,19 @@ class Evilution::Runner::BaselineRunner
82
84
 
83
85
  attr_reader :config, :hooks
84
86
 
87
+ # Resolves through config.spec_selector -- the object `run` uses, so
88
+ # spec_mappings / spec_pattern apply -- and skips unresolved sources unless
89
+ # the run falls back to the full suite.
90
+ def build_baseline
91
+ Evilution::Baseline.new(
92
+ timeout: config.timeout,
93
+ test_files: config.spec_files.empty? ? nil : config.spec_files,
94
+ spec_selector: config.spec_selector,
95
+ fallback_to_full_suite: config.fallback_to_full_suite?,
96
+ **integration_class.baseline_options
97
+ )
98
+ end
99
+
85
100
  def build_example_filter
86
101
  return nil unless config.example_targeting?
87
102
 
@@ -109,7 +124,7 @@ class Evilution::Runner::BaselineRunner
109
124
 
110
125
  Evilution::CoverageExampleFilter.new(map: map, lexical: lexical)
111
126
  rescue StandardError => e
112
- 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")
113
128
  lexical
114
129
  end
115
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
@@ -6,6 +6,7 @@ require_relative "../isolation/in_process"
6
6
  require_relative "../rails_detector"
7
7
  require_relative "../gem_detector"
8
8
  require_relative "../integration/loading/test_load_path"
9
+ require_relative "../rails_warmup"
9
10
 
10
11
  class Evilution::Runner::IsolationResolver
11
12
  PRELOAD_CANDIDATES = [
@@ -48,6 +49,7 @@ class Evilution::Runner::IsolationResolver
48
49
  prepare_load_path_for_preload(path)
49
50
  prepare_integration_for_preload
50
51
  require File.expand_path(path)
52
+ warm_up_rails
51
53
  rescue Evilution::ConfigError
52
54
  raise
53
55
  rescue ScriptError, StandardError => e
@@ -61,6 +63,21 @@ class Evilution::Runner::IsolationResolver
61
63
 
62
64
  attr_reader :config, :hooks
63
65
 
66
+ # Runs only after a preload (which is what loads Rails). This is primarily an
67
+ # optimisation for :fork isolation, where children inherit the warmed state.
68
+ def warm_up_rails
69
+ return unless config.warmup == :rails
70
+
71
+ application = ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
72
+ unless application
73
+ warn_message = "[evilution] warmup: rails requested but Rails is not loaded (or has no application) after preload; skipping"
74
+ Evilution::Diagnostic.warn(warn_message)
75
+ return
76
+ end
77
+
78
+ Evilution::RailsWarmup.new(application).call
79
+ end
80
+
64
81
  # Under :fork, allow preloading — caller resolves whether a path exists (an
65
82
  # explicit --preload / preload: value, or an auto-detected rails_helper) and
66
83
  # bails early when none does. Under :in_process, only allow preloading when
@@ -131,7 +148,11 @@ class Evilution::Runner::IsolationResolver
131
148
  # have both (a bare `require "support/foo"` must still resolve from spec/).
132
149
  # - minitest/test-unit: a test/test_helper.rb doing a non-relative
133
150
  # `require "support/..."` needs test/ on $LOAD_PATH. Route through
134
- # TestLoadPath -- the same policy the per-mutation test load uses -- so preload and mutation paths agree.
151
+ # TestLoadPath, the policy the per-mutation test load uses, with one
152
+ # deliberate difference: outside_root_dirs: false. A resolved test file
153
+ # outside test/ and spec/ keeps its own directory, but a preload there
154
+ # (config/evilution_preload.rb) must not, or config/ lands ahead of the
155
+ # gems and config/puma.rb shadows the puma gem (GH #1597).
135
156
  def prepare_load_path_for_preload(preload_path)
136
157
  if config.integration == :rspec
137
158
  prepare_rspec_preload_load_path
@@ -148,7 +169,7 @@ class Evilution::Runner::IsolationResolver
148
169
 
149
170
  def prepare_test_preload_load_path(preload_path)
150
171
  base = detected_rails_root || Evilution.project_base_dir
151
- Evilution::Integration::Loading::TestLoadPath.add!([preload_path], base: base)
172
+ Evilution::Integration::Loading::TestLoadPath.add!([preload_path], base: base, outside_root_dirs: false)
152
173
  end
153
174
 
154
175
  def resolve_spec_dir
@@ -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,25 +23,35 @@ 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?
16
-
17
- if @config.spec_files.any?
18
- should_neutralize = true
19
- spec_file = nil
20
- else
21
- spec_file = @spec_resolver.call(result.mutation.file_path) || @fallback_dir
22
- should_neutralize = baseline_result.failed_spec_files.include?(spec_file)
23
- end
24
- return result unless should_neutralize
25
-
26
- neutralize(result, spec_file)
26
+ return result unless result.killed? && result.known_failures_only?
27
+
28
+ neutralize(result, red_spec(result, baseline_result))
27
29
  end
28
30
 
29
31
  private
30
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
+
42
+ # The spec files the baseline ran for this source: every file the resolver
43
+ # returns (a spec_selector may map one source to several), or the fallback
44
+ # directory when nothing resolves -- but only if the run falls back to the
45
+ # full suite; otherwise the baseline never ran it.
46
+ def covering_specs(file_path)
47
+ specs = Array(@spec_resolver.call(file_path))
48
+ return specs unless specs.empty?
49
+
50
+ @config.fallback_to_full_suite? ? [@fallback_dir] : []
51
+ end
52
+
31
53
  # The spec that was already red is the fact a reader needs: it says what to
32
- # fix before the run can say anything about these mutations
33
- # (EV-5pob / GH #1606).
54
+ # fix before the run can say anything about these mutations.
34
55
  def neutralize(result, spec_file)
35
56
  Evilution::Result::MutationResult.new(
36
57
  mutation: result.mutation,
@@ -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
@@ -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