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
@@ -12,8 +12,10 @@ require_relative "state_guard/world_example_groups"
12
12
  require_relative "state_guard/world_sources_by_path"
13
13
  require_relative "state_guard/world_filtered_examples"
14
14
  require_relative "state_guard/reporter_arrays"
15
+ require_relative "state_guard/anonymous_example_group_examples"
15
16
  require_relative "state_guard/example_groups_constants"
16
17
  require_relative "state_guard/configuration_state"
18
+ require_relative "../../diagnostic"
17
19
 
18
20
  class Evilution::Integration::RSpec::StateGuard
19
21
  DEFAULT_STRATEGIES = [
@@ -22,6 +24,7 @@ class Evilution::Integration::RSpec::StateGuard
22
24
  WorldSourcesByPath.new,
23
25
  WorldFilteredExamples.new,
24
26
  ReporterArrays.new,
27
+ AnonymousExampleGroupExamples.new,
25
28
  ExampleGroupsConstants.new,
26
29
  ConfigurationState.new
27
30
  ].freeze
@@ -43,6 +46,6 @@ class Evilution::Integration::RSpec::StateGuard
43
46
  def release_one(strategy, captured)
44
47
  strategy.release(captured)
45
48
  rescue StandardError => e
46
- warn "[evilution] state release failed for #{strategy.class.name}: #{e.class}: #{e.message}"
49
+ Evilution::Diagnostic.warn("[evilution] state release failed for #{strategy.class.name}: #{e.class}: #{e.message}")
47
50
  end
48
51
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "base"
5
+ require_relative "known_failures"
5
6
  require_relative "../spec_resolver"
6
7
  require_relative "../spec_selector"
7
8
  require_relative "../related_spec_heuristic"
@@ -24,6 +25,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
24
25
  fallback_to_full_suite: false,
25
26
  spec_selector: nil,
26
27
  example_filter: nil,
28
+ known_failures: [],
27
29
  framework_loader: FrameworkLoader.new,
28
30
  test_file_resolver: nil,
29
31
  example_filter_applier: nil,
@@ -44,6 +46,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
44
46
  @crash_detector_lifecycle = crash_detector_lifecycle
45
47
  @result_builder = result_builder
46
48
  @state_guard = state_guard
49
+ @known_failures = Evilution::Integration::KnownFailures.new(known_failures)
47
50
  super(hooks: hooks)
48
51
  end
49
52
 
@@ -71,7 +74,7 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
71
74
  return @result_builder.unresolved_example(mutation) if targets.nil?
72
75
 
73
76
  result = run_targets(targets)
74
- return result unless confirm_survivor?(result, targets, files)
77
+ return result unless confirm?(result, targets, files)
75
78
 
76
79
  run_targets(files)
77
80
  end
@@ -85,8 +88,12 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
85
88
  # So a survivor is re-run against the whole resolved file, and that run is the
86
89
  # one reported. Only survivors pay for it, and only where the subset was
87
90
  # actually narrower than the file.
88
- def confirm_survivor?(result, targets, files)
89
- result[:passed] && targets != files
91
+ #
92
+ # The same goes for a subset in which nothing failed but examples that were
93
+ # failing before any mutation ran: that is no verdict either, and the rest of
94
+ # the file may hold one.
95
+ def confirm?(result, targets, files)
96
+ (result[:passed] || result[:known_failures_only]) && targets != files
90
97
  end
91
98
 
92
99
  def run_targets(targets)
@@ -142,7 +149,8 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
142
149
  claim_rspec_streams(out, err)
143
150
  detector = @crash_detector_lifecycle.register
144
151
  status = ::RSpec::Core::Runner.run(args, err, out)
145
- @result_builder.from_run(status, command, detector, examples_loaded:)
152
+ flagged = known_failures_only?(status)
153
+ @result_builder.from_run(status, command, detector, examples_loaded:, known_failures_only: flagged)
146
154
  rescue StandardError => e
147
155
  { passed: false, error: e.message, test_command: command }
148
156
  ensure
@@ -188,12 +196,22 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
188
196
  nil
189
197
  end
190
198
 
199
+ # Whether the run failed on nothing but examples the baseline already saw
200
+ # failing. Decided here, in the process that ran them: the names of every
201
+ # failing example need not travel back, only the answer.
202
+ def known_failures_only?(status)
203
+ return false if status.zero? || @known_failures.empty?
204
+
205
+ @known_failures.only?(ExampleIds.failed(::RSpec.world))
206
+ end
207
+
191
208
  def reset_examples
