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
@@ -21,7 +21,7 @@ class Evilution::Config
21
21
  example_targeting_fallback: :full_file, example_targeting_strategy: :lexical,
22
22
  example_targeting_cache: { max_files: 50, max_blocks: 10_000 },
23
23
  quiet_children: false, quiet_children_dir: "tmp/evilution_children",
24
- profile: :default, canary: true
24
+ profile: :default, canary: true, warmup: :none
25
25
  }.freeze
26
26
 
27
27
  attr_reader :target_files, :schema_version, :timeout, :format,
@@ -33,7 +33,7 @@ class Evilution::Config
33
33
  :fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
34
34
  :example_targeting, :example_targeting_fallback, :example_targeting_strategy,
35
35
  :example_targeting_cache,
36
- :spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary
36
+ :spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary, :warmup
37
37
 
38
38
  def initialize(**options)
39
39
  skip_file = options.delete(:skip_config_file) ? true : false
@@ -216,8 +216,9 @@ class Evilution::Config
216
216
  # - "call{name=debug|warn}"
217
217
 
218
218
  # Operator profile: default or strict (default: default).
219
- # strict adds aggressive truthiness mutators (e.g. replaces
220
- # `x.predicate?` with `nil`) intended for pre-merge audits.
219
+ # strict adds aggressive mutators (e.g. replaces `x.predicate?` with
220
+ # `nil`, or appends `rescue nil` to a raising call) intended for
221
+ # pre-merge audits.
221
222
  # profile: default
222
223
  YAML
223
224
  end
@@ -274,7 +275,7 @@ class Evilution::Config
274
275
 
275
276
  VALIDATED_ATTRS = %i[
276
277
  integration jobs fail_fast isolation ignore_patterns
277
- hooks preload spec_mappings spec_pattern profile
278
+ hooks preload spec_mappings spec_pattern profile warmup
278
279
  ].freeze
279
280
  private_constant :VALIDATED_ATTRS
280
281
 
@@ -311,6 +312,7 @@ require_relative "config/validators/example_targeting_fallback"
311
312
  require_relative "config/validators/example_targeting_strategy"
312
313
  require_relative "config/validators/example_targeting_cache"
313
314
  require_relative "config/validators/profile"
315
+ require_relative "config/validators/warmup"
314
316
  require_relative "config/builders"
315
317
  require_relative "config/builders/spec_resolver"
316
318
  require_relative "config/builders/spec_selector"
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../evilution"
3
+ require_relative "version"
4
4
 
5
5
  # Per-example line-coverage support: build a `source file:line -> [examples]`
6
6
  # map so mutation targeting can run exactly the examples that execute a line.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../evilution"
3
+ require_relative "version"
4
4
  require_relative "coverage/map"
5
5
 
6
6
  # Per-mutation example targeting backed by a real line-coverage Map (EV-ndjd).
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../evilution"
3
+ require_relative "version"
4
4
 
5
5
  # Evilution's own advisory messages -- "no matching spec found", and the like.
6
6
  #
@@ -7,6 +7,7 @@ require_relative "heuristic/dead_code"
7
7
  require_relative "heuristic/arithmetic_identity"
8
8
  require_relative "heuristic/comment_marking"
9
9
  require_relative "heuristic/void_context"
10
+ require_relative "heuristic/guarded_index_fetch"
10
11
 
11
12
  require_relative "../equivalent"
12
13
 
@@ -40,7 +41,8 @@ class Evilution::Equivalent::Detector
40
41
  Evilution::Equivalent::Heuristic::DeadCode.new,
41
42
  Evilution::Equivalent::Heuristic::ArithmeticIdentity.new,
42
43
  Evilution::Equivalent::Heuristic::CommentMarking.new,
43
- Evilution::Equivalent::Heuristic::VoidContext.new
44
+ Evilution::Equivalent::Heuristic::VoidContext.new,
45
+ Evilution::Equivalent::Heuristic::GuardedIndexFetch.new
44
46
  ]
