evilution 0.27.0 → 0.29.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 +65 -0
  3. data/.rubocop_todo.yml +0 -1
  4. data/CHANGELOG.md +39 -0
  5. data/README.md +19 -0
  6. data/lib/evilution/ast/constant_names.rb +28 -11
  7. data/lib/evilution/ast/pattern/parser.rb +29 -17
  8. data/lib/evilution/baseline.rb +5 -4
  9. data/lib/evilution/cli/commands/session_diff.rb +6 -4
  10. data/lib/evilution/cli/commands/subjects.rb +6 -3
  11. data/lib/evilution/cli/commands/util_mutation.rb +24 -19
  12. data/lib/evilution/cli/parser/command_extractor.rb +9 -11
  13. data/lib/evilution/cli/parser/file_args.rb +3 -1
  14. data/lib/evilution/cli/parser/options_builder.rb +36 -1
  15. data/lib/evilution/cli/parser/stdin_reader.rb +2 -2
  16. data/lib/evilution/cli/parser.rb +18 -20
  17. data/lib/evilution/cli/printers/environment.rb +19 -19
  18. data/lib/evilution/cli/printers/session_diff.rb +8 -8
  19. data/lib/evilution/compare/diff_extractor/evilution.rb +22 -0
  20. data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
  21. data/lib/evilution/compare/diff_extractor.rb +6 -0
  22. data/lib/evilution/compare/fingerprint.rb +15 -72
  23. data/lib/evilution/compare/line_normalizer.rb +72 -0
  24. data/lib/evilution/compare/normalizer.rb +27 -9
  25. data/lib/evilution/config/validators/profile.rb +11 -0
  26. data/lib/evilution/config.rb +49 -32
  27. data/lib/evilution/disable_comment.rb +21 -12
  28. data/lib/evilution/integration/crash_detector.rb +2 -2
  29. data/lib/evilution/integration/loading/mutation_applier.rb +17 -12
  30. data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
  31. data/lib/evilution/integration/minitest.rb +25 -16
  32. data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
  33. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +11 -3
  34. data/lib/evilution/integration/rspec.rb +4 -0
  35. data/lib/evilution/isolation/fork.rb +43 -28
  36. data/lib/evilution/isolation/in_process.rb +10 -6
  37. data/lib/evilution/mcp/info_tool/actions/subjects.rb +32 -23
  38. data/lib/evilution/mcp/info_tool/actions/tests.rb +22 -12
  39. data/lib/evilution/mcp/info_tool/request_parser.rb +3 -1
  40. data/lib/evilution/mcp/info_tool.rb +7 -3
  41. data/lib/evilution/mcp/mutate_tool/option_parser.rb +3 -1
  42. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -1
  43. data/lib/evilution/mcp/mutate_tool/survived_enricher.rb +19 -9
  44. data/lib/evilution/mcp/mutate_tool.rb +27 -14
  45. data/lib/evilution/mcp/session_tool.rb +27 -20
  46. data/lib/evilution/mutation.rb +60 -42
  47. data/lib/evilution/mutator/base.rb +23 -21
  48. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +11 -14
  49. data/lib/evilution/mutator/operator/argument_removal.rb +11 -14
  50. data/lib/evilution/mutator/operator/begin_unwrap.rb +17 -5
  51. data/lib/evilution/mutator/operator/bitwise_complement.rb +26 -19
  52. data/lib/evilution/mutator/operator/block_param_removal.rb +18 -8
  53. data/lib/evilution/mutator/operator/block_pass_removal.rb +19 -15
  54. data/lib/evilution/mutator/operator/case_when.rb +7 -5
  55. data/lib/evilution/mutator/operator/conditional_branch.rb +22 -22
  56. data/lib/evilution/mutator/operator/equality_to_identity.rb +8 -3
  57. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +17 -13
  58. data/lib/evilution/mutator/operator/index_to_at.rb +5 -4
  59. data/lib/evilution/mutator/operator/index_to_dig.rb +12 -6
  60. data/lib/evilution/mutator/operator/index_to_fetch.rb +5 -4
  61. data/lib/evilution/mutator/operator/keyword_argument.rb +30 -25
  62. data/lib/evilution/mutator/operator/mixin_removal.rb +20 -14
  63. data/lib/evilution/mutator/operator/multiple_assignment.rb +12 -13
  64. data/lib/evilution/mutator/operator/predicate_to_nil.rb +20 -0
  65. data/lib/evilution/mutator/operator/receiver_replacement.rb +9 -6
  66. data/lib/evilution/mutator/operator/regex_simplification.rb +62 -67
  67. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +9 -8
  68. data/lib/evilution/mutator/operator/rescue_removal.rb +4 -7
  69. data/lib/evilution/mutator/operator/superclass_removal.rb +21 -15
  70. data/lib/evilution/mutator/registry.rb +20 -0
  71. data/lib/evilution/parallel/work_queue/channel/frame.rb +5 -1
  72. data/lib/evilution/parallel/work_queue/dispatcher.rb +15 -8
  73. data/lib/evilution/parallel/work_queue/worker/loop.rb +1 -1
  74. data/lib/evilution/parallel/work_queue/worker.rb +10 -7
  75. data/lib/evilution/parallel/work_queue.rb +35 -18
  76. data/lib/evilution/process_cleanup.rb +19 -0
  77. data/lib/evilution/reporter/cli/item_formatters/coverage_gap.rb +13 -8
  78. data/lib/evilution/reporter/cli/line_formatters/mutations.rb +17 -8
  79. data/lib/evilution/reporter/html/baseline_keys.rb +1 -1
  80. data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
  81. data/lib/evilution/reporter/html/escape.rb +1 -1
  82. data/lib/evilution/reporter/html/section.rb +1 -1
  83. data/lib/evilution/reporter/html/sections.rb +4 -2
  84. data/lib/evilution/reporter/html/stylesheet.rb +1 -1
  85. data/lib/evilution/reporter/html.rb +8 -3
  86. data/lib/evilution/reporter/json.rb +52 -18
  87. data/lib/evilution/reporter/suggestion/diff_helpers.rb +0 -13
  88. data/lib/evilution/reporter/suggestion/diff_lines.rb +28 -0
  89. data/lib/evilution/reporter/suggestion/registry.rb +1 -5
  90. data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
  91. data/lib/evilution/reporter/suggestion/templates/minitest.rb +361 -649
  92. data/lib/evilution/reporter/suggestion/templates/rspec.rb +362 -603
  93. data/lib/evilution/reporter/suggestion/templates.rb +6 -0
  94. data/lib/evilution/result/error_info.rb +20 -0
  95. data/lib/evilution/result/memory_stats.rb +20 -0
  96. data/lib/evilution/result/mutation_result.rb +30 -14
  97. data/lib/evilution/runner/baseline_runner.rb +16 -10
  98. data/lib/evilution/runner/diagnostics.rb +14 -11
  99. data/lib/evilution/runner/isolation_resolver.rb +12 -11
  100. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +1 -3
  101. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +1 -2
  102. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +3 -10
  103. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +3 -10
  104. data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
  105. data/lib/evilution/runner/mutation_executor/result_cache.rb +4 -4
  106. data/lib/evilution/runner/mutation_executor/result_notifier.rb +1 -3
  107. data/lib/evilution/runner/mutation_executor/result_packer.rb +11 -9
  108. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +33 -13
  109. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +2 -4
  110. data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
  111. data/lib/evilution/runner/mutation_executor.rb +14 -20
  112. data/lib/evilution/runner/mutation_planner.rb +38 -19
  113. data/lib/evilution/runner/report_publisher.rb +1 -2
  114. data/lib/evilution/runner/subject_pipeline.rb +22 -13
  115. data/lib/evilution/runner.rb +36 -34
  116. data/lib/evilution/session/diff.rb +15 -6
  117. data/lib/evilution/spec_ast_cache.rb +26 -12
  118. data/lib/evilution/version.rb +1 -1
  119. data/lib/evilution.rb +1 -0
  120. data/script/memory_check +14 -6
  121. data/scripts/benchmark_density +10 -9
  122. data/scripts/compare_mutations +38 -21
  123. data/scripts/mutant_json_adapter +7 -4
  124. metadata +15 -3
  125. data/lib/evilution/reporter/html/namespace.rb +0 -11