192
209
  ::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
193
210
  end
194
211
  end
195
212
 
196
213
  require_relative "rspec/framework_loader"
214
+ require_relative "rspec/example_ids"
197
215
  require_relative "rspec/test_file_resolver"
198
216
  require_relative "rspec/unresolved_spec_warner"
199
217
  require_relative "rspec/example_filter_applier"
@@ -21,6 +21,12 @@ module Evilution::Integration::TestUnit::ResultBuilder
21
21
  end
22
22
  end
23
23
 
24
+ # Every test that failed was already failing in the baseline. Still a
25
+ # failed run, but flagged, so it is not taken for a kill.
26
+ def known_failures_only(command)
27
+ { passed: false, known_failures_only: true, test_command: command }
28
+ end
29
+
24
30
  def no_tests_ran(command)
25
31
  {
26
32
  passed: false,
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_unit"
4
+
5
+ # Names a Test::Unit test the same way in the baseline and in a mutation run:
6
+ # the name the framework gives it, `test_adds(CalcTest)`.
7
+ module Evilution::Integration::TestUnit::TestIds
8
+ # Only failures and errors fail a run; pendings, omissions and notifications
9
+ # are faults too, and are left out.
10
+ def self.faults(result)
11
+ result.failures + result.errors
12
+ end
13
+
14
+ # The ids of the tests that failed or raised in a finished run.
15
+ def self.failed(result)
16
+ faults(result).map(&:test_name)
17
+ end
18
+ end
@@ -2,9 +2,12 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "test_unit_crash_detector"
5
+ require_relative "known_failures"
5
6
  require_relative "loading/test_load_path"
7
+ require_relative "loading/test_class_cache"
6
8
  require_relative "../spec_resolver"
7
9
  require_relative "../spec_selector"
10
+ require_relative "../baseline"
8
11
 
9
12
  require_relative "../integration"
10
13
 
@@ -28,6 +31,12 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
28
31
  Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
29
32
  end
30
33
 
34
+ # Shared by every instance: the canary and the mutation runs build their
35
+ # own, and a file loaded for one must not be loaded again for the other.
36
+ def self.test_classes
37
+ @test_classes ||= Evilution::Integration::Loading::TestClassCache.new { SubjectClassRegistry.descendants }
38
+ end
39
+
31
40
  def self.baseline_options
32
41
  {
33
42
  runner: baseline_runner,
@@ -40,25 +49,41 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
40
49
  require_relative "test_unit/framework_loader"
41
50
  require_relative "test_unit/subject_class_registry"
42
51
  require_relative "test_unit/dispatcher"
52
+ require_relative "test_unit/test_ids"
43
53
  FrameworkLoader.new.call
44
54
  files = baseline_test_files(test_file)
45
55
  Evilution::Integration::Loading::TestLoadPath.add!(files)
46
56
  new_classes = SubjectClassRegistry.newly_loaded do
47
57
  files.each { |f| load(File.expand_path(f)) }
48
58
  end
49
- Dispatcher.call(new_classes, name: "evilution baseline").passed?
59
+ baseline_report(Dispatcher.call(new_classes, name: "evilution baseline"))
60
+ end
61
+
62
+ def self.baseline_report(result)
63
+ Evilution::Baseline::Report.build(
64
+ passed: result.passed?, failures: baseline_failures(result), failed_ids: TestIds.failed(result)
65
+ )
66
+ end
67
+
68
+ def self.baseline_failures(result)
69
+ TestIds.faults(result).map do |fault|
70
+ { id: fault.test_name, description: "", message: fault.message }
71
+ end
50
72
  end
51
73
 
52
74
  def self.baseline_test_files(test_file)
53
75
  File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
54
76
  end
55
77
 
56
- def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
78
+ # known_failures: the ids of the tests the baseline saw failing (see TestIds).
79
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil, known_failures: [])
57
80
  require_relative "test_unit/framework_loader"
58
81
  require_relative "test_unit/subject_class_registry"
59
82
  require_relative "test_unit/dispatcher"
60
83
  require_relative "test_unit/test_file_resolver"
61
84
  require_relative "test_unit/result_builder"
85
+ require_relative "test_unit/test_ids"
86
+ @known_failures = Evilution::Integration::KnownFailures.new(known_failures)
62
87
  @framework_loader = FrameworkLoader.new
63
88
  @file_resolver = TestFileResolver.new(
64
89
  test_files: test_files,
@@ -97,22 +122,25 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
97
122
 
98
123
  detector = reset_crash_detector
99
124
  result = Dispatcher.call(new_classes, name: "evilution-mutation")
125
+ # Before the crash check: a test that was already failing may fail by
126
+ # raising now, and a crash in it is no more a kill than its failure was.
127
+ return ResultBuilder.known_failures_only(command) if @known_failures.only?(TestIds.failed(result))
128
+
100
129
  result.faults.each { |fault| detector.record(fault) }
101
130
  ResultBuilder.call(passed: result.passed?, command: command, detector: detector)
102
131
  end
103
132
 
104
133
  def load_test_classes(files)
105
134
  Evilution::Integration::Loading::TestLoadPath.add!(files)
106
- SubjectClassRegistry.newly_loaded do
107
- files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
108
- end
135
+ paths = files.map { |f| File.expand_path(f, Evilution.project_base_dir) }
136
+ self.class.test_classes.fetch(paths) { |path| load(path) }
109
137
  end
110
138
 
111
139
  # Test::Unit has no public registry-clear analogous to
112
- # Minitest::Runnable.runnables.clear. SubjectClassRegistry's newly_loaded
113
- # block scopes each dispatch to classes loaded in *this* round, so stale
114
- # classes from prior mutations sit dormant on ObjectSpace without polluting
115
- # the run. #reset_state stays as a contract no-op for parity with Minitest.
140
+ # Minitest::Runnable.runnables.clear. Each dispatch is handed the classes of
141
+ # the files resolved for this mutation, so classes of other files sit
142
+ # dormant on ObjectSpace without polluting the run. #reset_state stays as a
143
+ # contract no-op for parity with Minitest.
116
144
  def reset_state
117
145
  # no-op — see comment above
118
146
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "fileutils"
4
5
  require "tmpdir"
5
6
  require_relative "../memory"
@@ -55,8 +56,23 @@ class Evilution::Isolation::Fork
55
56
  # group and the TERM/KILL ladder sweeps the whole subtree on timeout (EV-2sh8
56
57
  # / GH #1330). The block keeps the marshal-pipe protocol: write a
57
58
  # length-prefixed payload, then exit with the pass/fail code.
59
+ #
60
+ # The at_exit hook covers a child that never gets that far. The normal path
61
+ # ends in `exit!`, which skips it, so it runs only when the child is dying.
62
+ #
63
+ # Both reports go through method objects taken before the test command runs.
64
+ # When the mutation under test is one of this file, the child evaluates the
65
+ # mutated source and the methods here are replaced; a method object keeps the
66
+ # definition the child started with, so the result is not written by the
67
+ # mutant it describes.
58
68
  def spawn_child(read_io, write_io, sandbox_dir, mutation, test_command)
59
69
  @supervisor.spawn(sandbox_dir: sandbox_dir) do
70
+ write = method(:write_result)
71
+ report_death = method(:report_death)
72
+ child_pid = Process.pid
73
+ # A process the tests fork inherits this hook and the pipe with it; its
74
+ # death is not the child's to report.
75
+ at_exit { report_death.call(write_io, $ERROR_INFO, write) if Process.pid == child_pid }
60
76
  ENV["TMPDIR"] = sandbox_dir
61
77
  # Path-relativizing mutations (e.g. File.join(dir, name) -> name) would
62
78
  # otherwise write into the parent's CWD (typically the repo root) and
@@ -67,18 +83,54 @@ class Evilution::Isolation::Fork
67
83
  # paths to Evilution::PROJECT_ROOT instead of the sandbox CWD.
68
84
  Dir.chdir(sandbox_dir)
69
85
  Evilution.in_isolated_worker!
86
+ Evilution.single_mutation_process!
70
87
  read_io.close
71
88
  suppress_child_output
72
89
  @hooks.fire(:worker_process_start, mutation:) if @hooks
73
90
  result = execute_in_child(mutation, test_command)
74
- payload = Marshal.dump(result)
75
- write_io.write([payload.bytesize].pack("N"))
76
- write_io.write(payload)
77
- write_io.close
91
+ write.call(write_io, result)
78
92
  exit!(result[:passed] ? 0 : 1)
79
93
  end
80
94
  end
81
95
 
96
+ def write_result(write_io, result)
97
+ payload = Marshal.dump(result)
98
+ write_io.write([payload.bytesize].pack("N"))
99
+ write_io.write(payload)
100
+ write_io.close
101
+ end
102
+
103
+ # Some exceptions reach no `rescue` in the child. `execute_in_child` leaves
104
+ # everything that is not a ScriptError or StandardError alone, and Ruby may
105
+ # run neither `rescue` nor `ensure` when the stack overflows through
106
+ # `const_missing`, which a mutation of a process-wide `const_missing` does.
107
+ # Without a result the parent sees only an exit status.
108
+ #
109
+ # A child that exits on purpose or on a signal keeps its status as the
110
+ # report: the first is its own decision, the second is usually the parent's.
111
+ def report_death(write_io, error, write)
112
+ return if error.nil? || write_io.closed?
113
+ return if error.is_a?(SystemExit) || error.is_a?(SignalException)
114
+
115
+ write.call(write_io, death_result(error))
116
+ end
117
+
118
+ # Dying while the tests run, after the mutated source loaded, is what a test
119
+ # that crashes does, and counts the same. Dying before that says nothing
120
+ # about the tests.
121
+ def death_result(error)
122
+ testing = Evilution.died_running_tests?(error)
123
+ phase = testing ? "while running the tests" : "before the tests ran"
124
+ result = {
125
+ passed: false,
126
+ error: "child died #{phase}: #{error.class}: #{error.message}",
127
+ error_class: error.class.name,
128
+ error_backtrace: Array(error.backtrace).first(5)
129
+ }
130
+ result[:test_crashed] = true if testing
131
+ result
132
+ end
133
+
82
134
  # The parent owns read_io/write_io (write_io is closed right after spawn so
83
135
  # read_io can see EOF), so they are closed here rather than handed to the
84
136
  # supervisor. The supervisor reaps the child and removes the sandbox dir; on
@@ -203,28 +255,48 @@ class Evilution::Isolation::Fork
203
255
  buf
204
256
  end
205
257
 
258
+ # What arrives is not always what the child wrote: every process the child
259
+ # forks inherits the write end of the pipe. A result that does not load, or
260
+ # loads to something other than a result, costs this mutation its verdict
261
+ # and nothing more.
206
262
  def decode_payload(data, handle = nil, terminated: false)
207
263
  return empty_result(handle, terminated: terminated) if data.nil? || data.empty?
208
264
 
209
- { timeout: false }.merge(Marshal.load(data))
265
+ result = Marshal.load(data)
266
+ return unreadable_result(handle, "expected a Hash, got #{result.class}", terminated: terminated) unless result.is_a?(Hash)
267
+
268
+ { timeout: false }.merge(result)
269
+ rescue TypeError, ArgumentError => e
270
+ unreadable_result(handle, "#{e.class}: #{e.message.gsub(/\s+/, " ")}", terminated: terminated)
210
271
  end
211
272
 
212
273
  def empty_result(handle, terminated: false)
213
274
  { timeout: false, passed: false, error: child_death_reason(handle, terminated: terminated) }
214
275
  end
215
276
 
277
+ def unreadable_result(handle, detail, terminated: false)
278
+ ending = child_ending(handle, terminated: terminated)
279
+ subject = ending.nil? ? "child's result" : "#{ending}; its result"
280
+ { timeout: false, passed: false, error: "#{subject} was unreadable (#{detail})" }
281
+ end
282
+
216
283
  # A child that wrote no payload has usually left its stdout/stderr capture
217
284
  # empty too, so its termination status is the only evidence of what happened.
218
285
  # Reporting it separates a segfault from an OOM kill from a clean early exit,
219
286
  # all of which reach this path identically.
220
287
  def child_death_reason(handle, terminated: false)
221
- return "child was terminated after the reap deadline without writing a result" if terminated
288
+ ending = child_ending(handle, terminated: terminated)
289
+ ending.nil? ? "empty result from child" : "#{ending} without writing a result"
290
+ end
291
+
292
+ def child_ending(handle, terminated: false)
293
+ return "child was terminated after the reap deadline" if terminated
222
294
 
223
295
  status = handle.nil? ? nil : handle.status
224
- return "empty result from child" unless status.is_a?(Process::Status)
225
- return "child exited #{status.exitstatus} without writing a result" unless status.signaled?
296
+ return nil unless status.is_a?(Process::Status)
297
+ return "child exited #{status.exitstatus}" unless status.signaled?
226
298
 
227
- "child died on #{signal_name(status.termsig)} without writing a result"
299
+ "child died on #{signal_name(status.termsig)}"
228
300
  end
229
301
 
230
302
  def signal_name(termsig)
@@ -239,7 +311,7 @@ class Evilution::Isolation::Fork
239
311
 
240
312
  def classify_status(result)
241
313
  return :timeout if result[:timeout]
242
- return :killed if result[:test_crashed]
314
+ return :killed if result[:test_crashed] || result[:load_crashed]
243
315
  return :unresolved if result[:unresolved]
244
316
  return :error if result[:error]
245
317
  return :survived if result[:passed]
@@ -263,7 +335,8 @@ class Evilution::Isolation::Fork
263
335
  message: result[:error],
264
336
  klass: result[:error_class],
265
337
  backtrace: result[:error_backtrace]
266
- )
338
+ ),
339
+ known_failures_only: result[:known_failures_only]
267
340
  )