45
47
  end
46
48
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # Whether a condition, when truthy, says the read's key is there.
6
+ #
7
+ # That is the read itself, `recv[key].present?`, `recv.dig(key)`, or a key
8
+ # predicate on the same receiver and key -- alone or as one side of an `&&`,
9
+ # since both sides hold when the whole does. `refutes?` answers the mirror
10
+ # question, for conditions that are truthy when the key is missing.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition
12
+ KEY_PREDICATES = %i[key? has_key? include? member?].freeze
13
+ SINGLE_KEY_LOOKUPS = (KEY_PREDICATES + [:dig]).freeze
14
+ ABSENCE_TESTS = %i[nil? blank?].freeze
15
+
16
+ def initialize(read)
17
+ @read = read
18
+ end
19
+
20
+ def proves?(node)
21
+ case node
22
+ when Prism::ParenthesesNode then parenthesized?(node)
23
+ when Prism::AndNode then proves?(node.left) || proves?(node.right)
24
+ when Prism::CallNode then read_present?(node)
25
+ else false
26
+ end
27
+ end
28
+
29
+ # Whether a condition, when falsy, says the read's key is there: the
30
+ # negation of something that proves it, `recv[key].nil?` / `.blank?`, or
31
+ # either as one side of an `||`, since both sides are falsy when the whole
32
+ # is. This is what an early exit tests: `return if config[:k].nil?`.
33
+ def refutes?(node)
34
+ case node
35
+ when Prism::ParenthesesNode then single_statement(node) { |inner| refutes?(inner) }
36
+ when Prism::OrNode then refutes?(node.left) || refutes?(node.right)
37
+ when Prism::CallNode then read_absent?(node)
38
+ else false
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def parenthesized?(node)
45
+ single_statement(node) { |inner| proves?(inner) }
46
+ end
47
+
48
+ def single_statement(node)
49
+ body = node.body
50
+ body.is_a?(Prism::StatementsNode) && body.body.length == 1 && yield(body.body.first)
51
+ end
52
+
53
+ # `!cond` and `not cond` are both a call to `!`.
54
+ def read_absent?(call)
55
+ return proves?(call.receiver) if call.name == :!
56
+
57
+ ABSENCE_TESTS.include?(call.name) && @read.same?(call.receiver)
58
+ end
59
+
60
+ def read_present?(call)
61
+ return true if @read.same?(call)
62
+ return @read.same?(call.receiver) if call.name == :present? && call.arguments.nil?
63
+
64
+ SINGLE_KEY_LOOKUPS.include?(call.name) && @read.on_receiver?(call) && @read.keyed?(call)
65
+ end
66
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # Whether a guarded branch does anything that could take the key away again
6
+ # after the guard saw it: assigns the variable the receiver hangs off, or
7
+ # calls something on the receiver that changes it.
8
+ #
9
+ # The whole branch is searched, not just what precedes the read; inside a loop
10
+ # "before" and "after" trade places.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance
12
+ LOCAL_WRITES = [
13
+ Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
14
+ Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
15
+ ].freeze
16
+ INSTANCE_VARIABLE_WRITES = [
17
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
18
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
19
+ ].freeze
20
+ MUTATORS = %i[[]= delete clear replace store update shift pop delete_if keep_if].freeze
21
+
22
+ def initialize(read)
23
+ @read = read
24
+ end
25
+
26
+ def in?(node)
27
+ disturbs?(node) || node.compact_child_nodes.any? { |child| in?(child) }
28
+ end
29
+
30
+ private
31
+
32
+ def disturbs?(node)
33
+ reassigns_root?(node) || mutates_receiver?(node)
34
+ end
35
+
36
+ def reassigns_root?(node)
37
+ writes = if @read.local_root?
38
+ LOCAL_WRITES
39
+ elsif @read.instance_variable_root?
40
+ INSTANCE_VARIABLE_WRITES
41
+ else
42
+ []
43
+ end
44
+ writes.any? { |type| node.is_a?(type) } && node.name == @read.root_name
45
+ end
46
+
47
+ def mutates_receiver?(node)
48
+ node.is_a?(Prism::CallNode) && @read.on_receiver?(node) && mutator?(node.name)
49
+ end
50
+
51
+ def mutator?(name)
52
+ MUTATORS.include?(name) || name.end_with?("!")
53
+ end
54
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # A guard that leaves when the key is missing protects what comes after it in
6
+ # the same statement list:
7
+ #
8
+ # return unless config[:k]
9
+ # style << config[:k] # fetch cannot raise here
10
+ #
11
+ # The guard is an `unless` whose condition says the key is there, an `if`
12
+ # whose condition says it is not, or `cond or return` -- with a body ending in
13
+ # `return`, `next`, `break`, `raise` or `fail`, and no else branch to fall
14
+ # through. `next` and `break` leave only the current iteration, which is all
15
+ # the statements after them in the block's body belong to.
16
+ #
17
+ # Nothing between the guard and the read, nor the statement holding the read,
18
+ # may change the receiver; that statement is checked whole, since a loop in it
19
+ # runs its end before its start the second time round.
20
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::EarlyExit
21
+ EXIT_NODES = [Prism::ReturnNode, Prism::NextNode, Prism::BreakNode].freeze
22
+ EXIT_CALLS = %i[raise fail].freeze
23
+
24
+ def initialize(statements, read)
25
+ @statements = statements
26
+ @read = read
27
+ @condition = Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition.new(read)
28
+ end
29
+
30
+ # child: the statement of this list that holds the read.
31
+ def protects?(child)
32
+ return false unless @statements.is_a?(Prism::StatementsNode)
33
+
34
+ list = @statements.body
35
+ position = list.index { |statement| statement.equal?(child) }
36
+ guard = position && nearest_guard(list, position)
37
+ return false unless guard
38
+
39
+ list[(guard + 1)..position].none? { |statement| disturbance.in?(statement) }
40
+ end
41
+
42
+ private
43
+
44
+ # The last guard before the read: an earlier one would have more statements
45
+ # after it to answer for, not fewer.
46
+ def nearest_guard(list, position)
47
+ (0...position).reverse_each.find { |index| guard?(list[index]) }
48
+ end
49
+
50
+ def guard?(statement)
51
+ case statement
52
+ when Prism::UnlessNode then leaves?(statement, statement.else_clause) && @condition.proves?(statement.predicate)
53
+ when Prism::IfNode then leaves?(statement, statement.subsequent) && @condition.refutes?(statement.predicate)
54
+ when Prism::OrNode then exit?(statement.right) && @condition.proves?(statement.left)
55
+ else false
56
+ end
57
+ end
58
+
59
+ def leaves?(conditional, other_branch)
60
+ body = conditional.statements
61
+ other_branch.nil? && body.is_a?(Prism::StatementsNode) && exit?(body.body.last)
62
+ end
63
+
64
+ def exit?(node)
65
+ return true if EXIT_NODES.any? { |type| node.is_a?(type) }
66
+
67
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && EXIT_CALLS.include?(node.name)
68
+ end
69
+
70
+ def disturbance
71
+ @disturbance ||= Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance.new(@read)
72
+ end
73
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # One node that may guard a read: an `if` (ternary and modifier forms
6
+ # included), an `unless` with an else, or an `&&`. It protects the part that
7
+ # runs only when its condition held -- provided the condition says the key is
8
+ # there and that part leaves the receiver alone.
9
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::Guard
10
+ def initialize(node, read)
11
+ @node = node
12
+ @read = read
13
+ end
14
+
15
+ def protects?(child)
16
+ guarded = guarded_part
17
+ return false unless guarded && guarded.equal?(child)
18
+
19
+ Evilution::Equivalent::Heuristic::GuardedIndexFetch::Condition.new(@read).proves?(condition) &&
20
+ !Evilution::Equivalent::Heuristic::GuardedIndexFetch::Disturbance.new(@read).in?(guarded)
21
+ end
22
+
23
+ private
24
+
25
+ def guarded_part
26
+ case @node
27
+ when Prism::IfNode then @node.statements
28
+ when Prism::UnlessNode then @node.else_clause
29
+ when Prism::AndNode then @node.right
30
+ end
31
+ end
32
+
33
+ def condition
34
+ @node.is_a?(Prism::AndNode) ? @node.left : @node.predicate
35
+ end
36
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # One `recv[key]` read, reduced to what makes another read "the same": the
6
+ # receiver's source and a literal key.
7
+ #
8
+ # The receiver has to be something that can be named twice and mean the same
9
+ # thing both times: a variable, a constant, or calls without arguments or
10
+ # block on one of those. `lookup(id)[:k]` is not.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::IndexRead
12
+ KEY_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
13
+ NAMED_TYPES = [
14
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode,
15
+ Prism::GlobalVariableReadNode, Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::SelfNode
16
+ ].freeze
17
+
18
+ attr_reader :receiver_source, :key_source, :root
19
+
20
+ def self.from(node)
21
+ key = single_argument(node)
22
+ return nil unless key && literal?(key) && node.receiver && stable?(node.receiver)
23
+
24
+ new(receiver_source: node.receiver.slice, key_source: key.slice, root: root_of(node.receiver))
25
+ end
26
+
27
+ def self.single_argument(node)
28
+ return nil unless node.arguments && node.arguments.arguments.length == 1
29
+
30
+ node.arguments.arguments.first
31
+ end
32
+
33
+ def self.literal?(node)
34
+ KEY_TYPES.any? { |type| node.is_a?(type) }
35
+ end
36
+
37
+ def self.stable?(node)
38
+ return true if NAMED_TYPES.any? { |type| node.is_a?(type) }
39
+ return false unless node.is_a?(Prism::CallNode) && node.arguments.nil? && node.block.nil?
40
+
41
+ node.receiver.nil? || stable?(node.receiver)
42
+ end
43
+
44
+ # The variable a receiver hangs off: `user` in `user.settings`.
45
+ def self.root_of(node)
46
+ node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
47
+ node
48
+ end
49
+
50
+ def initialize(receiver_source:, key_source:, root:)
51
+ @receiver_source = receiver_source
52
+ @key_source = key_source
53
+ @root = root
54
+ end
55
+
56
+ def root_name
57
+ root.respond_to?(:name) ? root.name : nil
58
+ end
59
+
60
+ def local_root?
61
+ root.is_a?(Prism::LocalVariableReadNode)
62
+ end
63
+
64
+ def instance_variable_root?
65
+ root.is_a?(Prism::InstanceVariableReadNode)
66
+ end
67
+
68
+ # Whether a node is this read again: `recv[key]`.
69
+ def same?(node)
70
+ node.is_a?(Prism::CallNode) && node.name == :[] && on_receiver?(node) && keyed?(node)
71
+ end
72
+
73
+ def on_receiver?(call)
74
+ !call.receiver.nil? && call.receiver.slice == receiver_source
75
+ end
76
+
77
+ # Whether a call's only argument is this read's key.
78
+ def keyed?(call)
79
+ key = self.class.single_argument(call)
80
+ !key.nil? && key.slice == key_source
81
+ end
82
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guarded_index_fetch"
4
+
5
+ # The nodes from the subject down to the `recv[key]` call a mutation rewrote,
6
+ # outermost first; nil when it cannot be found.
7
+ #
8
+ # Line and column are not enough: in `a[:x][:y]` the outer and the inner read
9
+ # start at the same place. The mutated source settles it -- the rewritten read
10
+ # is the one whose receiver is now followed by `.fetch(`.
11
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch::NodePath
12
+ FETCH_CALL = ".fetch("
13
+
14
+ def call(mutation)
15
+ root = mutation.subject.node
16
+ mutated_source = mutation.mutated_source
17
+ return nil unless root && mutated_source
18
+
19
+ search(root, mutation, mutated_source, [])
20
+ end
21
+
22
+ private
23
+
24
+ def search(node, mutation, mutated_source, ancestors)
25
+ path = ancestors + [node]
26
+ return path if rewritten?(node, mutation, mutated_source)
27
+
28
+ node.compact_child_nodes.each do |child|
29
+ found = search(child, mutation, mutated_source, path)
30
+ return found if found
31
+ end
32
+ nil
33
+ end
34
+
35
+ def rewritten?(node, mutation, mutated_source)
36
+ return false unless node.is_a?(Prism::CallNode) && node.name == :[] && node.receiver
37
+
38
+ location = node.location
39
+ return false unless location.start_line == mutation.line && location.start_column == mutation.column
40
+
41
+ mutated_source.byteslice(node.receiver.location.end_offset, FETCH_CALL.bytesize) == FETCH_CALL
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../heuristic"
5
+
6
+ # `index_to_fetch` turns `recv[key]` into `recv.fetch(key)`, which differs only
7
+ # when the key is absent. Under a guard that is truthy only when the key is
8
+ # there -- `if config[:k]`, `config.key?(:k) && ...` -- it cannot be absent, so
9
+ # the mutant behaves like the original and no test can kill it.
10
+ #
11
+ # The read in the guard itself is a different matter: `if config.fetch(:k)`
12
+ # raises where `if config[:k]` took the other branch. Only reads inside the
13
+ # guarded part match -- the branch a guard opens, or the statements after a
14
+ # guard that leaves (`return unless config[:k]`).
15
+ #
16
+ # A heuristic, not a proof: a hash with a default value passes the guard with
17
+ # the key absent, and a method-call receiver may answer with another object
18
+ # the second time.
19
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch
20
+ OPERATOR = "index_to_fetch"
21
+
22
+ def match?(mutation)
23
+ return false unless mutation.operator_name == OPERATOR
24
+
25
+ path = NodePath.new.call(mutation)
26
+ return false unless path
27
+
28
+ read = IndexRead.from(path.last)
29
+ return false unless read
30
+
31
+ guarded?(path, read)
32
+ end
33
+
34
+ private
35
+
36
+ # Walks outwards from the read. Each step asks whether the node just left
37
+ # was the guarded part of its parent, or a statement that follows an early
38
+ # exit in its parent's list; a scope the guard does not reach into ends the
39
+ # walk.
40
+ def guarded?(path, read)
41
+ path.each_cons(2).reverse_each do |parent, child|
42
+ return false if scope_boundary?(parent, read)
43
+ return true if Guard.new(parent, read).protects?(child) || EarlyExit.new(parent, read).protects?(child)
44
+ end
45
+ false
46
+ end
47
+
48
+ # A nested method or lambda runs later, when the guard no longer speaks for
49
+ # the receiver. A block runs in place, unless it declares the receiver's
50
+ # name as its own variable.
51
+ def scope_boundary?(node, read)
52
+ case node
53
+ when Prism::DefNode, Prism::LambdaNode then true
54
+ when Prism::BlockNode then read.local_root? && node.locals.include?(read.root_name)
55
+ else false
56
+ end
57
+ end
58
+ end
59
+
60
+ require_relative "guarded_index_fetch/node_path"
61
+ require_relative "guarded_index_fetch/index_read"
62
+ require_relative "guarded_index_fetch/condition"
63
+ require_relative "guarded_index_fetch/disturbance"
64
+ require_relative "guarded_index_fetch/guard"
65
+ require_relative "guarded_index_fetch/early_exit"
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
- require_relative "../evilution"
4
+ require_relative "version"
5
5
  require_relative "spec_ast_cache"
