evilution 0.16.1 → 0.18.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +47 -46
  4. data/CHANGELOG.md +48 -0
  5. data/README.md +143 -50
  6. data/docs/ast_pattern_syntax.md +210 -0
  7. data/lib/evilution/ast/pattern/filter.rb +25 -0
  8. data/lib/evilution/ast/pattern/matcher.rb +107 -0
  9. data/lib/evilution/ast/pattern/parser.rb +185 -0
  10. data/lib/evilution/ast/pattern.rb +4 -0
  11. data/lib/evilution/ast/sorbet_sig_detector.rb +52 -0
  12. data/lib/evilution/cli.rb +400 -24
  13. data/lib/evilution/config.rb +43 -2
  14. data/lib/evilution/disable_comment.rb +90 -0
  15. data/lib/evilution/hooks/loader.rb +35 -0
  16. data/lib/evilution/hooks/registry.rb +60 -0
  17. data/lib/evilution/hooks.rb +58 -0
  18. data/lib/evilution/integration/base.rb +4 -0
  19. data/lib/evilution/integration/rspec.rb +6 -2
  20. data/lib/evilution/isolation/fork.rb +5 -0
  21. data/lib/evilution/mcp/session_diff_tool.rb +5 -35
  22. data/lib/evilution/mutator/base.rb +4 -1
  23. data/lib/evilution/mutator/operator/collection_return.rb +33 -0
  24. data/lib/evilution/mutator/operator/defined_check.rb +16 -0
  25. data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
  26. data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
  27. data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
  28. data/lib/evilution/mutator/operator/keyword_argument.rb +91 -0
  29. data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
  30. data/lib/evilution/mutator/operator/multiple_assignment.rb +47 -0
  31. data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
  32. data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
  33. data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
  34. data/lib/evilution/mutator/operator/regex_capture.rb +43 -0
  35. data/lib/evilution/mutator/operator/scalar_return.rb +37 -0
  36. data/lib/evilution/mutator/operator/splat_operator.rb +46 -0
  37. data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
  38. data/lib/evilution/mutator/operator/yield_statement.rb +51 -0
  39. data/lib/evilution/mutator/registry.rb +17 -3
  40. data/lib/evilution/parallel/pool.rb +7 -51
  41. data/lib/evilution/parallel/work_queue.rb +224 -0
  42. data/lib/evilution/reporter/cli.rb +22 -1
  43. data/lib/evilution/reporter/html.rb +76 -3
  44. data/lib/evilution/reporter/json.rb +23 -2
  45. data/lib/evilution/reporter/suggestion.rb +115 -1
  46. data/lib/evilution/result/summary.rb +20 -2
  47. data/lib/evilution/runner.rb +133 -13
  48. data/lib/evilution/session/diff.rb +85 -0
  49. data/lib/evilution/session/store.rb +5 -2
  50. data/lib/evilution/version.rb +1 -1
  51. data/lib/evilution.rb +23 -0
  52. metadata +28 -2
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hooks"
4
+
5
+ class Evilution::Hooks::Loader
6
+ def self.call(registry, config_hooks = nil)
7
+ return registry if config_hooks.nil?
8
+
9
+ unless config_hooks.is_a?(Hash)
10
+ raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{config_hooks.class}"
11
+ end
12
+ return registry if config_hooks.empty?
13
+
14
+ config_hooks.each do |event, paths|
15
+ event = event.to_sym
16
+ Array(paths).each do |path|
17
+ handler = load_hook_file(path)
18
+ registry.register(event) { |payload| handler.call(payload) }
19
+ end
20
+ end
21
+
22
+ registry
23
+ end
24
+
25
+ def self.load_hook_file(path)
26
+ raise Evilution::ConfigError, "hook file not found: #{path}" unless File.exist?(path)
27
+
28
+ result = Module.new.module_eval(File.read(path), path, 1)
29
+ raise Evilution::ConfigError, "hook file #{path} must return a Proc, got #{result.class}" unless result.is_a?(Proc)
30
+
31
+ result
32
+ end
33
+
34
+ private_class_method :load_hook_file
35
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hooks"
4
+
5
+ class Evilution::Hooks::Registry
6
+ def initialize(on_error: nil)
7
+ @handlers = Evilution::Hooks::EVENTS.to_h { |event| [event, []] }
8
+ @on_error = on_error
9
+ end
10
+
11
+ def register(event, &block)
12
+ validate_event!(event)
13
+ raise ArgumentError, "a block must be provided when registering handler for #{event}" unless block
14
+
15
+ @handlers[event] << block
16
+ self
17
+ end
18
+
19
+ def fire(event, **payload)
20
+ validate_event!(event)
21
+ errors = []
22
+
23
+ @handlers[event].each do |handler|
24
+ handler.call(payload)
25
+ rescue StandardError => e
26
+ errors << e
27
+ report_error(event, e)
28
+ end
29
+
30
+ errors
31
+ end
32
+
33
+ def clear(event = nil)
34
+ if event
35
+ validate_event!(event)
36
+ @handlers[event].clear
37
+ else
38
+ @handlers.each_value(&:clear)
39
+ end
40
+ end
41
+
42
+ def handlers_for(event)
43
+ validate_event!(event)
44
+ @handlers[event].dup
45
+ end
46
+
47
+ private
48
+
49
+ def validate_event!(event)
50
+ raise ArgumentError, "unknown hook event: #{event}" unless Evilution::Hooks::EVENTS.include?(event)
51
+ end
52
+
53
+ def report_error(event, error)
54
+ if @on_error
55
+ @on_error.call(event, error)
56
+ else
57
+ warn "[evilution] hook error in #{event}: #{error.message}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Evilution::Hooks
4
+ EVENTS = %i[
5
+ worker_process_start
6
+ mutation_insert_pre
7
+ mutation_insert_post
8
+ setup_integration_pre
9
+ setup_integration_post
10
+ ].freeze
11
+
12
+ def initialize
13
+ @handlers = EVENTS.to_h { |event| [event, []] }
14
+ end
15
+
16
+ def register(event, &block)
17
+ validate_event!(event)
18
+ raise ArgumentError, "a block must be provided when registering handler for #{event}" unless block
19
+
20
+ @handlers[event] << block
21
+ self
22
+ end
23
+
24
+ def fire(event, **payload)
25
+ validate_event!(event)
26
+ @handlers[event].each { |handler| handler.call(payload) }
27
+ end
28
+
29
+ def clear(event = nil)
30
+ if event
31
+ validate_event!(event)
32
+ @handlers[event].clear
33
+ else
34
+ @handlers.each_value(&:clear)
35
+ end
36
+ end
37
+
38
+ def handlers_for(event)
39
+ validate_event!(event)
40
+ @handlers[event].dup
41
+ end
42
+
43
+ def self.from_config(config_hooks)
44
+ hooks = new
45
+ return hooks if config_hooks.nil? || config_hooks.empty?
46
+
47
+ config_hooks.each do |event, callables|
48
+ Array(callables).each { |callable| hooks.register(event) { |payload| callable.call(payload) } }
49
+ end
50
+ hooks
51
+ end
52
+
53
+ private
54
+
55
+ def validate_event!(event)
56
+ raise ArgumentError, "unknown hook event: #{event}" unless EVENTS.include?(event)
57
+ end
58
+ end
@@ -3,6 +3,10 @@
3
3
  require_relative "../integration"