268
341
  end
269
342
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "fileutils"
4
5
  require "timeout"
5
6
  require "tmpdir"
@@ -9,14 +10,84 @@ require_relative "../result/mutation_result"
9
10
  require_relative "../isolation"
10
11
 
11
12
  class Evilution::Isolation::InProcess
13
+ # Raised into the run when its time is up. A SignalException, because the
14
+ # timeout has to end the whole test run and a test framework rescues nearly
15
+ # everything a test raises, Timeout::Error included: it would record one
16
+ # failed test and go on to the next, which may reach the same endless loop
17
+ # with no timer left to stop it. Minitest, Test::Unit and RSpec all let a
18
+ # SignalException through.
19
+ #
20
+ # Not an Interrupt, though it is one of those: tooling that handles the
21
+ # user's Ctrl-C (maxitest) rescues Interrupt and skips every test from then
22
+ # on, for the rest of the process.
23
+ class Expired < SignalException
24
+ def initialize(_message = nil)
25
+ super("SIGALRM")
26
+ end
27
+ end
28
+
12
29
  @null_out = File.open(File::NULL, "w")
13
30
  @null_err = File.open(File::NULL, "w")
31
+ @died = false
14
32
 
15
33
  class << self
16
34
  attr_reader :null_out, :null_err
35
+
36
+ # The mutation this process is testing, remembered so that a process dying
37
+ # in the middle of it can say which one it was. Ruby runs no `ensure` on
38
+ # some deaths (a stack overflow through `const_missing`, under the usual
39
+ # stack limit), so the mutation is still on record when the at_exit hook
40
+ # asks. The process is recorded with it: a process the tests fork inherits
41
+ # both the record and the hook, and its death is not this run's.
42
+ #
43
+ # The error stream is recorded as well, as it is before the mutation is
44
+ # tested: `$stderr` is the null stream while it is, and on such a death
45
+ # nothing puts it back.
46
+ def watch(mutation)
47
+ @watched = [Process.pid, mutation, $stderr]
48
+ return if @death_hook_installed
49
+
50
+ @death_hook_installed = true
51
+ at_exit { report_death($ERROR_INFO) }
52
+ end
53
+
54
+ def unwatch
55
+ @watched = nil
56
+ @died = false
57
+ end
58
+
59
+ def watched
60
+ pid, mutation, = @watched
61
+ mutation if pid == Process.pid
62
+ end
63
+
64
+ def report_death(error, stream: nil)
65
+ mutation = watched
66
+ return if mutation.nil? || error.nil?
67
+ return if error.is_a?(SystemExit) || error.is_a?(SignalException)
68
+
69
+ stream ||= @watched.last
70
+ stream.puts(
71
+ "[evilution] the run died while testing a mutation under in-process isolation: " \
72
+ "#{mutation} (#{error.class}: #{error.message}). " \
73
+ "In-process isolation cannot contain a mutation that takes the Ruby process down; " \
74
+ "re-run with --isolation fork."
75
+ )
76
+ stream.flush
77
+ @died = true
78
+ end
79
+
80
+ # Whether this process is ending on a death that was reported. Such a run
81
+ # measured nothing, which whoever decides the process status has to know:
82
+ # the hook that reports the death runs before the project's own, and an
83
+ # exit from there would skip them.
84
+ def died?
85
+ @died
86
+ end
17
87
  end
