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
@@ -9,9 +9,11 @@ class Evilution::Memory::LeakCheck
9
9
 
10
10
  attr_reader :samples
11
11
 
12
- def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB)
12
+ def initialize(iterations: DEFAULT_ITERATIONS, max_growth_kb: DEFAULT_MAX_GROWTH_KB,
13
+ warmup_iterations: WARMUP_ITERATIONS)
13
14
  @iterations = iterations
14
15
  @max_growth_kb = max_growth_kb
16
+ @warmup_iterations = warmup_iterations
15
17
  @samples = []
16
18
  end
17
19
 
@@ -32,8 +34,23 @@ class Evilution::Memory::LeakCheck
32
34
  samples.last - samples.first
33
35
  end
34
36
 
35
- def passed?
37
+ # Growth from the first sample to the last, less the largest rise between two
38
+ # consecutive samples.
39
+ #
40
+ # RSS does not grow by the byte. The allocator takes memory in chunks, so a
41
+ # workload that has stopped growing can still step up once, by several MB,
42
+ # wherever the heap happens to cross a boundary, and that one step decided
43
+ # the endpoint reading. A leak is not one step: it grows across the run, and
44
+ # taking out its largest step leaves the rest of it.
45
+ def sustained_growth_kb
36
46
  kb = growth_kb
47
+ return kb if kb.nil? || samples.size < 2
48
+
49
+ kb - [largest_step_kb, 0].max
50
+ end
51
+
52
+ def passed?
53
+ kb = sustained_growth_kb
37
54
  return false if kb.nil?
38
55
 
39
56
  kb <= @max_growth_kb
@@ -41,8 +58,12 @@ class Evilution::Memory::LeakCheck
41
58
 
42
59
  private
43
60
 
61
+ def largest_step_kb
62
+ samples.each_cons(2).map { |before, after| after - before }.max
63
+ end
64
+
44
65
  def warmup(&block)
45
- WARMUP_ITERATIONS.times { block.call }
66
+ @warmup_iterations.times { block.call }
46
67
  GC.start
47
68
  GC.compact if GC.respond_to?(:compact)
48
69
  end
@@ -74,10 +95,13 @@ class Evilution::Memory::LeakCheck
74
95
  end
75
96
 
76
97
  def result
98
+ sustained_kb = sustained_growth_kb
77
99
  {
78
100
  passed: passed?,
79
101
  growth_kb: growth_kb,
80
102
  growth_mb: growth_kb ? growth_kb / 1024.0 : nil,
103
+ sustained_growth_kb: sustained_kb,
104
+ sustained_growth_mb: sustained_kb ? sustained_kb / 1024.0 : nil,
81
105
  samples: samples,
82
106
  iterations: @iterations,
83
107
  max_growth_kb: @max_growth_kb,
@@ -8,26 +8,38 @@ class Evilution::Mutation
8
8
  Slice = Data.define(:original, :mutated)
9
9
  Location = Data.define(:file_path, :line, :column)
10
10
 
11
- attr_reader :subject, :operator_name, :parse_status, :location
11
+ # What a mutation holds once its sources are released (see strip_sources!),
12
+ # and when it was built without a slice. Their fields read as nil, so the
13
+ # readers below need no nil check of their own.
14
+ STRIPPED_SOURCES = Sources.new(original: nil, mutated: nil)
15
+ NO_SLICE = Slice.new(original: nil, mutated: nil)
16
+ private_constant :STRIPPED_SOURCES, :NO_SLICE
17
+
18
+ # restore_source: the original file, evaluated after the mutation's tests
19
+ # to put back what applying it changed (see
20
+ # Mutator::Base#build_restore_source). nil when a mutation was built
21
+ # without one.
22
+ attr_reader :subject, :operator_name, :parse_status, :location, :restore_source
12
23
 
13
24
  def initialize(subject:, operator_name:, sources:, location:,
14
- slice: nil, parse_status: :ok, eval_source: nil)
25
+ slice: nil, parse_status: :ok, eval_source: nil, restore_source: nil)
15
26
  @subject = subject
16
27
  @operator_name = operator_name
17
28
  @sources = sources
18
29
  @location = location
19
- @slice = slice
30
+ @slice = slice || NO_SLICE
20
31
  @parse_status = parse_status
