evilution 0.14.0 → 0.16.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +41 -41
  4. data/CHANGELOG.md +43 -0
  5. data/lib/evilution/ast/inheritance_scanner.rb +70 -0
  6. data/lib/evilution/ast/parser.rb +10 -6
  7. data/lib/evilution/cli.rb +6 -1
  8. data/lib/evilution/config.rb +7 -1
  9. data/lib/evilution/equivalent/detector.rb +5 -1
  10. data/lib/evilution/equivalent/heuristic/alias_swap.rb +5 -2
  11. data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
  12. data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
  13. data/lib/evilution/mutator/base.rb +16 -0
  14. data/lib/evilution/mutator/operator/bang_method.rb +48 -0
  15. data/lib/evilution/mutator/operator/bitwise_complement.rb +31 -0
  16. data/lib/evilution/mutator/operator/bitwise_replacement.rb +30 -0
  17. data/lib/evilution/mutator/operator/break_statement.rb +50 -0
  18. data/lib/evilution/mutator/operator/class_variable_write.rb +25 -0
  19. data/lib/evilution/mutator/operator/collection_replacement.rb +25 -1
  20. data/lib/evilution/mutator/operator/ensure_removal.rb +27 -0
  21. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +47 -0
  22. data/lib/evilution/mutator/operator/global_variable_write.rb +25 -0
  23. data/lib/evilution/mutator/operator/inline_rescue.rb +39 -0
  24. data/lib/evilution/mutator/operator/instance_variable_write.rb +25 -0
  25. data/lib/evilution/mutator/operator/local_variable_assignment.rb +16 -0
  26. data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
  27. data/lib/evilution/mutator/operator/next_statement.rb +50 -0
  28. data/lib/evilution/mutator/operator/redo_statement.rb +18 -0
  29. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +94 -0
  30. data/lib/evilution/mutator/operator/rescue_removal.rb +37 -0
  31. data/lib/evilution/mutator/operator/send_mutation.rb +11 -2
  32. data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
  33. data/lib/evilution/mutator/operator/zsuper_removal.rb +16 -0
  34. data/lib/evilution/mutator/registry.rb +19 -1
  35. data/lib/evilution/reporter/progress_bar.rb +84 -0
  36. data/lib/evilution/reporter/suggestion.rb +253 -1
  37. data/lib/evilution/runner.rb +105 -19
  38. data/lib/evilution/version.rb +1 -1
  39. data/lib/evilution.rb +20 -0
  40. metadata +24 -2
@@ -4,6 +4,8 @@ require_relative "heuristic/noop_source"
4
4
  require_relative "heuristic/method_body_nil"
5
5
  require_relative "heuristic/alias_swap"
6
6
  require_relative "heuristic/dead_code"
7
+ require_relative "heuristic/arithmetic_identity"
8
+ require_relative "heuristic/comment_marking"
7
9
 
8
10
  require_relative "../equivalent"
9
11
 
@@ -34,7 +36,9 @@ class Evilution::Equivalent::Detector
34
36
  Evilution::Equivalent::Heuristic::NoopSource.new,
35
37
  Evilution::Equivalent::Heuristic::MethodBodyNil.new,
36
38
  Evilution::Equivalent::Heuristic::AliasSwap.new,
37
- Evilution::Equivalent::Heuristic::DeadCode.new
39
+ Evilution::Equivalent::Heuristic::DeadCode.new,
40
+ Evilution::Equivalent::Heuristic::ArithmeticIdentity.new,
41
+ Evilution::Equivalent::Heuristic::CommentMarking.new
38
42
  ]
39
43
  end
40
44
  end
@@ -6,11 +6,14 @@ class Evilution::Equivalent::Heuristic::AliasSwap
6
6
  ALIAS_PAIRS = Set[
7
7
  Set[:detect, :find],
8
8
  Set[:length, :size],
9
- Set[:collect, :map]
9
+ Set[:collect, :map],
10
+ Set[:count, :length]
10
11
  ].freeze
11
12
 
13
+ MATCHING_OPERATORS = Set["send_mutation", "collection_replacement"].freeze
14
+
12
15
  def match?(mutation)