18
88
 
19
89
  def call(mutation:, test_command:, timeout:)
90
+ self.class.watch(mutation)
20
91
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
92
  rss_before = Evilution::Memory.rss_kb
22
93
  sandbox_dir = Dir.mktmpdir("evilution-run")
@@ -27,13 +98,14 @@ class Evilution::Isolation::InProcess
27
98
 
28
99
  build_mutation_result(mutation, result, duration, rss_before, rss_after, delta)
29
100
  ensure
101
+ self.class.unwatch
30
102
  FileUtils.rm_rf(sandbox_dir) if sandbox_dir
31
103
  end
32
104
 
33
105
  private
34
106
 
35
107
  # The Dir.chdir block is inside the Timeout.timeout block so that a
36
- # Timeout::Error raised mid-call still unwinds through Dir.chdir's ensure
108
+ # timeout raised mid-call still unwinds through Dir.chdir's ensure
37
109
  # and restores the parent CWD before the rescue clause runs. The sandbox
38
110
  # contains any relative-path writes from path-relativizing mutations
39
111
  # (EV-wqxu / GH #1278). Evilution.with_isolated_worker signals the rest of
@@ -41,7 +113,7 @@ class Evilution::Isolation::InProcess
41
113
  # SourceEvaluator/Integration) to anchor project-relative paths to