21
32
  @eval_source = eval_source
33
+ @restore_source = restore_source
22
34
  @diff = nil
23
35
  end
24
36
 
25
37
  def original_source
26
- @sources&.original
38
+ @sources.original
27
39
  end
28
40
 
29
41
  def mutated_source
30
- @sources&.mutated
42
+ @sources.mutated
31
43
  end
32
44
 
33
45
  # Source to feed to the load-time evaluator. Defaults to mutated_source
@@ -41,11 +53,11 @@ class Evilution::Mutation
41
53
  end
42
54
 
43
55
  def original_slice
44
- @slice&.original
56
+ @slice.original
45
57
  end
46
58
 
47
59
  def mutated_slice
48
- @slice&.mutated
60
+ @slice.mutated
49
61
  end
50
62
 
51
63
  def file_path
@@ -76,7 +88,7 @@ class Evilution::Mutation
76
88
 
77
89
  def strip_sources!
78
90
  diff # ensure diff is cached before clearing sources
79
- @sources = nil
91
+ @sources = STRIPPED_SOURCES
80
92
  end
81
93
 
82
94
  def to_s
@@ -100,7 +112,7 @@ class Evilution::Mutation
100
112
  end
101
113
 
102
114
  def compute_unified_diff
103
- return nil if @slice.nil?
115
+ return nil if @slice == NO_SLICE
104
116
 
105
117
  original_lines = @slice.original.lines
106
118
  mutated_lines = @slice.mutated.lines
@@ -4,6 +4,8 @@ require "prism"
4
4
 
5
5
  require_relative "../mutator"
6
6
  require_relative "primitives"
7
+ require_relative "constant_value_subjects"
8
+ require_relative "receiver_positions"
7
9
  require_relative "../integration/loading/body_call_neutralizer"
8
10
  require_relative "../ast/heredoc_span"
9
11
 
@@ -84,12 +86,12 @@ class Evilution::Mutator::Base < Prism::Visitor
84
86
  replacement_bytesize: replacement.bytesize
85
87
  )
86
88
 
87
- mutation = build_mutation_record(node, surgery, slices)
89
+ mutation = build_mutation_record(node, surgery, slices, offset)
88
90
  @mutations << mutation
89
91
  mutation
90
92
  end
91
93
 
92
- def build_mutation_record(node, surgery, slices)
94
+ def build_mutation_record(node, surgery, slices, offset)
93
95
  Evilution::Mutation.new(
94
96
  subject: @subject,
95
97
  operator_name: self.class.operator_name,
@@ -101,7 +103,8 @@ class Evilution::Mutator::Base < Prism::Visitor
101
103
  column: node.location.start_column
102
104
  ),
103
105
  parse_status: surgery.status,
104
- eval_source: build_eval_source(surgery)
106
+ eval_source: build_eval_source(surgery, offset),
107
+ restore_source: build_restore_source(offset)
105
108
  )
106
109
  end
107
110
 
@@ -112,10 +115,47 @@ class Evilution::Mutator::Base < Prism::Visitor
112
115
  # them at apply time. Passing the subject's file path lets the neutralizer
113
116
  # skip files the parent never preloaded — those are lazy plugin files whose
114
117
  # DSL calls are still needed for the child fork's first-time load.
115
- def build_eval_source(surgery)
118
+ # A scope or aasm subject's mutation sits inside a class-body declaration,
119
+ # which must run again for the mutated body to replace the loaded one, so
120
+ # that one declaration is kept.
121
+ def build_eval_source(surgery, offset)
116
122
  return surgery.source unless surgery.ok?
117
123
 
