evilution 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. metadata +113 -4
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutator"
4
+
5
+ # Visits every node that sends a message to a receiver, or defines
6
+ # something on one, and hands that receiver to #receiver_found before the
7
+ # receiver itself is visited.
8
+ #
9
+ # A plain call is one such node, but not the only one. An operator or
10
+ # logical write on an attribute or an index (`a.b += c`, `a[b] ||= c`) and
11
+ # an attribute or index target of a multiple assignment (`a.b, c[d] = e`)
12
+ # each hold their receiver in a node of their own. An operator that treats
13
+ # "the receiver of a call" specially — replacing it with `nil` only earns a
14
+ # NoMethodError — has to mean all of them.
15
+ #
16
+ # A definition has a receiver too: the object a singleton method is defined
17
+ # on (`def self.build`, `def helper.extra`) and the one a singleton class is
18
+ # opened on (`class << self`). Replacing it does not change the method that
19
+ # is already there — `def nil.build` defines another one, on `nil` — so the
20
+ # mutant could never be killed.
21
+ #
22
+ # The includer defines #receiver_found(node). `node` is nil where the
23
+ # message or the definition has no explicit receiver.
24
+ module Evilution::Mutator::ReceiverPositions
25
+ def visit_call_node(node)
26
+ receiver_found(node.receiver)
27
+ super
28
+ end
29
+
30
+ def visit_call_operator_write_node(node)
31
+ receiver_found(node.receiver)
32
+ super
33
+ end
34
+
35
+ def visit_call_or_write_node(node)
36
+ receiver_found(node.receiver)
37
+ super
38
+ end
39
+
40
+ def visit_call_and_write_node(node)
41
+ receiver_found(node.receiver)
42
+ super
43
+ end
44
+
45
+ def visit_index_operator_write_node(node)
46
+ receiver_found(node.receiver)
47
+ super
48
+ end
49
+
50
+ def visit_index_or_write_node(node)
51
+ receiver_found(node.receiver)
52
+ super
53
+ end
54
+
55
+ def visit_index_and_write_node(node)
56
+ receiver_found(node.receiver)
57
+ super
58
+ end
59
+
60
+ def visit_call_target_node(node)
61
+ receiver_found(node.receiver)
62
+ super
63
+ end
64
+
65
+ def visit_index_target_node(node)
66
+ receiver_found(node.receiver)
67
+ super
68
+ end
69
+
70
+ def visit_def_node(node)
71
+ receiver_found(node.receiver)
72
+ super
73
+ end
74
+
75
+ def visit_singleton_class_node(node)
76
+ receiver_found(node.expression)
77
+ super
78
+ end
79
+ end
@@ -4,7 +4,10 @@ require_relative "../mutator"
4
4
 
5
5
  class Evilution::Mutator::Registry
6
6
  STRICT_EXTRA_OPERATORS = [
7
- Evilution::Mutator::Operator::PredicateToNil
7
+ Evilution::Mutator::Operator::PredicateToNil,
8
+ Evilution::Mutator::Operator::ExceptionSwallow,
9
+ Evilution::Mutator::Operator::StatementReorder,
10
+ Evilution::Mutator::Operator::SafeNavigationInsertion
8
11
  ].freeze
9
12
 
10
13
  def self.for_profile(profile)
@@ -35,6 +38,8 @@ class Evilution::Mutator::Registry
35
38
  Evilution::Mutator::Operator::NilReplacement,
36
39
  Evilution::Mutator::Operator::IntegerLiteral,
37
40
  Evilution::Mutator::Operator::FloatLiteral,
41
+ Evilution::Mutator::Operator::ComplexLiteral,
42
+ Evilution::Mutator::Operator::RationalLiteral,
38
43
  Evilution::Mutator::Operator::StringLiteral,
39
44
  Evilution::Mutator::Operator::ArrayLiteral,
40
45
  Evilution::Mutator::Operator::HashLiteral,
