evilution 0.16.0 → 0.17.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.
- checksums.yaml +4 -4
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +19 -18
- data/CHANGELOG.md +23 -0
- data/docs/ast_pattern_syntax.md +210 -0
- data/lib/evilution/ast/pattern/filter.rb +25 -0
- data/lib/evilution/ast/pattern/matcher.rb +107 -0
- data/lib/evilution/ast/pattern/parser.rb +185 -0
- data/lib/evilution/ast/pattern.rb +4 -0
- data/lib/evilution/ast/source_surgeon.rb +3 -3
- data/lib/evilution/cli.rb +13 -1
- data/lib/evilution/config.rb +35 -2
- data/lib/evilution/hooks/loader.rb +35 -0
- data/lib/evilution/hooks/registry.rb +60 -0
- data/lib/evilution/hooks.rb +58 -0
- data/lib/evilution/integration/base.rb +4 -0
- data/lib/evilution/integration/rspec.rb +6 -2
- data/lib/evilution/isolation/fork.rb +5 -0
- data/lib/evilution/mutator/base.rb +4 -1
- data/lib/evilution/mutator/operator/index_assignment_removal.rb +18 -0
- data/lib/evilution/mutator/operator/index_to_dig.rb +58 -0
- data/lib/evilution/mutator/operator/index_to_fetch.rb +30 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +2 -1
- data/lib/evilution/mutator/operator/pattern_matching_alternative.rb +46 -0
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +97 -0
- data/lib/evilution/mutator/operator/pattern_matching_guard.rb +44 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +2 -1
- data/lib/evilution/mutator/registry.rb +9 -3
- data/lib/evilution/parallel/pool.rb +3 -1
- data/lib/evilution/reporter/cli.rb +1 -0
- data/lib/evilution/reporter/html.rb +7 -0
- data/lib/evilution/reporter/json.rb +1 -0
- data/lib/evilution/reporter/suggestion.rb +87 -1
- data/lib/evilution/result/summary.rb +3 -2
- data/lib/evilution/runner.rb +21 -9
- data/lib/evilution/session/store.rb +5 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +12 -0
- metadata +16 -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
|
|
@@ -4,8 +4,8 @@ require_relative "../ast"
|
|
|
4
4
|
|
|
5
5
|
module Evilution::AST::SourceSurgeon
|
|
6
6
|
def self.apply(source, offset:, length:, replacement:)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
binary = source.b
|
|
8
|
+
binary[offset, length] = replacement.b
|
|
9
|
+
binary.force_encoding(source.encoding)
|
|
10
10
|
end
|
|
11
11
|
end
|
data/lib/evilution/cli.rb
CHANGED
|
@@ -4,6 +4,9 @@ require "json"
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
require_relative "version"
|
|
6
6
|
require_relative "config"
|
|
7
|
+
require_relative "hooks"
|
|
8
|
+
require_relative "hooks/registry"
|
|
9
|
+
require_relative "hooks/loader"
|
|
7
10
|
require_relative "runner"
|
|
8
11
|
|
|
9
12
|
class Evilution::CLI
|
|
@@ -434,7 +437,8 @@ class Evilution::CLI
|
|
|
434
437
|
|
|
435
438
|
file_options = Evilution::Config.file_options
|
|
436
439
|
config = Evilution::Config.new(**@options, target_files: @files, line_ranges: @line_ranges)
|
|
437
|
-
|
|
440
|
+
hooks = build_hooks(config)
|
|
441
|
+
runner = Evilution::Runner.new(config: config, hooks: hooks)
|
|
438
442
|
summary = runner.call
|
|
439
443
|
summary.success?(min_score: config.min_score) ? 0 : 1
|
|
440
444
|
rescue Evilution::Error => e
|
|
@@ -446,6 +450,14 @@ class Evilution::CLI
|
|
|
446
450
|
2
|
|
447
451
|
end
|
|
448
452
|
|
|
453
|
+
def build_hooks(config)
|
|
454
|
+
return nil if config.hooks.empty?
|
|
455
|
+
|
|
456
|
+
registry = Evilution::Hooks::Registry.new
|
|
457
|
+
Evilution::Hooks::Loader.call(registry, config.hooks)
|
|
458
|
+
registry
|
|
459
|
+
end
|
|
460
|
+
|
|
449
461
|
def json_format?(config, file_options)
|
|
450
462
|
return config.json? if config
|
|
451
463
|
|
data/lib/evilution/config.rb
CHANGED
|
@@ -22,13 +22,15 @@ class Evilution::Config
|
|
|
22
22
|
progress: true,
|
|
23
23
|
save_session: false,
|
|
24
24
|
line_ranges: {},
|
|
25
|
-
spec_files: []
|
|
25
|
+
spec_files: [],
|
|
26
|
+
ignore_patterns: []
|
|
26
27
|
}.freeze
|
|
27
28
|
|
|
28
29
|
attr_reader :target_files, :timeout, :format,
|
|
29
30
|
:target, :min_score, :integration, :verbose, :quiet,
|
|
30
31
|
:jobs, :fail_fast, :baseline, :isolation, :incremental, :suggest_tests,
|
|
31
|
-
:progress, :save_session, :line_ranges, :spec_files
|
|
32
|
+
:progress, :save_session, :line_ranges, :spec_files, :hooks,
|
|
33
|
+
:ignore_patterns
|
|
32
34
|
|
|
33
35
|
def initialize(**options)
|
|
34
36
|
file_options = options.delete(:skip_config_file) ? {} : load_config_file
|
|
@@ -122,6 +124,17 @@ class Evilution::Config
|
|
|
122
124
|
|
|
123
125
|
# Generate concrete RSpec test code in suggestions (default: false)
|
|
124
126
|
# suggest_tests: false
|
|
127
|
+
|
|
128
|
+
# Hooks: Ruby files returning a Proc, keyed by lifecycle event
|
|
129
|
+
# hooks:
|
|
130
|
+
# worker_process_start: config/evilution_hooks/worker_start.rb
|
|
131
|
+
# mutation_insert_pre: config/evilution_hooks/mutation_pre.rb
|
|
132
|
+
|
|
133
|
+
# AST patterns to skip during mutation generation (default: [])
|
|
134
|
+
# See docs/ast_pattern_syntax.md for pattern syntax
|
|
135
|
+
# ignore_patterns:
|
|
136
|
+
# - "call{name=info, receiver=call{name=logger}}"
|
|
137
|
+
# - "call{name=debug|warn}"
|
|
125
138
|
YAML
|
|
126
139
|
end
|
|
127
140
|
|
|
@@ -157,6 +170,8 @@ class Evilution::Config
|
|
|
157
170
|
@save_session = merged[:save_session]
|
|
158
171
|
@line_ranges = merged[:line_ranges] || {}
|
|
159
172
|
@spec_files = Array(merged[:spec_files])
|
|
173
|
+
@ignore_patterns = validate_ignore_patterns(merged[:ignore_patterns])
|
|
174
|
+
@hooks = validate_hooks(merged[:hooks])
|
|
160
175
|
end
|
|
161
176
|
|
|
162
177
|
def validate_isolation(value)
|
|
@@ -180,6 +195,24 @@ class Evilution::Config
|
|
|
180
195
|
raise Evilution::ConfigError, "jobs must be a positive integer, got #{value.inspect}"
|
|
181
196
|
end
|
|
182
197
|
|
|
198
|
+
def validate_ignore_patterns(value)
|
|
199
|
+
patterns = Array(value)
|
|
200
|
+
patterns.each do |pattern|
|
|
201
|
+
unless pattern.is_a?(String)
|
|
202
|
+
raise Evilution::ConfigError,
|
|
203
|
+
"ignore_patterns must be an array of strings, got #{pattern.class} (#{pattern.inspect})"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
patterns
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_hooks(value)
|
|
210
|
+
return {} if value.nil?
|
|
211
|
+
raise Evilution::ConfigError, "hooks must be a mapping of event names to file paths, got #{value.class}" unless value.is_a?(Hash)
|
|
212
|
+
|
|
213
|
+
value
|
|
214
|
+
end
|
|
215
|
+
|
|
183
216
|
def load_config_file
|
|
184
217
|
self.class.file_options
|
|
185
218
|
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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,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
|
|
@@ -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)
|