13
- return false unless mutation.operator_name == "send_mutation"
16
+ return false unless MATCHING_OPERATORS.include?(mutation.operator_name)
14
17
 
15
18
  diff = mutation.diff
16
19
  removed = extract_method(diff, "- ")
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../heuristic"
4
+
5
+ class Evilution::Equivalent::Heuristic::ArithmeticIdentity
6
+ # Patterns where the original expression is an arithmetic identity operation.
7
+ # "x + 0" is identity (equals x), so mutating the 0 to something else means
8
+ # the original was a no-op — if the test doesn't catch it, it's likely equivalent.
9
+ ADDITIVE_IDENTITY = /[\w)\].]+\s*[+-]\s*0\b|\b0\s*\+\s*[\w(\[]/
10
+ MULTIPLICATIVE_IDENTITY = %r{[\w)\].]+\s*[*/]\s*1\b|\b1\s*\*\s*[\w(\[]}
11
+ EXPONENT_IDENTITY = /[\w)\].]+\s*\*\*\s*1\b/
12
+
13
+ def match?(mutation)
14
+ return false unless mutation.operator_name == "integer_literal"
15
+
16
+ removed = diff_line(mutation.diff, "- ")
17
+ return false unless removed
18
+
19
+ content = removed.sub(/^- /, "")
20
+ content.match?(ADDITIVE_IDENTITY) ||
21
+ content.match?(MULTIPLICATIVE_IDENTITY) ||
22
+ content.match?(EXPONENT_IDENTITY)
23
+ end
24
+
25
+ private
26
+
27
+ def diff_line(diff, prefix)
28
+ diff.split("\n").find { |l| l.start_with?(prefix) }
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../heuristic"
4
+
5
+ class Evilution::Equivalent::Heuristic::CommentMarking
6
+ MARKER = /#\s*evilution:equivalent\b/
7
+
8
+ def match?(mutation)
9
+ source = mutation.original_source
10
+ return false unless source
11
+
12
+ lines = source.lines
13
+ line_index = mutation.line - 1
14
+ return false if line_index.negative? || line_index >= lines.length
15
+
16
+ return true if lines[line_index].match?(MARKER)
17
+ return true if line_index.positive? && lines[line_index - 1].match?(MARKER)
18
+
19
+ false
20
+ end
21
+ end
@@ -49,4 +49,20 @@ class Evilution::Mutator::Base < Prism::Visitor
49
49
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
50
50
  .downcase
51
51
  end