@@ -136,7 +141,49 @@ class Evilution::Mutator::Registry
136
141
  Evilution::Mutator::Operator::ReceiverConstructorSwap,
137
142
  Evilution::Mutator::Operator::BlockBodyToNil,
138
143
  Evilution::Mutator::Operator::BlockBodyToRaise,
139
- Evilution::Mutator::Operator::BlockBodyPromotion
144
+ Evilution::Mutator::Operator::BlockBodyPromotion,
145
+ Evilution::Mutator::Operator::DataStructMember,
146
+ Evilution::Mutator::Operator::PinOperatorRemoval,
147
+ Evilution::Mutator::Operator::RightwardAssignment,
148
+ Evilution::Mutator::Operator::NumberedParameterSwap,
149
+ Evilution::Mutator::Operator::ForwardedArgumentDrop,
150
+ Evilution::Mutator::Operator::PatternWildcardWidening,
151
+ Evilution::Mutator::Operator::NoMatchingPatternElse,
152
+ Evilution::Mutator::Operator::ArgumentOrderPermutation,
153
+ Evilution::Mutator::Operator::KeywordValueSwap,
154
+ Evilution::Mutator::Operator::ComparisonOperandSwap,
155
+ Evilution::Mutator::Operator::IntegerDivisionToFdiv,
156
+ Evilution::Mutator::Operator::RoundHalfModeSwap,
157
+ Evilution::Mutator::Operator::OffByOneBoundary,
158
+ Evilution::Mutator::Operator::FormatSpecifierSwap,
159
+ Evilution::Mutator::Operator::AliasRemoval,
160
+ Evilution::Mutator::Operator::RegexpCharacterTypeComplement,
161
+ Evilution::Mutator::Operator::RegexpAnchorPromotion,
162
+ Evilution::Mutator::Operator::RegexpAlternationBranchDeletion,
163
+ Evilution::Mutator::Operator::RegexpQuantifierMinimumSwap,
164
+ Evilution::Mutator::Operator::RegexpCaptureToPassive,
165
+ Evilution::Mutator::Operator::RegexpNamedGroupRename,
166
+ Evilution::Mutator::Operator::RegexpOptionRemoval,
167
+ Evilution::Mutator::Operator::RescueHandlerPromotion,
168
+ Evilution::Mutator::Operator::RescueHandlerConcatenation,
169
+ Evilution::Mutator::Operator::RescueElseConcatenation,
170
+ Evilution::Mutator::Operator::ReturnKeywordRemoval,
171
+ Evilution::Mutator::Operator::MemoizationBreak,
172
+ Evilution::Mutator::Operator::ShortCircuitToEager,
173
+ Evilution::Mutator::Operator::FreezeRemoval,
174
+ Evilution::Mutator::Operator::RescueClassWidening,
175
+ Evilution::Mutator::Operator::VisibilityRemoval,
176
+ Evilution::Mutator::Operator::AttrAccessorSwap,
177
+ Evilution::Mutator::Operator::SleepToZero,
178
+ Evilution::Mutator::Operator::IndexReceiverToSelf,
179
+ Evilution::Mutator::Operator::IndexToKeyPredicate,
180
+ Evilution::Mutator::Operator::IndexRangeToDrop,
181
+ Evilution::Mutator::Operator::IndexWriteToValue,
182
+ Evilution::Mutator::Operator::ConstantReadToNil,
183
+ Evilution::Mutator::Operator::ConstantNamespaceStrip,
184
+ Evilution::Mutator::Operator::ConstantWriteToNil,
185
+ Evilution::Mutator::Operator::VariableReadToNil,
186
+ Evilution::Mutator::Operator::OperatorWriteReceiverPromotion
140
187
  ].each { |op| registry.register(op) }
141
188
  registry
142
189
  end
@@ -152,11 +199,20 @@ class Evilution::Mutator::Registry
152
199
 
153
200
  def mutations_for(subject, filter: nil, operator_options: {})
154
201
  @operators.flat_map do |operator_class|