4
4
 
5
5
  class Evilution::Integration::Base
6
+ def initialize(hooks: nil)
7
+ @hooks = hooks
8
+ end
9
+
6
10
  def call(mutation)
7
11
  raise NotImplementedError, "#{self.class}#call must be implemented"
8
12
  end
@@ -9,10 +9,10 @@ require_relative "../spec_resolver"
9
9
  require_relative "../integration"
10
10
 
11
11
  class Evilution::Integration::RSpec < Evilution::Integration::Base
12
- def initialize(test_files: nil)
12
+ def initialize(test_files: nil, hooks: nil)
13
13
  @test_files = test_files
14
14
  @rspec_loaded = false
15
- super()
15
+ super(hooks: hooks)
16
16
  end
17
17
 
18
18
  def call(mutation)
@@ -20,7 +20,9 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
20
20
  @temp_dir = nil
21
21
  @lock_file = nil
22
22
  ensure_rspec_loaded
23
+ @hooks.fire(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path) if @hooks
23
24
  apply_mutation(mutation)
25
+ @hooks.fire(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path) if @hooks
24
26
  run_rspec(mutation)
25
27
  ensure
26
28
  restore_original(mutation)
@@ -33,8 +35,10 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
33
35
  def ensure_rspec_loaded
34
36
  return if @rspec_loaded
