evilution 0.13.0 → 0.15.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.migration-hint-ts +1 -1
  3. data/.beads/issues.jsonl +17 -17
  4. data/CHANGELOG.md +39 -0
  5. data/lib/evilution/ast/inheritance_scanner.rb +70 -0
  6. data/lib/evilution/ast/parser.rb +73 -68
  7. data/lib/evilution/ast/source_surgeon.rb +7 -9
  8. data/lib/evilution/ast.rb +4 -0
  9. data/lib/evilution/baseline.rb +73 -75
  10. data/lib/evilution/cache.rb +75 -77
  11. data/lib/evilution/cli.rb +412 -173
  12. data/lib/evilution/config.rb +141 -136
  13. data/lib/evilution/equivalent/detector.rb +29 -27
  14. data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
  15. data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
  16. data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
  17. data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
  18. data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
  19. data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
  20. data/lib/evilution/equivalent/heuristic.rb +6 -0
  21. data/lib/evilution/equivalent.rb +4 -0
  22. data/lib/evilution/git/changed_files.rb +35 -37
  23. data/lib/evilution/git.rb +4 -0
  24. data/lib/evilution/integration/base.rb +5 -7
  25. data/lib/evilution/integration/rspec.rb +114 -116
  26. data/lib/evilution/integration.rb +4 -0
  27. data/lib/evilution/isolation/fork.rb +98 -100
  28. data/lib/evilution/isolation/in_process.rb +59 -61
  29. data/lib/evilution/isolation.rb +4 -0
  30. data/lib/evilution/mcp/mutate_tool.rb +172 -143
  31. data/lib/evilution/mcp/server.rb +12 -11
  32. data/lib/evilution/mcp/session_diff_tool.rb +89 -0
  33. data/lib/evilution/mcp/session_list_tool.rb +46 -0
  34. data/lib/evilution/mcp/session_show_tool.rb +53 -0
  35. data/lib/evilution/mcp.rb +4 -0
  36. data/lib/evilution/memory/leak_check.rb +80 -84
  37. data/lib/evilution/memory.rb +34 -36
  38. data/lib/evilution/mutation.rb +40 -42
  39. data/lib/evilution/mutator/base.rb +62 -48
  40. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
  41. data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
  42. data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
  43. data/lib/evilution/mutator/operator/array_literal.rb +18 -22
  44. data/lib/evilution/mutator/operator/block_removal.rb +16 -20
  45. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
  46. data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
  47. data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
  48. data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
  49. data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
  50. data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
  51. data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
  52. data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
  53. data/lib/evilution/mutator/operator/float_literal.rb +22 -26
  54. data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
  55. data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
  56. data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
  57. data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
  58. data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
  59. data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
  60. data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
  61. data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
  62. data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
  63. data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
  64. data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
  65. data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
  66. data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
  67. data/lib/evilution/mutator/operator/string_literal.rb +18 -22
  68. data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
  69. data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
  70. data/lib/evilution/mutator/operator.rb +6 -0
  71. data/lib/evilution/mutator/registry.rb +56 -56
  72. data/lib/evilution/mutator.rb +4 -0
  73. data/lib/evilution/parallel/pool.rb +56 -58
  74. data/lib/evilution/parallel.rb +4 -0
  75. data/lib/evilution/reporter/cli.rb +99 -101
  76. data/lib/evilution/reporter/html.rb +242 -244
  77. data/lib/evilution/reporter/json.rb +57 -59
  78. data/lib/evilution/reporter/suggestion.rb +354 -328
  79. data/lib/evilution/reporter.rb +4 -0
  80. data/lib/evilution/result/mutation_result.rb +43 -46
  81. data/lib/evilution/result/summary.rb +80 -81
  82. data/lib/evilution/result.rb +4 -0
  83. data/lib/evilution/runner.rb +401 -316
  84. data/lib/evilution/session/store.rb +147 -0
  85. data/lib/evilution/session.rb +4 -0
  86. data/lib/evilution/spec_resolver.rb +49 -47
  87. data/lib/evilution/subject.rb +14 -16
  88. data/lib/evilution/version.rb +1 -1
  89. data/lib/evilution.rb +16 -0
  90. metadata +24 -2