202
+ next [] unless operator_class.subject_kinds.include?(subject.kind)
203
+
155
204
  operator = build_operator(operator_class, operator_options)
156
205
  operator.call(subject, filter: filter)
157
206
  end
158
207
  end
159
208
 
209
+ # Whether any registered operator mutates subjects of this one's kind. A
210
+ # subject none does would only ever be listed with no mutations to its
211
+ # name.
212
+ def accepts?(subject)
213
+ @operators.any? { |operator_class| operator_class.subject_kinds.include?(subject.kind) }
214
+ end
215
+
160
216
  def operator_count
161
217
  @operators.length
162
218
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../mutator"
6
+ require_relative "../ast/local_reads"
7
+
8
+ # The rescue clauses of a `begin` (or of a method or block body with a rescue),
9
+ # and the rule for which of their handlers can run outside the rescue.
10
+ #
11
+ # Operators that move a handler — promoting it over the body, or running it
12
+ # after the body as well — share the rule: a handler that reads the rescued
13
+ # exception (its `=> e` variable or `$!` / `$@`), re-raises it with a bare
14
+ # `raise` / `fail`, or retries only makes sense inside the rescue, and moved
15
+ # out it would fail for that reason alone.
16
+ class Evilution::Mutator::RescueHandlers
17
+ EXCEPTION_GLOBALS = %i[$! $@].freeze
18
+ RERAISING_METHODS = %i[raise fail].freeze
19
+
20
+ attr_reader :clauses
21
+
22
+ def initialize(begin_node)
23
+ @clauses = []
24
+ clause = begin_node.rescue_clause
25
+ while clause
26
+ @clauses << clause
27
+ clause = clause.subsequent
28
+ end
29
+ end
30
+
31
+ # Whether a handler can run outside its rescue. A missing handler (an empty
32
+ # rescue clause) can.
33
+ def self.movable?(handler, reference)
34
+ return true if handler.nil?
35
+
36
+ !reads_exception?(handler, reference) && !rescue_only?(handler)
37
+ end
38
+
39
+ # Binding the exception to anything but a local (`=> @error`) leaves it set
40
+ # for code after the rescue, which the handler alone cannot speak for, so
41
+ # such a handler counts as reading it. An underscore name announces a binding
42
+ # that is not read.
43
+ def self.reads_exception?(handler, reference)
44
+ return true if nodes_in(handler).any? { |node| exception_global?(node) }
45
+ return false if reference.nil?
46
+ return true unless reference.is_a?(Prism::LocalVariableTargetNode)
47
+
48
+ name = reference.name.to_s
49
+ !name.start_with?("_") && Evilution::AST::LocalReads.new.call(handler, name)
50
+ end
51
+
52
+ def self.exception_global?(node)
53
+ node.is_a?(Prism::GlobalVariableReadNode) && EXCEPTION_GLOBALS.include?(node.name)
54
+ end
55
+
56
+ def self.rescue_only?(handler)
57
+ nodes_in(handler).any? do |node|
58
+ node.is_a?(Prism::RetryNode) ||
59
+ (node.is_a?(Prism::CallNode) && node.receiver.nil? && RERAISING_METHODS.include?(node.name) &&
60
+ node.arguments.nil?)
61
+ end
62
+ end
63
+
64
+ # What goes between the last statement of a body and code appended after
65
+ # it: a new line at that statement's indentation, or `; ` when the body
66
+ # does not end its line (`begin; a; rescue ...`).
67
+ def self.continuation(source, statements)
68
+ end_offset = statements.location.end_offset
69
+ return "; " unless source.byteslice(end_offset, 1) == "\n"
70
+
71
+ start_offset = statements.body.last.location.start_offset
72
+ line_start = (source.byterindex("\n", start_offset - 1) || -1) + 1
73
+ "\n#{source.byteslice(line_start, start_offset - line_start)}"
74
+ end
75
+
76
+ def self.nodes_in(node)
77
+ [node, *node.compact_child_nodes.flat_map { |child| nodes_in(child) }]
78
+ end
79
+
80
+ private_class_method :reads_exception?, :exception_global?, :rescue_only?, :nodes_in
81
+ end
@@ -14,6 +14,10 @@ module Evilution::Parallel::WorkQueue::Channel::Frame
14
14
  # parent itself forked, transferred over a private pipe inside our process
