evilution 0.28.0 → 0.30.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +106 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +49 -0
  5. data/README.md +194 -8
  6. data/docs/versioning.md +53 -0
  7. data/lib/evilution/ast/constant_names.rb +28 -11
  8. data/lib/evilution/ast/heredoc_span.rb +99 -0
  9. data/lib/evilution/ast/pattern/parser.rb +29 -17
  10. data/lib/evilution/baseline.rb +15 -2
  11. data/lib/evilution/cli/commands/compare.rb +13 -0
  12. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  13. data/lib/evilution/cli/commands/subjects.rb +6 -3
  14. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  15. data/lib/evilution/cli/parser/command_extractor.rb +12 -12
  16. data/lib/evilution/cli/parser/file_args.rb +3 -1
  17. data/lib/evilution/cli/parser/options_builder.rb +31 -3
  18. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  19. data/lib/evilution/cli/parser.rb +18 -20
  20. data/lib/evilution/cli/printers/environment.rb +19 -19
  21. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  22. data/lib/evilution/compare/normalizer.rb +10 -5
  23. data/lib/evilution/config/file_loader.rb +40 -1
  24. data/lib/evilution/config.rb +21 -11
  25. data/lib/evilution/disable_comment.rb +21 -12
  26. data/lib/evilution/equivalent/heuristic/dead_code.rb +8 -1
  27. data/lib/evilution/feedback/setup_warning.rb +79 -0
  28. data/lib/evilution/gem_detector.rb +132 -0
  29. data/lib/evilution/integration/loading/body_call_neutralizer.rb +190 -0
  30. data/lib/evilution/integration/loading/mutation_applier.rb +35 -15
  31. data/lib/evilution/integration/loading/redefinition_recovery.rb +58 -1
  32. data/lib/evilution/integration/minitest.rb +60 -16
  33. data/lib/evilution/integration/rspec/result_builder.rb +20 -1
  34. data/lib/evilution/integration/rspec.rb +20 -1
  35. data/lib/evilution/isolation/fork.rb +104 -27
  36. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  37. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  38. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  39. data/lib/evilution/mcp/info_tool/response_formatter.rb +14 -1
  40. data/lib/evilution/mcp/info_tool.rb +10 -2
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +4 -2
  42. data/lib/evilution/mcp/mutate_tool/report_trimmer.rb +58 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +49 -17
  45. data/lib/evilution/mcp/session_tool.rb +34 -22
  46. data/lib/evilution/mcp.rb +6 -0
  47. data/lib/evilution/mutation.rb +26 -16
  48. data/lib/evilution/mutator/base.rb +66 -16
  49. data/lib/evilution/mutator/operator/argument_method_call_replacement.rb +59 -0
  50. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  51. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  52. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  53. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  54. data/lib/evilution/mutator/operator/block_param_removal.rb +50 -8
  55. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  56. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  57. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  58. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  59. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +36 -14
  60. data/lib/evilution/mutator/operator/index_to_at.rb +18 -5
  61. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  62. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  63. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  64. data/lib/evilution/mutator/operator/last_expression_removal.rb +46 -0
  65. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  66. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  67. data/lib/evilution/mutator/operator/receiver_replacement.rb +38 -7
  68. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  69. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  70. data/lib/evilution/mutator/operator/rescue_removal.rb +58 -12
  71. data/lib/evilution/mutator/operator/splat_operator.rb +28 -1
  72. data/lib/evilution/mutator/operator/string_literal.rb +83 -6
  73. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  74. data/lib/evilution/mutator/registry.rb +2 -0
  75. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  76. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  77. data/lib/evilution/parallel/work_queue.rb +35 -18
  78. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  79. data/lib/evilution/reporter/cli/line_formatters/error_rate_warning.rb +29 -0
  80. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  81. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  82. data/lib/evilution/reporter/json.rb +54 -18
  83. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  84. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  85. data/lib/evilution/reporter/suggestion/templates/minitest.rb +20 -14
  86. data/lib/evilution/reporter/suggestion/templates/rspec.rb +19 -13
  87. data/lib/evilution/result/mutation_result.rb +12 -6
  88. data/lib/evilution/runner/baseline_runner.rb +20 -9
  89. data/lib/evilution/runner/diagnostics.rb +13 -9
  90. data/lib/evilution/runner/isolation_resolver.rb +75 -12
  91. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -1
  92. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +32 -10
  93. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -1
  94. data/lib/evilution/runner/mutation_executor.rb +2 -0
  95. data/lib/evilution/runner/mutation_planner.rb +53 -16
  96. data/lib/evilution/runner/subject_pipeline.rb +21 -11
  97. data/lib/evilution/runner.rb +3 -3
  98. data/lib/evilution/session/diff.rb +15 -6
  99. data/lib/evilution/session/schema.rb +44 -0
  100. data/lib/evilution/session/store.rb +5 -1
  101. data/lib/evilution/spec_ast_cache.rb +26 -12
  102. data/lib/evilution/version.rb +1 -1
  103. data/lib/evilution.rb +2 -0
  104. data/schema/evilution.config.schema.json +205 -0
  105. data/script/build_runtime_snapshot +88 -0
  106. data/script/memory_check +11 -5
  107. data/script/run_self_baseline +79 -0
  108. data/script/run_self_validation +54 -0
  109. data/scripts/benchmark_density +10 -9
  110. data/scripts/compare_mutations +38 -21
  111. data/scripts/mutant_json_adapter +7 -4
  112. metadata +16 -2