118
- @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path)
124
+ @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path, **redeclaration(offset))
125
+ end
126
+
127
+ # The original file, evaluated after a mutation's tests to put back what
128
+ # the mutation changed. Without it an in-process run would leave the last
129
+ # mutation of a file applied while the files after it are mutated, failing
130
+ # their tests for them. Its class-body calls are neutralized like the eval
131
+ # source's; a scope, aasm or callback subject keeps its own declaration, so
132
+ # the mutated callable is declared over again as written.
133
+ def build_restore_source(offset)
134
+ return self.class.original_restore_source(@subject.file_path, @file_source, @body_call_neutralizer) unless redeclared_subject?
135
+
136
+ @body_call_neutralizer.call(@file_source, file_path: @subject.file_path, **redeclaration(offset))
137
+ end
138
+
139
+ # Subjects whose body is written inside a class-body declaration and only
140
+ # takes effect when that declaration runs again.
141
+ REDECLARED_KINDS = %i[scope aasm callback].freeze
142
+ private_constant :REDECLARED_KINDS
143
+
144
+ # So is anything written in a concern's hook block, a method included: the
145
+ # block has to run again on the classes that took the concern already.
146
+ def redeclared_subject?
147
+ REDECLARED_KINDS.include?(@subject.kind) || @subject.concern_hook?
148
+ end
149
+
150
+ # What the neutralizer needs to keep a redeclared subject's declaration:
151
+ # where the mutation is, and the lines the declaration takes up in the
152
+ # file as it was loaded -- which is where the callbacks it registered then
153
+ # say they come from, whatever the mutation does to its length.
154
+ def redeclaration(offset)
155
+ return {} unless redeclared_subject?
156
+
157
+ first = @subject.line_number
158
+ { keep_offset: offset, keep_lines: first..(first + @subject.source.count("\n")) }
119
159
  end
120
160
 
121
161
  NEWLINE_BYTE = 10
@@ -157,6 +197,15 @@ class Evilution::Mutator::Base < Prism::Visitor
157
197
  .downcase
158
198
  end
159
199
 
200
+ # The kinds of Subject the operator mutates. Operators work on method
201
+ # bodies, and on the bodies of callables handed to a scope, aasm or
202
+ # callback declaration, which run as part of the class's behaviour; one that also has
203
+ # something to say about a constant subject (a definition outside any
204
+ # method) adds :constant.
205
+ def self.subject_kinds
206
+ %i[method scope aasm callback]
207
+ end
208
+
160
209
  @parse_cache = {}
161
210
 
162
211
  def self.parsed_tree_for(file_path, file_source)
@@ -169,7 +218,22 @@ class Evilution::Mutator::Base < Prism::Visitor
169
218
  tree
170
219
  end
171
220
 
221
+ @restore_cache = {}
222
+
223
+ # The restore source of a subject with no declaration of its own to keep is
224
+ # the same for every mutation of the file: neutralized once and shared.
225
+ def self.original_restore_source(file_path, file_source, neutralizer)
226
+ cache = Evilution::Mutator::Base.instance_variable_get(:@restore_cache)
227
+ entry = cache[file_path]
228
+ return entry[:source] if entry && entry[:source_hash] == file_source.hash
229
+
230
+ source = neutralizer.call(file_source, file_path: file_path).freeze
231
+ cache[file_path] = { source_hash: file_source.hash, source: source }
232
+ source
233
+ end
234
+
172
235
  def self.clear_parse_cache!
173
236
  Evilution::Mutator::Base.instance_variable_set(:@parse_cache, {})
237
+ Evilution::Mutator::Base.instance_variable_set(:@restore_cache, {})
174
238
  end
175
239
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutator"
4
+
5
+ # Extended by the operators that mutate a value wherever it is written, so
6
+ # that they also run on the value of a constant assignment: a literal in
7
+ # `LIMIT = 10` or in `DEFAULTS = { size: 1 }` is as much a literal as one in
8
+ # a method body.
9
+ module Evilution::Mutator::ConstantValueSubjects
10
+ def subject_kinds
11
+ super + %i[constant_write]
12
+ end
13
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+ require_relative "../../ast/class_body"
7
+
8
+ # Drop an alias declaration from a class or module body: `alias length size`
9
+ # and `alias_method :count, :size` are removed.
10
+ #
11
+ # A survivor means no example calls the method by its alias, so the alias is
12
+ # a surface the suite never exercises — dead, or reached only where nothing
13
+ # asserts.
14
+ #
15
+ # Declarations sit in the class body, outside every method, where no subject
16
+ # reaches them. They are attributed to the method anchoring the enclosing
17
+ # body (see Evilution::AST::ClassBody); a top-level alias has no body to be
18
+ # attributed to. An `alias_method` call inside a method is left to the
19
+ # generic call operators, and global-variable aliases (`alias $new $old`)
20
+ # are not method surface.
21
+ class Evilution::Mutator::Operator::AliasRemoval < Evilution::Mutator::Base
22
+ def call(subject, filter: nil)
23
+ @subject = subject
24
+ @file_source = File.read(subject.file_path)
25
+ @mutations = []
26
+ @filter = filter
27
+
28
+ declarations_for(subject).each { |declaration| remove(declaration) }
29
+ @mutations
30
+ end
31
+
32
+ private
33
+
34
+ def remove(declaration)
35
+ add_mutation(
36
+ offset: declaration.location.start_offset,
37
+ length: declaration.location.length,
38
+ replacement: "",
39
+ node: declaration
40
+ )
41
+ end
42
+
43
+ def declarations_for(subject)
44
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
45
+
46
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
47
+ body.declarations { |node| declaration?(node) }
48
+ end
49
+ end
50
+
51
+ def declaration?(node)
52
+ return true if node.is_a?(Prism::AliasMethodNode)
53
+
54
+ node.is_a?(Prism::CallNode) && node.name == :alias_method && node.receiver.nil?
55
+ end
56
+ end
@@ -11,7 +11,7 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
11
11
  ].freeze
