evilution 1.3.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -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 +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. metadata +83 -4
@@ -263,7 +263,8 @@ class Evilution::Isolation::Fork
263
263
  message: result[:error],
264
264
  klass: result[:error_class],
265
265
  backtrace: result[:error_backtrace]
266
- )
266
+ ),
267
+ known_failures_only: result[:known_failures_only]
267
268
  )
268
269
  end
269
270
  end
@@ -9,6 +9,22 @@ require_relative "../result/mutation_result"
9
9
  require_relative "../isolation"
10
10
 
11
11
  class Evilution::Isolation::InProcess
12
+ # Raised into the run when its time is up. A SignalException, because the
13
+ # timeout has to end the whole test run and a test framework rescues nearly
14
+ # everything a test raises, Timeout::Error included: it would record one
15
+ # failed test and go on to the next, which may reach the same endless loop
16
+ # with no timer left to stop it. Minitest, Test::Unit and RSpec all let a
17
+ # SignalException through.
18
+ #
19
+ # Not an Interrupt, though it is one of those: tooling that handles the
20
+ # user's Ctrl-C (maxitest) rescues Interrupt and skips every test from then
21
+ # on, for the rest of the process.
22
+ class Expired < SignalException
23
+ def initialize(_message = nil)
24
+ super("SIGALRM")
25
+ end
26
+ end
27
+
12
28
  @null_out = File.open(File::NULL, "w")
13
29
  @null_err = File.open(File::NULL, "w")
14
30
 
@@ -33,7 +49,7 @@ class Evilution::Isolation::InProcess
33
49
  private
34
50
 
35
51
  # The Dir.chdir block is inside the Timeout.timeout block so that a
36
- # Timeout::Error raised mid-call still unwinds through Dir.chdir's ensure
52
+ # timeout raised mid-call still unwinds through Dir.chdir's ensure
37
53
  # and restores the parent CWD before the rescue clause runs. The sandbox
38
54
  # contains any relative-path writes from path-relativizing mutations
39
55
  # (EV-wqxu / GH #1278). Evilution.with_isolated_worker signals the rest of
@@ -41,7 +57,7 @@ class Evilution::Isolation::InProcess
41
57
  # SourceEvaluator/Integration) to anchor project-relative paths to
42
58
  # PROJECT_ROOT for the duration of the call.
43
59
  def execute_with_timeout(mutation, test_command, timeout, sandbox_dir)
44
- result = Timeout.timeout(timeout) do
60
+ result = Timeout.timeout(timeout, Expired) do
45
61
  Evilution.with_isolated_worker do
46
62
  Dir.chdir(sandbox_dir) do
47
63
  suppress_output { test_command.call(mutation) }
@@ -49,7 +65,7 @@ class Evilution::Isolation::InProcess
49
65
  end
50
66
  end
51
67
  { timeout: false }.merge(result)
52
- rescue Timeout::Error
68
+ rescue Expired
53
69
  { timeout: true }
54
70
  rescue ScriptError, StandardError => e
55
71
  {
@@ -106,7 +122,8 @@ class Evilution::Isolation::InProcess
106
122
  message: result[:error],
107
123
  klass: result[:error_class],
108
124
  backtrace: result[:error_backtrace]
109
- )
125
+ ),
126
+ known_failures_only: result[:known_failures_only]
110
127
  )
111
128
  end
112
129
  end
@@ -32,8 +32,8 @@ module Evilution::MCP::InfoTool::StatusGlossary
32
32
  },
33
33
  {
34
34
  "status" => "neutral",
35
- "meaning" => "Baseline tests already failed before the mutation was applied — pre-existing " \
36
- "test-suite problem (flaky spec, infra collision, fixture setup failure). " \
35
+ "meaning" => "No verdict: the tests failed only on examples that were already failing before " \
36
+ "the mutation was applied, or the test process crashed on infrastructure. " \
37
37
  "Not a meaningful mutation signal.",
38
38
  "counted_in_score" => false
39
39
  },
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "../mutate_tool"
5
5
  require_relative "../../reporter/suggestion"
6
+ require_relative "../../diagnostic"
6
7
 
7
8
  module Evilution::MCP::MutateTool::ProgressStreamer
8
9
  def self.build(server_context:, suggest_tests:, integration:)
@@ -21,7 +22,7 @@ module Evilution::MCP::MutateTool::ProgressStreamer
21
22
  detail = build_suggestion_detail(result.mutation, suggestion)
22
23
  server_context.report_progress(survivor_index, message: ::JSON.generate(detail))
23
24
  rescue StandardError => e
24
- warn "[evilution] progress stream disabled after error: #{e.class}: #{e.message}"
25
+ Evilution::Diagnostic.warn("[evilution] progress stream disabled after error: #{e.class}: #{e.message}")
25
26
  disabled = true
