/
corpus.rb
341 lines (296 loc) · 9.87 KB
/
corpus.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# frozen_string_literal: true
require 'anima'
require 'morpher'
require 'mutant'
require 'parallel'
# @api private
module MutantSpec
ROOT = Pathname.new(__FILE__).parent.parent.parent
# Namespace module for corpus testing
#
# rubocop:disable MethodLength
module Corpus
TMP = ROOT.join('tmp').freeze
EXCLUDE_GLOB_FORMAT = '{%s}'.freeze
# Not in the docs. Number from chatting with their support.
# 2 processors allocated per container, 4 processes works well.
CIRCLE_CI_CONTAINER_PROCESSES = 4
private_constant(*constants(false))
# Project under corpus test
# rubocop:disable ClassLength
class Project
MUTEX = Mutex.new
MUTATION_GENERATION_MESSAGE = 'Total Mutations/Time/Parse-Errors: %s/%0.2fs - %0.2f/s'.freeze
START_MESSAGE = 'Starting - %s'.freeze
FINISH_MESSAGE = 'Mutations - %4i - %s'.freeze
DEFAULT_MUTATION_COUNT = 0
include Adamantium, Anima.new(
:expected_errors,
:mutation_coverage,
:mutation_generation,
:integration,
:name,
:namespace,
:repo_uri,
:repo_ref,
:ruby_glob_pattern,
:exclude
)
# Verify mutation coverage
#
# @return [self]
# if successful
#
# @raise [Exception]
def verify_mutation_coverage
checkout
Dir.chdir(repo_path) do
Bundler.with_clean_env do
install_mutant
system(
%W[
bundle exec mutant
--use #{integration}
--include lib
--require #{name}
#{namespace}*
]
)
end
end
end
# Verify mutation generation
#
# @return [self]
# if successful
#
# @raise [Exception]
# otherwise
def verify_mutation_generation
checkout
start = Time.now
options = {
finish: method(:finish),
start: method(:start),
in_processes: parallel_processes
}
total = Parallel.map(effective_ruby_paths, options, &method(:count_mutations_and_check_errors))
.inject(DEFAULT_MUTATION_COUNT, :+)
took = Time.now - start
puts MUTATION_GENERATION_MESSAGE % [total, took, total / took]
self
end
# Checkout repository
#
# @return [self]
def checkout
return self if noinstall?
TMP.mkdir unless TMP.directory?
if repo_path.exist?
Dir.chdir(repo_path) do
system(%w[git fetch origin])
system(%w[git reset --hard])
system(%w[git clean -f -d -x])
end
else
system(%W[git clone #{repo_uri} #{repo_path}])
end
Dir.chdir(repo_path) do
system(%W[git checkout #{repo_ref}])
system(%w[git reset --hard])
system(%w[git clean -f -d -x])
end
self
end
memoize :checkout
private
# Count mutations and check error results against whitelist
#
# @param path [Pathname] path responsible for exception
#
# @return [Integer] mutations generated
def count_mutations_and_check_errors(path)
relative_path = path.relative_path_from(repo_path)
count = count_mutations(path)
expected_errors.assert_success(relative_path)
count
rescue Exception => exception # rubocop:disable Lint/RescueException
expected_errors.assert_error(relative_path, exception)
DEFAULT_MUTATION_COUNT
end
# Count mutations generated for provided source file
#
# @param path [Pathname] path to a source file
#
# @raise [Exception] any error specified by integrations.yml
#
# @return [Integer] number of mutations generated
def count_mutations(path)
node = Parser::CurrentRuby.parse(path.read)
return DEFAULT_MUTATION_COUNT unless node
Mutant::Mutator.mutate(node).length
end
# Install mutant
#
# @return [undefined]
def install_mutant
return if noinstall?
relative = ROOT.relative_path_from(repo_path)
repo_path.join('Gemfile').open('a') do |file|
file << "gem 'mutant', path: '#{relative}'\n"
file << "gem 'mutant-rspec', path: '#{relative}'\n"
file << "gem 'mutant-minitest', path: '#{relative}'\n"
file << "eval_gemfile File.expand_path('#{relative.join('Gemfile.shared')}')\n"
end
lockfile = repo_path.join('Gemfile.lock')
lockfile.delete if lockfile.exist?
system(%w[bundle])
end
# The effective ruby file paths
#
# @return [Array<Pathname>]
def effective_ruby_paths
Pathname
.glob(repo_path.join(ruby_glob_pattern))
.sort_by(&:size)
.reverse
.reject { |path| exclude.include?(path.relative_path_from(repo_path).to_s) }
end
# Number of parallel processes to use
#
# @return [Integer]
def parallel_processes
if ENV.key?('CI')
CIRCLE_CI_CONTAINER_PROCESSES
else
Etc.nprocessors
end
end
# Repository path
#
# @return [Pathname]
def repo_path
TMP.join(name)
end
# Test if installation should be skipped
#
# @return [Boolean]
def noinstall?
ENV.key?('NOINSTALL')
end
# Print start progress
#
# @param [Pathname] path
# @param [Integer] _index
#
# @return [undefined]
#
def start(path, _index)
MUTEX.synchronize do
puts START_MESSAGE % path
end
end
# Print finish progress
#
# @param [Pathname] path
# @param [Integer] _index
# @param [Integer] count
#
# @return [undefined]
#
def finish(path, _index, count)
MUTEX.synchronize do
puts FINISH_MESSAGE % [count, path]
end
end
# Helper method to execute system commands
#
# @param [Array<String>] arguments
#
# rubocop:disable GuardClause - guard clause without else does not make sense
def system(arguments)
return if Kernel.system(*arguments)
if block_given?
yield
else
fail "System command failed!: #{arguments.join(' ')}"
end
end
# Mapping of files which we expect to cause errors during mutation generation
class ErrorWhitelist
class UnnecessaryExpectation < StandardError
MESSAGE = 'Expected to encounter %s while mutating "%s"'.freeze
def initialize(*error_info)
super(MESSAGE % error_info)
end
end # UnnecessaryExpectation
include Concord.new(:map), Adamantium
# Assert that we expect to encounter the provided exception for this path
#
# @param path [Pathname]
# @param exception [Exception]
#
# @raise provided exception if we are not expecting this error
#
# This method is reraising exceptions but rubocop can't tell
# rubocop:disable Style/SignalException
#
# @return [undefined]
def assert_error(path, exception)
original_error = exception.cause || exception
raise exception unless map.fetch(original_error.inspect, []).include?(path)
end
# Assert that we expect to not encounter an error for the specified path
#
# @param path [Pathname]
#
# @raise [UnnecessaryExpectation] if we are expecting an exception for this path
#
# @return [undefined]
def assert_success(path)
map.each do |error, paths|
fail UnnecessaryExpectation.new(error, path) if paths.include?(path)
end
end
# Return representation as hash
#
# @note this method is necessary for morpher loader to be invertible
#
# @return [Hash{Pathname => String}]
def to_h
map
end
end # ErrorWhitelist
LOADER = Morpher.build do
s(:block,
s(:guard, s(:primitive, Array)),
s(:map,
s(:block,
s(:guard, s(:primitive, Hash)),
s(:hash_transform,
s(:key_symbolize, :repo_uri, s(:guard, s(:primitive, String))),
s(:key_symbolize, :repo_ref, s(:guard, s(:primitive, String))),
s(:key_symbolize, :ruby_glob_pattern, s(:guard, s(:primitive, String))),
s(:key_symbolize, :name, s(:guard, s(:primitive, String))),
s(:key_symbolize, :namespace, s(:guard, s(:primitive, String))),
s(:key_symbolize, :integration, s(:guard, s(:primitive, String))),
s(:key_symbolize, :mutation_coverage,
s(:guard, s(:or, s(:primitive, TrueClass), s(:primitive, FalseClass)))),
s(:key_symbolize, :mutation_generation,
s(:guard, s(:or, s(:primitive, TrueClass), s(:primitive, FalseClass)))),
s(:key_symbolize, :expected_errors,
s(:block,
s(:guard, s(:primitive, Hash)),
s(:custom,
[
->(hash) { hash.map { |key, values| [key, values.map(&Pathname.method(:new))] }.to_h },
->(hash) { hash.map { |key, values| [key, values.map(&:to_s)] }.to_h }
]),
s(:load_attribute_hash, s(:param, ErrorWhitelist)))),
s(:key_symbolize, :exclude, s(:map, s(:guard, s(:primitive, String))))),
s(:anima_load, Project))))
end
ALL = LOADER.call(YAML.load_file(ROOT.join('spec', 'integrations.yml')))
end # Project
end # Corpus
end # MutantSpec