@@ -50,8 +50,10 @@ class Evilution::MCP::SessionTool < MCP::Tool
50
50
 
51
51
  VALID_ACTIONS = %w[list show diff].freeze
52
52
 
53
+ LimitResult = Data.define(:limit, :error)
54
+ private_constant :LimitResult
55
+
53
56
  class << self
54
- # rubocop:disable Lint/UnusedMethodArgument
55
57
  def call(server_context:, action: nil, results_dir: nil, limit: nil, path: nil, base: nil, head: nil)
56
58
  return error_response("config_error", "action is required") unless action
57
59
  return error_response("config_error", "unknown action: #{action}") unless VALID_ACTIONS.include?(action)
@@ -62,33 +64,32 @@ class Evilution::MCP::SessionTool < MCP::Tool
62
64
  when "diff" then diff_action(base: base, head: head, results_dir: results_dir)
63
65
  end
64
66
  end
65
- # rubocop:enable Lint/UnusedMethodArgument
66
67
 
67
68
  private
68
69
 
69
70
  def list_action(results_dir:, limit:)
70
- normalized_limit, limit_error = normalize_limit(limit)
71
- return error_response("config_error", limit_error) if limit_error
71
+ result = normalize_limit(limit)
72
+ return error_response("config_error", result.error) if result.error
72
73
 