35
37
 
38
+ @hooks.fire(:setup_integration_pre, integration: :rspec) if @hooks
36
39
  require "rspec/core"
37
40
  @rspec_loaded = true
41
+ @hooks.fire(:setup_integration_post, integration: :rspec) if @hooks
38
42
  rescue LoadError => e
39
43
  raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
40
44
  end
@@ -9,6 +9,10 @@ require_relative "../isolation"
9
9
  class Evilution::Isolation::Fork
10
10
  GRACE_PERIOD = 2
11
11
 
12
+ def initialize(hooks: nil)
13
+ @hooks = hooks
14
+ end
15
+
12
16
  def call(mutation:, test_command:, timeout:)
13
17
  sandbox_dir = Dir.mktmpdir("evilution-run")
14
18
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -18,6 +22,7 @@ class Evilution::Isolation::Fork
18
22
  ENV["TMPDIR"] = sandbox_dir
19
23
  read_io.close
20
24
  suppress_child_output
25
+ @hooks.fire(:worker_process_start, mutation: mutation) if @hooks
21
26
  result = execute_in_child(mutation, test_command)
22
27
  Marshal.dump(result, write_io)
23
28
  write_io.close
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "mcp"
5
5
  require_relative "../session/store"
6
+ require_relative "../session/diff"
6
7
 
7
8
  require_relative "../mcp"
8
9
 
@@ -33,7 +34,10 @@ class Evilution::MCP::SessionDiffTool < MCP::Tool
33
34
  base_data = store.load(base)
34
35
  head_data = store.load(head)
35
36
 
36
- ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(build_diff(base_data, head_data)) }])
37
+ diff = Evilution::Session::Diff.new
38
+ result = diff.call(base_data, head_data)
39
+
40
+ ::MCP::Tool::Response.new([{ type: "text", text: ::JSON.generate(result.to_h) }])
37
41
  rescue Evilution::Error => e
38
42
  error_response("not_found", e.message)
39
43
  rescue ::JSON::ParserError => e
@@ -45,40 +49,6 @@ class Evilution::MCP::SessionDiffTool < MCP::Tool
45
49
 
46
50
  private
47
51
 
48
- def build_diff(base_data, head_data)
49
- base_survivors = base_data["survived"] || []
50
- head_survivors = head_data["survived"] || []
51
-
52
- base_keys = base_survivors.to_set { |m| mutation_key(m) }
53
- head_keys = head_survivors.to_set { |m| mutation_key(m) }
54
-
55
- {
56
- "summary" => build_summary_diff(base_data, head_data),
57
- "fixed" => base_survivors.reject { |m| head_keys.include?(mutation_key(m)) },
58
- "new_survivors" => head_survivors.reject { |m| base_keys.include?(mutation_key(m)) },
59
- "persistent" => head_survivors.select { |m| base_keys.include?(mutation_key(m)) }
60
- }
61
- end
62
-
63
- def build_summary_diff(base_data, head_data)
64
- base_summary = base_data["summary"] || {}
65
- head_summary = head_data["summary"] || {}
66
- base_score = base_summary["score"] || 0.0
67
- head_score = head_summary["score"] || 0.0
68
-
69
- {
70
- "base_score" => base_score,
71
- "head_score" => head_score,
72
- "score_delta" => (head_score - base_score).round(4),
73
- "base_survived" => base_summary["survived"] || 0,
74
- "head_survived" => head_summary["survived"] || 0
75
- }
76
- end
77
-
78
- def mutation_key(mutation)
79
- [mutation["operator"], mutation["file"], mutation["line"], mutation["subject"]]
80
- end
81
-
82
52
  def error_response(type, message)