15
15
  # tree. No external/untrusted input ever reaches this code. See
16
16
  # .rubocop.yml (Security/MarshalLoad) for the full rationale.
17
+ #
18
+ # Trusted is not the same as well-formed: a process the worker forked holds
19
+ # the pipe too. A frame of the announced length that does not load is a
20
+ # BadFrame; one shorter than announced is the end of the stream, nil.
17
21
  def decode(header, payload)
18
22
  return nil if header.nil? || header.bytesize < 4
19
23
 
@@ -21,5 +25,7 @@ module Evilution::Parallel::WorkQueue::Channel::Frame
21
25
  return nil if payload.nil? || payload.bytesize < length
22
26
 
23
27
  Marshal.load(payload)
28
+ rescue TypeError, ArgumentError => e
29
+ raise Evilution::Parallel::WorkQueue::Channel::BadFrame, "#{e.class}: #{e.message.gsub(/\s+/, " ")}"
24
30
  end
25
31
  end
@@ -1,8 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "io/wait"
3
4
  require_relative "../work_queue"
4
5
 
5
6
  module Evilution::Parallel::WorkQueue::Channel
7
+ # A frame that arrived but is not one the peer wrote: bytes that do not
8
+ # decode, or a frame that stops before the length its header announced.
9
+ #
10
+ # The pipes of the pool are inherited by the processes a worker forks, so
11
+ # the code under test can write into them. After such a frame the stream is
12
+ # out of step and nothing read from it later can be trusted; the reader
13
+ # gives the peer up. The end of the stream is not this: it reads as nil.
14
+ class BadFrame < StandardError; end
15
+
16
+ # Seconds a frame may take to arrive once its first byte has, header and
17
+ # payload together. A writer sends a frame in one call, so the rest follows
18
+ # at once; a header that announces bytes nobody will send must not hold the
19
+ # reader for ever.
20
+ FRAME_TIMEOUT = 10
21
+
6
22
  module_function
7
23
 
8
24
  def write(io, object)
@@ -10,14 +26,51 @@ module Evilution::Parallel::WorkQueue::Channel
10
26
  io.flush
11
27
  end
12
28
 
13
- def read(io)
14
- header = io.read(4)
15
- return nil if header.nil? || header.bytesize < 4
29
+ # Waits for a frame without a limit, since an idle peer is no fault, and
30
+ # for the rest of one that has begun no longer than `frame_timeout`: one
31
+ # allowance for the whole frame.
32
+ def read(io, frame_timeout: FRAME_TIMEOUT)
33
+ io.wait_readable
34
+ allowance = Allowance.new(deadline: now + frame_timeout, seconds: frame_timeout)
35
+ header = read_exactly(io, 4, allowance)
36
+ return nil if header.bytesize < 4
16
37
 
17
- length = header.unpack1("N")
18
- payload = io.read(length)
38
+ payload = read_exactly(io, header.unpack1("N"), allowance)
19
39
  Frame.decode(header, payload)
20
40
  end
41
+
42
+ # What is left of the time a frame is given, and the figure it was given,
43
+ # which the message of a frame cut short names.
44
+ Allowance = Data.define(:deadline, :seconds)
45
+ private_constant :Allowance
46
+
47
+ # Up to `count` bytes: fewer only when the stream ended first.
48
+ def read_exactly(io, count, allowance)
49
+ buffer = String.new
50
+
51
+ while buffer.bytesize < count
52
+ chunk = io.read_nonblock(count - buffer.bytesize, exception: false)
53
+ return buffer if chunk.nil?
54
+
55
+ if chunk == :wait_readable
56
+ await(io, allowance, buffer.bytesize, count)
57
+ else
58
+ buffer << chunk
59
+ end
60
+ end
61
+ buffer
62
+ end
63
+
64
+ def await(io, allowance, arrived, count)
65
+ remaining = allowance.deadline - now
66
+ return if remaining.positive? && io.wait_readable(remaining)
67
+
68
+ raise BadFrame, "frame cut short: #{arrived} of #{count} bytes arrived within #{allowance.seconds}s"
69
+ end
70
+
71
+ def now
72
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ end
21
74
  end
