evilution 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap two adjacent commands: `charge(card); send_receipt(user)` becomes
6
+ # `send_receipt(user); charge(card)`.
7
+ #
8
+ # A survivor means no example depends on the order the two side effects
9
+ # happen in. Most pairs of statements could swap without anyone noticing, so
10
+ # only pairs that are likely to matter are touched:
11
+ #
12
+ # - both statements are commands: they call a method, append, write an index,
13
+ # yield or call super. Assigning a local or instance variable stores a
14
+ # value, and statements without such effects swap freely.
15
+ # - neither writes a variable the other reads or writes. Such a pair fails at
16
+ # once when swapped, which tests nothing about order.
17
+ # - neither contains control flow (`return`, `break`, `next`, `redo`, `retry`,
18
+ # `raise`, `fail`), which would skip the other statement.
19
+ # - the second is not the last statement of its body, which gives the body
20
+ # its value.
21
+ # - neither contains a heredoc, whose body sits outside the statement's
22
+ # source range.
23
+ # - they are not writes to different literal keys of the same receiver
24
+ # (`opts[:a] = 1; opts[:b] = 2`), which cannot affect each other.
25
+ class Evilution::Mutator::Operator::StatementReorder < Evilution::Mutator::Base
26
+ CONTROL_FLOW_TYPES = [
27
+ Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode
28
+ ].freeze
29
+ RAISING_METHODS = %i[raise fail].freeze
30
+
31
+ # Calls that change their receiver without a method name: `a << b`, `a[k] = v`.
32
+ MUTATING_OPERATORS = %i[<< []=].freeze
33
+ METHOD_NAME = /\A[a-zA-Z_]/
34
+
35
+ EFFECT_TYPES = [Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode].freeze
36
+
37
+ # Statements that store a value rather than act.
38
+ VALUE_WRITE_TYPES = [
39
+ Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode,
40
+ Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
41
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
42
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode
43
+ ].freeze
44
+
45
+ # Nodes that set a variable, and nodes that read one.
46
+ WRITE_TYPES = [
47
+ *VALUE_WRITE_TYPES, Prism::LocalVariableTargetNode, Prism::InstanceVariableTargetNode,
48
+ Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode,
49
+ Prism::ClassVariableAndWriteNode, Prism::ClassVariableTargetNode,
50
+ Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode, Prism::GlobalVariableOrWriteNode,
51
+ Prism::GlobalVariableAndWriteNode, Prism::GlobalVariableTargetNode
52
+ ].freeze
53
+ LITERAL_KEY_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
54
+
55
+ READ_TYPES = [
56
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
57
+ Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
58
+ ].freeze
59
+
60
+ def visit_statements_node(node)
61
+ statements = node.body
62
+ # The last statement stays put, so pairs end one before it.
63
+ statements[0...-1].each_cons(2) do |first, second|
64
+ emit_swap(first, second) if swappable?(first, second)
65
+ end
66
+ super
67
+ end
68
+
69
+ private
70
+
71
+ def swappable?(first, second)
72
+ [first, second].all? { |statement| command?(statement) && !control_flow?(statement) && !heredoc?(statement) } &&
73
+ independent?(first, second) && !distinct_key_writes?(first, second)
74
+ end
75
+
76
+ def command?(statement)
77
+ return false if VALUE_WRITE_TYPES.include?(statement.class)
78
+
79
+ nodes_in(statement).any? { |node| effect?(node) }
80
+ end
81
+
82
+ def effect?(node)
83
+ return true if EFFECT_TYPES.include?(node.class)
84
+ return false unless node.is_a?(Prism::CallNode)
85
+
86
+ node.name.to_s.match?(METHOD_NAME) || MUTATING_OPERATORS.include?(node.name)
87
+ end
88
+
89
+ def control_flow?(statement)
90
+ nodes_in(statement).any? do |node|
91
+ CONTROL_FLOW_TYPES.include?(node.class) ||
92
+ (node.is_a?(Prism::CallNode) && node.receiver.nil? && RAISING_METHODS.include?(node.name))
93
+ end
94
+ end
95
+
96
+ def heredoc?(statement)
97
+ nodes_in(statement).any? do |node|
98
+ node.respond_to?(:heredoc?) && node.heredoc?
99
+ end
100
+ end
101
+
102
+ def distinct_key_writes?(first, second)
103
+ writes = [index_write(first), index_write(second)]
104
+ return false if writes.include?(nil)
105
+ return false unless writes.first.receiver.slice == writes.last.receiver.slice
106
+
107
+ distinct_literal_keys?(writes.map { |write| write.arguments.arguments.first })
108
+ end
109
+
110
+ def distinct_literal_keys?(keys)
111
+ keys.all? { |key| LITERAL_KEY_TYPES.include?(key.class) } && keys.first.slice != keys.last.slice
112
+ end
113
+
114
+ # `h[k] = v` on its own or under a modifier condition (`h[k] = v if v`).
115
+ def index_write(statement)
116
+ if statement.is_a?(Prism::IfNode) || statement.is_a?(Prism::UnlessNode)
117
+ body = statement.statements
118
+ statement = body.body.first if body && body.body.length == 1
119
+ end
120
+ statement if statement.is_a?(Prism::CallNode) && statement.name == :[]=
121
+ end
122
+
123
+ def independent?(first, second)
124
+ first_writes = names(first, WRITE_TYPES)
125
+ second_writes = names(second, WRITE_TYPES)
126
+
127
+ !first_writes.intersect?(names(second, READ_TYPES) | second_writes) &&
128
+ !second_writes.intersect?(names(first, READ_TYPES))
129
+ end
130
+
131
+ def names(statement, types)
132
+ nodes_in(statement).filter_map { |node| node.name if types.include?(node.class) }.to_set
133
+ end
134
+
135
+ def nodes_in(node)
136
+ [node, *node.compact_child_nodes.flat_map { |child| nodes_in(child) }]
137
+ end
138
+
139
+ # Whatever separates the two statements — a line break and indentation, or
140
+ # `; ` — stays between them.
141
+ def emit_swap(first, second)
142
+ start_offset = first.location.start_offset
143
+ end_offset = second.location.end_offset
144
+ separator = byteslice_source(first.location.end_offset, second.location.start_offset - first.location.end_offset)
145
+
146
+ add_mutation(
147
+ offset: start_offset,
148
+ length: end_offset - start_offset,
149
+ replacement: "#{second.slice}#{separator}#{first.slice}",
150
+ node: first
151
+ )
152
+ end
153
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+ require_relative "send_mutation"
5
+ require_relative "collection_replacement"
6
+
7
+ # Apply the call-site selector tables to a symbol block-pass: `map(&:to_s)`
8
+ # becomes `map(&:to_i)`, `map(&:first)` becomes `map(&:last)`.
9
+ #
10
+ # send_mutation and collection_replacement only see explicit calls, so a
11
+ # selector passed as `&:sym` was never swapped — only removed. This reads
12
+ # both tables live, so selectors added to them apply here too.
13
+ #
14
+ # Replacements that are Ruby aliases of the original (`collect` / `map`,
15
+ # `find` / `detect`, `select` / `filter`, `length` / `size` / `count`) are
16
+ # skipped: on the receivers these selectors are normally mapped over, the
17
+ # mutant behaves identically.
18
+ class Evilution::Mutator::Operator::SymbolToProcReplacement < Evilution::Mutator::Base
19
+ TABLES = [
20
+ Evilution::Mutator::Operator::SendMutation::REPLACEMENTS,
21
+ Evilution::Mutator::Operator::CollectionReplacement::REPLACEMENTS
22
+ ].freeze
23
+ private_constant :TABLES
24
+
25
+ ALIAS_GROUPS = [
26
+ %i[map collect],
27
+ %i[find detect],
28
+ %i[select filter],
29
+ %i[length size count]
30
+ ].freeze
31
+ private_constant :ALIAS_GROUPS
32
+
33
+ def visit_block_argument_node(node)
34
+ symbol = node.expression
35
+ replace_selector(node, symbol) if symbol.is_a?(Prism::SymbolNode)
36
+ super
37
+ end
38
+
39
+ private
40
+
41
+ def replace_selector(node, symbol)
42
+ selector = symbol.unescaped.to_sym
43
+ location = symbol.value_loc
44
+
45
+ replacements_for(selector).each do |replacement|
46
+ add_mutation(
47
+ offset: location.start_offset,
48
+ length: location.length,
49
+ replacement: replacement.to_s,
50
+ node: node
51
+ )
52
+ end
53
+ end
54
+
55
+ def replacements_for(selector)
56
+ aliases = ALIAS_GROUPS.find { |group| group.include?(selector) } || []
57
+ TABLES.flat_map { |table| table.fetch(selector, []) }.uniq - aliases
58
+ end
59
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Replace a lenient `to_i` with the strict `Integer()` conversion:
6
+ # `value.to_i` becomes `Integer(value)`, `value.to_i(16)` becomes
7
+ # `Integer(value, 16)`.
8
+ #
9
+ # `to_i` never fails — malformed input ("abc", "12abc", "") and nil become 0
10
+ # or a leading-digits prefix — where `Integer()` raises. `Integer()` also
11
+ # honours radix prefixes ("0x1A" is 26, "012" is octal 10) that `to_i`
12
+ # ignores. A survivor means no test feeds invalid or prefixed input.
13
+ #
14
+ # Numeric literal receivers are skipped: both conversions agree on them.
15
+ class Evilution::Mutator::Operator::ToIToInteger < Evilution::Mutator::Base
16
+ NUMERIC_LITERALS = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode].freeze
17
+ private_constant :NUMERIC_LITERALS
18
+
19
+ def visit_call_node(node)
20
+ rewrite(node) if node.name == :to_i && convertible?(node)
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ def convertible?(node)
27
+ receiver = node.receiver
28
+ receiver && node.block.nil? && NUMERIC_LITERALS.none? { |type| receiver.is_a?(type) }
29
+ end
30
+
31
+ def rewrite(node)
32
+ arguments = node.arguments ? node.arguments.arguments : []
33
+ return if arguments.length > 1 || arguments.any?(Prism::SplatNode)
34
+
35
+ parts = [node.receiver, *arguments].map { |part| source_of(part) }
36
+ replace_span(node: node, target: node, replacement: "Integer(#{parts.join(", ")})")
37
+ end
38
+ end
@@ -49,4 +49,29 @@ module Evilution::Mutator::Primitives
49
49
  location = child.location
