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.
- checksums.yaml +4 -4
- data/.beads/.migration-hint-ts +1 -1
- data/.beads/issues.jsonl +17 -17
- data/CHANGELOG.md +39 -0
- data/lib/evilution/ast/inheritance_scanner.rb +70 -0
- data/lib/evilution/ast/parser.rb +73 -68
- data/lib/evilution/ast/source_surgeon.rb +7 -9
- data/lib/evilution/ast.rb +4 -0
- data/lib/evilution/baseline.rb +73 -75
- data/lib/evilution/cache.rb +75 -77
- data/lib/evilution/cli.rb +412 -173
- data/lib/evilution/config.rb +141 -136
- data/lib/evilution/equivalent/detector.rb +29 -27
- data/lib/evilution/equivalent/heuristic/alias_swap.rb +32 -33
- data/lib/evilution/equivalent/heuristic/arithmetic_identity.rb +30 -0
- data/lib/evilution/equivalent/heuristic/comment_marking.rb +21 -0
- data/lib/evilution/equivalent/heuristic/dead_code.rb +41 -45
- data/lib/evilution/equivalent/heuristic/method_body_nil.rb +11 -15
- data/lib/evilution/equivalent/heuristic/noop_source.rb +5 -9
- data/lib/evilution/equivalent/heuristic.rb +6 -0
- data/lib/evilution/equivalent.rb +4 -0
- data/lib/evilution/git/changed_files.rb +35 -37
- data/lib/evilution/git.rb +4 -0
- data/lib/evilution/integration/base.rb +5 -7
- data/lib/evilution/integration/rspec.rb +114 -116
- data/lib/evilution/integration.rb +4 -0
- data/lib/evilution/isolation/fork.rb +98 -100
- data/lib/evilution/isolation/in_process.rb +59 -61
- data/lib/evilution/isolation.rb +4 -0
- data/lib/evilution/mcp/mutate_tool.rb +172 -143
- data/lib/evilution/mcp/server.rb +12 -11
- data/lib/evilution/mcp/session_diff_tool.rb +89 -0
- data/lib/evilution/mcp/session_list_tool.rb +46 -0
- data/lib/evilution/mcp/session_show_tool.rb +53 -0
- data/lib/evilution/mcp.rb +4 -0
- data/lib/evilution/memory/leak_check.rb +80 -84
- data/lib/evilution/memory.rb +34 -36
- data/lib/evilution/mutation.rb +40 -42
- data/lib/evilution/mutator/base.rb +62 -48
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +32 -36
- data/lib/evilution/mutator/operator/argument_removal.rb +32 -36
- data/lib/evilution/mutator/operator/arithmetic_replacement.rb +26 -30
- data/lib/evilution/mutator/operator/array_literal.rb +18 -22
- data/lib/evilution/mutator/operator/block_removal.rb +16 -20
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +38 -42
- data/lib/evilution/mutator/operator/boolean_operator_replacement.rb +41 -45
- data/lib/evilution/mutator/operator/collection_replacement.rb +32 -36
- data/lib/evilution/mutator/operator/comparison_replacement.rb +24 -28
- data/lib/evilution/mutator/operator/compound_assignment.rb +114 -118
- data/lib/evilution/mutator/operator/conditional_branch.rb +25 -29
- data/lib/evilution/mutator/operator/conditional_flip.rb +26 -30
- data/lib/evilution/mutator/operator/conditional_negation.rb +25 -29
- data/lib/evilution/mutator/operator/float_literal.rb +22 -26
- data/lib/evilution/mutator/operator/hash_literal.rb +18 -22
- data/lib/evilution/mutator/operator/integer_literal.rb +2 -0
- data/lib/evilution/mutator/operator/method_body_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/method_call_removal.rb +12 -16
- data/lib/evilution/mutator/operator/mixin_removal.rb +80 -0
- data/lib/evilution/mutator/operator/negation_insertion.rb +12 -16
- data/lib/evilution/mutator/operator/nil_replacement.rb +13 -17
- data/lib/evilution/mutator/operator/range_replacement.rb +12 -16
- data/lib/evilution/mutator/operator/receiver_replacement.rb +16 -20
- data/lib/evilution/mutator/operator/regexp_mutation.rb +15 -19
- data/lib/evilution/mutator/operator/return_value_removal.rb +12 -16
- data/lib/evilution/mutator/operator/send_mutation.rb +36 -40
- data/lib/evilution/mutator/operator/statement_deletion.rb +13 -17
- data/lib/evilution/mutator/operator/string_literal.rb +18 -22
- data/lib/evilution/mutator/operator/superclass_removal.rb +65 -0
- data/lib/evilution/mutator/operator/symbol_literal.rb +17 -21
- data/lib/evilution/mutator/operator.rb +6 -0
- data/lib/evilution/mutator/registry.rb +56 -56
- data/lib/evilution/mutator.rb +4 -0
- data/lib/evilution/parallel/pool.rb +56 -58
- data/lib/evilution/parallel.rb +4 -0
- data/lib/evilution/reporter/cli.rb +99 -101
- data/lib/evilution/reporter/html.rb +242 -244
- data/lib/evilution/reporter/json.rb +57 -59
- data/lib/evilution/reporter/suggestion.rb +354 -328
- data/lib/evilution/reporter.rb +4 -0
- data/lib/evilution/result/mutation_result.rb +43 -46
- data/lib/evilution/result/summary.rb +80 -81
- data/lib/evilution/result.rb +4 -0
- data/lib/evilution/runner.rb +401 -316
- data/lib/evilution/session/store.rb +147 -0
- data/lib/evilution/session.rb +4 -0
- data/lib/evilution/spec_resolver.rb +49 -47
- data/lib/evilution/subject.rb +14 -16
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +16 -0
- metadata +24 -2
data/lib/evilution/runner.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "config"
|
|
4
4
|
require_relative "ast/parser"
|
|
5
|
+
require_relative "ast/inheritance_scanner"
|
|
5
6
|
require_relative "memory"
|
|
6
7
|
require_relative "mutator/registry"
|
|
7
8
|
require_relative "isolation/fork"
|
|
@@ -18,416 +19,500 @@ require_relative "result/summary"
|
|
|
18
19
|
require_relative "baseline"
|
|
19
20
|
require_relative "cache"
|
|
20
21
|
require_relative "parallel/pool"
|
|
22
|
+
require_relative "session/store"
|
|
23
|
+
|
|
24
|
+
class Evilution::Runner
|
|
25
|
+
attr_reader :config
|
|
26
|
+
|
|
27
|
+
def initialize(config: Evilution::Config.new, on_result: nil)
|
|
28
|
+
@config = config
|
|
29
|
+
@on_result = on_result
|
|
30
|
+
@parser = Evilution::AST::Parser.new
|
|
31
|
+
@registry = Evilution::Mutator::Registry.default
|
|
32
|
+
@isolator = build_isolator
|
|
33
|
+
@cache = config.incremental? ? Evilution::Cache.new : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
attr_reader :config
|
|
39
|
+
subjects = parse_and_filter_subjects
|
|
40
|
+
log_memory("after parse_subjects", "#{subjects.length} subjects")
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
baseline_result = run_baseline(subjects)
|
|
43
|
+
|
|
44
|
+
mutations = generate_mutations(subjects)
|
|
45
|
+
equivalent_mutations, mutations = filter_equivalent(mutations)
|
|
46
|
+
release_subject_nodes(subjects)
|
|
47
|
+
clear_operator_caches
|
|
48
|
+
results, truncated = run_mutations(mutations, baseline_result)
|
|
49
|
+
results += equivalent_mutations.map do |m|
|
|
50
|
+
m.strip_sources!
|
|
51
|
+
equivalent_result(m)
|
|
32
52
|
end
|
|
53
|
+
log_memory("after run_mutations", "#{results.length} results")
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
55
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
36
56
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
log_memory("after parse_subjects", "#{subjects.length} subjects")
|
|
57
|
+
summary = Evilution::Result::Summary.new(results: results, duration: duration, truncated: truncated)
|
|
58
|
+
output_report(summary)
|
|
59
|
+
save_session(summary)
|
|
41
60
|
|
|
42
|
-
|
|
61
|
+
summary
|
|
62
|
+
end
|
|
43
63
|
|
|
44
|
-
|
|
45
|
-
equivalent_mutations, mutations = filter_equivalent(mutations)
|
|
46
|
-
release_subject_nodes(subjects)
|
|
47
|
-
results, truncated = run_mutations(mutations, baseline_result)
|
|
48
|
-
results += equivalent_mutations.map do |m|
|
|
49
|
-
m.strip_sources!
|
|
50
|
-
equivalent_result(m)
|
|
51
|
-
end
|
|
52
|
-
log_memory("after run_mutations", "#{results.length} results")
|
|
64
|
+
private
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
attr_reader :parser, :registry, :isolator, :cache, :on_result
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
def parse_and_filter_subjects
|
|
69
|
+
subjects = parse_subjects
|
|
70
|
+
subjects = filter_by_descendants(subjects) if descendants_target?
|
|
71
|
+
subjects = filter_by_target(subjects) if method_target?
|
|
72
|
+
subjects = filter_by_line_ranges(subjects) if config.line_ranges?
|
|
73
|
+
subjects
|
|
74
|
+
end
|
|
58
75
|
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
def parse_subjects
|
|
77
|
+
files = resolve_target_files
|
|
78
|
+
files.flat_map { |file| parser.call(file) }
|
|
79
|
+
end
|
|
61
80
|
|
|
62
|
-
|
|
81
|
+
def resolve_target_files
|
|
82
|
+
return resolve_source_glob if source_glob_target?
|
|
83
|
+
return config.target_files unless config.target_files.empty?
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
Evilution::Git::ChangedFiles.new.call
|
|
86
|
+
end
|
|
65
87
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
end
|
|
88
|
+
def source_glob_target?
|
|
89
|
+
config.target&.start_with?("source:")
|
|
90
|
+
end
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
|
|
92
|
+
def descendants_target?
|
|
93
|
+
config.target&.start_with?("descendants:")
|
|
94
|
+
end
|
|
73
95
|
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
def method_target?
|
|
97
|
+
config.target? && !source_glob_target? && !descendants_target?
|
|
98
|
+
end
|
|
76
99
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
subjects.select { |s| s.name.start_with?("#{config.target}#") }
|
|
82
|
-
end
|
|
83
|
-
raise Error, "no method found matching '#{config.target}'" if matched.empty?
|
|
100
|
+
def resolve_source_glob
|
|
101
|
+
pattern = config.target.delete_prefix("source:")
|
|
102
|
+
files = Dir.glob(pattern)
|
|
103
|
+
raise Evilution::Error, "no files found matching '#{pattern}'" if files.empty?
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
files.sort
|
|
106
|
+
end
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
def filter_by_descendants(subjects)
|
|
109
|
+
base_name = config.target.delete_prefix("descendants:")
|
|
110
|
+
files = resolve_target_files
|
|
111
|
+
inheritance = Evilution::AST::InheritanceScanner.call(files)
|
|
112
|
+
class_names = resolve_descendant_set(base_name, inheritance)
|
|
113
|
+
raise Evilution::Error, "no classes found matching '#{config.target}'" if class_names.empty?
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
subject_start <= range.last && subject_end >= range.first
|
|
96
|
-
end
|
|
97
|
-
end
|
|
115
|
+
subjects.select { |s| class_names.include?(s.name.split(/[#.]/).first) }
|
|
116
|
+
end
|
|
98
117
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
def resolve_descendant_set(base_name, inheritance)
|
|
119
|
+
descendants = Set.new
|
|
120
|
+
known = inheritance.key?(base_name) || inheritance.value?(base_name)
|
|
121
|
+
return descendants unless known
|
|
122
|
+
|
|
123
|
+
descendants.add(base_name)
|
|
124
|
+
changed = true
|
|
125
|
+
while changed
|
|
126
|
+
changed = false
|
|
127
|
+
inheritance.each do |child, parent|
|
|
128
|
+
next unless descendants.include?(parent)
|
|
129
|
+
next if descendants.include?(child)
|
|
130
|
+
|
|
131
|
+
descendants.add(child)
|
|
132
|
+
changed = true
|
|
102
133
|
end
|
|
103
134
|
end
|
|
135
|
+
descendants
|
|
136
|
+
end
|
|
104
137
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
138
|
+
def filter_by_target(subjects)
|
|
139
|
+
matched = subjects.select(&target_matcher)
|
|
140
|
+
raise Evilution::Error, "no method found matching '#{config.target}'" if matched.empty?
|
|
108
141
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
end
|
|
142
|
+
matched
|
|
143
|
+
end
|
|
112
144
|
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
def target_matcher
|
|
146
|
+
target = config.target
|
|
147
|
+
if target.end_with?("*")
|
|
148
|
+
prefix = target.chomp("*")
|
|
149
|
+
->(s) { s.name.split(/[#.]/).first.start_with?(prefix) }
|
|
150
|
+
elsif target.end_with?("#", ".")
|
|
151
|
+
prefix = target
|
|
152
|
+
->(s) { s.name.start_with?(prefix) }
|
|
153
|
+
elsif target.include?("#") || target.include?(".")
|
|
154
|
+
->(s) { s.name == target }
|
|
155
|
+
else
|
|
156
|
+
->(s) { s.name.start_with?("#{target}#") || s.name.start_with?("#{target}.") }
|
|
115
157
|
end
|
|
158
|
+
end
|
|
116
159
|
|
|
117
|
-
|
|
118
|
-
|
|
160
|
+
def filter_by_line_ranges(subjects)
|
|
161
|
+
subjects.select do |subject|
|
|
162
|
+
range = config.line_ranges[subject.file_path]
|
|
163
|
+
next true unless range
|
|
119
164
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
log_baseline_complete(result)
|
|
124
|
-
result
|
|
165
|
+
subject_start = subject.line_number
|
|
166
|
+
subject_end = subject_start + subject.source.count("\n")
|
|
167
|
+
subject_start <= range.last && subject_end >= range.first
|
|
125
168
|
end
|
|
169
|
+
end
|
|
126
170
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
else
|
|
131
|
-
run_mutations_sequential(mutations, baseline_result)
|
|
132
|
-
end
|
|
171
|
+
def generate_mutations(subjects)
|
|
172
|
+
subjects.flat_map do |subject|
|
|
173
|
+
registry.mutations_for(subject)
|
|
133
174
|
end
|
|
175
|
+
end
|
|
134
176
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
results = []
|
|
139
|
-
survived_count = 0
|
|
140
|
-
truncated = false
|
|
141
|
-
|
|
142
|
-
mutations.each_with_index do |mutation, index|
|
|
143
|
-
result = execute_or_fetch(mutation) do
|
|
144
|
-
test_command = ->(m) { integration.call(m) }
|
|
145
|
-
isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
146
|
-
end
|
|
147
|
-
mutation.strip_sources!
|
|
148
|
-
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
149
|
-
results << result
|
|
150
|
-
survived_count += 1 if result.survived?
|
|
151
|
-
log_progress(index + 1, result.status)
|
|
152
|
-
log_mutation_diagnostics(result)
|
|
153
|
-
|
|
154
|
-
if config.fail_fast? && survived_count >= config.fail_fast
|
|
155
|
-
truncated = true
|
|
156
|
-
break
|
|
157
|
-
end
|
|
158
|
-
end
|
|
177
|
+
def filter_equivalent(mutations)
|
|
178
|
+
Evilution::Equivalent::Detector.new.call(mutations)
|
|
179
|
+
end
|
|
159
180
|
|
|
160
|
-
|
|
161
|
-
|
|
181
|
+
def release_subject_nodes(subjects)
|
|
182
|
+
subjects.each(&:release_node!)
|
|
183
|
+
end
|
|
162
184
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
worker_isolator = Isolation::InProcess.new
|
|
167
|
-
spec_resolver = baseline_result&.failed? ? SpecResolver.new : nil
|
|
168
|
-
state = { results: [], survived_count: 0, truncated: false, completed: 0 }
|
|
185
|
+
def clear_operator_caches
|
|
186
|
+
Evilution::Mutator::Base.clear_parse_cache!
|
|
187
|
+
end
|
|
169
188
|
|
|
170
|
-
|
|
171
|
-
|
|
189
|
+
def equivalent_result(mutation)
|
|
190
|
+
Evilution::Result::MutationResult.new(mutation: mutation, status: :equivalent, duration: 0.0)
|
|
191
|
+
end
|
|
172
192
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
end
|
|
193
|
+
def run_baseline(subjects)
|
|
194
|
+
return nil unless config.baseline? && subjects.any?
|
|
176
195
|
|
|
177
|
-
|
|
178
|
-
|
|
196
|
+
log_baseline_start
|
|
197
|
+
baseline = Evilution::Baseline.new(timeout: config.timeout)
|
|
198
|
+
result = baseline.call(subjects)
|
|
199
|
+
log_baseline_complete(result)
|
|
200
|
+
result
|
|
201
|
+
end
|
|
179
202
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
batch_results = rebuild_results(batch, compact_results)
|
|
186
|
-
batch_results.each { |r| store_cached_result(r.mutation, r) }
|
|
187
|
-
batch_results
|
|
203
|
+
def run_mutations(mutations, baseline_result = nil)
|
|
204
|
+
if config.jobs > 1
|
|
205
|
+
run_mutations_parallel(mutations, baseline_result)
|
|
206
|
+
else
|
|
207
|
+
run_mutations_sequential(mutations, baseline_result)
|
|
188
208
|
end
|
|
209
|
+
end
|
|
189
210
|
|
|
190
|
-
|
|
191
|
-
|
|
211
|
+
def run_mutations_sequential(mutations, baseline_result = nil)
|
|
212
|
+
integration = build_integration
|
|
213
|
+
spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
|
|
214
|
+
results = []
|
|
215
|
+
survived_count = 0
|
|
216
|
+
truncated = false
|
|
192
217
|
|
|
193
|
-
|
|
194
|
-
|
|
218
|
+
mutations.each_with_index do |mutation, index|
|
|
219
|
+
result = execute_or_fetch(mutation) do
|
|
195
220
|
test_command = ->(m) { integration.call(m) }
|
|
196
|
-
|
|
197
|
-
|
|
221
|
+
isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
222
|
+
end
|
|
223
|
+
mutation.strip_sources!
|
|
224
|
+
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
225
|
+
results << result
|
|
226
|
+
survived_count += 1 if result.survived?
|
|
227
|
+
on_result&.call(result)
|
|
228
|
+
log_progress(index + 1, result.status)
|
|
229
|
+
log_mutation_diagnostics(result)
|
|
230
|
+
|
|
231
|
+
if config.fail_fast? && survived_count >= config.fail_fast
|
|
232
|
+
truncated = true
|
|
233
|
+
break
|
|
198
234
|
end
|
|
199
235
|
end
|
|
200
236
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
204
|
-
state[:results] << result
|
|
205
|
-
state[:survived_count] += 1 if result.survived?
|
|
206
|
-
state[:completed] += 1
|
|
207
|
-
log_progress(state[:completed], result.status)
|
|
208
|
-
log_mutation_diagnostics(result)
|
|
209
|
-
end
|
|
237
|
+
[results, truncated]
|
|
238
|
+
end
|
|
210
239
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
240
|
+
def run_mutations_parallel(mutations, baseline_result = nil)
|
|
241
|
+
integration = build_integration
|
|
242
|
+
pool = Evilution::Parallel::Pool.new(size: config.jobs)
|
|
243
|
+
worker_isolator = Evilution::Isolation::InProcess.new
|
|
244
|
+
spec_resolver = baseline_result&.failed? ? Evilution::SpecResolver.new : nil
|
|
245
|
+
state = { results: [], survived_count: 0, truncated: false, completed: 0 }
|
|
214
246
|
|
|
215
|
-
|
|
216
|
-
|
|
247
|
+
mutations.each_slice(config.jobs) do |batch|
|
|
248
|
+
break if state[:truncated]
|
|
217
249
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
else
|
|
221
|
-
spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
|
|
222
|
-
neutralize = baseline_result.failed_spec_files.include?(spec_file)
|
|
223
|
-
end
|
|
224
|
-
return result unless neutralize
|
|
225
|
-
|
|
226
|
-
Result::MutationResult.new(
|
|
227
|
-
mutation: result.mutation,
|
|
228
|
-
status: :neutral,
|
|
229
|
-
duration: result.duration,
|
|
230
|
-
test_command: result.test_command,
|
|
231
|
-
child_rss_kb: result.child_rss_kb,
|
|
232
|
-
memory_delta_kb: result.memory_delta_kb
|
|
233
|
-
)
|
|
250
|
+
batch_results = run_parallel_batch(batch, pool, worker_isolator, integration)
|
|
251
|
+
process_batch(batch_results, baseline_result, spec_resolver, state)
|
|
234
252
|
end
|
|
235
253
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
status: result.status,
|
|
239
|
-
duration: result.duration,
|
|
240
|
-
killing_test: result.killing_test,
|
|
241
|
-
test_command: result.test_command,
|
|
242
|
-
child_rss_kb: result.child_rss_kb,
|
|
243
|
-
memory_delta_kb: result.memory_delta_kb
|
|
244
|
-
}
|
|
245
|
-
end
|
|
254
|
+
[state[:results], state[:truncated]]
|
|
255
|
+
end
|
|
246
256
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
memory_delta_kb: data[:memory_delta_kb]
|
|
257
|
-
)
|
|
258
|
-
end
|
|
259
|
-
end
|
|
257
|
+
def run_parallel_batch(batch, pool, worker_isolator, integration)
|
|
258
|
+
uncached_indices, cached_results = partition_cached(batch)
|
|
259
|
+
worker_results = run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
|
|
260
|
+
compact_results = merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
|
|
261
|
+
batch.each(&:strip_sources!)
|
|
262
|
+
batch_results = rebuild_results(batch, compact_results)
|
|
263
|
+
batch_results.each { |r| store_cached_result(r.mutation, r) }
|
|
264
|
+
batch_results
|
|
265
|
+
end
|
|
260
266
|
|
|
261
|
-
|
|
262
|
-
|
|
267
|
+
def run_uncached_workers(batch, uncached_indices, pool, worker_isolator, integration)
|
|
268
|
+
return [] if uncached_indices.empty?
|
|
269
|
+
|
|
270
|
+
uncached = uncached_indices.map { |i| batch[i] }
|
|
271
|
+
pool.map(uncached) do |mutation|
|
|
272
|
+
test_command = ->(m) { integration.call(m) }
|
|
273
|
+
result = worker_isolator.call(mutation: mutation, test_command: test_command, timeout: config.timeout)
|
|
274
|
+
compact_result(result)
|
|
263
275
|
end
|
|
276
|
+
end
|
|
264
277
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
278
|
+
def process_batch(batch_results, baseline_result, spec_resolver, state)
|
|
279
|
+
batch_results.each do |result|
|
|
280
|
+
result = neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
281
|
+
state[:results] << result
|
|
282
|
+
state[:survived_count] += 1 if result.survived?
|
|
283
|
+
state[:completed] += 1
|
|
284
|
+
on_result&.call(result)
|
|
285
|
+
log_progress(state[:completed], result.status)
|
|
286
|
+
log_mutation_diagnostics(result)
|
|
270
287
|
end
|
|
271
288
|
|
|
272
|
-
|
|
273
|
-
|
|
289
|
+
log_memory("after batch", "#{state[:completed]} complete")
|
|
290
|
+
state[:truncated] = true if should_truncate?(state[:survived_count])
|
|
291
|
+
end
|
|
274
292
|
|
|
275
|
-
|
|
293
|
+
def neutralize_if_baseline_failed(result, baseline_result, spec_resolver)
|
|
294
|
+
return result unless result.survived? && baseline_result && baseline_result.failed?
|
|
295
|
+
|
|
296
|
+
if config.spec_files.any?
|
|
297
|
+
neutralize = true
|
|
298
|
+
else
|
|
299
|
+
spec_file = spec_resolver.call(result.mutation.file_path) || "spec"
|
|
300
|
+
neutralize = baseline_result.failed_spec_files.include?(spec_file)
|
|
301
|
+
end
|
|
302
|
+
return result unless neutralize
|
|
303
|
+
|
|
304
|
+
Evilution::Result::MutationResult.new(
|
|
305
|
+
mutation: result.mutation,
|
|
306
|
+
status: :neutral,
|
|
307
|
+
duration: result.duration,
|
|
308
|
+
test_command: result.test_command,
|
|
309
|
+
child_rss_kb: result.child_rss_kb,
|
|
310
|
+
memory_delta_kb: result.memory_delta_kb
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def compact_result(result)
|
|
315
|
+
{
|
|
316
|
+
status: result.status,
|
|
317
|
+
duration: result.duration,
|
|
318
|
+
killing_test: result.killing_test,
|
|
319
|
+
test_command: result.test_command,
|
|
320
|
+
child_rss_kb: result.child_rss_kb,
|
|
321
|
+
memory_delta_kb: result.memory_delta_kb
|
|
322
|
+
}
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def rebuild_results(batch, compact_results)
|
|
326
|
+
batch.zip(compact_results).map do |mutation, data|
|
|
327
|
+
Evilution::Result::MutationResult.new(
|
|
328
|
+
mutation: mutation,
|
|
329
|
+
status: data[:status],
|
|
330
|
+
duration: data[:duration],
|
|
331
|
+
killing_test: data[:killing_test],
|
|
332
|
+
test_command: data[:test_command],
|
|
333
|
+
child_rss_kb: data[:child_rss_kb],
|
|
334
|
+
memory_delta_kb: data[:memory_delta_kb]
|
|
335
|
+
)
|
|
276
336
|
end
|
|
337
|
+
end
|
|
277
338
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
339
|
+
def should_truncate?(survived_count)
|
|
340
|
+
config.fail_fast? && survived_count >= config.fail_fast
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def build_isolator
|
|
344
|
+
case resolve_isolation
|
|
345
|
+
when :fork then Evilution::Isolation::Fork.new
|
|
346
|
+
when :in_process then Evilution::Isolation::InProcess.new
|
|
286
347
|
end
|
|
348
|
+
end
|
|
287
349
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return unless reporter
|
|
350
|
+
def resolve_isolation
|
|
351
|
+
return :fork if config.isolation == :fork
|
|
291
352
|
|
|
292
|
-
|
|
293
|
-
|
|
353
|
+
:in_process
|
|
354
|
+
end
|
|
294
355
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
356
|
+
def build_integration
|
|
357
|
+
case config.integration
|
|
358
|
+
when :rspec
|
|
359
|
+
test_files = config.spec_files.empty? ? nil : config.spec_files
|
|
360
|
+
Evilution::Integration::RSpec.new(test_files: test_files)
|
|
361
|
+
else
|
|
362
|
+
raise Evilution::Error, "unknown integration: #{config.integration}"
|
|
302
363
|
end
|
|
364
|
+
end
|
|
303
365
|
|
|
304
|
-
|
|
305
|
-
|
|
366
|
+
def output_report(summary)
|
|
367
|
+
reporter = build_reporter
|
|
368
|
+
return unless reporter
|
|
306
369
|
|
|
307
|
-
|
|
370
|
+
output = reporter.call(summary)
|
|
371
|
+
return if config.quiet
|
|
372
|
+
|
|
373
|
+
if config.html?
|
|
374
|
+
path = "evilution-report.html"
|
|
375
|
+
File.write(path, output)
|
|
376
|
+
warn "HTML report written to #{path}"
|
|
377
|
+
else
|
|
378
|
+
$stdout.puts(output)
|
|
308
379
|
end
|
|
380
|
+
end
|
|
309
381
|
|
|
310
|
-
|
|
311
|
-
|
|
382
|
+
def log_baseline_start
|
|
383
|
+
return if config.quiet || !config.text? || !$stderr.tty?
|
|
312
384
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
end
|
|
385
|
+
$stderr.write("Running baseline test suite...\n")
|
|
386
|
+
end
|
|
316
387
|
|
|
317
|
-
|
|
318
|
-
|
|
388
|
+
def log_baseline_complete(result)
|
|
389
|
+
return if config.quiet || !config.text? || !$stderr.tty?
|
|
319
390
|
|
|
320
|
-
|
|
321
|
-
|
|
391
|
+
count = result.failed_spec_files.size
|
|
392
|
+
$stderr.write("Baseline complete: #{count} failing spec file#{"s" unless count == 1}\n")
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def log_progress(current, status)
|
|
396
|
+
return if config.quiet || !config.text? || !$stderr.tty?
|
|
397
|
+
|
|
398
|
+
$stderr.write("mutation #{current} #{status}\n")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def log_memory(phase, context = nil)
|
|
402
|
+
return unless config.verbose && !config.quiet
|
|
403
|
+
|
|
404
|
+
rss = Evilution::Memory.rss_mb
|
|
405
|
+
return unless rss
|
|
406
|
+
|
|
407
|
+
gc = gc_stats_string
|
|
408
|
+
msg = format("[memory] %<phase>s: %<rss>.1f MB", phase: phase, rss: rss)
|
|
409
|
+
context = [context, gc].compact.join(", ")
|
|
410
|
+
msg += " (#{context})" unless context.empty?
|
|
411
|
+
$stderr.write("#{msg}\n")
|
|
412
|
+
end
|
|
322
413
|
|
|
323
|
-
|
|
324
|
-
|
|
414
|
+
def log_mutation_diagnostics(result)
|
|
415
|
+
return unless config.verbose && !config.quiet
|
|
325
416
|
|
|
326
|
-
|
|
327
|
-
|
|
417
|
+
parts = []
|
|
418
|
+
parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
|
|
328
419
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
msg += " (#{context})" unless context.empty?
|
|
333
|
-
$stderr.write("#{msg}\n")
|
|
420
|
+
if result.memory_delta_kb
|
|
421
|
+
sign = result.memory_delta_kb.negative? ? "" : "+"
|
|
422
|
+
parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
|
|
334
423
|
end
|
|
335
424
|
|
|
336
|
-
|
|
337
|
-
return unless config.verbose && !config.quiet
|
|
425
|
+
parts << gc_stats_string
|
|
338
426
|
|
|
339
|
-
|
|
340
|
-
parts << format("child_rss: %<mb>.1f MB", mb: result.child_rss_kb / 1024.0) if result.child_rss_kb
|
|
427
|
+
return if parts.empty?
|
|
341
428
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
parts << format("delta: %<sign>s%<mb>.1f MB", sign: sign, mb: result.memory_delta_kb / 1024.0)
|
|
345
|
-
end
|
|
429
|
+
$stderr.write("[verbose] #{result.mutation}: #{parts.join(", ")}\n")
|
|
430
|
+
end
|
|
346
431
|
|
|
347
|
-
|
|
432
|
+
def gc_stats_string
|
|
433
|
+
stats = GC.stat
|
|
434
|
+
format(
|
|
435
|
+
"heap_live_slots: %<live>d, allocated: %<alloc>d, freed: %<freed>d",
|
|
436
|
+
live: stats[:heap_live_slots],
|
|
437
|
+
alloc: stats[:total_allocated_objects],
|
|
438
|
+
freed: stats[:total_freed_objects]
|
|
439
|
+
)
|
|
440
|
+
end
|
|
348
441
|
|
|
349
|
-
|
|
442
|
+
def save_session(summary)
|
|
443
|
+
return unless config.save_session?
|
|
350
444
|
|
|
351
|
-
|
|
352
|
-
|
|
445
|
+
Evilution::Session::Store.new.save(summary)
|
|
446
|
+
rescue StandardError => e
|
|
447
|
+
warn "[evilution] failed to save session: #{e.message}" unless config.quiet
|
|
448
|
+
end
|
|
353
449
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
450
|
+
def build_reporter
|
|
451
|
+
case config.format
|
|
452
|
+
when :json
|
|
453
|
+
Evilution::Reporter::JSON.new
|
|
454
|
+
when :text
|
|
455
|
+
Evilution::Reporter::CLI.new
|
|
456
|
+
when :html
|
|
457
|
+
Evilution::Reporter::HTML.new
|
|
362
458
|
end
|
|
459
|
+
end
|
|
363
460
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
Reporter::JSON.new
|
|
368
|
-
when :text
|
|
369
|
-
Reporter::CLI.new
|
|
370
|
-
when :html
|
|
371
|
-
Reporter::HTML.new
|
|
372
|
-
end
|
|
373
|
-
end
|
|
461
|
+
def partition_cached(batch)
|
|
462
|
+
uncached_indices = []
|
|
463
|
+
cached_results = {}
|
|
374
464
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if cached
|
|
382
|
-
cached_results[i] = compact_result(cached)
|
|
383
|
-
else
|
|
384
|
-
uncached_indices << i
|
|
385
|
-
end
|
|
465
|
+
batch.each_with_index do |mutation, i|
|
|
466
|
+
cached = fetch_cached_result(mutation)
|
|
467
|
+
if cached
|
|
468
|
+
cached_results[i] = compact_result(cached)
|
|
469
|
+
else
|
|
470
|
+
uncached_indices << i
|
|
386
471
|
end
|
|
387
|
-
|
|
388
|
-
[uncached_indices, cached_results]
|
|
389
472
|
end
|
|
390
473
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
|
|
394
|
-
batch.each_index.map { |i| result_map[i] }
|
|
395
|
-
end
|
|
474
|
+
[uncached_indices, cached_results]
|
|
475
|
+
end
|
|
396
476
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
477
|
+
def merge_parallel_results(batch, uncached_indices, cached_results, worker_results)
|
|
478
|
+
result_map = cached_results.dup
|
|
479
|
+
uncached_indices.each_with_index { |batch_idx, worker_idx| result_map[batch_idx] = worker_results[worker_idx] }
|
|
480
|
+
batch.each_index.map { |i| result_map[i] }
|
|
481
|
+
end
|
|
400
482
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
end
|
|
483
|
+
def execute_or_fetch(mutation)
|
|
484
|
+
cached = fetch_cached_result(mutation)
|
|
485
|
+
return cached if cached
|
|
405
486
|
|
|
406
|
-
|
|
407
|
-
|
|
487
|
+
result = yield
|
|
488
|
+
store_cached_result(mutation, result)
|
|
489
|
+
result
|
|
490
|
+
end
|
|
408
491
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return nil unless %i[killed timeout].include?(data[:status])
|
|
492
|
+
def fetch_cached_result(mutation)
|
|
493
|
+
return nil unless cache
|
|
412
494
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
duration: data[:duration],
|
|
417
|
-
killing_test: data[:killing_test],
|
|
418
|
-
test_command: data[:test_command]
|
|
419
|
-
)
|
|
420
|
-
end
|
|
495
|
+
data = cache.fetch(mutation)
|
|
496
|
+
return nil unless data
|
|
497
|
+
return nil unless %i[killed timeout].include?(data[:status])
|
|
421
498
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
499
|
+
Evilution::Result::MutationResult.new(
|
|
500
|
+
mutation: mutation,
|
|
501
|
+
status: data[:status],
|
|
502
|
+
duration: data[:duration],
|
|
503
|
+
killing_test: data[:killing_test],
|
|
504
|
+
test_command: data[:test_command]
|
|
505
|
+
)
|
|
506
|
+
end
|
|
425
507
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
508
|
+
def store_cached_result(mutation, result)
|
|
509
|
+
return unless cache
|
|
510
|
+
return unless result.killed? || result.timeout?
|
|
511
|
+
|
|
512
|
+
cache.store(mutation,
|
|
513
|
+
status: result.status,
|
|
514
|
+
duration: result.duration,
|
|
515
|
+
killing_test: result.killing_test,
|
|
516
|
+
test_command: result.test_command)
|
|
432
517
|
end
|
|
433
518
|
end
|