52
+
53
+ @parse_cache = {}
54
+
55
+ def self.parsed_tree_for(file_path, file_source)
56
+ cache = Evilution::Mutator::Base.instance_variable_get(:@parse_cache)
57
+ entry = cache[file_path]
58
+ return entry[:tree] if entry && entry[:source_hash] == file_source.hash
59
+
60
+ tree = Prism.parse(file_source).value
61
+ cache[file_path] = { source_hash: file_source.hash, tree: tree }
62
+ tree
63
+ end
64
+
65
+ def self.clear_parse_cache!
66
+ Evilution::Mutator::Base.instance_variable_set(:@parse_cache, {})
67
+ end
52
68
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BangMethod < Evilution::Mutator::Base
6
+ KNOWN_BANG_PAIRS = %i[
7
+ sort map collect select reject uniq compact flatten
8
+ shuffle reverse slice gsub sub strip chomp chop squeeze
9
+ delete encode merge update save
10
+ ].to_set.freeze
11
+
12
+ def visit_call_node(node)
13
+ return super unless node.receiver
14
+
15
+ loc = node.message_loc
16
+ return super unless loc
17
+
18
+ name = node.name.to_s
19
+
20
+ if name.end_with?("!")
21
+ generate_non_bang(node, loc, name)
22
+ elsif KNOWN_BANG_PAIRS.include?(node.name)
23
+ generate_bang(node, loc, name)
24
+ end
25
+
26
+ super
27
+ end
28
+
29
+ private
30
+
31
+ def generate_non_bang(node, loc, name)
32
+ add_mutation(
33
+ offset: loc.start_offset,
34
+ length: loc.length,
35
+ replacement: name.chomp("!"),
36
+ node: node
37
+ )
38
+ end
39
+
40
+ def generate_bang(node, loc, name)
41
+ add_mutation(
42
+ offset: loc.start_offset,
43
+ length: loc.length,
44
+ replacement: "#{name}!",
45
+ node: node
46
+ )
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BitwiseComplement < Evilution::Mutator::Base
6
+ def visit_call_node(node)
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 = @file_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
+ )
27
+ end
28
+
29
+ super
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BitwiseReplacement < Evilution::Mutator::Base
6
+ REPLACEMENTS = {
7
+ :& => %i[| ^],
8
+ :| => %i[& ^],
9
+ :^ => %i[& |]
10
+ }.freeze
11
+
12
+ def visit_call_node(node)
13
+ replacements = REPLACEMENTS[node.name]
14
+ return super unless replacements
15
+
16
+ loc = node.message_loc
17
+ return super unless loc
18
+
19
+ replacements.each do |replacement|
20
+ add_mutation(
21
+ offset: loc.start_offset,
22
+ length: loc.length,
23
+ replacement: replacement.to_s,
24
+ node: node
25
+ )
26
+ end
27
+
28
+ super
29
+ end
30
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BreakStatement < Evilution::Mutator::Base
6
+ def visit_break_node(node)
7
+ generate_removal(node)
8
+ generate_nil_value(node)
9
+ generate_next_swap(node)
10
+
11
+ super
12
+ end
13
+
14
+ private
15
+
16
+ def generate_removal(node)
17
+ loc = node.location
18
+
19
+ add_mutation(
20
+ offset: loc.start_offset,
21
+ length: loc.length,
22
+ replacement: "nil",
23
+ node: node
24
+ )
25
+ end
26
+
27
+ def generate_nil_value(node)
28
+ return if node.arguments.nil?
29
+
30
+ args_loc = node.arguments.location
31
+
32
+ add_mutation(
33
+ offset: args_loc.start_offset,
34
+ length: args_loc.length,
35
+ replacement: "nil",
36
+ node: node
37
+ )
38
+ end
39
+
40
+ def generate_next_swap(node)
41
+ keyword_loc = node.keyword_loc
42
+
43
+ add_mutation(
44
+ offset: keyword_loc.start_offset,
45
+ length: keyword_loc.length,
46
+ replacement: "next",
47
+ node: node
48
+ )
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::ClassVariableWrite < Evilution::Mutator::Base
6
+ def visit_class_variable_write_node(node)
7
+ # Mutation 1: remove assignment, keep only the value expression
8
+ add_mutation(
9
+ offset: node.location.start_offset,
10
+ length: node.location.length,
11
+ replacement: node.value.slice,
12
+ node: node
13
+ )
14
+
15
+ # Mutation 2: replace value with nil
16
+ add_mutation(
17
+ offset: node.value.location.start_offset,
18
+ length: node.value.location.length,
19
+ replacement: "nil",
20
+ node: node
21
+ )
22
+
23
+ super
24
+ end
25
+ end
@@ -17,7 +17,31 @@ class Evilution::Mutator::Operator::CollectionReplacement < Evilution::Mutator::
17
17
  any?: [:all?],
18
18
  all?: [:any?],
19
19
  count: [:length],
20
- length: [:count]
20
+ length: [:count],
21
+ pop: [:shift],
22
+ shift: [:pop],
23
+ push: [:unshift],
24
+ unshift: [:push],
25
+ each_key: [:each_value],
26
+ each_value: [:each_key],
27
+ assoc: [:rassoc],
28
+ rassoc: [:assoc],
29
+ grep: [:grep_v],
30
+ grep_v: [:grep],
31
+ take: [:drop],
32
+ drop: [:take],
33
+ min: [:max],
34
+ max: [:min],
35
+ min_by: [:max_by],
36
+ max_by: [:min_by],
37
+ compact: [:flatten],
38
+ flatten: [:compact],
39
+ zip: [:product],
40
+ product: [:zip],
41
+ first: [:last],
42
+ last: [:first],
43
+ keys: [:values],
44
+ values: [:keys]
21
45
  }.freeze