26
27
  end
27
28
  end
@@ -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,10 +8,14 @@ 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
+ # restore_source: the source that puts back what applying this mutation
12
+ # changed and re-evaluating another mutation of the file would not -- a
13
+ # scope declaration (see Mutator::Base#build_restore_source). nil when
14
+ # nothing needs restoring.
15
+ attr_reader :subject, :operator_name, :parse_status, :location, :restore_source
12
16
 
13
17
  def initialize(subject:, operator_name:, sources:, location:,
14
- slice: nil, parse_status: :ok, eval_source: nil)
18
+ slice: nil, parse_status: :ok, eval_source: nil, restore_source: nil)
15
19
  @subject = subject
16
20
  @operator_name = operator_name
17
21
  @sources = sources
@@ -19,6 +23,7 @@ class Evilution::Mutation
19
23
  @slice = slice
20
24
  @parse_status = parse_status
21
25
  @eval_source = eval_source
26
+ @restore_source = restore_source
22
27
  @diff = nil
23
28
  end
24
29
 
@@ -84,12 +84,12 @@ class Evilution::Mutator::Base < Prism::Visitor
84
84
  replacement_bytesize: replacement.bytesize
85
85
  )
86
86
 
87
- mutation = build_mutation_record(node, surgery, slices)
87
+ mutation = build_mutation_record(node, surgery, slices, offset)
88
88
  @mutations << mutation
89
89
  mutation
90
90
  end
91
91
 
92
- def build_mutation_record(node, surgery, slices)
92
+ def build_mutation_record(node, surgery, slices, offset)
93
93
  Evilution::Mutation.new(
94
94
  subject: @subject,
95
95
  operator_name: self.class.operator_name,
@@ -101,7 +101,8 @@ class Evilution::Mutator::Base < Prism::Visitor
101
101
  column: node.location.start_column
102
102
  ),
103
103
  parse_status: surgery.status,
104
- eval_source: build_eval_source(surgery)
104
+ eval_source: build_eval_source(surgery, offset),
105
+ restore_source: build_restore_source(offset)
105
106
  )
106
107
  end
107
108
 
@@ -112,10 +113,44 @@ class Evilution::Mutator::Base < Prism::Visitor
112
113
  # them at apply time. Passing the subject's file path lets the neutralizer
113
114
  # skip files the parent never preloaded — those are lazy plugin files whose
114
115
  # DSL calls are still needed for the child fork's first-time load.
115
- def build_eval_source(surgery)
116
+ # A scope or aasm subject's mutation sits inside a class-body declaration,
117
+ # which must run again for the mutated body to replace the loaded one, so
118
+ # that one declaration is kept.
119
+ def build_eval_source(surgery, offset)
116
120
  return surgery.source unless surgery.ok?
117
121
 
118
- @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path)
122
+ @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path, **redeclaration(offset))
123
+ end
124
+
125
+ # Re-evaluating any mutation of a file redefines all its methods, which is
126
+ # how an in-process run puts a mutated method back. Class-body calls are
127
+ # neutralized in that source, so a mutated scope or aasm callable would stay
128
+ # installed; the original file with just that declaration kept replaces it
129
+ # again.
130
+ def build_restore_source(offset)
131
+ return nil unless redeclared_subject?
132
+
133
+ @body_call_neutralizer.call(@file_source, file_path: @subject.file_path, **redeclaration(offset))
134
+ end
135
+
136
+ # Subjects whose body is written inside a class-body declaration and only
137
+ # takes effect when that declaration runs again.
138
+ REDECLARED_KINDS = %i[scope aasm callback].freeze
139
+ private_constant :REDECLARED_KINDS
140
+
141
+ def redeclared_subject?
142
+ REDECLARED_KINDS.include?(@subject.kind)
143
+ end
144
+
145
+ # What the neutralizer needs to keep a redeclared subject's declaration:
146
+ # where the mutation is, and the lines the declaration takes up in the
147
+ # file as it was loaded -- which is where the callbacks it registered then
148
+ # say they come from, whatever the mutation does to its length.
149
+ def redeclaration(offset)
150
+ return {} unless redeclared_subject?
151
+
152
+ first = @subject.line_number
153
+ { keep_offset: offset, keep_lines: first..(first + @subject.source.count("\n")) }
119
154
  end
120
155
 
121
156
  NEWLINE_BYTE = 10
@@ -157,6 +192,15 @@ class Evilution::Mutator::Base < Prism::Visitor
157
192
  .downcase
158
193
  end
159
194
 
195
+ # The kinds of Subject the operator mutates. Operators work on method
196
+ # bodies, and on the bodies of callables handed to a scope, aasm or
197
+ # callback declaration, which run as part of the class's behaviour; one that also has
198
+ # something to say about a constant subject (a definition outside any
199
+ # method) adds :constant.
200
+ def self.subject_kinds
201
+ %i[method scope aasm callback]
202
+ end
203
+
160
204
  @parse_cache = {}