83
53
  ::MCP::Tool::Response.new(
84
54
  [{ type: "text", text: ::JSON.generate({ error: { type: type, message: message } }) }],
@@ -13,10 +13,11 @@ class Evilution::Mutator::Base < Prism::Visitor
13
13
  @file_source = nil
14
14
  end
15
15
 
16
- def call(subject)
16
+ def call(subject, filter: nil)
17
17
  @subject = subject
18
18
  @file_source = File.read(subject.file_path)
19
19
  @mutations = []
20
+ @filter = filter
20
21
  visit(subject.node)
21
22
  @mutations
22
23
  end
@@ -24,6 +25,8 @@ class Evilution::Mutator::Base < Prism::Visitor
24
25
  private
25
26
 
26
27
  def add_mutation(offset:, length:, replacement:, node:)
28
+ return if @filter && @filter.skip?(node)
29
+
27
30
  mutated_source = Evilution::AST::SourceSurgeon.apply(
28
31
  @file_source,
29
32
  offset: offset,
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::CollectionReturn < Evilution::Mutator::Base
6
+ def visit_def_node(node)
7
+ body = node.body
8
+ if body.is_a?(Prism::StatementsNode) && body.body.length > 1
9
+ return_node = body.body.last
10
+ replacement = collection_replacement(return_node)
11
+
12
+ if replacement
13
+ add_mutation(
14
+ offset: body.location.start_offset,
15
+ length: body.location.length,
16
+ replacement: replacement,
17
+ node: node
18
+ )
19
+ end
20
+ end
21
+
22
+ super
23
+ end
24
+
25
+ def collection_replacement(node)
26
+ case node
27
+ when Prism::ArrayNode
28
+ "[]" if node.elements.any?
29
+ when Prism::HashNode
30
+ "{}" if node.elements.any?
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::DefinedCheck < Evilution::Mutator::Base
6
+ def visit_defined_node(node)
7
+ add_mutation(
8
+ offset: node.location.start_offset,
9
+ length: node.location.length,
10
+ replacement: "true",
11
+ node: node
12
+ )
13
+
14
+ super
15
+ end
16
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexAssignmentRemoval < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.name == :[]= && node.receiver
8
+ add_mutation(
9
+ offset: node.location.start_offset,
10
+ length: node.location.length,
11
+ replacement: "nil",
12
+ node: node
13
+ )
14
+ end
15
+
16
+ super
17
+ end
18
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
6
+ def initialize
7
+ super
8
+ @consumed = Set.new
9
+ end
10
+
11
+ def visit_call_node(node)
12
+ if chain_head?(node)
13
+ root, args = collect_chain(node)
14
+ root_source = @file_source[root.location.start_offset, root.location.length]
15
+ arg_sources = args.map { |a| @file_source[a.location.start_offset, a.location.length] }
16
+
17
+ add_mutation(
18
+ offset: node.location.start_offset,
19
+ length: node.location.length,
20
+ replacement: "#{root_source}.dig(#{arg_sources.join(", ")})",
21
+ node: node
22
+ )
23
+ end
24
+
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def chain_head?(node)
31
+ return false if @consumed.include?(node.object_id)
32
+ return false unless single_arg_index?(node)
33
+ return false unless single_arg_index?(node.receiver)
34
+
35
+ true
36
+ end
37
+
38
+ def single_arg_index?(node)
39
+ node.is_a?(Prism::CallNode) &&
40
+ node.name == :[] &&
41
+ node.receiver &&
42
+ node.arguments &&
43
+ node.arguments.arguments.length == 1
44
+ end
45
+
46
+ def collect_chain(node)
47
+ args = []
48
+ current = node
49
+
50
+ while single_arg_index?(current)
51
+ @consumed.add(current.object_id)
52
+ args.unshift(current.arguments.arguments.first)
53
+ current = current.receiver
54
+ end
55
+
56
+ [current, args]
57
+ end
58
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if indexable?(node)
8
+ receiver_source = @file_source[node.receiver.location.start_offset, node.receiver.location.length]
9
+ arg_source = @file_source[node.arguments.location.start_offset, node.arguments.location.length]
10
+
11
+ add_mutation(
12
+ offset: node.location.start_offset,
13
+ length: node.location.length,
14
+ replacement: "#{receiver_source}.fetch(#{arg_source})",
15
+ node: node
16
+ )
17
+ end
18
+
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def indexable?(node)
25
+ node.name == :[] &&
26
+ node.receiver &&
27
+ node.arguments &&
28
+ node.arguments.arguments.length == 1
29
+ end
30
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
6
+ def visit_def_node(node)
7
+ params = node.parameters
8
+ if params
9
+ mutate_optional_keyword_defaults(params)
10
+ mutate_optional_keyword_removal(params)
11
+ mutate_keyword_rest_removal(params)
12
+ end
13
+
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def mutate_optional_keyword_defaults(params)
20
+ params.keywords.each do |kw|
21
+ next unless kw.is_a?(Prism::OptionalKeywordParameterNode)
22
+
23
+ name_loc = kw.name_loc
24
+ kw_loc = kw.location
25
+
26
+ add_mutation(
27
+ offset: kw_loc.start_offset,
28
+ length: kw_loc.length,
29
+ replacement: @file_source[name_loc.start_offset...name_loc.end_offset],
30
+ node: kw
31
+ )
32
+ end
33
+ end
34
+
35
+ def mutate_optional_keyword_removal(params)
36
+ all_params = collect_all_params(params)
37
+ return if all_params.length < 2
38
+
39
+ params.keywords.each do |kw|
40
+ next unless kw.is_a?(Prism::OptionalKeywordParameterNode)
41
+
42
+ remaining = all_params.reject { |p| p.equal?(kw) }
43
+ replacement = remaining.map(&:slice).join(", ")
44
+
45
+ add_mutation(
46
+ offset: params.location.start_offset,
47
+ length: params.location.length,
48
+ replacement: replacement,
49
+ node: kw
50
+ )
51
+ end
52
+ end
53
+
54
+ def mutate_keyword_rest_removal(params)
55
+ kr = params.keyword_rest
56
+ return unless kr.is_a?(Prism::KeywordRestParameterNode)
57
+
58
+ all_params = collect_all_params(params)
59
+
60
+ 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
+ )
67
+ 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
+ )
77
+ end
78
+ end
79
+
80
+ 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
90
+ end
91
+ end
@@ -7,10 +7,11 @@ require_relative "../operator"
7
7
  class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