42
114
  # PROJECT_ROOT for the duration of the call.
43
115
  def execute_with_timeout(mutation, test_command, timeout, sandbox_dir)
44
- result = Timeout.timeout(timeout) do
116
+ result = Timeout.timeout(timeout, Expired) do
45
117
  Evilution.with_isolated_worker do
46
118
  Dir.chdir(sandbox_dir) do
47
119
  suppress_output { test_command.call(mutation) }
@@ -49,15 +121,34 @@ class Evilution::Isolation::InProcess
49
121
  end
50
122
  end
51
123
  { timeout: false }.merge(result)
52
- rescue Timeout::Error
124
+ rescue Expired
53
125
  { timeout: true }
54
126
  rescue ScriptError, StandardError => e
127
+ failure_result(e, e.message)
128
+ rescue SignalException, SystemExit
129
+ raise
130
+ rescue Exception => e # rubocop:disable Lint/RescueException -- turned into this mutation's result
131
+ crash_result(e)
132
+ end
133
+
134
+ # An exception outside StandardError that no test framework held back: a
135
+ # stack overflow that unwound, most likely. Left alone it would end the run.
136
+ # Leaving the test run that way, after the mutated source loaded, is what a
137
+ # test that crashes does, and counts the same.
138
+ def crash_result(error)
139
+ message = "#{error.class}: #{error.message}"
140
+ return failure_result(error, message) unless Evilution.died_running_tests?(error)
141
+
142
+ failure_result(error, "test run ended by #{message}").merge(test_crashed: true)
143
+ end
144
+
145
+ def failure_result(error, message)
55
146
  {
56
147
  timeout: false,
57
148
  passed: false,
58
- error: e.message,
59
- error_class: e.class.name,
60
- error_backtrace: Array(e.backtrace).first(5)
149
+ error: message,
150
+ error_class: error.class.name,
151
+ error_backtrace: Array(error.backtrace).first(5)
61
152
  }