73
74
  store_opts = {}
74
75
  store_opts[:results_dir] = results_dir if results_dir
75
76
  store = Evilution::Session::Store.new(**store_opts)
76
77
  entries = store.list
77
- entries = entries.first(normalized_limit) unless normalized_limit.nil?
78
+ entries = entries.first(result.limit) unless result.limit.nil?
78
79
 
79
80
  payload = entries.map { |e| e.transform_keys(&:to_s) }
80
81
  success_response(payload)
81
82
  end
82
83
 
83
84
  def normalize_limit(limit)
84
- return [nil, nil] if limit.nil?
85
+ return LimitResult.new(limit: nil, error: nil) if limit.nil?
85
86
 
86
87
  coerced = Integer(limit)
87
- return [nil, "limit must be a non-negative integer"] if coerced.negative?
88
+ return LimitResult.new(limit: nil, error: "limit must be a non-negative integer") if coerced.negative?
88
89
 
89
- [coerced, nil]
90
+ LimitResult.new(limit: coerced, error: nil)
90
91
  rescue ArgumentError, TypeError
91
- [nil, "limit must be a non-negative integer"]
92
+ LimitResult.new(limit: nil, error: "limit must be a non-negative integer")
92
93
  end
93
94
 
94
95
  def show_action(path:, results_dir:)
@@ -109,19 +110,11 @@ class Evilution::MCP::SessionTool < MCP::Tool
109
110
  end
110
111
 
111
112
  def diff_action(base:, head:, results_dir:)
112
- return error_response("config_error", "base is required") unless base
113
- return error_response("config_error", "head is required") unless head
114
-
115
113
  dir = results_dir || Evilution::Session::Store::DEFAULT_DIR
116
- return error_response("config_error", "base must be under results directory") unless within?(base, dir)
117
- return error_response("config_error", "head must be under results directory") unless within?(head, dir)
118
-
119
- store = Evilution::Session::Store.new(results_dir: dir)
120
- base_data = store.load(base)
121
- head_data = store.load(head)
114
+ validation = validate_diff_args(base, head, dir)
115
+ return validation if validation
122
116
 
123
- diff = Evilution::Session::Diff.new
124
- result = diff.call(base_data, head_data)
117
+ result = load_and_diff(base, head, dir)
125
118
  success_response(result.to_h)
126
119
  rescue Evilution::Error => e
127
120
  error_response("not_found", e.message)
@@ -131,6 +124,20 @@ class Evilution::MCP::SessionTool < MCP::Tool
131
124
  error_response("runtime_error", e.message)
