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
@@ -6,6 +6,7 @@ require_relative "constant_pinner"
6
6
  require_relative "concern_state_cleaner"
7
7
  require_relative "source_evaluator"
8
8
  require_relative "redefinition_recovery"
9
+ require_relative "../../diagnostic"
9
10
 
10
11
  # Composes the load-time pipeline that applies a mutation's new source to the
11
12
  # running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
@@ -19,6 +20,14 @@ require_relative "redefinition_recovery"
19
20
  # RedefinitionRecovery stays as a safety net for cases the neutralizer's
20
21
  # allowlist heuristic misses. Returns nil on success or a failure-shaped
21
22
  # hash on any error.
23
+ #
24
+ # A mutated source that raises while it is evaluated never reaches the tests.
25
+ # When the original still evaluates right after it, the mutation is what broke
26
+ # the load, and the failure is marked `load_crashed`: the mutant is detected,
27
+ # as one that crashes a test is. When the original fails too, or there is none
28
+ # to compare with, the failure says nothing about the mutant and stays an
29
+ # error. A syntax error is never the mutant's doing in that sense: it means
30
+ # the mutation was not valid Ruby to begin with.
22
31
  class Evilution::Integration::Loading::MutationApplier
23
32
  def initialize(syntax_validator: Evilution::Integration::Loading::SyntaxValidator.new,
24
33
  constant_pinner: Evilution::Integration::Loading::ConstantPinner.new,
@@ -42,11 +51,45 @@ class Evilution::Integration::Loading::MutationApplier
42
51
  rescue SyntaxError => e
43
52
  failure_result(e, "syntax error in mutated source: #{e.message}")
44
53
  rescue ScriptError, StandardError => e
45
- failure_result(e, "#{e.class}: #{e.message}")
54
+ load_failure_result(mutation, e)
55
+ end
56
+
57
+ # Evaluates the mutation's restore_source: the original file. Runs after the
58
+ # tests: a failure here must not change the mutation's result, so it is
59
+ # reported and swallowed. Concern state is cleared first, as it is before
60
+ # applying: a concern ignores an `included` block handed to it twice from
61
+ # the same place, and would keep the mutated one.
62
+ def restore(mutation)
63
+ evaluate_original(mutation)
64
+ rescue ScriptError, StandardError => e
65
+ Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
46
66
  end
47
67
 
48
68
  private
49
69
 
70
+ # Whether there was an original to evaluate.
71
+ def evaluate_original(mutation)
72
+ source = mutation.respond_to?(:restore_source) ? mutation.restore_source : nil
73
+ return false unless source
74
+
75
+ @concern_state_cleaner.call(mutation.file_path)
76
+ @source_evaluator.call(source, mutation.file_path)
77
+ true
78
+ end
79
+
80
+ def original_loads?(mutation)
81
+ evaluate_original(mutation)
82
+ rescue ScriptError, StandardError
83
+ false
84
+ end
85
+
86
+ def load_failure_result(mutation, error)
87
+ message = "#{error.class}: #{error.message}"
88
+ return failure_result(error, message) unless original_loads?(mutation)
89
+
90
+ failure_result(error, "load failed: #{message}").merge(load_crashed: true)
91
+ end
92
+
50
93
  def resolve_eval_target(mutation)
51
94
  return mutation.eval_source if mutation.respond_to?(:eval_source)
52
95
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # The methods a class defined itself, held while a concern's hook block is run
6
+ # on it a second time.
7
+ #
8
+ # The first time, the block ran when the class took the concern, and whatever
9
+ # the class went on to define under the same name replaced the block's method.
10
+ # Run again, the block would put its own back in that place: code that is dead
11
+ # for the class would come alive, and its tests would fail whatever the mutant.
12
+ #
13
+ # A method counts as the class's own when it was not written in the block's
14
+ # file. One that is found written there afterwards was replaced by the block,
15
+ # and is put back. A class that defines such a method in the concern's own file
16
+ # cannot be told apart and is not protected.
17
+ class Evilution::Integration::Loading::OwnMethods
18
+ # Called through UnboundMethods: a class may define any of these for its
19
+ # own purposes.
20
+ PUBLIC = Module.instance_method(:public_instance_methods)
21
+ PROTECTED = Module.instance_method(:protected_instance_methods)
22
+ PRIVATE = Module.instance_method(:private_instance_methods)
23
+ LOOKUP = Module.instance_method(:instance_method)
24
+ DEFINED = Module.instance_method(:method_defined?)
25
+ PRIVATELY_DEFINED = Module.instance_method(:private_method_defined?)
26
+ DEFINE = Module.instance_method(:define_method)
27
+ SINGLETON_CLASS = Kernel.instance_method(:singleton_class)
28
+ VISIBILITIES = { public: PUBLIC, protected: PROTECTED, private: PRIVATE }.freeze
29
+ SETTERS = VISIBILITIES.keys.to_h { |visibility| [visibility, Module.instance_method(visibility)] }.freeze
30
+ private_constant :PUBLIC, :PROTECTED, :PRIVATE, :LOOKUP, :DEFINED, :PRIVATELY_DEFINED, :DEFINE, :SINGLETON_CLASS,
31
+ :VISIBILITIES, :SETTERS
32
+
33
+ Kept = Struct.new(:owner, :name, :definition, :visibility)
34
+ private_constant :Kept
35
+
36
+ # Runs the block, then puts back the methods of `klass` that the code of
37
+ # `file` replaced meanwhile -- also when the block raises.
38
+ def self.keeping(klass, file)
39
+ own = new(klass, file)
40
+ begin
41
+ yield
42
+ ensure
43
+ own.put_back
44
+ end
45
+ end
46
+
47
+ def initialize(klass, file)
48
+ @file = file
49
+ @kept = [klass, SINGLETON_CLASS.bind_call(klass)].flat_map { |owner| own_methods_of(owner) }
50
+ end
51
+
52
+ def put_back
53
+ @kept.each { |kept| restore(kept) if replaced?(kept) }
54
+ end
55
+
56
+ private
57
+
58
+ def own_methods_of(owner)
59
+ VISIBILITIES.flat_map do |visibility, list|
60
+ list.bind_call(owner, false).filter_map do |name|
61
+ definition = LOOKUP.bind_call(owner, name)
62
+ Kept.new(owner, name, definition, visibility) unless written_in_file?(definition)
63
+ end
64
+ end
65
+ end
66
+
67
+ def written_in_file?(definition)
68
+ location = definition.source_location
69
+ !location.nil? && location.first == @file
70
+ end
71
+
72
+ # A method the block removed stays removed.
73
+ def replaced?(kept)
74
+ return false unless defined_on?(kept.owner, kept.name)
75
+
76
+ written_in_file?(LOOKUP.bind_call(kept.owner, kept.name))
77
+ end
78
+
79
+ def defined_on?(owner, name)
80
+ DEFINED.bind_call(owner, name) || PRIVATELY_DEFINED.bind_call(owner, name)
81
+ end
82
+
83
+ def restore(kept)
84
+ DEFINE.bind_call(kept.owner, kept.name, kept.definition)
85
+ SETTERS.fetch(kept.visibility).bind_call(kept.owner, kept.name)
86
+ end
87
+ end
@@ -24,6 +24,7 @@ class Evilution::Integration::Loading::RedefinitionRecovery
24
24
 
25
25
  def initialize(constant_names: Evilution::AST::ConstantNames.new)
26
26
  @constant_names = constant_names
27
+ @warned_messages = Set.new
27
28
  end
28
29
 
29
30
  def call(source, &block)
@@ -73,10 +74,8 @@ class Evilution::Integration::Loading::RedefinitionRecovery
73
74
  end
74
75
 
75
76
  def warn_once_for(error)
76
- return if @warned_messages&.include?(error.message)
77
+ return unless @warned_messages.add?(error.message)
77
78
 
78
- @warned_messages ||= []
79
- @warned_messages << error.message
80
79
  $stderr.write(
81
80
  "[evilution] swallowed idempotency violation on re-eval: " \
82
81
  "#{error.class}: #{error.message}. " \
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../loading"
4
4
  require_relative "reeval_warning_filter"
5
+ require_relative "superseded_modules"
5
6
 
6
7
  # Evaluate source with __FILE__ set to the absolute original path so that
7
8
  # `require_relative` and `__dir__` resolve against the real source tree, where
@@ -13,13 +14,19 @@ require_relative "reeval_warning_filter"
13
14
  # between this eval path and a plain `require` of the same file is that we
14
15
  # substitute the mutated bytes — the privilege level is identical.
15
16
  class Evilution::Integration::Loading::SourceEvaluator
17
+ def initialize(superseded_modules: Evilution::Integration::Loading::SupersededModules.new)
18
+ @superseded_modules = superseded_modules
19
+ end
20
+
16
21
  def call(source, file_path)
17
22
  # When the isolator has chdir'd into a per-mutation sandbox (EV-wqxu /
18
23
  # GH #1278), anchor the eval __FILE__ against PROJECT_ROOT so siblings
19
24
  # `require_relative` can find each other from the real source tree.
20
25
  absolute = File.expand_path(file_path, Evilution.project_base_dir)
21
- Evilution::Integration::Loading::ReevalWarningFilter.suppress do
22
- eval(source, TOPLEVEL_BINDING, absolute, 1)
26
+ @superseded_modules.track(absolute) do
27
+ Evilution::Integration::Loading::ReevalWarningFilter.suppress do
28
+ eval(source, TOPLEVEL_BINDING, absolute, 1)
29
+ end
23
30
  end
24
31
  end
25
32
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Empties the anonymous modules an earlier evaluation of a file left behind.
6
+ #
7
+ # A file can install a module it never names:
8
+ #
9
+ # handler = Module.new { def const_missing(name) = ... }
10
+ # Object.extend(handler)
11
+ #
12
+ # Evaluating the file again builds a new module and extends with it again, and
13
+ # Ruby has no way to take the old one off. Under in-process isolation the file
14
+ # is evaluated for every mutation and every restore, so the modules pile up on
15
+ # their host, and a `super` in the mutation under test runs the code of every
16
+ # mutation before it. Its tests then fail on a predecessor's bug, and the
17
+ # mutation is reported killed. Fork isolation never sees this: a child holds
18
+ # one mutation.
19
+ #
20
+ # A module cannot be taken off, but its methods can be removed, and method
21
+ # lookup passes through a module that has none. So the modules created while a
22
+ # file is evaluated are remembered, and emptied when the same file is evaluated
23
+ # next.
24
+ #
25
+ # Two limits keep this to what the new evaluation really replaces. Only
26
+ # modules are emptied, not anonymous classes, whose instances may be held
27
+ # elsewhere. And only methods written in the file itself are removed: a module
28
+ # some library built on the file's behalf holds methods written in the
29
+ # library, which evaluating the file again does not define anew.
30
+ class Evilution::Integration::Loading::SupersededModules
31
+ @registry = {}
32
+
33
+ class << self
34
+ # Created modules by file, for the process: the evaluations of one file
35
+ # are not all made through the same instance.
36
+ attr_reader :registry
37
+ end
38
+
39
+ MODULE_NAME = Module.instance_method(:name)
40
+ INSTANCE_OF = Kernel.instance_method(:instance_of?)
41
+ private_constant :MODULE_NAME, :INSTANCE_OF
42
+
43
+ def initialize(registry: self.class.registry)
44
+ @registry = registry
45
+ end
46
+
47
+ # Evaluates the block as an evaluation of `file_path`. The modules of the
48
+ # evaluation before are emptied first, so that the new one starts from what
49
+ # a fresh process would hold; the modules this one creates are remembered
50
+ # whether or not it completes.
51
+ #
52
+ # A fork child holds one mutation and ends with it, so there is nothing for
53
+ # it to empty later, and walking the heap of a process just forked is dear:
54
+ # every page touched has to be mapped first. It evaluates and no more.
55
+ def track(file_path)
56
+ return yield if Evilution.single_mutation_process?
57
+
58
+ retire(file_path)
59
+ known = anonymous_modules
60
+ yield
61
+ ensure
62
+ remember(file_path, known) if known
63
+ end
64
+
65
+ private
66
+
67
+ # Nothing is kept for a file whose evaluation created none, which is most
68
+ # files: an entry is only worth carrying when there is a module to empty.
69
+ def remember(file_path, known)
70
+ created = anonymous_modules.reject { |mod| known.key?(mod) }.keys
71
+ @registry[file_path] = created unless created.empty?
72
+ end
73
+
74
+ def retire(file_path)
75
+ Array(@registry.delete(file_path)).each { |mod| empty(mod, file_path) }
76
+ end
77
+
78
+ def empty(mod, file_path)
79
+ return if mod.frozen?
80
+
81
+ own = mod.instance_methods(false) + mod.private_instance_methods(false)
82
+ own.each { |name| mod.send(:remove_method, name) if written_in?(mod.instance_method(name), file_path) }
83
+ end
84
+
85
+ def written_in?(method, file_path)
86
+ location = method.source_location
87
+ !location.nil? && location.first == file_path
88
+ end
89
+
90
+ # No message is sent to the modules walked. ObjectSpace yields everything
91
+ # that is a Module, and some of it answers messages in its own way: a class
92
+ # may define `name`, `hash` or `==` for itself, and ActiveSupport's
93
+ # DeprecatedConstantProxy, a Module subclass, turns any message into a
94
+ # deprecation, which a project may have set to raise. Hence the identity
95
+ # keys and the methods bound from Module and Kernel.
96
+ def anonymous_modules
97
+ found = {}.compare_by_identity
98
+ ObjectSpace.each_object(Module) do |mod|
99
+ found[mod] = true if INSTANCE_OF.bind_call(mod, Module) && MODULE_NAME.bind_call(mod).nil?
100
+ end
101
+ found
102
+ end
103
+ end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "prism"
4
3
  require_relative "../loading"
4
+ require_relative "../../ast/syntax_check"
5
5
 
6
6
  class Evilution::Integration::Loading::SyntaxValidator
7
7
  ERROR_MESSAGE = "mutated source has syntax errors"
8
8
 
9
9
  def call(source)
10
- return nil if Prism.parse(source).success?
10
+ return nil if Evilution::AST::SyntaxCheck.valid?(source)
11
11
 
12
12
  {
13
13
  passed: false,
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Remembers which test classes each test file registered when it was loaded,
6
+ # so a process that runs many mutations loads a test file once.
7
+ #
8
+ # Minitest and Test::Unit learn of a test class when it is first defined.
9
+ # Loading its file again reopens the class and registers nothing, so the
10
+ # second mutation run in a process would find no tests. The classes of the
11
+ # first load are handed back instead.
12
+ #
13
+ # The block given to .new returns the classes registered so far; what a load
14
+ # adds to them is taken to be that file's.
15
+ class Evilution::Integration::Loading::TestClassCache
16
+ def initialize(&registered)
17
+ @registered = registered
18
+ @classes = {}
19
+ @partial = {}
20
+ end
21
+
22
+ # The classes the given files registered. Yields each file that has not
23
+ # been loaded yet; the block loads it.
24
+ def fetch(files, &)
25
+ files.flat_map { |file| @classes[file] ||= load_once(file, &) }.uniq
26
+ end
27
+
28
+ def clear
29
+ @classes.clear
30
+ @partial.clear
31
+ end
32
+
33
+ private
34
+
35
+ # A load that raises is tried again next time. Classes it defined before
36
+ # raising are already registered and will not register again, so they are
37
+ # kept for that next attempt.
38
+ def load_once(file)
39
+ before = @registered.call
40
+ yield file
41
+ @partial.fetch(file, []) | (@registered.call - before)
42
+ rescue ScriptError, StandardError
43
+ @partial[file] = @partial.fetch(file, []) | (@registered.call - before)
44
+ raise
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../minitest"
4
+
5
+ # Names a Minitest test the same way in the baseline and in a mutation run:
6
+ # its class and its method, `CalcTest#test_adds`.
7
+ #
8
+ # A test class without a name has only its method to go by, so two such
9
+ # classes with a method of the same name are told apart by neither run.
10
+ module Evilution::Integration::Minitest::TestIds
11
+ def self.of(result)
12
+ class_name = result.respond_to?(:class_name) ? result.class_name : result.class.name
13
+ "#{class_name}##{result.name}"
14
+ end
15
+
16
+ # The ids of the tests that failed, from the summary reporter of the run.
17
+ # It keeps every result that did not pass, skips included.
18
+ def self.failed(summary)
19
+ summary.results.reject(&:skipped?).map { |result| of(result) }
20
+ end
21
+ end
@@ -4,9 +4,12 @@ require "stringio"
4
4
  require_relative "base"
5
5
  require_relative "../diagnostic"
6
6
  require_relative "minitest_crash_detector"
7
+ require_relative "known_failures"
7
8
  require_relative "loading/test_load_path"
9
+ require_relative "loading/test_class_cache"
8
10
  require_relative "../spec_resolver"
9
11
  require_relative "../spec_selector"
12
+ require_relative "../baseline"
10
13
 
11
14
  require_relative "../integration"
12
15
 
@@ -47,12 +50,22 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
47
50
  options = ::Minitest.process_args(["--seed", "0"])
48
51
  options[:io] = out
49
52
  reporter = ::Minitest::CompositeReporter.new
50
- reporter << ::Minitest::SummaryReporter.new(out, options)
53
+ summary = ::Minitest::SummaryReporter.new(out, options)
54
+ reporter << summary
51
55
  initialize_minitest_state(reporter, options)
52
56
  reporter.start
53
57
  dispatch_minitest_suites(reporter, options)
54
58
  reporter.report
55
- reporter.passed?
59
+ Evilution::Baseline::Report.build(
60
+ passed: reporter.passed?, failures: baseline_failures(summary), failed_ids: TestIds.failed(summary)
61
+ )
62
+ end
63
+
64
+ # The summary reporter keeps every result that did not pass, skips included.
65
+ def self.baseline_failures(summary)
66
+ summary.results.reject(&:skipped?).map do |result|
67
+ { id: TestIds.of(result), description: "", message: result.failure.message }
68
+ end
56
69
  end
57
70
 
58
71
  # Mirror Minitest.run's preamble: seed setup + plugin init. Without seeding
@@ -84,6 +97,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
84
97
  end
85
98
  end
86
99
 
100
+ # Shared by every instance: the canary and the mutation runs build their
101
+ # own, and a file loaded for one must not be loaded again for the other.
102
+ def self.test_classes
103
+ @test_classes ||= Evilution::Integration::Loading::TestClassCache.new { ::Minitest::Runnable.runnables.dup }
104
+ end
105
+
87
106
  def self.baseline_options
88
107
  {
89
108
  runner: baseline_runner,
@@ -92,8 +111,10 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
92
111
  }
93
112
  end
94
113
 
95
- def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
114
+ # known_failures: the ids of the tests the baseline saw failing (see TestIds).
115
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil, known_failures: [])
96
116
  @test_files = test_files
117
+ @known_failures = Evilution::Integration::KnownFailures.new(known_failures)
97
118
  @minitest_loaded = false
98
119
  @spec_selector = spec_selector || Evilution::SpecSelector.new(
99
120
  spec_resolver: Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
@@ -132,17 +153,27 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
132
153
  end
133
154
 
134
155
  def execute_minitest(mutation, files, command)
135
- Evilution::Integration::Loading::TestLoadPath.add!(files)
136
- files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
156
+ load_test_classes(files)
137
157
 
138
158
  detector = reset_crash_detector
139
159
  run = run_minitest(build_args(mutation), detector)
140
160
 
141
161
  return no_tests_ran_result(command) if run[:count].zero?
162
+ # Before the crash check: a test that was already failing may fail by
163
+ # raising now, and a crash in it is no more a kill than its failure was.
164
+ return known_failures_only_result(command) if @known_failures.only?(run[:failed_ids])
142
165
 
143
166
  build_minitest_result(run[:passed], command, detector)
144
167
  end
145
168
 
169
+ # Leaves Minitest with the test classes of these files and no others.
170
+ def load_test_classes(files)
171
+ Evilution::Integration::Loading::TestLoadPath.add!(files)
172
+ paths = files.map { |f| File.expand_path(f, Evilution.project_base_dir) }
173
+ classes = self.class.test_classes.fetch(paths) { |path| load(path) }
174
+ ::Minitest::Runnable.runnables.replace(classes)
175
+ end
176
+
146
177
  # Zero dispatched test methods means the run carries no signal — the result
147
178
  # is neither survived nor killed. Most common cause: the project's tests use
148
179
  # a framework other than Minitest (e.g. the test-unit gem, whose
@@ -160,6 +191,12 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
160
191
  }
161
192
  end
162
193
 
194
+ # Every test that failed was already failing in the baseline. Still a
195
+ # failed run, but flagged, so it is not taken for a kill.
196
+ def known_failures_only_result(command)
197
+ { passed: false, known_failures_only: true, test_command: command }
198
+ end
199
+
163
200
  def unresolved_result(mutation)
164
201
  {
165
202
  passed: false,
@@ -190,7 +227,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
190
227
  dispatch_minitest_suites(reporter, options)
191
228
  reporter.report
192
229
 
193
- { passed: summary.passed?, count: minitest_method_count }
230
+ { passed: summary.passed?, count: minitest_method_count, failed_ids: TestIds.failed(summary) }
194
231
  end
195
232
 
196
233
  # Attach evilution's own reporters to the composite AFTER plugin init, and
@@ -293,3 +330,5 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
293
330
  end
294
331
  end
295
332
  end
333
+
334
+ require_relative "minitest/test_ids"
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
3
4
  require_relative "../rspec"
5
+ require_relative "../../baseline"
6
+ require_relative "example_ids"
4
7
 
5
8
  class Evilution::Integration::RSpec::BaselineRunner
6
9
  def call(spec_file)
@@ -9,10 +12,51 @@ class Evilution::Integration::RSpec::BaselineRunner
9
12
  # FrameworkLoader#add_spec_load_path for rationale.
10
13
  spec_dir = File.expand_path("spec", Evilution.project_base_dir)
11
14
  $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
12
- ::RSpec.reset
15
+ reset_examples
16
+ output = StringIO.new
13
17
  status = ::RSpec::Core::Runner.run(
14
- ["--format", "progress", "--no-color", "--order", "defined", spec_file]
18
+ ["--format", "progress", "--no-color", "--order", "defined", spec_file], output, output
15
19
  )
16
- status.zero?
20
+ report(status.zero?, output.string)
21
+ end
22
+
23
+ private
24
+
25
+ # Examples are cleared, the configuration is kept: a helper loaded by
26
+ # --preload is not loaded again by the spec's own require, so an
27
+ # RSpec.configure block dropped here never comes back, and a spec relying on
28
+ # it goes red in the baseline alone.
29
+ def reset_examples
30
+ ::RSpec.respond_to?(:clear_examples) ? ::RSpec.clear_examples : ::RSpec.reset
31
+ end
32
+
33
+ # What RSpec printed is the only account of a failure outside any example --
34
+ # a file that does not load, a suite hook that raises.
35
+ def report(passed, output)
36
+ return Evilution::Baseline::Report.build(passed: true) if passed
37
+
38
+ failures = failed_examples
39
+ Evilution::Baseline::Report.build(
40
+ passed: false, failures: failures, error: failures.empty? ? output : nil,
41
+ failed_ids: Evilution::Integration::RSpec::ExampleIds.failed(::RSpec.world)
42
+ )
43
+ end
44
+
45
+ def failed_examples
46
+ world = ::RSpec.world
47
+ return [] unless world.respond_to?(:all_examples)
48
+
49
+ world.all_examples
50
+ .select { |example| example.execution_result.status == :failed }
51
+ .map { |example| failure(example) }
52
+ end
53
+
54
+ def failure(example)
55
+ exception = example.execution_result.exception
56
+ {
57
+ id: example.id,
58
+ description: example.full_description,
59
+ message: exception ? "#{exception.class}: #{exception.message}" : nil
60
+ }
17
61
  end
18
62
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rspec"
4
+
5
+ # Names an example the same way wherever it runs.
6
+ #
7
+ # RSpec's own id starts with the spec file as it was given: `./spec/a_spec.rb`
8
+ # from the project root, an absolute path from an isolated worker that has
9
+ # changed directory. The baseline and a mutation run can therefore disagree
10
+ # about the same example, so the path is made absolute and symlinks are
11
+ # resolved before the position within the file is added.
12
+ module Evilution::Integration::RSpec::ExampleIds
13
+ FAILED = :failed
14
+
15
+ def self.of(example)
16
+ metadata = example.metadata
17
+ "#{canonical(metadata[:rerun_file_path])}[#{metadata[:scoped_id]}]"
18
+ end
19
+
20
+ # The ids of the examples that failed in the run RSpec.world still holds.
21
+ def self.failed(world)
22
+ return [] unless world.respond_to?(:all_examples)
23
+
24
+ world.all_examples
25
+ .select { |example| example.execution_result.status == FAILED }
26
+ .map { |example| of(example) }
27
+ end
28
+
29
+ def self.canonical(path)
30
+ File.realpath(path)
31
+ rescue SystemCallError
32
+ File.expand_path(path)
33
+ end
34
+ private_class_method :canonical
35
+ end
@@ -21,8 +21,13 @@ class Evilution::Integration::RSpec::ResultBuilder
21
21
  }
22
22
  end
23
23
 
24
- def from_run(status, command, detector, examples_loaded: nil)
24
+ # known_failures_only: every example that failed was already failing in the
25
+ # baseline. Still a failed run, but flagged, so it is not taken for a kill.
26
+ def from_run(status, command, detector, examples_loaded: nil, known_failures_only: false)
25
27
  return { passed: true, test_command: command } if status.zero?
28
+ # Before the crash check: an example that was already failing may fail by
29
+ # raising now, and a crash in it is no more a kill than its failure was.
30
+ return { passed: false, known_failures_only: true, test_command: command } if known_failures_only
26
31
 
27
32
  if detector.only_crashes?
28
33
  classes = detector.unique_crash_classes
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../state_guard"
4
+ require_relative "../../rspec"
5
+
6
+ # Drops the examples a run registers on RSpec::Core::AnonymousExampleGroup.
7
+ #
8
+ # Example#initialize appends every example to its group's `examples`. Most
9
+ # groups are thrown away with the run, but AnonymousExampleGroup is a constant:
10
+ # whatever lands there stays for the life of the process. A run with a suite
11
+ # hook puts a SuiteHookContext there, and the reporter notifying a non-example
12
+ # exception puts an Example there. Each holds that run's reporter, and the
13
+ # reporter holds every example the run loaded, so one in-process run after
14
+ # another retained them all, about 1 MB per mutation on the memory_check
15
+ # fixture.
16
+ #
17
+ # Forked runs never leak this (the child dies), only the in-process path.
18
+ class Evilution::Integration::RSpec::StateGuard::AnonymousExampleGroupExamples
19
+ def snapshot
20
+ examples&.length
21
+ end
22
+
23
+ def release(length)
24
+ return unless length
25
+
26
+ examples&.slice!(length..)
27
+ end
28
+
29
+ private
30
+
31
+ def examples
32
+ return nil unless defined?(::RSpec::Core::AnonymousExampleGroup)
33
+
34
+ ::RSpec::Core::AnonymousExampleGroup.examples
35
+ end
36
+ end