22
46
 
23
47
  def visit_call_node(node)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::EnsureRemoval < Evilution::Mutator::Base
6
+ def visit_ensure_node(node)
7
+ remove_start = line_start_after_newline(node.ensure_keyword_loc.start_offset)
8
+ remove_end = line_start_after_newline(node.end_keyword_loc.start_offset)
9
+
10
+ add_mutation(
11
+ offset: remove_start,
12
+ length: remove_end - remove_start,
13
+ replacement: "",
14
+ node: node
15
+ )
16
+
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ def line_start_after_newline(offset)
23
+ pos = offset
24
+ pos -= 1 while pos.positive? && @file_source[pos - 1] != "\n"
25
+ pos
26
+ end
27
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::ExplicitSuperMutation < Evilution::Mutator::Base
6
+ def visit_super_node(node)
7
+ replace_with_zsuper(node)
8
+ args = node.arguments&.arguments
9
+ mutate_arguments(node, args) if args && !args.empty?
10
+
11
+ super
12
+ end
13
+
14
+ private
15
+
16
+ def replace_with_zsuper(node)
17
+ add_mutation(
18
+ offset: node.location.start_offset,
19
+ length: node.location.length,
20
+ replacement: "super",
21
+ node: node
22
+ )
23
+ end
24
+
25
+ def mutate_arguments(node, args)
26
+ # Remove all arguments: super(a, b) -> super()
27
+ add_mutation(
28
+ offset: node.arguments.location.start_offset,
29
+ length: node.arguments.location.length,
30
+ replacement: "",
31
+ node: node
32
+ )
33
+
34
+ return unless args.length >= 2
35
+
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
46
+ end
47
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::GlobalVariableWrite < Evilution::Mutator::Base
6
+ def visit_global_variable_write_node(node)
7
+ # Mutation 1: remove assignment, keep only the value expression
8
+ add_mutation(
9
+ offset: node.location.start_offset,
10
+ length: node.location.length,
11
+ replacement: node.value.slice,
12
+ node: node
13
+ )
14
+
15
+ # Mutation 2: replace value with nil
16
+ add_mutation(
17
+ offset: node.value.location.start_offset,
18
+ length: node.value.location.length,
19
+ replacement: "nil",
20
+ node: node
21
+ )
22
+
23
+ super
24
+ end
25
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::InlineRescue < Evilution::Mutator::Base
6
+ def visit_rescue_modifier_node(node)
7
+ generate_rescue_removal(node)
8
+ generate_nil_fallback(node)
9
+
10
+ super
11
+ end
12
+
13
+ private
14
+
15
+ def generate_rescue_removal(node)
16
+ expr_end = node.expression.location.start_offset + node.expression.location.length
17
+ rescue_end = node.rescue_expression.location.start_offset + node.rescue_expression.location.length
18
+
19
+ add_mutation(
20
+ offset: expr_end,
21
+ length: rescue_end - expr_end,
22
+ replacement: "",
23
+ node: node
24
+ )
25
+ end
26
+
27
+ def generate_nil_fallback(node)
28
+ return if node.rescue_expression.is_a?(Prism::NilNode)
29
+
30
+ fallback_loc = node.rescue_expression.location
31
+
32
+ add_mutation(
33
+ offset: fallback_loc.start_offset,
34
+ length: fallback_loc.length,
35
+ replacement: "nil",
36
+ node: node
37
+ )
38
+ end
39
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::InstanceVariableWrite < Evilution::Mutator::Base
6
+ def visit_instance_variable_write_node(node)
7
+ # Mutation 1: remove assignment, keep only the value expression
8
+ add_mutation(
9
+ offset: node.location.start_offset,
10
+ length: node.location.length,
11
+ replacement: node.value.slice,
12
+ node: node
13
+ )
14
+
15
+ # Mutation 2: replace value with nil
16
+ add_mutation(
17
+ offset: node.value.location.start_offset,
18
+ length: node.value.location.length,
19
+ replacement: "nil",
20
+ node: node
21
+ )
22
+
23
+ super
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::LocalVariableAssignment < Evilution::Mutator::Base
6
+ def visit_local_variable_write_node(node)
7
+ add_mutation(
8
+ offset: node.location.start_offset,
9
+ length: node.location.length,
10
+ replacement: node.value.slice,
11
+ node: node
12
+ )
13
+
14
+ super
15
+ end
16
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ class Evilution::Mutator::Operator::MixinRemoval < Evilution::Mutator::Base
8
+ MIXIN_METHODS = %i[include extend prepend].freeze
9
+
10
+ def call(subject)
11
+ @subject = subject
12
+ @file_source = File.read(subject.file_path)
13
+ @mutations = []
14
+
15
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
16
+ enclosing = find_enclosing_scope(tree, subject.line_number)
17
+ return @mutations unless enclosing
18
+
19
+ first_method_line = find_first_method_line(enclosing)
20
+ return @mutations unless first_method_line == subject.line_number
21
+
22
+ find_mixin_calls(enclosing).each do |call_node|
23
+ add_mutation(
24
+ offset: call_node.location.start_offset,
25
+ length: call_node.location.length,
26
+ replacement: "",
27
+ node: call_node
28
+ )
29
+ end
30
+
31
+ @mutations
32
+ end
33
+
34
+ private
35
+
36
+ def find_enclosing_scope(tree, target_line)
37
+ finder = ScopeFinder.new(target_line)
38
+ finder.visit(tree)
39
+ finder.result
40
+ end
41
+
42
+ def find_first_method_line(scope_node)
43
+ return nil unless scope_node.body
44
+
45
+ scope_node.body.body.each do |node|
46
+ return node.location.start_line if node.is_a?(Prism::DefNode)
47
+ end
48
+ nil
49
+ end
50
+
51
+ def find_mixin_calls(scope_node)
52
+ return [] unless scope_node.body
53
+
54
+ scope_node.body.body.select do |node|
55
+ node.is_a?(Prism::CallNode) &&
56
+ MIXIN_METHODS.include?(node.name) &&
57
+ node.receiver.nil?
58
+ end
59
+ end
60
+
61
+ # Visitor to find the ClassNode or ModuleNode enclosing a given line number.
62
+ class ScopeFinder < Prism::Visitor
63
+ attr_reader :result
64
+
65
+ def initialize(target_line)
66
+ @target_line = target_line
67
+ @result = nil
68
+ end
69
+
70
+ def visit_class_node(node)
71
+ @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
72
+ super
73
+ end
74
+
75
+ def visit_module_node(node)
76
+ @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
77
+ super
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::NextStatement < Evilution::Mutator::Base
6
+ def visit_next_node(node)
7
+ generate_removal(node)
8
+ generate_nil_value(node)
9
+ generate_break_swap(node)
10
+
11
+ super
12
+ end
13
+
14
+ private
15
+
16
+ def generate_removal(node)
17
+ loc = node.location
18
+
19
+ add_mutation(
20
+ offset: loc.start_offset,
21
+ length: loc.length,
22
+ replacement: "nil",
23
+ node: node
24
+ )
25
+ end
26
+
27
+ def generate_nil_value(node)
28
+ return if node.arguments.nil?
29
+
30
+ args_loc = node.arguments.location
31
+
32
+ add_mutation(
33
+ offset: args_loc.start_offset,
34
+ length: args_loc.length,
35
+ replacement: "nil",
36
+ node: node
37
+ )
38
+ end
39
+
40
+ def generate_break_swap(node)
41
+ keyword_loc = node.keyword_loc
42
+
43
+ add_mutation(
44
+ offset: keyword_loc.start_offset,
45
+ length: keyword_loc.length,
46
+ replacement: "break",
47
+ node: node
48
+ )
49
+ end
50
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::RedoStatement < Evilution::Mutator::Base
6
+ def visit_redo_node(node)
7
+ loc = node.location
8
+
9
+ add_mutation(
10
+ offset: loc.start_offset,
11
+ length: loc.length,
12
+ replacement: "nil",
13
+ node: node
14
+ )
15
+
16
+ super
17
+ end
18
+ end