132
125
  end
133
126
 
127
+ def validate_diff_args(base, head, dir)
128
+ return error_response("config_error", "base is required") unless base
129
+ return error_response("config_error", "head is required") unless head
130
+ return error_response("config_error", "base must be under results directory") unless within?(base, dir)
131
+ return error_response("config_error", "head must be under results directory") unless within?(head, dir)
132
+
133
+ nil
134
+ end
135
+
136
+ def load_and_diff(base, head, dir)
137
+ store = Evilution::Session::Store.new(results_dir: dir)
138
+ Evilution::Session::Diff.new.call(store.load(base), store.load(head))
139
+ end
140
+
134
141
  def within?(path, results_dir)
135
142
  resolved_root = canonical_path(results_dir)
136
143
  resolved_path = canonical_path(path)
@@ -1,30 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "diff/lcs"
4
+ require_relative "../evilution"
4
5
 
5
6
  class Evilution::Mutation
6
- attr_reader :subject, :operator_name, :original_source,
7
- :mutated_source, :original_slice, :mutated_slice,
8
- :file_path, :line, :column, :parse_status
9
-
10
- # rubocop:disable Metrics/ParameterLists
11
- def initialize(subject:, operator_name:, original_source:, mutated_source:,
12
- file_path:, line:, column: 0, original_slice: nil, mutated_slice: nil,
13
- parse_status: :ok)
14
- # rubocop:enable Metrics/ParameterLists
7
+ Sources = Data.define(:original, :mutated)
8
+ Slice = Data.define(:original, :mutated)
9
+ Location = Data.define(:file_path, :line, :column)
10
+
11
+ attr_reader :subject, :operator_name, :parse_status, :location
12
+
13
+ def initialize(subject:, operator_name:, sources:, location:, slice: nil, parse_status: :ok)
15
14
  @subject = subject
16
15
  @operator_name = operator_name
17
- @original_source = original_source
18
- @mutated_source = mutated_source
19
- @original_slice = original_slice
20
- @mutated_slice = mutated_slice
21
- @file_path = file_path
22
- @line = line
23
- @column = column
16
+ @sources = sources
17
+ @location = location
18
+ @slice = slice
24
19
  @parse_status = parse_status
25
20
  @diff = nil
26
21
  end
27
22
 
23
+ def original_source
24
+ @sources&.original
25
+ end
26
+
27
+ def mutated_source
28
+ @sources&.mutated
29
+ end
30
+
31
+ def original_slice
32
+ @slice&.original
33
+ end
34
+
35
+ def mutated_slice
36
+ @slice&.mutated
37
+ end
38
+
39
+ def file_path
40
+ @location.file_path
41
+ end
42
+
43
+ def line
44
+ @location.line
45
+ end
46
+
47
+ def column
48
+ @location.column
49
+ end
50
+
28
51
  def unparseable?
29
52
  @parse_status == :unparseable
30
53
  end
@@ -41,45 +64,46 @@ class Evilution::Mutation
41
64
 
42
65
  def strip_sources!
43
66
  diff # ensure diff is cached before clearing sources
44
- @original_source = nil
45
- @mutated_source = nil
67
+ @sources = nil
68
+ end
69
+
70
+ def to_s
71
+ "#{operator_name}: #{file_path}:#{line}"
46
72
  end
47
73
 
48
74
  private
49
75
 
50
76
  def compute_diff
51
- original_lines = original_source.lines
52
- mutated_lines = mutated_source.lines
53
- diffs = ::Diff::LCS.diff(original_lines, mutated_lines)
54
-
77
+ diffs = ::Diff::LCS.diff(original_source.lines, mutated_source.lines)
55
78
  return "" if diffs.empty?
56
79
 
57
- result = []
58
- diffs.flatten(1).each do |change|
59
- case change.action
60
- when "-"
61
- result << "- #{change.element.chomp}"
62
- when "+"
63
- result << "+ #{change.element.chomp}"
64
- end
80
+ diffs.flatten(1).filter_map { |change| format_diff_change(change) }.join("\n")
81
+ end
82
+
83
+ def format_diff_change(change)
84
+ case change.action
85
+ when "-" then "- #{change.element.chomp}"
86
+ when "+" then "+ #{change.element.chomp}"
65
87
  end