22
75
 
23
76
  require_relative "channel/frame"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dispatcher"
4
+ require_relative "../channel"
5
+
6
+ # Reads what a worker sent and says what it is: a result of an item the worker
7
+ # holds, the end of its stream (nil), or something that cannot be trusted.
8
+ #
9
+ # The result pipe is inherited by the processes a worker forks, so a frame on
10
+ # it is not always the worker's. One that does not decode is UNREADABLE, and
11
+ # so is one that decodes to something other than a result: what a stray
12
+ # process writes may happen to be a marshal dump, and taken at its word it
13
+ # would move the Dispatcher's counters or be raised as the error of the run.
14
+ # After either, the stream is out of step, and the Dispatcher gives the worker
15
+ # up.
16
+ #
17
+ # Keeping the wire-level judgement here leaves the Dispatcher with what to do
18
+ # about each answer.
19
+ module Evilution::Parallel::WorkQueue::Dispatcher::Reply
20
+ UNREADABLE = Object.new.freeze
21
+
22
+ module_function
23
+
24
+ def from(worker)
25
+ message = worker.read_result
26
+ return nil if message.nil?
27
+
28
+ result_of?(worker, message) ? message : UNREADABLE
29
+ rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
30
+ UNREADABLE
31
+ end
32
+
33
+ def result_of?(worker, message)
34
+ return false unless message.is_a?(Array) && message.length == 3
35
+
36
+ index, status, value = message
37
+ return false unless worker.in_flight_indices.include?(index)
38
+
39
+ status == :ok || (status == :error && value.is_a?(Exception))
40
+ end
41
+ end
@@ -74,8 +74,9 @@ class Evilution::Parallel::WorkQueue::Dispatcher
74
74
  end
75
75
 
76
76
  def handle(worker, io_to_worker, result_ios)
77
- message = worker.read_result
77
+ message = Reply.from(worker)
78
78
  return handle_dead(worker, io_to_worker, result_ios) if message.nil?
79
+ return handle_unreadable(worker, io_to_worker, result_ios) if message.equal?(Reply::UNREADABLE)
79
80
 
80
81
  record(message, worker)
81
82
  return false if recycle_and_dispatch(worker, io_to_worker, result_ios)
@@ -96,6 +97,17 @@ class Evilution::Parallel::WorkQueue::Dispatcher
96
97
  @deadlines.refresh(worker)
97
98
  end
98
99
 
100
+ # After a frame that is not the worker's, nothing read from its pipe later
101
+ # can be trusted: the stream is out of step. The worker is given up the way
102
+ # a dead one is, after being killed, since it may well be alive. Only the
103
+ # items it held lose their result.
104
+ def handle_unreadable(worker, io_to_worker, result_ios)
105
+ worker.kill
106
+ mark_unfinished(worker, Evilution::Parallel::WorkQueue::UNREADABLE)
107
+ retire_or_replace(worker, io_to_worker, result_ios)
108
+ false
109
+ end
110
+
99
111
  # A worker that exited without replying loses only its in-flight item(s)
100
112
  # (marked :died) and is recycled; the run continues rather than aborting.
101
113
  def handle_dead(worker, io_to_worker, result_ios)
@@ -183,3 +195,4 @@ class Evilution::Parallel::WorkQueue::Dispatcher
183
195
  end
184
196
 
185
197
  require_relative "dispatcher/deadline_tracker"
