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,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matcher"
4
+
5
+ class Evilution::AST::Pattern::Parser
6
+ def initialize(input)
7
+ @input = input.strip
8
+ @pos = 0
9
+ end
10
+
11
+ def parse
12
+ raise Evilution::ConfigError, "invalid pattern: empty string" if @input.empty?
13
+
14
+ result = parse_pattern
15
+ skip_whitespace
16
+ raise Evilution::ConfigError, "unexpected characters at position #{@pos}: #{@input[@pos..]}" unless @pos >= @input.length
17
+
18
+ result
19
+ end
20
+
21
+ private
22
+
23
+ def parse_pattern
24
+ skip_whitespace
25
+
26
+ if peek_string("**")
27
+ advance(2)
28
+ Evilution::AST::Pattern::DeepWildcardMatcher.new
29
+ elsif current_char == "_" && !identifier_continues?(1)
30
+ advance(1)
31
+ Evilution::AST::Pattern::AnyNodeMatcher.new
32
+ else
33
+ parse_node_pattern
34
+ end
35
+ end
36
+
37
+ def parse_node_pattern
38
+ node_type = consume_identifier
39
+ skip_whitespace
40
+
41
+ attributes = {}
42
+ if current_char == "{"
43
+ advance(1)
44
+ attributes = parse_attributes
45
+ skip_whitespace
46
+ expect_char("}")
47
+ end
48
+
49
+ Evilution::AST::Pattern::NodeMatcher.new(node_type, attributes)
50
+ end
51
+
52
+ def parse_attributes
53
+ attrs = {}
54
+ skip_whitespace
55
+
56
+ return attrs if current_char == "}"
57
+
58
+ loop do
59
+ skip_whitespace
60
+ name = consume_identifier
61
+ skip_whitespace
62
+ expect_char("=", "expected '=' after attribute name '#{name}'")
63
+ skip_whitespace
64
+ value = parse_value
65
+ attrs[name] = value
66
+ skip_whitespace
67
+
68
+ break unless current_char == ","
69
+
70
+ advance(1)
71
+ end
72
+
73
+ attrs
74
+ end
75
+
76
+ def parse_value
77
+ skip_whitespace
78
+
79
+ if current_char == "!"
80
+ advance(1)
81
+ skip_whitespace
82
+ inner = parse_value
83
+ Evilution::AST::Pattern::NegationMatcher.new(inner)
84
+ elsif current_char == "*" && !peek_string("**")
85
+ advance(1)
86
+ Evilution::AST::Pattern::WildcardValueMatcher.new
87
+ elsif peek_string("**")
88
+ advance(2)
89
+ Evilution::AST::Pattern::DeepWildcardMatcher.new
90
+ elsif current_char == "_" && !identifier_continues?(1)
91
+ advance(1)
92
+ Evilution::AST::Pattern::AnyNodeMatcher.new
93
+ else
94
+ parse_value_or_nested
95
+ end
96
+ end
97
+
98
+ def parse_value_or_nested
99
+ id = consume_identifier
100
+ skip_whitespace
101
+
102
+ if current_char == "{"
103
+ advance(1)
104
+ attrs = parse_attributes
105
+ skip_whitespace
106
+ expect_char("}")
107
+ Evilution::AST::Pattern::NodeMatcher.new(id, attrs)
108
+ else
109
+ parse_alternatives_from(id)
110
+ end
111
+ end
112
+
113
+ def parse_alternatives_from(first)
114
+ values = [first]
115
+
116
+ while current_char == "|"
117
+ advance(1)
118
+ skip_whitespace
119
+ if current_char == "*" && !peek_string("**")
120
+ advance(1)
121
+ values << "*"
122
+ else
123
+ values << consume_identifier
124
+ end
125
+ end
126
+
127
+ if values.length == 1
128
+ Evilution::AST::Pattern::ValueMatcher.new(first)
129
+ else
130
+ Evilution::AST::Pattern::AlternativesMatcher.new(values)
131
+ end
132
+ end
133
+
134
+ def consume_identifier
135
+ raise Evilution::ConfigError, "unexpected end of pattern at position #{@pos}" if current_char.nil?
136
+
137
+ unless current_char.match?(/[a-zA-Z_]/)
138
+ raise Evilution::ConfigError, "invalid identifier starting with '#{current_char}' at position #{@pos}"
139
+ end
140
+
141
+ start = @pos
142
+ advance(1)
143
+ advance(1) while @pos < @input.length && @input[@pos].match?(/[a-zA-Z0-9_]/)
144
+
145
+ @input[start...@pos]
146
+ end
147
+
148
+ def skip_whitespace
149
+ advance(1) while @pos < @input.length && @input[@pos] == " "
150
+ end
151
+
152
+ def current_char
153
+ @pos < @input.length ? @input[@pos] : nil
154
+ end
155
+
156
+ def peek_string(str)
157
+ @input[@pos, str.length] == str
158
+ end
159
+
160
+ def identifier_continues?(offset)
161
+ char = @input[@pos + offset]
162
+ char && char.match?(/[a-zA-Z0-9_]/)
163
+ end
164
+
165
+ def advance(n)
166
+ @pos += n
167
+ end
168
+
169
+ def expect_char(char, message = nil)
170
+ current = current_char
171
+
172
+ if current == char
173
+ advance(1)
174
+ return
175
+ end
176
+
177
+ if current.nil?
178
+ raise Evilution::ConfigError,
179
+ (message || "unexpected end of pattern, expected '#{char}' at position #{@pos}")
180
+ else
181
+ raise Evilution::ConfigError,
182
+ (message || "unexpected character '#{current}', expected '#{char}' at position #{@pos}")
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Evilution::AST::Pattern
4
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Evilution::AST::SorbetSigDetector
6
+ def call(source)
7
+ return [] if source.empty?
8
+
9
+ result = Prism.parse(source)
10
+ return [] if result.failure?
11
+
12
+ ranges = []
13
+ collect_sig_ranges(result.value, ranges, :byte)
14
+ ranges
15
+ end
16
+
17
+ def line_ranges(source)
18
+ return [] if source.empty?
19
+
20
+ result = Prism.parse(source)
21
+ return [] if result.failure?
22
+
23
+ ranges = []
24
+ collect_sig_ranges(result.value, ranges, :line)
25
+ ranges
26
+ end
27
+
28
+ private
29
+
30
+ def collect_sig_ranges(node, ranges, mode)
31
+ if sig_block?(node)
32
+ loc = node.location
33
+ ranges << if mode == :byte
34
+ (loc.start_offset...loc.end_offset)
35
+ else
36
+ (loc.start_line..loc.end_line)
37
+ end
38
+ end
39
+
40
+ node.child_nodes.each do |child|
41
+ collect_sig_ranges(child, ranges, mode) if child
42
+ end
43
+ end
44
+
45
+ def sig_block?(node)
46
+ node.is_a?(Prism::CallNode) &&
47
+ node.name == :sig &&
48
+ node.receiver.nil? &&
49
+ node.arguments.nil? &&
50
+ !node.block.nil?
51
+ end
52
+ end