@@ -4,27 +4,31 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::BlockPassRemoval < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
- if node.block.is_a?(Prism::BlockArgumentNode)
8
- block_node = node.block
9
- call_start = node.location.start_offset
10
- node_end = call_start + node.location.length
11
- block_end = block_node.location.start_offset + block_node.location.length
12
-
13
- prefix = @file_source.byteslice(call_start...block_node.location.start_offset).rstrip
14
- suffix = @file_source.byteslice(block_end...node_end)
15
-
16
- # Clean up: remove trailing comma from prefix, remove empty parens
17
- prefix = prefix.sub(/,\s*\z/, "")
18
- replacement = "#{prefix}#{suffix}".sub(/\(\s*\)/, "")
19
-
7
+ block_node = node.block
8
+ if block_node.is_a?(Prism::BlockArgumentNode)
20
9
  add_mutation(
21
- offset: call_start,
10
+ offset: node.location.start_offset,
22
11
  length: node.location.length,
23
- replacement: replacement,
12
+ replacement: build_replacement(node, block_node),
24
13
  node: node
25
14
  )
26
15
  end
27
16
 
28
17
  super
29
18
  end
19
+
20
+ private
21
+
22
+ # Drop the block-pass argument plus the trailing comma it leaves behind, and
23
+ # collapse the resulting `()` if the block-pass was the only argument.
24
+ def build_replacement(node, block_node)
25
+ call_start = node.location.start_offset
26
+ node_end = call_start + node.location.length
27
+ block_start = block_node.location.start_offset
28
+ block_end = block_start + block_node.location.length
29
+
30
+ prefix = @file_source.byteslice(call_start...block_start).rstrip.sub(/,\s*\z/, "")
31
+ suffix = @file_source.byteslice(block_end...node_end)
32
+ "#{prefix}#{suffix}".sub(/\(\s*\)/, "")
33
+ end
30
34
  end
@@ -40,16 +40,18 @@ class Evilution::Mutator::Operator::CaseWhen < Evilution::Mutator::Base
40
40
  end
41
41
 
42
42
  def remove_else_branch(node)
43
- return if node.else_clause.nil?
44
- return if node.else_clause.statements.nil?
43
+ else_clause = node.else_clause
44
+ return if else_clause.nil? || else_clause.statements.nil?
45
+
46
+ start_offset = else_clause.else_keyword_loc.start_offset
47
+ stmts_loc = else_clause.statements.location
48
+ end_offset = stmts_loc.start_offset + stmts_loc.length
45
49
 
46
- start_offset = node.else_clause.else_keyword_loc.start_offset
47
- end_offset = node.else_clause.statements.location.start_offset + node.else_clause.statements.location.length
48
50
  add_mutation(
49
51
  offset: start_offset,
50
52
  length: end_offset - start_offset,
51
53
  replacement: "",
52
- node: node.else_clause
54
+ node: else_clause
53
55
  )
54
56
  end
55
57
  end
@@ -4,29 +4,29 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::ConditionalBranch < Evilution::Mutator::Base
6
6
  def visit_if_node(node)
7
- if node.statements && node.subsequent.nil?
8
- add_mutation(
9
- offset: node.statements.location.start_offset,
10
- length: node.statements.location.length,
11
- replacement: "nil",
12
- node: node
13
- )
14
- elsif node.statements && node.subsequent&.statements
15
- add_mutation(
16
- offset: node.statements.location.start_offset,
17
- length: node.statements.location.length,
18
- replacement: "nil",
19
- node: node
20
- )
21
-
22
- add_mutation(
23
- offset: node.subsequent.statements.location.start_offset,
24
- length: node.subsequent.statements.location.length,
25
- replacement: "nil",
26
- node: node
27
- )
28
- end
7
+ return super unless node.statements
8
+
9
+ add_nil_mutation(node.statements, node)
10
+ add_nil_mutation_to_else(node.subsequent, node)
29
11
 