62
153
  end
63
154
 
@@ -81,7 +172,7 @@ class Evilution::Isolation::InProcess
81
172
 
82
173
  def classify_status(result)
83
174
  return :timeout if result[:timeout]
84
- return :killed if result[:test_crashed]
175
+ return :killed if result[:test_crashed] || result[:load_crashed]
85
176
  return :unresolved if result[:unresolved]
86
177
  return :error if result[:error]
87
178
  return :survived if result[:passed]
@@ -106,7 +197,8 @@ class Evilution::Isolation::InProcess
106
197
  message: result[:error],
107
198
  klass: result[:error_class],
108
199
  backtrace: result[:error_backtrace]
109
- )
200
+ ),
201
+ known_failures_only: result[:known_failures_only]
110
202
  )
111
203
  end
112
204
  end
@@ -8,7 +8,11 @@ module Evilution::MCP::InfoTool::StatusGlossary
8
8
  {
9
9
  "status" => "killed",
10
10
  "meaning" => "A test failed when the mutation was applied — the test suite caught the mutation. " \
11
- "This is the desired outcome.",
11
+ "This is the desired outcome. Also a mutation that made its own file raise while loading, " \
12
+ "before any test ran, when the original file still loads (error_message starts with " \
13
+ "'load failed:'), and one whose test process died on an exception no handler saw while " \
14
+ "the tests ran, as a crashing test would (error_message starts with 'child died' under " \
15
+ "fork isolation, 'test run ended by' under in-process isolation).",
12
16
  "counted_in_score" => true
13
17
  },