198
+ require_relative "dispatcher/reply"
@@ -23,6 +23,11 @@ module Evilution::Parallel::WorkQueue::Worker::Loop
23
23
  Evilution::Parallel::WorkQueue::Channel.write(
24
24
  res_io, [Evilution::Parallel::WorkQueue::STATS, busy, wall]
25
25
  )
26
+ rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
27
+ # A command that does not decode leaves a stream this worker cannot follow.
28
+ # It ends without the stats frame, which the parent, still waiting for a
29
+ # result, would take for one; the closed pipe tells the parent to replace it.
30
+ nil
26
31
  ensure
27
32
  cmd_io.close unless cmd_io.closed?
28
33
  res_io.close unless res_io.closed?
@@ -91,8 +91,8 @@ class Evilution::Parallel::WorkQueue::Worker
91
91
  @in_flight_indices << index
92
92
  end
93
93
 
94
- def read_result
95
- Evilution::Parallel::WorkQueue::Channel.read(@res_read)
94
+ def read_result(frame_timeout: Evilution::Parallel::WorkQueue::Channel::FRAME_TIMEOUT)
95
+ Evilution::Parallel::WorkQueue::Channel.read(@res_read, frame_timeout: frame_timeout)
96
96
  end
97
97
 
98
98
  def shutdown
@@ -138,16 +138,25 @@ class Evilution::Parallel::WorkQueue::Worker
138
138
 
139
139
  private
140
140
 
141
+ # The grace period covers the read as well as the wait for it: a stats frame
142
+ # that begins inside it and never completes must not outlast it.
141
143
  def drain_stats
142
144
  zero = Timing.new(busy: 0.0, wall: 0.0)
145
+ deadline = monotonic_now + Evilution::Parallel::WorkQueue::TIMING_GRACE_PERIOD
143
146
  return zero unless @res_read.wait_readable(Evilution::Parallel::WorkQueue::TIMING_GRACE_PERIOD)
144
147
 
145
- message = read_result
146
- return zero if message.nil?
148
+ message = read_result(frame_timeout: [deadline - monotonic_now, 0].max)
149
+ return zero unless message.is_a?(Array)
147
150
 
148
151
  tag, busy, wall = message
149
152
  return zero unless tag == Evilution::Parallel::WorkQueue::STATS
150
153
 
151
154
  Timing.new(busy: busy, wall: wall)
155
+ rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
156
+ zero
157
+ end
158
+
159
+ def monotonic_now
160
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
161
  end
153
162
  end
@@ -16,6 +16,7 @@ class Evilution::Parallel::WorkQueue
16
16
  Unfinished = Data.define(:reason)
17
17
  TIMED_OUT = Unfinished.new(reason: :timeout)
18
18
  DIED = Unfinished.new(reason: :died)
19
+ UNREADABLE = Unfinished.new(reason: :unreadable)
19
20
 
20
21
  def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil, worker_max_items: nil)
21
22
  Validators::PositiveInt.call!(:size, size)
@@ -80,17 +81,16 @@ class Evilution::Parallel::WorkQueue
80
81
  deadline = monotonic_now + TIMING_GRACE_PERIOD
81
82
 
82
83
  until io_to_worker.empty?
83
- remaining = deadline - monotonic_now
84
- break if remaining <= 0
85
- break unless poll_and_apply(io_to_worker, remaining)
84
+ break if deadline - monotonic_now <= 0
85
+ break unless poll_and_apply(io_to_worker, deadline)
86
86
  end
87
87
  end
88
88
 
89
- def poll_and_apply(io_to_worker, remaining)
90
- readable, = IO.select(io_to_worker.keys, nil, nil, remaining)
89
+ def poll_and_apply(io_to_worker, deadline)
90
+ readable, = IO.select(io_to_worker.keys, nil, nil, deadline - monotonic_now)
91
91
  return false unless readable
92
92
 
93
- readable.each { |io| apply_final_timing(io_to_worker.delete(io), io) }
93
+ readable.each { |io| apply_final_timing(io_to_worker.delete(io), io, deadline) }
94
94
  true