50
50
  byteslice_source(location.start_offset, location.length)
51
51
  end
52
+
53
+ # `operand`'s source, parenthesized unless it is a primary expression — so
54
+ # it can take a method call: `a + b` becomes `(a + b)` in `(a + b).eql?(c)`.
55
+ def receiver_source(operand)
56
+ primary_operand?(operand) ? source_of(operand) : "(#{source_of(operand)})"
57
+ end
58
+
59
+ PRIMARY_OPERANDS = [
60
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
61
+ Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::SelfNode,
62
+ Prism::ParenthesesNode, Prism::StringNode, Prism::IntegerNode, Prism::FloatNode, Prism::ArrayNode,
63
+ Prism::RegularExpressionNode
64
+ ].freeze
65
+ private_constant :PRIMARY_OPERANDS
66
+
67
+ METHOD_NAME = /\A[[:alpha:]_]/
68
+ private_constant :METHOD_NAME
69
+
70
+ # A named method call or an index (`a.size`, `a[0]`) binds tighter than a
71
+ # following `.`; an operator send (`a + b`, `!a`, `-a`) does not.
72
+ def primary_operand?(operand)
73
+ return true if PRIMARY_OPERANDS.any? { |type| operand.is_a?(type) }
74
+
75
+ operand.is_a?(Prism::CallNode) && (operand.name.match?(METHOD_NAME) || operand.name == :[])
76
+ end
52
77
  end