14
18
  {
@@ -27,13 +31,20 @@ module Evilution::MCP::InfoTool::StatusGlossary
27
31
  {
28
32
  "status" => "error",
29
33
  "meaning" => "Mutation execution raised an unexpected error (syntax error at load time, " \
30
- "boot failure, test-infrastructure crash). The mutation could not be evaluated.",
34
+ "boot failure, test-infrastructure crash). The mutation could not be evaluated. " \
35
+ "Includes a test process that died on an unhandled exception before the tests ran " \
36
+ "('child died ... before the tests ran'; error_class is the exception's own class), and " \
37
+ "results that are no verdict on the mutation at all: a fork child's result that " \
38
+ "was unreadable, a worker's result that could not be read (the worker was replaced), " \
39
+ "a child that died on a signal without writing a result (SIGABRT: the Ruby VM aborted; " \
40
+ "SIGSEGV; SIGKILL, e.g. out of memory). Re-run that one mutation; if a VM abort repeats, " \
41
+ "the mutant crashes the interpreter.",
31
42
  "counted_in_score" => false
32
43
  },
33
44
  {
34
45
  "status" => "neutral",
35
- "meaning" => "Baseline tests already failed before the mutation was applied — pre-existing " \
36
- "test-suite problem (flaky spec, infra collision, fixture setup failure). " \
46
+ "meaning" => "No verdict: the tests failed only on examples that were already failing before " \
47
+ "the mutation was applied, or the test process crashed on infrastructure. " \
37
48
  "Not a meaningful mutation signal.",
38
49
  "counted_in_score" => false
39
50
  },
@@ -18,7 +18,8 @@ class Evilution::MCP::InfoTool < MCP::Tool
18
18
  tool_name "evilution-info"
19
19
  description "Discover what evilution sees before running any mutations. " \
20
20
  "One tool, five actions: " \
21
- "'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
21
+ "'subjects' lists every mutation subject in the target files (methods, and code outside them: " \
22
+ "constant assignments, scopes, AASM and callback declarations) with its file, line, and mutation count; " \
22
23
  "'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
23
24
  "'environment' dumps the effective config (version, ruby, config file, timeout, " \
24
25
  "integration, isolation, and every other setting); " \
@@ -36,7 +37,7 @@ class Evilution::MCP::InfoTool < MCP::Tool
36
37
  type: "string",
37
38
  enum: VALID_ACTIONS,
38
39
  description: "Which discovery operation to perform. " \
39
- "'subjects' lists mutatable methods; 'tests' resolves specs for sources; " \
40
+ "'subjects' lists mutation subjects; 'tests' resolves specs for sources; " \
40
41
  "'environment' dumps effective config; 'statuses' returns the result-status glossary; " \
41
42
  "'feedback' returns the discussion URL and consent/privacy guidance."
42
43
  },
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "../mutate_tool"
5
5
  require_relative "../../reporter/suggestion"
6
+ require_relative "../../diagnostic"
6
7
 
7
8
  module Evilution::MCP::MutateTool::ProgressStreamer
8
9
  def self.build(server_context:, suggest_tests:, integration:)
@@ -21,7 +22,7 @@ module Evilution::MCP::MutateTool::ProgressStreamer
21
22
  detail = build_suggestion_detail(result.mutation, suggestion)
22
23
  server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
23
24
  rescue StandardError => e
24
- warn "[evilution] progress stream disabled after error: #{e.class}: #{e.message}"
25
+ Evilution::Diagnostic.warn("[evilution] progress stream disabled after error: #{e.class}: #{e.message}")
25
26
  disabled = true
26
27
  end
27
28
  end