95
95
  end
96
96
 
@@ -98,15 +98,21 @@ class Evilution::Parallel::WorkQueue
98
98
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
99
  end
100
100
 
101
- def apply_final_timing(worker, io)
102
- message = Evilution::Parallel::WorkQueue::Channel.read(io)
103
- return if message.nil?
101
+ # The timings are a courtesy: a frame that cannot be read here costs the
102
+ # figures and nothing else. The read gets what is left of the grace period,
103
+ # so a frame that begins inside it and never completes does not outlast it.
104
+ def apply_final_timing(worker, io, deadline)
105
+ allowed = [deadline - monotonic_now, 0].max
106
+ message = Evilution::Parallel::WorkQueue::Channel.read(io, frame_timeout: allowed)
107
+ return unless message.is_a?(Array)
104
108
 
105
109
  tag, busy_time, wall_time = message
106
110
  return unless tag == STATS
107
111
 
108
112
  worker.busy_time = busy_time
109
113
  worker.wall_time = wall_time
114
+ rescue Evilution::Parallel::WorkQueue::Channel::BadFrame
115
+ nil
110
116
  end
111
117
  end
112
118
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require_relative "version"
5
5
  require_relative "temp_dir_tracker"
6
+ require_relative "diagnostic"
6
7
 
7
8
  # Single owner of the process-lifecycle invariant: every pid spawned here is
8
9
  # group-isolated, tracked in a signal-safe registry, group-signalled through a
@@ -219,8 +220,8 @@ class Evilution::ProcessSupervisor
219
220
  # group: a later group-kill won't sweep its subtree. Don't raise (spawn
220
221
  # must still return a usable handle), but surface it so the leak is
221
222
  # debuggable rather than silent.
222
- warn "evilution: could not isolate process #{pid} into its own process " \
223
- "group (#{e.class}: #{e.message}); grandchildren may survive a kill."
223
+ Evilution::Diagnostic.warn("evilution: could not isolate process #{pid} into its own process " \
224
+ "group (#{e.class}: #{e.message}); grandchildren may survive a kill.")
224
225
  end
225
226
 
226
227
  # True once the child has been reaped (now or earlier). WNOHANG returns the
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+ require_relative "../../../baseline"
5
+
6
+ # An example that was failing before any mutation ran fails in every mutation
7
+ # run it takes part in. Where nothing else failed, that failure is not a kill:
8
+ # the mutation is recorded neutral and drops out of the score. Left unsaid, a
9
+ # red spec file would read as full marks, so the score never goes out without
10
+ # saying how many kills were not counted, for which spec file, and why it was
11
+ # red.
12
+ class Evilution::Reporter::CLI::LineFormatters::BaselineNeutralizedNotice
13
+ DETAIL_INDENT = " "
14
+
15
+ def initialize(failure_formatter: Evilution::Baseline::FailureFormatter.new)
16
+ @failure_formatter = failure_formatter
17
+ end
18
+
19
+ def format(summary)
20
+ neutralizations = summary.baseline_neutralizations
21
+ return nil if neutralizations.empty?
22
+
23
+ neutralizations.flat_map { |neutralization| [headline(neutralization), *detail(neutralization)] }.join("\n")
24
+ end
25
+
26
+ private
27
+
28
+ def headline(neutralization)
29
+ count = neutralization.count
30
+ subject = count == 1 ? "1 kill" : "#{count} kills"
31
+ verdict = count == 1 ? "that mutation has no verdict" : "those mutations have no verdict"
32
+ "! #{subject} not counted#{spec_files(neutralization)}: only examples already failing in the baseline " \
33
+ "failed, so #{verdict}."
34
+ end
35
+
36
+ def spec_files(neutralization)
37
+ names = neutralization.spec_file ? [neutralization.spec_file] : neutralization.failures.map(&:spec_file)
38
+ names.empty? ? "" : " for #{names.join(", ")}"
39
+ end
40
+
41
+ # One spec file's detail sits straight under the headline that names it;
42
+ # several are each put under their own name.
43
+ def detail(neutralization)
44
+ failures = neutralization.failures
45
+ return failure_lines(failures.first, DETAIL_INDENT) if failures.length == 1
46
+
47
+ failures.flat_map do |failure|
48
+ ["#{DETAIL_INDENT}#{failure.spec_file}:", *failure_lines(failure, "#{DETAIL_INDENT} ")]
49
+ end
50
+ end
51
+
52
+ def failure_lines(failure, indent)
53
+ @failure_formatter.call(failure).map { |line| "#{indent}#{line}" }
54
+ end
55
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../cli"
4
4
  require_relative "line_formatters/mutations"