6
+ require_relative "ast/value_object_definition"
7
+ require_relative "ast/scope_declaration"
8
+ require_relative "ast/aasm_declaration"
6
9
 
7
10
  class Evilution::ExampleFilter
8
11
  VALID_FALLBACKS = %i[full_file unresolved].freeze
@@ -119,8 +122,50 @@ class Evilution::ExampleFilter
119
122
  @class_stack.pop
120
123
  end
121
124
 
125
+ # A value-object definition assigned to a constant names the type its
126
+ # specs build (`Point = Data.define(:x, :y)` -> `Point`), like a class.
127
+ def visit_constant_write_node(node)
128
+ within_value_object(node, node.name.to_s) { super }
129
+ end
130
+
131
+ def visit_constant_path_write_node(node)
132
+ within_value_object(node, node.target.name.to_s) { super }
133
+ end
134
+
135
+ # A scope declaration names the class method its specs call
136
+ # (`scope :for_owner, -> { }` -> `for_owner`), like a def; nothing inside
137
+ # its body names anything closer.
138
+ #
139
+ # An aasm event or state names the methods its specs call (`event :ship`
140
+ # -> `ship!`, `state :paid` -> `paid?`), which share its name as a word.
141
+ def visit_call_node(node)
142
+ return super unless target_within?(node)
143
+
144
+ token = declaration_token(node)
145
+ return super unless token
146
+
147
+ @token = token
148
+ end
149
+
122
150
  private