12
12
 
13
13
  def visit_call_node(node)
14
- args = node.arguments&.arguments
14
+ args = argument_nodes(node)
15
15
  args.each_index { |i| emit_nil_substitution(node, args, i) } if mutable?(node, args)
16
16
 
17
17
  super
@@ -30,7 +30,7 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
30
30
  end
31
31
 
32
32
  def mutable?(node, args)
33
- args && args.length >= 1 && positional_only?(args) && node.name != :[]=
33
+ positional_only?(args) && node.name != :[]=
34
34
  end
35
35
 
36
36
  def positional_only?(args)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap neighbouring positional arguments: `compute(a, b)` becomes
6
+ # `compute(b, a)`.
7
+ #
8
+ # A survivor means no example tells the two values apart — typically a test
9
+ # whose fixtures are symmetric, so the method could take them in either order
10
+ # unnoticed.
11
+ #
12
+ # Keyword arguments and a block argument stay where they are, and a splat
13
+ # keeps its neighbours in place: it stands for an unknown number of
14
+ # arguments, so a value swapped with it would not land where the original
15
+ # stood. Identical neighbours are not swapped, since that would reproduce the
16
+ # original call.
17
+ class Evilution::Mutator::Operator::ArgumentOrderPermutation < Evilution::Mutator::Base
18
+ # `raise ArgumentError, "message"` reversed raises TypeError wherever it is
19
+ # reached, which only shows that the line ran.
20
+ RAISING_METHODS = %i[raise fail].freeze
21
+
22
+ # The format string swapped with a value raises TypeError the same way;
23
+ # what the string renders is FormatSpecifierSwap's job.
24
+ FORMAT_METHODS = %i[format sprintf printf].freeze
25
+
26
+ # Core methods that treat their arguments as an unordered set of
27
+ # alternatives, so any order gives the same answer.
28
+ ORDER_FREE_METHODS = %i[start_with? end_with?].freeze
29
+
30
+ # Arguments whose position does not map to a single value.
31
+ POSITIONLESS_TYPES = [Prism::SplatNode, Prism::ForwardingArgumentsNode].freeze
32
+
33
+ def visit_call_node(node)
34
+ swap_arguments(node) if swappable_call?(node)
35
+ super
36
+ end
37
+
38
+ def visit_super_node(node)
39
+ swap_arguments(node)
40
+ super
41
+ end
42
+
43
+ def visit_yield_node(node)
44
+ swap_arguments(node)
45
+ super
46
+ end
47
+
48
+ private
49
+
50
+ # The last argument of an index write is the value being stored, not a peer
51
+ # of the index.
52
+ def swappable_call?(node)
53
+ return false if node.name == :[]=
54
+ return false if ORDER_FREE_METHODS.include?(node.name)
55
+ return false if set_literal?(node)
56
+
57
+ !(node.receiver.nil? && (RAISING_METHODS.include?(node.name) || FORMAT_METHODS.include?(node.name)))
58
+ end
59
+
60
+ # `Set[a, b]` builds a set, where the order of elements does not count.
61
+ def set_literal?(node)
62
+ return false unless node.name == :[]
63
+
64
+ receiver = node.receiver
65
+ case receiver
66
+ when Prism::ConstantReadNode then receiver.name == :Set
67
+ when Prism::ConstantPathNode then receiver.parent.nil? && receiver.name == :Set
68
+ else false
69
+ end
70
+ end
71
+
72
+ def swap_arguments(node)
73
+ return if node.arguments.nil?
74
+
75
+ positionals = node.arguments.arguments.grep_v(Prism::KeywordHashNode)
76
+ positionals.each_cons(2) do |left, right|
77
+ emit_swap(node, left, right) if swappable_pair?(left, right)
78
+ end
79
+ end
80
+
81
+ def swappable_pair?(left, right)
82
+ return false if [left, right].any? { |argument| POSITIONLESS_TYPES.include?(argument.class) }
83
+
84
+ left.slice != right.slice
85
+ end
86
+
87
+ # Whatever separates the two arguments — the comma, line breaks, comments —
88
+ # stays between them, so the call keeps its layout.
89
+ def emit_swap(node, left, right)
90
+ start_offset = left.location.start_offset
91
+ end_offset = right.location.end_offset
92
+ separator = byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
93
+
94
+ add_mutation(
95
+ offset: start_offset,
96
+ length: end_offset - start_offset,
97
+ replacement: "#{right.slice}#{separator}#{left.slice}",
98
+ node: node
99
+ )
100
+ end
101
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../operator"
4
+ require_relative "../../ast/value_object_definition"
4
5
 