@@ -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
@@ -1,22 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class NegationInsertion < Base
7
- def visit_call_node(node)
8
- if node.name.to_s.end_with?("?")
9
- add_mutation(
10
- offset: node.location.start_offset,
11
- length: 0,
12
- replacement: "!",
13
- node: node
14
- )
15
- end
3
+ require_relative "../operator"
16
4
 
17
- super
18
- end
19
- end
5
+ class Evilution::Mutator::Operator::NegationInsertion < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.name.to_s.end_with?("?")
8
+ add_mutation(
9
+ offset: node.location.start_offset,
10
+ length: 0,
11
+ replacement: "!",
12
+ node: node
13
+ )
20
14
  end
15
+
16
+ super
21
17
  end
22
18
  end
@@ -1,24 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class NilReplacement < Base
7
- REPLACEMENTS = %w[true false 0 ""].freeze
3
+ require_relative "../operator"
8
4
 
9
- def visit_nil_node(node)
10
- REPLACEMENTS.each do |replacement|
11
- add_mutation(
12
- offset: node.location.start_offset,
13
- length: node.location.length,
14
- replacement: replacement,
15
- node: node
16
- )
17
- end
5
+ class Evilution::Mutator::Operator::NilReplacement < Evilution::Mutator::Base
6
+ REPLACEMENTS = %w[true false 0 ""].freeze
18
7
 
19
- super
20
- end
21
- end
8
+ def visit_nil_node(node)
9
+ REPLACEMENTS.each do |replacement|
10
+ add_mutation(
11
+ offset: node.location.start_offset,
12
+ length: node.location.length,
13
+ replacement: replacement,
14
+ node: node
15
+ )
22
16
  end
17
+
18
+ super
23
19
  end
24
20
  end
@@ -1,22 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class RangeReplacement < Base
7
- def visit_range_node(node)
8
- replacement = node.operator == ".." ? "..." : ".."
3
+ require_relative "../operator"
9
4
 
10
- add_mutation(
11
- offset: node.operator_loc.start_offset,
12
- length: node.operator_loc.length,
13
- replacement: replacement,
14
- node: node
15
- )
5
+ class Evilution::Mutator::Operator::RangeReplacement < Evilution::Mutator::Base
6
+ def visit_range_node(node)
7
+ replacement = node.operator == ".." ? "..." : ".."
16
8
 
17
- super
18
- end
19
- end
20
- end
9
+ add_mutation(
10
+ offset: node.operator_loc.start_offset,
11
+ length: node.operator_loc.length,
12
+ replacement: replacement,
13
+ node: node
14
+ )
15
+
16
+ super
21
17
  end
22
18
  end
@@ -1,27 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class ReceiverReplacement < Base
7
- def visit_call_node(node)
8
- if node.receiver.is_a?(Prism::SelfNode)
9
- call_without_self = @file_source.byteslice(
10
- node.message_loc.start_offset,
11
- node.location.start_offset + node.location.length - node.message_loc.start_offset
12
- )
3
+ require_relative "../operator"
13
4
 
14
- add_mutation(
15
- offset: node.location.start_offset,
16
- length: node.location.length,
17
- replacement: call_without_self,
18
- node: node
19
- )
20
- end
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
+ )
21
12
 
22
- super
23
- end
24
- end
13
+ add_mutation(
14
+ offset: node.location.start_offset,
15
+ length: node.location.length,
16
+ replacement: call_without_self,
17
+ node: node
18
+ )
25
19
  end
20
+
21
+ super
26
22
  end
27
23
  end
@@ -1,27 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class RegexpMutation < Base
7
- NEVER_MATCH = 'a\A'
8
- ALWAYS_MATCH = ".*"
3
+ require_relative "../operator"
9
4
 
10
- REPLACEMENTS = [NEVER_MATCH, ALWAYS_MATCH].freeze
5
+ class Evilution::Mutator::Operator::RegexpMutation < Evilution::Mutator::Base
6
+ NEVER_MATCH = 'a\A'
7
+ ALWAYS_MATCH = ".*"
11
8
 
12
- def visit_regular_expression_node(node)
13
- REPLACEMENTS.each do |replacement|
14
- add_mutation(
15
- offset: node.content_loc.start_offset,
16
- length: node.content_loc.length,
17
- replacement: replacement,
18
- node: node
19
- )
20
- end
9
+ REPLACEMENTS = [NEVER_MATCH, ALWAYS_MATCH].freeze
21
10
 