123
151
 
152
+ def declaration_token(node)
153
+ if Evilution::AST::ScopeDeclaration.body_of(node)
154
+ Evilution::AST::ScopeDeclaration.scope_name(node)
155
+ else
156
+ Evilution::AST::AasmDeclaration.token_at(node, @target_line)
157
+ end
158
+ end
159
+
160
+ def within_value_object(node, name)
161
+ return yield unless Evilution::AST::ValueObjectDefinition.match?(node.value)
162
+
163
+ @class_stack.push(name)
164
+ capture_if_match(node)
165
+ yield
166
+ @class_stack.pop
167
+ end
168
+
124
169
  def capture_if_match(node)
125
170
  return if @found
126
171
  return unless target_within?(node)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../hooks"
4
+ require_relative "../diagnostic"
4
5
 
5
6
  class Evilution::Hooks::Registry
6
7
  def initialize(on_error: nil)
@@ -54,7 +55,7 @@ class Evilution::Hooks::Registry
54
55
  if @on_error
55
56
  @on_error.call(event, error)
56
57
  else
57
- warn "[evilution] hook error in #{event}: #{error.message}"
58
+ Evilution::Diagnostic.warn("[evilution] hook error in #{event}: #{error.message}")
58
59
  end
59
60
  end
60
61
  end
@@ -25,6 +25,8 @@ class Evilution::Integration::Base
25
25
 
26
26
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
27
27
  run_tests(mutation)
28
+ ensure
29
+ @mutation_applier.restore(mutation)
28
30
  end
29
31
 
30
32
  private
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ # The tests the baseline saw failing before any mutation ran, by the ids the
6
+ # integration gives them.
7
+ #
8
+ # Such a test fails whatever the mutation is, so a mutation run in which
9
+ # nothing else failed has no verdict. The question is asked in the process
10
+ # that ran the tests: the names of everything that failed need not travel
11
+ # back, only the answer.
12
+ class Evilution::Integration::KnownFailures
13
+ def initialize(ids)
14
+ @ids = ids.to_set
15
+ end
16
+
17
+ def empty?
18
+ @ids.empty?
19
+ end
20
+
21
+ # Whether failed_ids names at least one test, and none the baseline did not
22
+ # already see failing.
23
+ def only?(failed_ids)
24
+ !failed_ids.empty? && failed_ids.all? { |id| @ids.include?(id) }
25
+ end
26
+ end