5
6
  class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
6
7
  SKIP_TYPES = [
@@ -11,7 +12,7 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
11
12
  ].freeze
12
13
 
13
14
  def visit_call_node(node)
14
- args = node.arguments&.arguments
15
+ args = argument_nodes(node)
15
16
  args.each_index { |i| emit_argument_removal(node, args, i) } if mutable?(node, args)
16
17
 
17
18
  super
@@ -30,7 +31,14 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
30
31
  end
31
32
 
32
33
  def mutable?(node, args)
33
- args && args.length >= 2 && positional_only?(args) && node.name != :[]=
34
+ args.length >= 2 && positional_only?(args) && node.name != :[]= && !member_list?(node)
35
+ end
36
+
37
+ # DataStructMember owns the member list of `Data.define` / `Struct.new` and
38
+ # drops each member itself; removing an argument here would emit the same
39
+ # mutant a second time.
40
+ def member_list?(node)
41
+ Evilution::AST::ValueObjectDefinition.match?(node)
34
42
  end
35
43
 
36
44
  def positional_only?(args)
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::ArrayLiteral < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  def visit_array_node(node)
7
9
  if node.opening_loc && node.elements.any?
8
10
  add_mutation(
@@ -18,8 +20,35 @@ class Evilution::Mutator::Operator::ArrayLiteral < Evilution::Mutator::Base
18
20
  replacement: "nil",
19
21
  node: node
20
22
  )
23
+
24
+ mutate_delete_elements(node.elements)
25
+ mutate_promote_only_element(node)
21
26
  end
22
27
 
23
28
  super
24
29
  end