22
- super
23
- end
24
- end
11
+ def visit_regular_expression_node(node)
12
+ REPLACEMENTS.each do |replacement|
13
+ add_mutation(
14
+ offset: node.content_loc.start_offset,
15
+ length: node.content_loc.length,
16
+ replacement: replacement,
17
+ node: node
18
+ )
25
19
  end
20
+
21
+ super
26
22
  end
27
23
  end
@@ -1,22 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class ReturnValueRemoval < Base
7
- def visit_return_node(node)
8
- if node.arguments
9
- add_mutation(
10
- offset: node.location.start_offset,
11
- length: node.location.length,
12
- replacement: "return",
13
- node: node
14
- )
15
- end
3
+ require_relative "../operator"
16
4
 
17
- super
18
- end
19
- end
5
+ class Evilution::Mutator::Operator::ReturnValueRemoval < Evilution::Mutator::Base
6
+ def visit_return_node(node)
7
+ if node.arguments
8
+ add_mutation(
9
+ offset: node.location.start_offset,
10
+ length: node.location.length,
11
+ replacement: "return",
12
+ node: node
13
+ )
20
14
  end
15
+
16
+ super
21
17
  end
22
18
  end
@@ -1,49 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class SendMutation < Base
7
- REPLACEMENTS = {
8
- flat_map: [:map],
9
- map: [:flat_map],
10
- collect: [:map],
11
- public_send: [:send],
12
- send: [:public_send],
13
- gsub: [:sub],
14
- sub: [:gsub],
15
- detect: [:find],
16
- find: [:detect],
17
- each_with_object: [:inject],
18
- inject: [:each_with_object],
19
- reverse_each: [:each],
20
- each: [:reverse_each],
21
- length: [:size],
22
- size: [:length],
23
- values_at: [:fetch_values],
24
- fetch_values: [:values_at]
25
- }.freeze
3
+ require_relative "../operator"
26
4
 
27
- def visit_call_node(node)
28
- replacements = REPLACEMENTS[node.name]
29
- return super unless replacements
30
- return super unless node.receiver
5
+ class Evilution::Mutator::Operator::SendMutation < Evilution::Mutator::Base
6
+ REPLACEMENTS = {
7
+ flat_map: [:map],
8
+ map: [:flat_map],
9
+ collect: [:map],
10
+ public_send: [:send],
11
+ send: [:public_send],
12
+ gsub: [:sub],
13
+ sub: [:gsub],
14
+ detect: [:find],
15
+ find: [:detect],
16
+ each_with_object: [:inject],
17
+ inject: [:each_with_object],
18
+ reverse_each: [:each],
19
+ each: [:reverse_each],
20
+ length: [:size],
21
+ size: [:length],
22
+ values_at: [:fetch_values],
23
+ fetch_values: [:values_at]
24
+ }.freeze
31
25
 
32
- loc = node.message_loc
33
- return super unless loc
26
+ def visit_call_node(node)
27
+ replacements = REPLACEMENTS[node.name]
28
+ return super unless replacements
29
+ return super unless node.receiver
34
30
 
35
- replacements.each do |replacement|
36
- add_mutation(
37
- offset: loc.start_offset,
38
- length: loc.length,
39
- replacement: replacement.to_s,
40
- node: node
41
- )
42
- end
31
+ loc = node.message_loc
32
+ return super unless loc
43
33
 
44
- super
45
- end
46
- end
34
+ replacements.each do |replacement|
35
+ add_mutation(
36
+ offset: loc.start_offset,
37
+ length: loc.length,
38
+ replacement: replacement.to_s,
39
+ node: node
40
+ )
47
41
  end
42
+
43
+ super
48
44
  end
49
45
  end
@@ -1,24 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class StatementDeletion < Base
7
- def visit_statements_node(node)
8
- if node.body.length > 1
9
- node.body.each do |child|
10
- add_mutation(
11
- offset: child.location.start_offset,
12
- length: child.location.length,
13
- replacement: "",
14
- node: child
15
- )
16
- end
17
- end
3
+ require_relative "../operator"
18
4
 
19
- super
20
- end
5
+ class Evilution::Mutator::Operator::StatementDeletion < Evilution::Mutator::Base
6
+ def visit_statements_node(node)
7
+ if node.body.length > 1
8
+ node.body.each do |child|
9
+ add_mutation(
10
+ offset: child.location.start_offset,
11
+ length: child.location.length,
12
+ replacement: "",
13
+ node: child
14
+ )
21
15
  end