66
- result.join("\n")
67
88
  end
68
89
 
69
90
  def compute_unified_diff
70
- return nil if @original_slice.nil? || @mutated_slice.nil?
91
+ return nil if @slice.nil?
71
92
 
72
- original_lines = @original_slice.lines
73
- mutated_lines = @mutated_slice.lines
74
- body = ::Diff::LCS.sdiff(original_lines, mutated_lines).map { |c| format_sdiff_change(c) }.join("\n")
93
+ original_lines = @slice.original.lines
94
+ mutated_lines = @slice.mutated.lines
75
95
  [
76
96
  "--- a/#{file_path}",
77
97
  "+++ b/#{file_path}",
78
98
  "@@ -#{line},#{original_lines.length} +#{line},#{mutated_lines.length} @@",
79
- body
99
+ unified_diff_body(original_lines, mutated_lines)
80
100
  ].reject(&:empty?).join("\n")
81
101
  end
82
102
 
103
+ def unified_diff_body(original_lines, mutated_lines)
104
+ ::Diff::LCS.sdiff(original_lines, mutated_lines).map { |c| format_sdiff_change(c) }.join("\n")
105
+ end
106
+
83
107
  def format_sdiff_change(change)
84
108
  case change.action
85
109
  when "=" then " #{change.old_element.chomp}"
@@ -88,10 +112,4 @@ class Evilution::Mutation
88
112
  when "!" then "-#{change.old_element.chomp}\n+#{change.new_element.chomp}"
89
113
  end
90
114
  end
91
-
92
- public
93
-
94
- def to_s
95
- "#{operator_name}: #{file_path}:#{line}"
96
- end
97
115
  end
@@ -5,6 +5,9 @@ require "prism"
5
5
  require_relative "../mutator"
6
6
 
7
7
  class Evilution::Mutator::Base < Prism::Visitor
8
+ AffectedSlices = Data.define(:original, :mutated)
9
+ private_constant :AffectedSlices
10
+
8
11
  attr_reader :mutations
9
12
 
10
13
  def initialize(**_options)
@@ -28,31 +31,30 @@ class Evilution::Mutator::Base < Prism::Visitor
28
31
  return if @filter && @filter.skip?(node)
29
32
 
30
33
  surgery = Evilution::AST::SourceSurgeon.apply(
31
- @file_source,
32
- offset: offset,
33
- length: length,
34
- replacement: replacement
34
+ @file_source, offset: offset, length: length, replacement: replacement
35
35
  )