30
+
31
+ private
32
+
33
+ # Delete each element in turn: a survivor means no example checks that
34
+ # element. A single element is left to the `[]` replacement. A heredoc
35
+ # element stays: its body sits after the closing bracket, so one cut cannot
36
+ # take both, and the cut that does not parse is dropped.
37
+ def mutate_delete_elements(elements)
38
+ return if elements.one?
39
+
40
+ elements.each_index { |index| delete_element(elements, index) }
41
+ end
42
+
43
+ # `[x]` becomes `x`: a survivor means the wrapping does not matter, as when
44
+ # the caller flattens or splats the value anyway. The words of `%w[a]` are
45
+ # not written as expressions and stay. An element that is not an expression
46
+ # on its own (`*x`, the bare keywords of `[a: 1]`) does not parse once
47
+ # promoted and is dropped, and so is a heredoc, whose body cannot follow
48
+ # the cut.
49
+ def mutate_promote_only_element(node)
50
+ return unless node.elements.one? && node.opening == "["
51
+
52
+ replace_span(node:, target: node, replacement: receiver_source(node.elements.first))
53
+ end
25
54
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+ require_relative "../../ast/class_body"
7
+
8
+ # Mutate an attribute declaration in a class or module body two ways.
9
+ #
10
+ # Widening: `attr_reader :name` and `attr_writer :name` become
11
+ # `attr_accessor :name`. A survivor means no example checks that the missing
12
+ # half is missing: that the attribute cannot be written, or cannot be read.
13
+ #
14
+ # Removal: an `attr_reader`, `attr_writer` or `attr_accessor` declaration is
15
+ # dropped. A survivor means no example uses any of the methods it defines.
16
+ #
17
+ # Declarations sit in the class body, outside every method, where no subject
18
+ # reaches them. They are attributed to the method anchoring the enclosing
19
+ # body (see Evilution::AST::ClassBody); a top-level declaration has no body
20
+ # to be attributed to. A declaration wrapped in a visibility call
21
+ # (`private attr_reader :name`) is left alone: removing it would leave a bare
22
+ # `private` behind.
23
+ class Evilution::Mutator::Operator::AttrAccessorSwap < Evilution::Mutator::Base
24
+ WIDENED = "attr_accessor"
25
+ NARROW = %i[attr_reader attr_writer].freeze
26
+ DECLARATIONS = [*NARROW, :attr_accessor].freeze
27
+
28
+ def call(subject, filter: nil)
29
+ @subject = subject
30
+ @file_source = File.read(subject.file_path)
31
+ @mutations = []
32
+ @filter = filter
33
+
34
+ declarations_for(subject).each do |declaration|
35
+ widen(declaration) if NARROW.include?(declaration.name)
36
+ remove(declaration)
37
+ end
38
+ @mutations
39
+ end
40
+
41
+ private
42
+
43
+ def widen(declaration)
44
+ name = declaration.message_loc
45
+ add_mutation(offset: name.start_offset, length: name.length, replacement: WIDENED, node: declaration)
46
+ end
47
+
48
+ def remove(declaration)
49
+ add_mutation(
50
+ offset: declaration.location.start_offset,
51
+ length: declaration.location.length,
52
+ replacement: "",
53
+ node: declaration
54
+ )
55
+ end
56
+
57
+ def declarations_for(subject)
58
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
59
+
60
+ Evilution::AST::ClassBody.anchored_at(tree, subject.line_number).flat_map do |body|
61
+ body.declarations { |node| declaration?(node) }
62
+ end
63
+ end
64
+
65
+ def declaration?(node)
66
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && DECLARATIONS.include?(node.name) && !node.arguments.nil?
67
+ end
68
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::BooleanLiteralReplacement < Evilution::Mutator::Base
6
+ extend Evilution::Mutator::ConstantValueSubjects
7
+
6
8
  def visit_true_node(node)
7
9
  add_mutation(
8
10
  offset: node.location.start_offset,
@@ -13,9 +13,13 @@ require_relative "../operator"
13
13
  # (every statement of a body except the last) has no value to replace;
14
14
  # deleting it is statement_deletion's job. A call used as the receiver of
15
15
  # another call turns into `nil.foo`, a NoMethodError any test that reaches it
16
- # kills; the outer call is nil-ified instead. An attribute or index write
17
- # (`a.b = c`, `a[i] = c`) is a side effect, not a value.
16
+ # kills; the outer call is nil-ified instead. The receiver of a write
17
+ # (`user.account.total += 1`, `cache[key] ||= value`) is skipped likewise. An
18
+ # attribute or index write (`a.b = c`, `a[i] = c`) is a side effect, not a
19
+ # value.
18
20
  class Evilution::Mutator::Operator::CallToNil < Evilution::Mutator::Base
21
+ include Evilution::Mutator::ReceiverPositions
22
+
19
23
  def call(subject, **)
20
24
  @skipped_calls = Set.new
21
25
  super
@@ -27,8 +31,13 @@ class Evilution::Mutator::Operator::CallToNil < Evilution::Mutator::Base
27
31
  end
28
32
 
29
33
  def visit_call_node(node)
30
- @skipped_calls.add(node.receiver)
31
34
  mutate_to_nil(node) unless @skipped_calls.include?(node) || node.attribute_write?
32
35
  super
33
36
  end
37
+
38
+ private
39
+
40
+ def receiver_found(node)
41
+ @skipped_calls.add(node)
42
+ end
34
43
  end