30
12
  super
31
13
  end
14
+
15
+ private
16
+
17
+ def add_nil_mutation_to_else(subsequent, node)
18
+ return unless subsequent.is_a?(Prism::ElseNode)
19
+ return if subsequent.statements.nil?
20
+
21
+ add_nil_mutation(subsequent.statements, node)
22
+ end
23
+
24
+ def add_nil_mutation(statements, node)
25
+ add_mutation(
26
+ offset: statements.location.start_offset,
27
+ length: statements.location.length,
28
+ replacement: "nil",
29
+ node: node
30
+ )
31
+ end
32
32
  end
@@ -5,9 +5,8 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::EqualityToIdentity < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if node.name == :== && node.receiver && node.arguments
8
- receiver_text = @file_source.byteslice(node.receiver.location.start_offset, node.receiver.location.length)
9
- arg = node.arguments.arguments.first
10
- arg_text = @file_source.byteslice(arg.location.start_offset, arg.location.length)
8
+ receiver_text = loc_text(node.receiver.location)
9
+ arg_text = loc_text(node.arguments.arguments.first.location)
11
10
 
12
11
  add_mutation(
13
12
  offset: node.location.start_offset,
@@ -19,4 +18,10 @@ class Evilution::Mutator::Operator::EqualityToIdentity < Evilution::Mutator::Bas
19
18
 
20
19
  super
21
20
  end
21
+
22
+ private
23
+
24
+ def loc_text(loc)
25
+ @file_source.byteslice(loc.start_offset, loc.length)
26
+ end
22
27
  end
@@ -23,25 +23,47 @@ class Evilution::Mutator::Operator::ExplicitSuperMutation < Evilution::Mutator::
23
23
  end
24
24
 
25
25
  def mutate_arguments(node, args)
26
- # Remove all arguments: super(a, b) -> super()
26
+ emit_remove_all_args(node)
27
+ return unless args.length >= 2
28
+
29
+ args.each_index { |i| emit_remove_arg_at(node, args, i) }
30
+ end
31
+
32
+ # super(a, b) -> super()
33
+ #
34
+ # The argument list's byte range covers only the args themselves; the
35
+ # separator (`,` + whitespace) between the last arg and a following
36
+ # `&block` (or the closing `)` after a trailing comma) is owned by the
37
+ # SuperNode itself. Replacing only `arguments.location` with `""` leaves
38
+ # `super(, &block)` or `super(,)`. Extend the removal range to the start
39
+ # of the block argument when present, otherwise to the closing paren — so
40
+ # the separator goes along with the args.
41
+ def emit_remove_all_args(node)
42
+ start_offset = node.arguments.location.start_offset
43
+ end_offset = trailing_args_boundary(node)
44
+
27
45
  add_mutation(
28
- offset: node.arguments.location.start_offset,
29
- length: node.arguments.location.length,
46
+ offset: start_offset,
47
+ length: end_offset - start_offset,
30
48
  replacement: "",
31
49
  node: node
32
50
  )
51
+ end
33
52
 
34
- return unless args.length >= 2
53
+ def trailing_args_boundary(node)
54
+ return node.block.location.start_offset if node.block
55
+ return node.rparen_loc.start_offset if node.rparen_loc
56
+
57
+ node.arguments.location.end_offset
58
+ end
35
59
 
36
- # Remove individual arguments
37
- args.each_index do |i|
38
- remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
39
- add_mutation(
40
- offset: node.arguments.location.start_offset,
41
- length: node.arguments.location.length,
42
- replacement: remaining.join(", "),
43
- node: node
44
- )
45
- end
60
+ def emit_remove_arg_at(node, args, i)
61
+ remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
62
+ add_mutation(
63
+ offset: node.arguments.location.start_offset,
64
+ length: node.arguments.location.length,
65
+ replacement: remaining.join(", "),
66
+ node: node
67
+ )
46
68
  end
47
69
  end
@@ -5,13 +5,10 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if indexable?(node)
8
- receiver_source = @file_source.byteslice(node.receiver.location.start_offset, node.receiver.location.length)
9
- arg_source = @file_source.byteslice(node.arguments.location.start_offset, node.arguments.location.length)
10
-
11
8
  add_mutation(
12
9
  offset: node.location.start_offset,
13
10
  length: node.location.length,
14
- replacement: "#{receiver_source}.at(#{arg_source})",
11
+ replacement: "#{loc_text(node.receiver.location)}.at(#{loc_text(node.arguments.location)})",
15
12
  node: node
16
13
  )
17
14
  end
@@ -21,10 +18,26 @@ class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
21
18
 
22
19
  private
23
20
 
21
+ def loc_text(loc)
22
+ @file_source.byteslice(loc.start_offset, loc.length)
23
+ end
24
+
25
+ # EV-pn5y / GH #1173: Hash has no #at method, so the symbol/string keys that
26
+ # almost always indicate a Hash receiver are skipped here — otherwise the
27
+ # mutated source crashes with NoMethodError instead of yielding a measurable
28
+ # mutation. Integer literals and variable/expression keys are still mutated;
29
+ # if the receiver in those cases turns out to be a Hash the mutation will
30
+ # still raise NoMethodError at runtime, but those shapes are far rarer in
31
+ # practice and the AST gives no reliable receiver-type signal to filter on.
24
32
  def indexable?(node)
25
33
  node.name == :[] &&
26
34
  node.receiver &&
27
35
  node.arguments &&
28
- node.arguments.arguments.length == 1
36
+ node.arguments.arguments.length == 1 &&
37
+ !hash_key_shape?(node.arguments.arguments.first)
38
+ end
39
+
40
+ def hash_key_shape?(arg)
41
+ arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
29
42
  end
30
43
  end
@@ -3,6 +3,9 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
6
+ Chain = Data.define(:root, :args)
7
+ private_constant :Chain
8
+
6
9
  def initialize(**options)
7
10
  super
8
11
  @consumed = Set.new
@@ -10,14 +13,11 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
10
13
 
11
14
  def visit_call_node(node)
12
15
  if chain_head?(node)
13
- root, args = collect_chain(node)
14
- root_source = byteslice_source(root.location.start_offset, root.location.length)
15
- arg_sources = args.map { |a| byteslice_source(a.location.start_offset, a.location.length) }
16
-
16
+ chain = collect_chain(node)
17
17
  add_mutation(
18
18
  offset: node.location.start_offset,
19
19
  length: node.location.length,
20
- replacement: "#{root_source}.dig(#{arg_sources.join(", ")})",
20
+ replacement: dig_replacement(chain),
21
21
  node: node
22
22
  )
23
23
  end
@@ -27,6 +27,12 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
27
27
 
28
28
  private
29
29
 
30
+ def dig_replacement(chain)
31
+ root_source = byteslice_source(chain.root.location.start_offset, chain.root.location.length)
32
+ arg_sources = chain.args.map { |a| byteslice_source(a.location.start_offset, a.location.length) }
33
+ "#{root_source}.dig(#{arg_sources.join(", ")})"
34
+ end
35
+
30
36
  def chain_head?(node)
31
37
  return false if @consumed.include?(node.object_id)
32
38
  return false unless single_arg_index?(node)
@@ -53,6 +59,6 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
53
59
  current = current.receiver
54
60
  end
55
61
 
56
- [current, args]
62
+ Chain.new(root: current, args: args)
57
63
  end
58
64
  end
@@ -5,13 +5,10 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if indexable?(node)
8
- receiver_source = byteslice_source(node.receiver.location.start_offset, node.receiver.location.length)
9
- arg_source = byteslice_source(node.arguments.location.start_offset, node.arguments.location.length)
10
-
11
8
  add_mutation(
12
9
  offset: node.location.start_offset,
13
10
  length: node.location.length,
14
- replacement: "#{receiver_source}.fetch(#{arg_source})",
11
+ replacement: "#{loc_text(node.receiver.location)}.fetch(#{loc_text(node.arguments.location)})",
15
12
  node: node
16
13
  )
17
14
  end
@@ -21,6 +18,10 @@ class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
21
18
 
22
19
  private
23
20
 
21
+ def loc_text(loc)
22
+ byteslice_source(loc.start_offset, loc.length)
23
+ end
24
+
24
25
  def indexable?(node)
25
26
  node.name == :[] &&
26
27
  node.receiver &&
@@ -56,36 +56,41 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
56
56
  return unless kr.is_a?(Prism::KeywordRestParameterNode)
57
57
 
58
58
  all_params = collect_all_params(params)
59
-
60
59
  if all_params.length < 2
61
- add_mutation(
62
- offset: kr.location.start_offset,
63
- length: kr.location.length,
64
- replacement: "",
65
- node: kr
66
- )
60
+ emit_remove_only_kr(kr)
67
61
  else
68
- remaining = all_params.reject { |p| p.equal?(kr) }
69
- replacement = remaining.map(&:slice).join(", ")
70
-
71
- add_mutation(
72
- offset: params.location.start_offset,
73
- length: params.location.length,
74
- replacement: replacement,
75
- node: kr
76
- )
62
+ emit_remove_kr_with_remaining(params, all_params, kr)
77
63
  end
78
64
  end
79
65
 
66
+ def emit_remove_only_kr(kr)
67
+ add_mutation(
68
+ offset: kr.location.start_offset,
69
+ length: kr.location.length,
70
+ replacement: "",
71
+ node: kr
72
+ )
73
+ end
74
+
75
+ def emit_remove_kr_with_remaining(params, all_params, kr)
76
+ remaining = all_params.reject { |p| p.equal?(kr) }
77
+ add_mutation(
78
+ offset: params.location.start_offset,
79
+ length: params.location.length,
80
+ replacement: remaining.map(&:slice).join(", "),
81
+ node: kr
82
+ )
83
+ end
84
+
80
85
  def collect_all_params(params)
81
- result = []
82
- result.concat(params.requireds)
83
- result.concat(params.optionals)
84
- result << params.rest if params.rest
85
- result.concat(params.posts)
86
- result.concat(params.keywords)
87
- result << params.keyword_rest if params.keyword_rest
88
- result << params.block if params.block
89
- result
86
+ [
87
+ *params.requireds,
88
+ *params.optionals,
89
+ params.rest,
90
+ *params.posts,
91
+ *params.keywords,
92
+ params.keyword_rest,
93
+ params.block
94
+ ].compact
90
95
  end
91
96
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ # Removes a trailing literal expression (true/false/nil/integer/symbol) from
6
+ # a method body. Targets the idiomatic Ruby pattern `def foo?; side_effect;
7
+ # true; end` where the explicit literal return value is the high-signal
8
+ # behavior under test — dropping it makes the method return whatever the
9
+ # preceding statement evaluates to. Strong against predicates and
10
+ # command-query split methods.
11
+ class Evilution::Mutator::Operator::LastExpressionRemoval < Evilution::Mutator::Base
12
+ LITERAL_NODE_TYPES = [
13
+ Prism::TrueNode,
14
+ Prism::FalseNode,
15
+ Prism::NilNode,
16
+ Prism::IntegerNode,
17
+ Prism::SymbolNode
18
+ ].freeze
19
+
20
+ def visit_def_node(node)
21
+ last_literal = trailing_literal(node)
22
+ if last_literal
23
+ add_mutation(
24
+ offset: last_literal.location.start_offset,
25
+ length: last_literal.location.length,
26
+ replacement: "",
27
+ node: last_literal
28
+ )
29
+ end
30
+
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def trailing_literal(node)
37
+ body = node.body
38
+ return nil unless body.is_a?(Prism::StatementsNode)
39
+ return nil if body.body.empty?
40
+
41
+ last = body.body.last
42
+ return nil unless LITERAL_NODE_TYPES.any? { |t| last.is_a?(t) }
43
+
44
+ last
45
+ end
46
+ end
@@ -13,27 +13,33 @@ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
13
13
  @mutations = []
14
14
  @filter = filter
15
15
 
16
- tree = self.class.parsed_tree_for(subject.file_path, @file_source)
17
- enclosing = find_enclosing_scope(tree, subject.line_number)
16
+ enclosing = find_target_scope(subject)
18
17
  return @mutations unless enclosing
19
18
 
20
- first_method_line = find_first_method_line(enclosing)
21
- return @mutations unless first_method_line == subject.line_number
22
-
23
- find_mixin_calls(enclosing).each do |call_node|
24
- add_mutation(
25
- offset: call_node.location.start_offset,
26
- length: call_node.location.length,
27
- replacement: "",
28
- node: call_node
29
- )
30
- end
31
-
19
+ find_mixin_calls(enclosing).each { |call_node| emit_mixin_removal(call_node) }
32
20
  @mutations
33
21
  end
34
22
 
35
23
  private
36
24
 
25
+ def find_target_scope(subject)
26
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
27
+ enclosing = find_enclosing_scope(tree, subject.line_number)
28
+ return nil unless enclosing
29
+ return nil unless find_first_method_line(enclosing) == subject.line_number
30
+
31
+ enclosing
32
+ end
33
+
34
+ def emit_mixin_removal(call_node)
35
+ add_mutation(
36
+ offset: call_node.location.start_offset,
37
+ length: call_node.location.length,
38
+ replacement: "",
39
+ node: call_node
40
+ )
41
+ end
42
+
37
43
  def find_enclosing_scope(tree, target_line)
38
44
  finder = ScopeFinder.new(target_line)
39
45
  finder.visit(tree)
@@ -18,19 +18,18 @@ class Evilution::Mutator::Operator::MultipleAssignment < Evilution::Mutator::Bas
18
18
  private
19
19
 
20
20
  def mutate_target_removal(node, lefts, values)
21
- lefts.each_index do |i|
22
- remaining_lefts = lefts.each_with_index.filter_map { |l, j| l.slice if j != i }
23
- remaining_values = values.each_with_index.filter_map { |v, j| v.slice if j != i }
24
-
25
- replacement = "#{remaining_lefts.join(", ")} = #{remaining_values.join(", ")}"
26
-
27
- add_mutation(
28
- offset: node.location.start_offset,
29
- length: node.location.length,
30
- replacement: replacement,
31
- node: node
32
- )
33
- end
21
+ lefts.each_index { |i| emit_target_removal_at(node, lefts, values, i) }
22
+ end
23
+
24
+ def emit_target_removal_at(node, lefts, values, i)
25
+ remaining_lefts = lefts.each_with_index.filter_map { |l, j| l.slice if j != i }
26
+ remaining_values = values.each_with_index.filter_map { |v, j| v.slice if j != i }
27
+ add_mutation(
28
+ offset: node.location.start_offset,
29
+ length: node.location.length,
30
+ replacement: "#{remaining_lefts.join(", ")} = #{remaining_values.join(", ")}",
31
+ node: node
32
+ )
34
33
  end
35
34
 
36
35
  def mutate_swap(node, lefts, values)
@@ -3,21 +3,52 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::ReceiverReplacement < Evilution::Mutator::Base
6
- def visit_call_node(node)
7
- if node.receiver.is_a?(Prism::SelfNode)
8
- call_without_self = @file_source.byteslice(
9
- node.message_loc.start_offset,
10
- node.location.start_offset + node.location.length - node.message_loc.start_offset
11
- )
6
+ # Ruby reserved words. A call like `self.class` — when stripped of its
7
+ # `self.` receiver — becomes the bare token `class`, which the parser reads
8
+ # as the class-definition keyword rather than a method call. Producing this
9
+ # mutation guarantees an unparseable result. Skip when the call's method
10
+ # name collides with any reserved keyword.
11
+ RUBY_RESERVED_KEYWORDS = %i[
12
+ BEGIN END __ENCODING__ __FILE__ __LINE__
13
+ alias and begin break case class def defined? do else elsif end
14
+ ensure false for if in module next nil not or redo rescue retry
15
+ return self super then true undef unless until when while yield
16
+ ].to_set.freeze
12
17
 
18
+ def visit_call_node(node)
19
+ if eligible_self_call?(node)
13
20
  add_mutation(
14
21
  offset: node.location.start_offset,
15
22
  length: node.location.length,
16
- replacement: call_without_self,
23
+ replacement: call_without_self_text(node),
17
24
  node: node
18
25
  )
19
26
  end
20
27
 
21
28
  super
22
29
  end
30
+
31
+ private
32
+
33
+ def eligible_self_call?(node)
34
+ return false unless node.receiver.is_a?(Prism::SelfNode)
35
+ return false if reserved_keyword_method?(node.name)
36
+
37
+ true
38
+ end
39
+
40
+ # `self.class = value` is a writer call whose Prism `name` is `:class=` —
41
+ # not `:class` — so a literal lookup in RUBY_RESERVED_KEYWORDS misses it,
42
+ # and stripping the receiver leaves `class = value` (parse error). Normalize
43
+ # the trailing `=` from writer forms before comparing.
44
+ def reserved_keyword_method?(name)
45
+ base = name.to_s.chomp("=").to_sym
46
+ RUBY_RESERVED_KEYWORDS.include?(base)
47
+ end
48
+
49
+ def call_without_self_text(node)
50
+ message_start = node.message_loc.start_offset
51
+ call_end = node.location.start_offset + node.location.length
52
+ @file_source.byteslice(message_start, call_end - message_start)
53
+ end
23
54
  end