36
- mutated_source = surgery.source
37
-
38
- original_slice, mutated_slice = slice_affected_lines(
39
- mutated_source: mutated_source,
36
+ slices = slice_affected_lines(
37
+ mutated_source: surgery.source,
40
38
  offset: offset,
41
39
  length: length,
42
40
  replacement_bytesize: replacement.bytesize
43
41
  )
44
42
 
45
- @mutations << Evilution::Mutation.new(
43
+ @mutations << build_mutation_record(node, surgery, slices)
44
+ end
45
+
46
+ def build_mutation_record(node, surgery, slices)
47
+ Evilution::Mutation.new(
46
48
  subject: @subject,
47
49
  operator_name: self.class.operator_name,
48
- original_source: @file_source,
49
- mutated_source: mutated_source,
50
- original_slice: original_slice,
51
- mutated_slice: mutated_slice,
52
- parse_status: surgery.status,
53
- file_path: @subject.file_path,
54
- line: node.location.start_line,
55
- column: node.location.start_column
50
+ sources: Evilution::Mutation::Sources.new(original: @file_source, mutated: surgery.source),
51
+ slice: Evilution::Mutation::Slice.new(original: slices.original, mutated: slices.mutated),
52
+ location: Evilution::Mutation::Location.new(
53
+ file_path: @subject.file_path,
54
+ line: node.location.start_line,
55
+ column: node.location.start_column
56
+ ),
57
+ parse_status: surgery.status
56
58
  )
57
59
  end
58
60
 
@@ -64,10 +66,10 @@ class Evilution::Mutator::Base < Prism::Visitor
64
66
  orig_line_end = line_end_byte(@file_source, [offset + length - 1, line_start].max)
65
67
  mut_line_end = line_end_byte(mutated_source, [offset + replacement_bytesize - 1, line_start].max)
66
68
 
67
- [
68
- @file_source.byteslice(line_start, orig_line_end - line_start),
69
- mutated_source.byteslice(line_start, mut_line_end - line_start)
70
- ]
69
+ AffectedSlices.new(
70
+ original: @file_source.byteslice(line_start, orig_line_end - line_start),
71
+ mutated: mutated_source.byteslice(line_start, mut_line_end - line_start)
72
+ )
71
73
  end
72
74
 
73
75
  def line_start_byte(source, offset)
@@ -12,26 +12,23 @@ class Evilution::Mutator::Operator::ArgumentNilSubstitution < Evilution::Mutator
12
12
 
13
13
  def visit_call_node(node)
14
14
  args = node.arguments&.arguments
15
-
16
- if mutable?(node, args)
17
- args.each_index do |i|
18
- parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
19
- replacement = parts.join(", ")
20
-
21
- add_mutation(
22
- offset: node.arguments.location.start_offset,
23
- length: node.arguments.location.length,
24
- replacement: replacement,
25
- node: node
26
- )
27
- end
28
- end
15
+ args.each_index { |i| emit_nil_substitution(node, args, i) } if mutable?(node, args)
29
16
 
30
17
  super
31
18
  end
32
19
 
33
20
  private
34
21
 
22
+ def emit_nil_substitution(node, args, i)
23
+ parts = args.each_with_index.map { |a, j| j == i ? "nil" : a.slice }
24
+ add_mutation(
25
+ offset: node.arguments.location.start_offset,
26
+ length: node.arguments.location.length,
27
+ replacement: parts.join(", "),
28
+ node: node
29
+ )
30
+ end
31
+
35
32
  def mutable?(node, args)
36
33
  args && args.length >= 1 && positional_only?(args) && node.name != :[]=
37
34
  end
@@ -12,26 +12,23 @@ class Evilution::Mutator::Operator::ArgumentRemoval < Evilution::Mutator::Base
12
12
 
13
13
  def visit_call_node(node)
14
14
  args = node.arguments&.arguments
15
-
16
- if mutable?(node, args)
17
- args.each_index do |i|
18
- remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
19
- replacement = remaining.join(", ")
20
-
21
- add_mutation(
22
- offset: node.arguments.location.start_offset,
23
- length: node.arguments.location.length,
24
- replacement:,
25
- node:
26
- )
27
- end
28
- end
15
+ args.each_index { |i| emit_argument_removal(node, args, i) } if mutable?(node, args)
29
16
 
30
17
  super
31
18
  end
32
19
 
33
20
  private
34
21
 
22
+ def emit_argument_removal(node, args, i)
23
+ remaining = args.each_with_index.filter_map { |a, j| a.slice if j != i }
24
+ add_mutation(
25
+ offset: node.arguments.location.start_offset,
26
+ length: node.arguments.location.length,
27
+ replacement: remaining.join(", "),
28
+ node: node
29
+ )
30
+ end
31
+
35
32
  def mutable?(node, args)
36
33
  args && args.length >= 2 && positional_only?(args) && node.name != :[]=
37
34
  end
@@ -4,18 +4,30 @@ require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::BeginUnwrap < Evilution::Mutator::Base
6
6
  def visit_begin_node(node)
7
- return super if node.rescue_clause || node.else_clause || node.ensure_clause
8
- return super if node.statements.nil?
9
- return super if node.begin_keyword_loc.nil?
7
+ return super unless unwrappable?(node)
10
8
 
11
- body_text = @file_source.byteslice(node.statements.location.start_offset, node.statements.location.length)
12
9
  add_mutation(
13
10
  offset: node.location.start_offset,
14
11
  length: node.location.length,
15
- replacement: body_text,
12
+ replacement: body_text(node),
16
13
  node: node
17
14
  )
18
15
 
19
16
  super
20
17
  end
18
+
19
+ private
20
+
21
+ def unwrappable?(node)
22
+ return false if node.rescue_clause || node.else_clause || node.ensure_clause
23
+ return false if node.statements.nil?
24
+ return false if node.begin_keyword_loc.nil?
25
+
26
+ true
27
+ end
28
+
29
+ def body_text(node)
30
+ loc = node.statements.location
31
+ @file_source.byteslice(loc.start_offset, loc.length)
32
+ end
21
33
  end
@@ -5,27 +5,34 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::BitwiseComplement < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if node.name == :~ && node.receiver && node.arguments.nil?
8
- loc = node.message_loc
9
- receiver_loc = node.receiver.location
10
-
11
- # Remove ~: replace entire ~expr with just the receiver expression
12
- receiver_source = byteslice_source(receiver_loc.start_offset, receiver_loc.length)
13
- add_mutation(
14
- offset: node.location.start_offset,
15
- length: node.location.length,
16
- replacement: receiver_source,
17
- node: node
18
- )
19
-
20
- # Swap ~ with unary minus
21
- add_mutation(
22
- offset: loc.start_offset,
23
- length: loc.length,
24
- replacement: "-",
25
- node: node
26
- )
8
+ emit_remove_complement(node)
9
+ emit_swap_to_minus(node)
27
10
  end
28
11
 
29
12
  super
30
13
  end
14
+
15
+ private
16
+
17
+ # Replace `~expr` with just `expr`.
18
+ def emit_remove_complement(node)
19
+ receiver_loc = node.receiver.location
20
+ add_mutation(
21
+ offset: node.location.start_offset,
22
+ length: node.location.length,
23
+ replacement: byteslice_source(receiver_loc.start_offset, receiver_loc.length),
24
+ node: node
25
+ )
26
+ end
27
+
28
+ # Swap `~` with unary minus.
29
+ def emit_swap_to_minus(node)
30
+ loc = node.message_loc
31
+ add_mutation(
32
+ offset: loc.start_offset,
33
+ length: loc.length,
34
+ replacement: "-",
35
+ node: node
36
+ )
37
+ end
31
38
  end
@@ -38,14 +38,7 @@ class Evilution::Mutator::Operator::BlockParamRemoval < Evilution::Mutator::Base
38
38
  end
39
39
 
40
40
  def remove_block_param(node)
41
- block_loc = node.parameters.block.location
42
- params_text = @file_source.byteslice(node.parameters.location.start_offset, node.parameters.location.length)
43
- block_rel = block_loc.start_offset - node.parameters.location.start_offset
44
-
45
- # Find the comma before the block param and remove ", &block"
46
- comma_pos = params_text.rindex(",", block_rel - 1)
47
- remove_start = node.parameters.location.start_offset + comma_pos
48
- remove_end = block_loc.start_offset + block_loc.length
41
+ remove_start, remove_end = block_param_removal_range(node)
49
42
 
50
43
  add_mutation(
51
44
  offset: remove_start,
@@ -54,4 +47,21 @@ class Evilution::Mutator::Operator::BlockParamRemoval < Evilution::Mutator::Base
54
47
  node: node
55
48
  )
56
49
  end
50
+
51
+ # Range covering ", &block" — from the comma before the block param to the end of the block param.
52
+ def block_param_removal_range(node)
53
+ params_loc = node.parameters.location
54
+ block_loc = node.parameters.block.location
55
+ comma_pos = params_text(params_loc).rindex(",", block_loc.start_offset - params_loc.start_offset - 1)
56
+
57
+ [params_loc.start_offset + comma_pos, end_offset(block_loc)]
58
+ end
59
+
60
+ def params_text(params_loc)
61
+ @file_source.byteslice(params_loc.start_offset, params_loc.length)
62
+ end
63
+
64
+ def end_offset(loc)
65
+ loc.start_offset + loc.length
66
+ end
57
67
  end
@@ -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