161
205
 
162
206
  def self.parsed_tree_for(file_path, file_source)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ # Drop an alias declaration from a class or module body: `alias length size`
8
+ # and `alias_method :count, :size` are removed.
9
+ #
10
+ # A survivor means no example calls the method by its alias, so the alias is
11
+ # a surface the suite never exercises — dead, or reached only where nothing
12
+ # asserts.
13
+ #
14
+ # Declarations sit in the class body, outside every method, where no subject
15
+ # reaches them. They are attributed to the first method of the innermost
16
+ # enclosing class, module or `class << self`, the way DataStructMember and
17
+ # MixinRemoval attribute theirs. An `alias_method` call inside a method is
18
+ # left to the generic call operators, and global-variable aliases
19
+ # (`alias $new $old`) are not method surface.
20
+ class Evilution::Mutator::Operator::AliasRemoval < Evilution::Mutator::Base
21
+ def call(subject, filter: nil)
22
+ @subject = subject
23
+ @file_source = File.read(subject.file_path)
24
+ @mutations = []
25
+ @filter = filter
26
+
27
+ declarations_for(subject).each { |declaration| remove(declaration) }
28
+ @mutations
29
+ end
30
+
31
+ private
32
+
33
+ def remove(declaration)
34
+ add_mutation(
35
+ offset: declaration.location.start_offset,
36
+ length: declaration.location.length,
37
+ replacement: "",
38
+ node: declaration
39
+ )
40
+ end
41
+
42
+ def declarations_for(subject)
43
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
44
+ collector = DeclarationCollector.new
45
+ collector.visit(tree)
46
+
47
+ collector.declarations.filter_map do |declaration, scope|
48
+ declaration if first_method_line(scope) == subject.line_number
49
+ end
50
+ end
51
+
52
+ def first_method_line(scope)
53
+ finder = FirstMethodFinder.new
54
+ scope.compact_child_nodes.each { |child| finder.visit(child) }
55
+ finder.line
56
+ end
57
+
58
+ # Collects alias declarations written in a class, module or singleton class
59
+ # body, each paired with the innermost such scope. Method bodies are not
60
+ # searched, and a top-level alias has no scope to attribute it to.
61
+ class DeclarationCollector < Prism::Visitor
62
+ attr_reader :declarations
63
+
64
+ def initialize
65
+ super
66
+ @declarations = []
67
+ @scopes = []
68
+ end
69
+
70
+ def visit_class_node(node)
71
+ within(node) { super }
72
+ end
73
+
74
+ def visit_module_node(node)
75
+ within(node) { super }
76
+ end
77
+
78
+ def visit_singleton_class_node(node)
79
+ within(node) { super }
80
+ end
81
+
82
+ def visit_def_node(_node); end
83
+
84
+ def visit_alias_method_node(node)
85
+ record(node)
86
+ super
87
+ end
88
+
89
+ # Blocks in the body (`included do ... end`) are searched too: an alias
90
+ # there still belongs to the enclosing scope.
91
+ def visit_call_node(node)
92
+ record(node) if node.name == :alias_method && node.receiver.nil?
93
+ super
94
+ end
95
+
96
+ private
97
+
98
+ def record(node)
99
+ @declarations << [node, @scopes.last] unless @scopes.empty?
100
+ end
101
+
102
+ def within(scope)
103
+ @scopes.push(scope)
104
+ yield
105
+ ensure
106
+ @scopes.pop
107
+ end
108
+ end
109
+
110
+ # Finds the line of the first method written in a scope. Nested classes,
111
+ # modules and singleton classes are not searched: their methods are subjects
112
+ # of that inner scope.
113
+ class FirstMethodFinder < Prism::Visitor
114
+ attr_reader :line
115
+
116
+ def visit_class_node(_node); end
117
+
118
+ def visit_module_node(_node); end
119
+
120
+ def visit_singleton_class_node(_node); end
121
+
122
+ def visit_def_node(node)
123
+ @line = node.location.start_line if @line.nil?
124
+ end
125
+ end
126
+ end
@@ -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 = [
@@ -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 && 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)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Swap the operands of a spaceship comparison: `a <=> b` becomes `b <=> a`.
6
+ #
7
+ # The result changes sign, so a sort block sorts the other way and a custom
8
+ # `<=>` orders the other way round. A survivor means no example asserts the
9
+ # direction of the ordering.
10
+ #
11
+ # Two-argument ordering methods (`between?`, `clamp`) are reversed by
12
+ # ArgumentOrderPermutation. Identical operands are not swapped, since that
13
+ # would reproduce the original comparison, and neither is a safe-navigation
14
+ # call, where moving the other operand into the receiver's place changes what
15
+ # may be nil.
16
+ class Evilution::Mutator::Operator::ComparisonOperandSwap < Evilution::Mutator::Base
17
+ def visit_call_node(node)
18
+ swap_operands(node) if spaceship?(node)
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ # Ruby has no receiverless spaceship, but the explicit form can be written
25
+ # with no argument or several (`a.<=>`, `a.<=>(b, c)`); those have no pair
26
+ # of operands to swap.
27
+ def spaceship?(node)
28
+ return false unless node.name == :<=> && !node.safe_navigation?
29
+ return false if node.arguments.nil?
30
+
31
+ node.arguments.arguments.length == 1
32
+ end
33
+
34
+ # Whatever sits between the operands — the operator and its spacing, or
35
+ # `.<=>(` in the explicit form — stays in place.
36
+ def swap_operands(node)
37
+ left = node.receiver
38
+ right = node.arguments.arguments.first
39
+ return if left.slice == right.slice
40
+
41
+ start_offset = left.location.start_offset
42
+
43
+ add_mutation(
44
+ offset: start_offset,
45
+ length: right.location.end_offset - start_offset,
46
+ replacement: "#{right.slice}#{source_between(left, right)}#{left.slice}",
47
+ node: node
48
+ )
49
+ end
50
+
51
+ def source_between(left, right)
52
+ byteslice_source(left.location.end_offset, right.location.start_offset - left.location.end_offset)
53
+ end
54
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+ require_relative "../../ast/value_object_definition"
7
+
8
+ # Mutate the member list of a value-object definition: `Data.define(:a, :b)`
9
+ # and `Struct.new(:a, :b)` lose one member at a time, and have each adjacent
10
+ # pair of members swapped.
11
+ #
12
+ # A surviving drop means no example reads or sets that member. A surviving swap
13
+ # means the type is built positionally somewhere the order is never asserted,
14
+ # so two members could trade values unnoticed.
15
+ #
16
+ # Definitions outside any method — assigned to a constant, or the superclass
17
+ # of a class — are constant subjects of their own, and this operator mutates
18
+ # them there. A constant subject is the definition alone: definitions nested
19
+ # in its block are subjects of their own too.
20
+ class Evilution::Mutator::Operator::DataStructMember < Evilution::Mutator::Base
21
+ # Arguments whose position and meaning are known: members are symbols, a
22
+ # string is the class name `Struct.new("Name", ...)` takes first, and the
23
+ # keyword hash carries `keyword_init:`. Anything else (a splat, a variable)
24
+ # hides the member list, so the definition is left alone.
25
+ KNOWN_ARGUMENT_TYPES = [Prism::SymbolNode, Prism::StringNode, Prism::KeywordHashNode].freeze
26
+
27
+ def self.subject_kinds
28
+ %i[method constant]
29
+ end
30
+
31
+ # A constant subject's node is the definition itself, so the visit stops
32
+ # there rather than reaching definitions nested in its block.
33
+ def visit_call_node(node)
34
+ mutate_members(node) if Evilution::AST::ValueObjectDefinition.match?(node)
35
+ super unless @subject.kind == :constant
36
+ end
37
+
38
+ private
39
+
40
+ def mutate_members(node)
41
+ arguments = node.arguments ? node.arguments.arguments : []
42
+ members = member_indexes(arguments)
43
+ # A lone member has no neighbour to swap with, and dropping it would leave
44
+ # `Struct.new()`, which raises on Rubies that require at least one member.
45
+ return if members.length < 2
46
+
47
+ slices = arguments.map(&:slice)
48
+ members.each { |index| emit_member_drop(node, slices, index) }
49
+ members.each_cons(2) { |left, right| emit_member_swap(node, slices, left, right) }
50
+ end
51
+
52
+ def member_indexes(arguments)
53
+ return [] unless arguments.all? { |argument| KNOWN_ARGUMENT_TYPES.include?(argument.class) }
54
+
55
+ arguments.each_index.select { |index| arguments[index].is_a?(Prism::SymbolNode) }
56
+ end
57
+
58
+ def emit_member_drop(node, slices, index)
59
+ remaining = slices.dup
60
+ remaining.delete_at(index)
61
+ replace_arguments(node, remaining)
62
+ end
63
+
64
+ def emit_member_swap(node, slices, left, right)
65
+ return if slices[left] == slices[right]
66
+
67
+ swapped = slices.dup
68
+ swapped[left], swapped[right] = swapped[right], swapped[left]
69
+ replace_arguments(node, swapped)
70
+ end
71
+
72
+ def replace_arguments(node, slices)
73
+ location = node.arguments.location
74
+
75
+ add_mutation(
76
+ offset: location.start_offset,
77
+ length: location.length,
78
+ replacement: slices.join(", "),
79
+ node: node
80
+ )
81
+ end
82
+ end