22
16
  end
17
+
18
+ super
23
19
  end
24
20
  end
@@ -1,29 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class StringLiteral < Base
7
- def visit_string_node(node)
8
- replacement = node.content.empty? ? '"mutation"' : '""'
3
+ require_relative "../operator"
9
4
 
10
- add_mutation(
11
- offset: node.location.start_offset,
12
- length: node.location.length,
13
- replacement: replacement,
14
- node: node
15
- )
5
+ class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
6
+ def visit_string_node(node)
7
+ replacement = node.content.empty? ? '"mutation"' : '""'
16
8
 
17
- add_mutation(
18
- offset: node.location.start_offset,
19
- length: node.location.length,
20
- replacement: "nil",
21
- node: node
22
- )
9
+ add_mutation(
10
+ offset: node.location.start_offset,
11
+ length: node.location.length,
12
+ replacement: replacement,
13
+ node: node
14
+ )
23
15
 
24
- super
25
- end
26
- end
27
- end
16
+ add_mutation(
17
+ offset: node.location.start_offset,
18
+ length: node.location.length,
19
+ replacement: "nil",
20
+ node: node
21
+ )
22
+
23
+ super
28
24
  end
29
25
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../operator"
6
+
7
+ class Evilution::Mutator::Operator::SuperclassRemoval < Evilution::Mutator::Base
8
+ def call(subject)
9
+ @subject = subject
10
+ @file_source = File.read(subject.file_path)
11
+ @mutations = []
12
+
13
+ tree = self.class.parsed_tree_for(subject.file_path, @file_source)
14
+ enclosing = find_enclosing_class(tree, subject.line_number)
15
+ return @mutations unless enclosing
16
+ return @mutations unless enclosing.superclass
17
+
18
+ first_method_line = find_first_method_line(enclosing)
19
+ return @mutations unless first_method_line == subject.line_number
20
+
21
+ name_end = enclosing.constant_path.location.start_offset + enclosing.constant_path.location.length
22
+ superclass_end = enclosing.superclass.location.start_offset + enclosing.superclass.location.length
23
+
24
+ add_mutation(
25
+ offset: name_end,
26
+ length: superclass_end - name_end,
27
+ replacement: "",
28
+ node: enclosing
29
+ )
30
+
31
+ @mutations
32
+ end
33
+
34
+ private
35
+
36
+ def find_enclosing_class(tree, target_line)
37
+ finder = ClassFinder.new(target_line)
38
+ finder.visit(tree)
39
+ finder.result
40
+ end
41
+
42
+ def find_first_method_line(class_node)
43
+ return nil unless class_node.body
44
+
45
+ class_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
+ # Visitor to find the ClassNode enclosing a given line number.
52
+ class ClassFinder < Prism::Visitor
53
+ attr_reader :result
54
+
55
+ def initialize(target_line)
56
+ @target_line = target_line
57
+ @result = nil
58
+ end
59
+
60
+ def visit_class_node(node)
61
+ @result = node if @target_line.between?(node.location.start_line, node.location.end_line)
62
+ super
63
+ end
64
+ end
65
+ end
@@ -1,27 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Evilution
4
- module Mutator
5
- module Operator
6
- class SymbolLiteral < Base
7
- def visit_symbol_node(node)
8
- add_mutation(
9
- offset: node.location.start_offset,
10
- length: node.location.length,
11
- replacement: ":__evilution_mutated__",
12
- node: node
13
- )
3
+ require_relative "../operator"
14
4
 
15
- add_mutation(
16
- offset: node.location.start_offset,
17
- length: node.location.length,
18
- replacement: "nil",
19
- node: node
20
- )
5
+ class Evilution::Mutator::Operator::SymbolLiteral < Evilution::Mutator::Base
6
+ def visit_symbol_node(node)
7
+ add_mutation(
8
+ offset: node.location.start_offset,
9
+ length: node.location.length,
10
+ replacement: ":__evilution_mutated__",
11
+ node: node
12
+ )
21
13
 
22
- super
23
- end
24
- end
25
- end
14
+ add_mutation(
15
+ offset: node.location.start_offset,
16
+ length: node.location.length,
17
+ replacement: "nil",
18
+ node: node
19
+ )
20
+
21
+ super
26
22
  end
27
23
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutator"
4
+
5
+ module Evilution::Mutator::Operator
6
+ end