5
5
  require_relative "line_formatters/score"
6
+ require_relative "line_formatters/baseline_neutralized_notice"
6
7
  require_relative "line_formatters/error_rate_warning"
7
8
  require_relative "line_formatters/unresolved_rate_warning"
8
9
  require_relative "line_formatters/unresolved_targets"
@@ -15,6 +16,7 @@ class Evilution::Reporter::CLI::MetricsBlock
15
16
  DEFAULT_LINES = [
16
17
  Evilution::Reporter::CLI::LineFormatters::Mutations.new,
17
18
  Evilution::Reporter::CLI::LineFormatters::Score.new,
19
+ Evilution::Reporter::CLI::LineFormatters::BaselineNeutralizedNotice.new,
18
20
  Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning.new,
19
21
  Evilution::Reporter::CLI::LineFormatters::UnresolvedRateWarning.new,
20
22
  Evilution::Reporter::CLI::LineFormatters::UnresolvedTargets.new,
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../json"
4
+
5
+ # The summary fields that speak for a red baseline: the kills it took out of
6
+ # the score because only examples that were already failing failed, and why
7
+ # each spec file was red. Each is present only when there is something to say.
8
+ class Evilution::Reporter::JSON::Baseline
9
+ def call(summary)
10
+ fields = {}
11
+ fields[:baseline_neutralized] = summary.baseline_neutralized if summary.baseline_neutralized.positive?
12
+ fields[:baseline_failures] = summary.baseline_failures.map(&:to_h) unless summary.baseline_failures.empty?
13
+ fields
14
+ end
15
+ end
@@ -8,9 +8,10 @@ require_relative "../reporter"
8
8
  require_relative "../session/schema"
9
9
 
10
10
  class Evilution::Reporter::JSON
11
- def initialize(suggest_tests: false, integration: :rspec, subjects: Subjects.new)
11
+ def initialize(suggest_tests: false, integration: :rspec, subjects: Subjects.new, baseline: Baseline.new)
12
12
  @suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: suggest_tests, integration: integration)
13
13
  @subjects = subjects
14
+ @baseline = baseline
14
15
  end
15
16
 
16
17
  def call(summary)
@@ -104,10 +105,13 @@ class Evilution::Reporter::JSON
104
105
  end
105
106
 
106
107
  # What the run had to say about itself rather than about the mutations: a
107
- # target that was never tested, work that had to be redone serially.
108
+ # target that was never tested, work that had to be redone serially,
109
+ # survivors a red baseline took out of the score.
108
110
  def append_diagnostic_summary_fields(data, summary)
109
111
  data[:unresolved_target_files] = summary.unresolved_target_files if summary.unresolved_targets?
110
112
  data[:infra_retried] = summary.infra_retried if summary.infra_retried.positive?
113
+ data[:uncovered_code] = summary.uncovered_code if summary.uncovered_code?
114
+ data.merge!(@baseline.call(summary))
111
115
  end
112
116
 
113
117
  def build_mutation_detail(result)
@@ -183,3 +187,4 @@ class Evilution::Reporter::JSON
183
187
  end
184
188
 
185
189
  require_relative "json/subjects"
190
+ require_relative "json/baseline"