@@ -4,7 +4,9 @@ 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
8
10
  ].freeze
9
11
 
10
12
  def self.for_profile(profile)
@@ -113,7 +115,55 @@ class Evilution::Mutator::Registry
113
115
  Evilution::Mutator::Operator::EqualityToIdentity,
114
116
  Evilution::Mutator::Operator::LambdaBody,
115
117
  Evilution::Mutator::Operator::BeginUnwrap,
116
- Evilution::Mutator::Operator::BlockParamRemoval
118
+ Evilution::Mutator::Operator::BlockParamRemoval,
119
+ Evilution::Mutator::Operator::CallToNil,
120
+ Evilution::Mutator::Operator::SafeNavigationRemoval,
121
+ Evilution::Mutator::Operator::AttributeWriteToRead,
122
+ Evilution::Mutator::Operator::ArgumentPropagation,
123
+ Evilution::Mutator::Operator::ArgumentListRemoval,
124
+ Evilution::Mutator::Operator::SymbolToProcReplacement,
125
+ Evilution::Mutator::Operator::DynamicDispatchResolution,
126
+ Evilution::Mutator::Operator::KeywordArgumentRemoval,
127
+ Evilution::Mutator::Operator::DoubleNegationRemoval,
128
+ Evilution::Mutator::Operator::RegexpAnchorToPredicate,
129
+ Evilution::Mutator::Operator::ReduceToSum,
130
+ Evilution::Mutator::Operator::ArrayCoercionToLiteral,
131
+ Evilution::Mutator::Operator::ToIToInteger,
132
+ Evilution::Mutator::Operator::CoercionEmptying,
133
+ Evilution::Mutator::Operator::ConstGetToConstantPath,
134
+ Evilution::Mutator::Operator::ProcToLambda,
135
+ Evilution::Mutator::Operator::DigToFetchChain,
136
+ Evilution::Mutator::Operator::InequalityToNegatedIdentity,
137
+ Evilution::Mutator::Operator::BinaryOperandPromotion,
138
+ Evilution::Mutator::Operator::ReceiverConstructorSwap,
139
+ Evilution::Mutator::Operator::BlockBodyToNil,
140
+ Evilution::Mutator::Operator::BlockBodyToRaise,
141
+ Evilution::Mutator::Operator::BlockBodyPromotion,
142
+ Evilution::Mutator::Operator::DataStructMember,
143
+ Evilution::Mutator::Operator::PinOperatorRemoval,
144
+ Evilution::Mutator::Operator::RightwardAssignment,
145
+ Evilution::Mutator::Operator::NumberedParameterSwap,
146
+ Evilution::Mutator::Operator::ForwardedArgumentDrop,
147
+ Evilution::Mutator::Operator::PatternWildcardWidening,
148
+ Evilution::Mutator::Operator::NoMatchingPatternElse,
149
+ Evilution::Mutator::Operator::ArgumentOrderPermutation,
150
+ Evilution::Mutator::Operator::KeywordValueSwap,
151
+ Evilution::Mutator::Operator::ComparisonOperandSwap,
152
+ Evilution::Mutator::Operator::IntegerDivisionToFdiv,
153
+ Evilution::Mutator::Operator::OffByOneBoundary,
154
+ Evilution::Mutator::Operator::FormatSpecifierSwap,
155
+ Evilution::Mutator::Operator::AliasRemoval,
156
+ Evilution::Mutator::Operator::RegexpCharacterTypeComplement,
157
+ Evilution::Mutator::Operator::RegexpAnchorPromotion,
158
+ Evilution::Mutator::Operator::RegexpAlternationBranchDeletion,
159
+ Evilution::Mutator::Operator::RegexpQuantifierMinimumSwap,
160
+ Evilution::Mutator::Operator::RegexpCaptureToPassive,
161
+ Evilution::Mutator::Operator::RegexpNamedGroupRename,
162
+ Evilution::Mutator::Operator::RegexpOptionRemoval,
163
+ Evilution::Mutator::Operator::RescueHandlerPromotion,
164
+ Evilution::Mutator::Operator::RescueHandlerConcatenation,
165
+ Evilution::Mutator::Operator::RescueElseConcatenation,
166
+ Evilution::Mutator::Operator::ReturnKeywordRemoval
117
167
  ].each { |op| registry.register(op) }