8
8
  MIXIN_METHODS = %i[include extend prepend].freeze
9
9
 
10
- def call(subject)
10
+ def call(subject, filter: nil)
11
11
  @subject = subject
12
12
  @file_source = File.read(subject.file_path)
13
13
  @mutations = []
14
+ @filter = filter
14
15
 
15
16
  tree = self.class.parsed_tree_for(subject.file_path, @file_source)
16
17
  enclosing = find_enclosing_scope(tree, subject.line_number)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::MultipleAssignment < Evilution::Mutator::Base
6
+ def visit_multi_write_node(node)
7
+ lefts = node.lefts
8
+ values = node.value.is_a?(Prism::ArrayNode) ? node.value.elements : nil
9
+
10
+ if values && lefts.length == values.length && lefts.length >= 2 && node.rest.nil?
11
+ mutate_target_removal(node, lefts, values)
12
+ mutate_swap(node, lefts, values) if lefts.length == 2
13
+ end
14
+
15
+ super
16
+ end
17
+
18
+ private
19
+
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
34
+ end
35
+
36
+ def mutate_swap(node, lefts, values)
37
+ swapped_lefts = "#{lefts[1].slice}, #{lefts[0].slice}"
38
+ replacement = "#{swapped_lefts} = #{values.map(&:slice).join(", ")}"
39
+
40
+ add_mutation(
41
+ offset: node.location.start_offset,
42
+ length: node.location.length,
43
+ replacement: replacement,
44
+ node: node
45
+ )
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::PatternMatchingAlternative < Evilution::Mutator::Base
6
+ def visit_alternation_pattern_node(node)
7
+ remove_left(node)
8
+ remove_right(node)
9
+ swap_order(node)
10
+ super
11
+ end
12
+
13
+ private
14
+
15
+ def remove_left(node)
16
+ add_mutation(
17
+ offset: node.location.start_offset,
18
+ length: node.location.length,
19
+ replacement: source_for(node.right),
20
+ node: node
21
+ )
22
+ end
23
+
24
+ def remove_right(node)
25
+ add_mutation(
26
+ offset: node.location.start_offset,
27
+ length: node.location.length,
28
+ replacement: source_for(node.left),
29
+ node: node
30
+ )
31
+ end
32
+
33
+ def swap_order(node)
34
+ operator = @file_source.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
35
+ add_mutation(
36
+ offset: node.location.start_offset,
37
+ length: node.location.length,
38
+ replacement: "#{source_for(node.right)} #{operator} #{source_for(node.left)}",
39
+ node: node
40
+ )
41
+ end
42
+
43
+ def source_for(node)
44
+ @file_source.byteslice(node.location.start_offset, node.location.length)
45
+ end
46
+ end