118
168
  registry
119
169
  end
@@ -129,6 +179,8 @@ class Evilution::Mutator::Registry
129
179
 
130
180
  def mutations_for(subject, filter: nil, operator_options: {})
131
181
  @operators.flat_map do |operator_class|
182
+ next [] unless operator_class.subject_kinds.include?(subject.kind)
183
+
132
184
  operator = build_operator(operator_class, operator_options)
133
185
  operator.call(subject, filter: filter)
134
186
  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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../parallel"
3
4
  require_relative "work_queue"
4
5
 
5
6
  class Evilution::Parallel::Pool
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "erb"
4
4
  require "yaml"
5
- require_relative "../evilution"
5
+ require_relative "version"
6
6
 
7
7
  # EV-kdns / GH #817: nudge users running parallel jobs against SQLite to adopt
8
8
  # the parallel_tests convention. WorkQueue sets TEST_ENV_NUMBER per worker, but
@@ -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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostic"
4
+
5
+ # Warms Rails state that initialises lazily on a process's first request, once
6
+ # in the parent after the preload, so forked mutations inherit it instead of
7
+ # each paying the cold start.
8
+ #
9
+ # Every step is side-effect free. A real request is deliberately NOT used:
10
+ # controller callbacks change process-global state -- I18n.locale,
11
+ # PaperTrail.request, a session row -- that every fork would then inherit,
12
+ # failing unrelated tests and reporting them as kills.
13
+ #
14
+ # A step whose library is not loaded is skipped silently; a step that raises
15
+ # (no application stylesheet, an unusual asset setup) is skipped with a note.
16
+ # Warm-up is an optimisation and must never fail or skew the run.
17
+ class Evilution::RailsWarmup
18
+ def initialize(application, stream: $stderr)
19
+ @application = application
20
+ @stream = stream
21
+ end
22
+
23
+ def call
24
+ step("i18n") { warm_i18n }
25
+ step("routes") { warm_routes }
26
+ step("assets") { warm_assets }
27
+ step("templates") { warm_templates }
28
+ end
29
+
30
+ private
31
+
32
+ def step(name)
33
+ yield
34
+ rescue StandardError, ScriptError => e
35
+ Evilution::Diagnostic.warn("[evilution] rails warmup: skipped #{name} (#{e.class}: #{e.message})", stream: @stream)
36
+ end
37
+
38
+ def warm_i18n
39
+ ::I18n.eager_load! if defined?(::I18n) && ::I18n.respond_to?(:eager_load!)
40
+ end
41
+
42
+ # Rails 8 can draw routes lazily; building url_helpers compiles the helpers
43
+ # module every request would otherwise build on first use.
44
+ def warm_routes
45
+ routes = @application.routes
46
+ routes.eager_load! if routes.respond_to?(:eager_load!)
47
+ routes.url_helpers
48
+ end
49
+
50
+ def warm_assets
51
+ ::ActionController::Base.helpers.stylesheet_path("application") if defined?(::ActionController::Base)
52
+ end
53
+
54
+ # Optional: jhawthorn/actionview_precompiler compiles templates up front.
55
+ def warm_templates
56
+ return unless Gem.loaded_specs.key?("actionview_precompiler")
57
+
58
+ require "actionview_precompiler"
59
+ ::ActionviewPrecompiler.precompile
60
+ end
61
+ end
@@ -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,
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../html"
3
4
  require_relative "section"
4
5
  require_relative "stylesheet"
5
6
  require_relative "baseline_keys"
@@ -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"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result"
4
+
5
+ # The kills one red spec file left uncounted -- mutations whose tests failed
6
+ # on nothing but examples that were already failing -- with why it was red.
7
+ #
8
+ # spec_file is nil when no single red file can be named, as in a run given
9
+ # its spec files explicitly; failures then holds all of them.
10
+ Evilution::Result::BaselineNeutralization = Data.define(:spec_file, :count, :failures)