mutant 0.15.1 → 0.16.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/VERSION +1 -1
- data/lib/mutant/cli/command/root.rb +1 -1
- data/lib/mutant/cli/command/session.rb +281 -0
- data/lib/mutant/cli/command.rb +16 -2
- data/lib/mutant/config.rb +1 -1
- data/lib/mutant/expression/method.rb +0 -2
- data/lib/mutant/expression/methods.rb +0 -2
- data/lib/mutant/expression/namespace.rb +0 -2
- data/lib/mutant/isolation/fork.rb +2 -6
- data/lib/mutant/isolation.rb +31 -0
- data/lib/mutant/matcher/null.rb +1 -1
- data/lib/mutant/mutation/runner/sink.rb +23 -10
- data/lib/mutant/mutation/runner.rb +1 -0
- data/lib/mutant/mutation.rb +3 -20
- data/lib/mutant/mutator/node/literal/integer.rb +61 -0
- data/lib/mutant/parallel/connection.rb +0 -2
- data/lib/mutant/parallel/driver.rb +0 -2
- data/lib/mutant/reporter/cli/printer/alive_results.rb +27 -0
- data/lib/mutant/reporter/cli/printer/env_result.rb +52 -9
- data/lib/mutant/reporter/cli/printer/subject_result.rb +103 -5
- data/lib/mutant/reporter/cli/printer.rb +24 -1
- data/lib/mutant/repository/diff.rb +1 -2
- data/lib/mutant/result/exception.rb +29 -0
- data/lib/mutant/result/json_writer.rb +43 -0
- data/lib/mutant/result/process_status.rb +37 -0
- data/lib/mutant/result/session.rb +63 -0
- data/lib/mutant/result/test.rb +30 -0
- data/lib/mutant/result.rb +201 -96
- data/lib/mutant/segment/recorder.rb +0 -2
- data/lib/mutant/timer.rb +3 -1
- data/lib/mutant/transform/json.rb +68 -0
- data/lib/mutant/transform.rb +33 -25
- data/lib/mutant/world.rb +6 -0
- data/lib/mutant/zombifier.rb +0 -2
- data/lib/mutant.rb +13 -4
- metadata +33 -7
- data/lib/mutant/reporter/cli/printer/coverage_result.rb +0 -19
- data/lib/mutant/reporter/cli/printer/mutation_result.rb +0 -84
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d35acd82d2b4cf6222613f653da44131636400215c56a14a00f1ecd2ef212079
|
|
4
|
+
data.tar.gz: ae7ad7d71685be5108bb5fcc6be1b0cae5cd32f56e2848d25467c82d2f72771c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1481530eb5c56560e06a828086df6d8a5f12d47b3180bafc47f996ccd3a6db22b2cd0ddd213bb12c6934cf878341bad5f987f8cb91113ad0db58a0c68760d203
|
|
7
|
+
data.tar.gz: 657f62dc386d13f729dfb92fc78afed7c426cd4e64f53ca938c403c6cd4ad06ad856b379dde9d890d2a96ae692f64521975d9d3e0fd850095a4b03ab82d9b906
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.16.0
|
|
@@ -10,7 +10,7 @@ module Mutant
|
|
|
10
10
|
class Root < self
|
|
11
11
|
NAME = 'mutant'
|
|
12
12
|
SHORT_DESCRIPTION = 'mutation testing engine main command'
|
|
13
|
-
SUBCOMMANDS = [Environment::Run, Environment::Test::Run::Root, Environment, Util].freeze
|
|
13
|
+
SUBCOMMANDS = [Environment::Run, Environment::Test::Run::Root, Environment, Session, Util].freeze
|
|
14
14
|
end # Root
|
|
15
15
|
end # Command
|
|
16
16
|
end # CLI
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutant
|
|
4
|
+
module CLI
|
|
5
|
+
class Command
|
|
6
|
+
class Session < self
|
|
7
|
+
NAME = 'session'
|
|
8
|
+
SHORT_DESCRIPTION = 'Session history subcommands'
|
|
9
|
+
|
|
10
|
+
RESULTS_DIR = '.mutant/results'
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def session_files
|
|
15
|
+
dir = world.pathname.new(RESULTS_DIR)
|
|
16
|
+
|
|
17
|
+
return [] unless dir.directory?
|
|
18
|
+
|
|
19
|
+
dir.glob('*.json')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_session_file(path)
|
|
23
|
+
world.parse_json(path.read)
|
|
24
|
+
.bind(&Result::Session::JSON.load_transform.public_method(:call))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Shared base for commands that operate on a session
|
|
28
|
+
class SessionCommand < self
|
|
29
|
+
OPTIONS = %i[add_session_id_option].freeze
|
|
30
|
+
|
|
31
|
+
UUID_FORMAT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
32
|
+
|
|
33
|
+
private_constant(:UUID_FORMAT)
|
|
34
|
+
|
|
35
|
+
def display_config
|
|
36
|
+
@display_config || Reporter::CLI::Printer::DisplayConfig::DEFAULT
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def action
|
|
40
|
+
path = resolve_session_path or return Either::Left.new('No sessions found')
|
|
41
|
+
|
|
42
|
+
return Either::Left.new("Session file not found: #{path}") unless path.file?
|
|
43
|
+
|
|
44
|
+
load_session_file(path).either(
|
|
45
|
+
lambda { |error|
|
|
46
|
+
Either::Left.new("Failed to load session: #{error}\nRun `mutant session gc` to remove incompatible sessions.")
|
|
47
|
+
},
|
|
48
|
+
method(:run_report)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def add_session_id_option(parser)
|
|
55
|
+
parser.on('--session-id=ID', 'Session ID to operate on (default: latest)') do |value|
|
|
56
|
+
fail(OptionParser::InvalidArgument, "invalid UUID format: #{value}") unless UUID_FORMAT.match?(value)
|
|
57
|
+
|
|
58
|
+
@session_id = value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def add_verbose_option(parser)
|
|
63
|
+
parser.separator("\nDisplay Options:\n\n")
|
|
64
|
+
parser.on('--verbose', 'Show verbose output') do
|
|
65
|
+
@display_config = Reporter::CLI::Printer::DisplayConfig::VERBOSE
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_session_path
|
|
70
|
+
if @session_id
|
|
71
|
+
world.pathname.new("#{RESULTS_DIR}/#{@session_id}.json")
|
|
72
|
+
else
|
|
73
|
+
session_files.last
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_report(session)
|
|
78
|
+
print("Session: #{session.session_id}")
|
|
79
|
+
|
|
80
|
+
print_report(session)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
abstract_method :print_report
|
|
84
|
+
end # SessionCommand
|
|
85
|
+
|
|
86
|
+
class List < self
|
|
87
|
+
NAME = 'list'
|
|
88
|
+
SHORT_DESCRIPTION = 'List past mutation testing sessions'
|
|
89
|
+
SUBCOMMANDS = [].freeze
|
|
90
|
+
|
|
91
|
+
HEADER_FORMAT = '%-6s %-10s %-8s %-10s %-10s %-36s %s'
|
|
92
|
+
ROW_FORMAT = '%-6s %-10s %-8s %-10s %-10s %-36s %s'
|
|
93
|
+
INCOMPATIBLE = '--------------- [incompatible] ---------------'
|
|
94
|
+
|
|
95
|
+
def action
|
|
96
|
+
print_header
|
|
97
|
+
session_files.reverse_each(&method(:print_session))
|
|
98
|
+
|
|
99
|
+
Either::Right.new(nil)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def print_header
|
|
105
|
+
print(HEADER_FORMAT % ['ALIVE', 'MUTATIONS', 'SUBJECTS', 'RUNTIME', 'KILLTIME', 'SESSION ID', 'TIMESTAMP'])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def print_session(path)
|
|
109
|
+
load_session_file(path).either(
|
|
110
|
+
->(_error) { print(colorize_unsupported(path)) },
|
|
111
|
+
method(:print_session_row)
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def print_session_row(session)
|
|
116
|
+
subjects = session.subject_results
|
|
117
|
+
|
|
118
|
+
print(ROW_FORMAT % [
|
|
119
|
+
subjects.sum(&:amount_mutations_alive),
|
|
120
|
+
subjects.sum(&:amount_mutations),
|
|
121
|
+
subjects.length,
|
|
122
|
+
format_time(session.runtime),
|
|
123
|
+
format_time(session.killtime),
|
|
124
|
+
session.session_id,
|
|
125
|
+
session.timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
|
126
|
+
])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def format_time(seconds)
|
|
130
|
+
'%.2fs' % seconds
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def colorize_unsupported(path)
|
|
134
|
+
session_id = path.basename('.json')
|
|
135
|
+
|
|
136
|
+
Unparser::Color::RED.format(INCOMPATIBLE.ljust(54)) + session_id.to_s
|
|
137
|
+
end
|
|
138
|
+
end # List
|
|
139
|
+
|
|
140
|
+
class Show < SessionCommand
|
|
141
|
+
include Mutant::Reporter::CLI::Printer::AliveResults
|
|
142
|
+
|
|
143
|
+
NAME = 'show'
|
|
144
|
+
SHORT_DESCRIPTION = 'Show results of a past session'
|
|
145
|
+
SUBCOMMANDS = [].freeze
|
|
146
|
+
OPTIONS = (superclass::OPTIONS + %i[add_verbose_option]).freeze
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def print_report(session)
|
|
151
|
+
failed = session.subject_results.reject(&:success?)
|
|
152
|
+
|
|
153
|
+
print("Time: #{session.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
154
|
+
print("Version: #{session.mutant_version}")
|
|
155
|
+
print("Ruby: #{session.ruby_version}")
|
|
156
|
+
print("Subjects: #{session.subject_results.length}")
|
|
157
|
+
print("Alive: #{failed.flat_map(&:uncovered_results).length}")
|
|
158
|
+
|
|
159
|
+
print_alive_results(failed)
|
|
160
|
+
|
|
161
|
+
Either::Right.new(nil)
|
|
162
|
+
end
|
|
163
|
+
end # Show
|
|
164
|
+
|
|
165
|
+
class Subject < SessionCommand
|
|
166
|
+
include Mutant::Reporter::CLI::Printer::AliveResults
|
|
167
|
+
|
|
168
|
+
NAME = 'subject'
|
|
169
|
+
SHORT_DESCRIPTION = 'List subjects or show alive mutations for a specific subject'
|
|
170
|
+
SUBCOMMANDS = [].freeze
|
|
171
|
+
OPTIONS = (superclass::OPTIONS + %i[add_verbose_option]).freeze
|
|
172
|
+
|
|
173
|
+
HEADER_FORMAT = '%-6s %-6s %s'
|
|
174
|
+
ROW_FORMAT = '%-6s %-6s %s'
|
|
175
|
+
|
|
176
|
+
def parse_remaining_arguments(arguments)
|
|
177
|
+
case arguments.length
|
|
178
|
+
when 0 then Either::Right.new(self)
|
|
179
|
+
when 1
|
|
180
|
+
@expression = Mutant::Util.one(arguments)
|
|
181
|
+
Either::Right.new(self)
|
|
182
|
+
else
|
|
183
|
+
Either::Left.new('Expected zero or one subject expression argument')
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def print_report(session)
|
|
190
|
+
if @expression
|
|
191
|
+
print_subject_detail(session)
|
|
192
|
+
else
|
|
193
|
+
print_subject_list(session)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def print_subject_list(session)
|
|
198
|
+
print(HEADER_FORMAT % %w[ALIVE TOTAL SUBJECT])
|
|
199
|
+
|
|
200
|
+
session.subject_results
|
|
201
|
+
.sort_by { |subject_result| -subject_result.uncovered_results.length }
|
|
202
|
+
.each(&method(:print_subject_row))
|
|
203
|
+
|
|
204
|
+
Either::Right.new(nil)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def print_subject_row(subject_result)
|
|
208
|
+
alive = subject_result.uncovered_results.length
|
|
209
|
+
total = subject_result.amount_mutations
|
|
210
|
+
|
|
211
|
+
print(ROW_FORMAT % [alive, total, subject_result.expression_syntax])
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def print_subject_detail(session)
|
|
215
|
+
subject_result = session.subject_results.detect do |subject_result|
|
|
216
|
+
subject_result.expression_syntax.eql?(@expression)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
return Either::Left.new("Subject not found: #{@expression}") unless subject_result
|
|
220
|
+
|
|
221
|
+
print_alive_results([subject_result])
|
|
222
|
+
|
|
223
|
+
Either::Right.new(nil)
|
|
224
|
+
end
|
|
225
|
+
end # Subject
|
|
226
|
+
|
|
227
|
+
class GC < self
|
|
228
|
+
NAME = 'gc'
|
|
229
|
+
SHORT_DESCRIPTION = 'Remove incompatible and old session results'
|
|
230
|
+
SUBCOMMANDS = [].freeze
|
|
231
|
+
OPTIONS = %i[add_gc_options].freeze
|
|
232
|
+
|
|
233
|
+
DEFAULT_KEEP = 100
|
|
234
|
+
|
|
235
|
+
def initialize(*)
|
|
236
|
+
super
|
|
237
|
+
@keep = DEFAULT_KEEP
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def action
|
|
241
|
+
incompatible, compatible = partition_sessions
|
|
242
|
+
|
|
243
|
+
incompatible.each(&:delete)
|
|
244
|
+
|
|
245
|
+
excess = compatible.length > @keep ? compatible.first(compatible.length - @keep) : []
|
|
246
|
+
excess.each(&:delete)
|
|
247
|
+
|
|
248
|
+
print("Removed #{incompatible.length + excess.length} session(s)")
|
|
249
|
+
|
|
250
|
+
Either::Right.new(nil)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
def add_gc_options(parser)
|
|
256
|
+
parser.on('--keep=N', Integer, "Keep N most recent sessions (default: #{DEFAULT_KEEP})") do |value|
|
|
257
|
+
@keep = value
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def partition_sessions
|
|
262
|
+
incompatible = []
|
|
263
|
+
compatible = []
|
|
264
|
+
|
|
265
|
+
session_files.each do |path|
|
|
266
|
+
load_session_file(path).either(
|
|
267
|
+
->(_error) { incompatible << path },
|
|
268
|
+
->(_session) { compatible << path }
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
[incompatible, compatible]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
end # GC
|
|
276
|
+
|
|
277
|
+
SUBCOMMANDS = [List, Show, Subject, GC].freeze
|
|
278
|
+
end # Session
|
|
279
|
+
end # Command
|
|
280
|
+
end # CLI
|
|
281
|
+
end # Mutant
|
data/lib/mutant/cli/command.rb
CHANGED
|
@@ -220,8 +220,22 @@ module Mutant
|
|
|
220
220
|
"#{full_name}: #{message}\n\n#{parser}"
|
|
221
221
|
end
|
|
222
222
|
|
|
223
|
-
def
|
|
224
|
-
world.stdout
|
|
223
|
+
def output
|
|
224
|
+
world.stdout
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def puts(message)
|
|
228
|
+
output.puts(message)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
alias_method :print, :puts
|
|
232
|
+
|
|
233
|
+
def parse_remaining_arguments(arguments)
|
|
234
|
+
if arguments.empty?
|
|
235
|
+
Either::Right.new(self)
|
|
236
|
+
else
|
|
237
|
+
Either::Left.new("unexpected arguments: #{arguments.join(' ')}")
|
|
238
|
+
end
|
|
225
239
|
end
|
|
226
240
|
end # Command
|
|
227
241
|
# rubocop:enable Metrics/ClassLength
|
data/lib/mutant/config.rb
CHANGED
|
@@ -120,7 +120,7 @@ module Mutant
|
|
|
120
120
|
.lmap { |exception| "MUTANT_JOBS environment variable has invalid value: #{jobs.inspect} - #{exception}" }
|
|
121
121
|
.fmap { |jobs_value| DEFAULT.with(jobs: jobs_value) }
|
|
122
122
|
else
|
|
123
|
-
Either::Right.new(DEFAULT)
|
|
123
|
+
Either::Right.new(DEFAULT.with(coverage_criteria: CoverageCriteria::EMPTY))
|
|
124
124
|
end
|
|
125
125
|
end
|
|
126
126
|
private_class_method :load_env_config
|
|
@@ -132,11 +132,7 @@ module Mutant
|
|
|
132
132
|
def load_result(result_fragments)
|
|
133
133
|
@value = world.marshal.load(result_fragments.join)
|
|
134
134
|
rescue ArgumentError => exception
|
|
135
|
-
@exception = Exception.
|
|
136
|
-
backtrace: exception.backtrace,
|
|
137
|
-
message: exception.message,
|
|
138
|
-
original_class: exception.class
|
|
139
|
-
)
|
|
135
|
+
@exception = Mutant::Result::Exception.from_exception(exception)
|
|
140
136
|
end
|
|
141
137
|
|
|
142
138
|
# rubocop:disable Metrics/MethodLength
|
|
@@ -197,7 +193,7 @@ module Mutant
|
|
|
197
193
|
end
|
|
198
194
|
|
|
199
195
|
def handle_status(status)
|
|
200
|
-
@process_status = status
|
|
196
|
+
@process_status = Mutant::Result::ProcessStatus.from_process_status(status)
|
|
201
197
|
end
|
|
202
198
|
|
|
203
199
|
def peek_child
|
data/lib/mutant/isolation.rb
CHANGED
|
@@ -21,6 +21,37 @@ module Mutant
|
|
|
21
21
|
def valid_value?
|
|
22
22
|
timeout.nil? && exception.nil? && (process_status.nil? || process_status.success?)
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
dump = Transform::Success.new(
|
|
26
|
+
block: lambda do |object|
|
|
27
|
+
{
|
|
28
|
+
'exception' => object.exception && Mutant::Result::Exception::JSON.dump(object.exception).from_right,
|
|
29
|
+
'log' => object.log,
|
|
30
|
+
'process_status' => object.process_status && Mutant::Result::ProcessStatus::JSON.dump(object.process_status).from_right,
|
|
31
|
+
'timeout' => object.timeout,
|
|
32
|
+
'value' => object.value && Mutant::Result::Test::JSON.dump(object.value).from_right
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
load = Transform::Sequence.new(
|
|
38
|
+
steps: [
|
|
39
|
+
Transform::Hash.new(
|
|
40
|
+
required: [
|
|
41
|
+
Transform::Hash::Key.new(value: 'exception', transform: Transform::Nullable.new(transform: Mutant::Result::Exception::JSON.load_transform)),
|
|
42
|
+
Transform::Hash::Key.new(value: 'log', transform: Transform::STRING),
|
|
43
|
+
Transform::Hash::Key.new(value: 'process_status', transform: Transform::Nullable.new(transform: Mutant::Result::ProcessStatus::JSON.load_transform)),
|
|
44
|
+
Transform::Hash::Key.new(value: 'timeout', transform: Transform::Nullable.new(transform: Transform::FLOAT)),
|
|
45
|
+
Transform::Hash::Key.new(value: 'value', transform: Transform::Nullable.new(transform: Mutant::Result::Test::JSON.load_transform))
|
|
46
|
+
],
|
|
47
|
+
optional: []
|
|
48
|
+
),
|
|
49
|
+
Transform::Hash::Symbolize.new,
|
|
50
|
+
Transform::Success.new(block: method(:new).to_proc)
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
JSON = Transform::JSON.new(dump_transform: dump, load_transform: load)
|
|
24
55
|
end # Result
|
|
25
56
|
|
|
26
57
|
# Call block in isolation
|
data/lib/mutant/matcher/null.rb
CHANGED
|
@@ -40,21 +40,30 @@ module Mutant
|
|
|
40
40
|
# @param [Parallel::Response] response
|
|
41
41
|
#
|
|
42
42
|
# @return [self]
|
|
43
|
+
# rubocop:disable Metrics/AbcSize
|
|
44
|
+
# rubocop:disable Metrics/MethodLength
|
|
43
45
|
def response(response)
|
|
44
46
|
fail response.error if response.error
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
mutation = env.mutations.fetch(response.result.mutation_index)
|
|
49
|
+
subject = mutation.subject
|
|
50
|
+
mutation_result = mutation_result(mutation, response.result)
|
|
49
51
|
|
|
50
52
|
@subject_results[subject] = Result::Subject.new(
|
|
51
|
-
subject
|
|
52
|
-
coverage_results:
|
|
53
|
-
|
|
53
|
+
amount_mutations: subject.mutations.length,
|
|
54
|
+
coverage_results: previous_coverage_results(subject).dup << coverage_result(mutation_result),
|
|
55
|
+
expression_syntax: subject.expression.syntax,
|
|
56
|
+
identification: subject.identification,
|
|
57
|
+
node: subject.node,
|
|
58
|
+
source: subject.source,
|
|
59
|
+
source_path: subject.source_path.to_s,
|
|
60
|
+
tests: env.selections.fetch(subject)
|
|
54
61
|
)
|
|
55
62
|
|
|
56
63
|
self
|
|
57
64
|
end
|
|
65
|
+
# rubocop:enable Metrics/AbcSize
|
|
66
|
+
# rubocop:enable Metrics/MethodLength
|
|
58
67
|
|
|
59
68
|
private
|
|
60
69
|
|
|
@@ -65,11 +74,15 @@ module Mutant
|
|
|
65
74
|
)
|
|
66
75
|
end
|
|
67
76
|
|
|
68
|
-
def mutation_result(mutation_index_result)
|
|
77
|
+
def mutation_result(mutation, mutation_index_result)
|
|
69
78
|
Result::Mutation.new(
|
|
70
|
-
isolation_result:
|
|
71
|
-
mutation
|
|
72
|
-
|
|
79
|
+
isolation_result: mutation_index_result.isolation_result,
|
|
80
|
+
mutation_diff: mutation.diff.diff,
|
|
81
|
+
mutation_identification: mutation.identification,
|
|
82
|
+
mutation_node: mutation.node,
|
|
83
|
+
mutation_source: mutation.source,
|
|
84
|
+
mutation_type: mutation.class::SYMBOL,
|
|
85
|
+
runtime: mutation_index_result.runtime
|
|
73
86
|
)
|
|
74
87
|
end
|
|
75
88
|
|
|
@@ -18,6 +18,7 @@ module Mutant
|
|
|
18
18
|
env
|
|
19
19
|
.record(:analysis) { run_driver(reporter, async_driver(env)) }
|
|
20
20
|
.tap { |result| env.record(:report) { reporter.report(result) } }
|
|
21
|
+
.tap { |result| Result::JSONWriter.new(env:, result:).call }
|
|
21
22
|
end
|
|
22
23
|
private_class_method :run_mutation_analysis
|
|
23
24
|
|
data/lib/mutant/mutation.rb
CHANGED
|
@@ -90,15 +90,6 @@ module Mutant
|
|
|
90
90
|
# @return [String]
|
|
91
91
|
def original_source = subject.source
|
|
92
92
|
|
|
93
|
-
# Test if mutation is killed by test reports
|
|
94
|
-
#
|
|
95
|
-
# @param [Result::Test] test_result
|
|
96
|
-
#
|
|
97
|
-
# @return [Boolean]
|
|
98
|
-
def self.success?(test_result)
|
|
99
|
-
self::TEST_PASS_SUCCESS.equal?(test_result.passed)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
93
|
# Insert mutated node
|
|
103
94
|
#
|
|
104
95
|
# @param kernel [Kernel]
|
|
@@ -126,25 +117,17 @@ module Mutant
|
|
|
126
117
|
|
|
127
118
|
# Evil mutation that should case mutations to fail tests
|
|
128
119
|
class Evil < self
|
|
129
|
-
SYMBOL
|
|
130
|
-
TEST_PASS_SUCCESS = false
|
|
131
|
-
|
|
120
|
+
SYMBOL = 'evil'
|
|
132
121
|
end # Evil
|
|
133
122
|
|
|
134
123
|
# Neutral mutation that should not cause mutations to fail tests
|
|
135
124
|
class Neutral < self
|
|
136
|
-
|
|
137
|
-
SYMBOL = 'neutral'
|
|
138
|
-
TEST_PASS_SUCCESS = true
|
|
139
|
-
|
|
125
|
+
SYMBOL = 'neutral'
|
|
140
126
|
end # Neutral
|
|
141
127
|
|
|
142
128
|
# Noop mutation, special case of neutral
|
|
143
129
|
class Noop < Neutral
|
|
144
|
-
|
|
145
|
-
SYMBOL = 'noop'
|
|
146
|
-
TEST_PASS_SUCCESS = true
|
|
147
|
-
|
|
130
|
+
SYMBOL = 'noop'
|
|
148
131
|
end # Noop
|
|
149
132
|
|
|
150
133
|
end # Mutation
|
|
@@ -11,11 +11,72 @@ module Mutant
|
|
|
11
11
|
|
|
12
12
|
children :value
|
|
13
13
|
|
|
14
|
+
# Integer overflow boundary probe zones.
|
|
15
|
+
#
|
|
16
|
+
# Each entry pairs a zone boundary with a safe prime sentinel
|
|
17
|
+
# value that falls within that zone. The literal is mutated to
|
|
18
|
+
# the sentinel of the next zone above its absolute value,
|
|
19
|
+
# probing whether consuming code handles values that cross the
|
|
20
|
+
# boundary of the next integer width.
|
|
21
|
+
#
|
|
22
|
+
# Safe primes (p = 2q + 1 where both p and q are prime) are
|
|
23
|
+
# chosen as sentinels because they cannot arise from simple
|
|
24
|
+
# arithmetic (multiplication, bit shifts, masking), providing
|
|
25
|
+
# strong guarantees against coincidental mutation kills. A
|
|
26
|
+
# mutation surviving against a safe prime is a strong signal
|
|
27
|
+
# of missing boundary validation for that integer width.
|
|
28
|
+
#
|
|
29
|
+
# Zone layout:
|
|
30
|
+
#
|
|
31
|
+
# Zone Boundary Sentinel
|
|
32
|
+
# int8 128 167
|
|
33
|
+
# uint8 256 467
|
|
34
|
+
# int16 32768 55_487
|
|
35
|
+
# uint16 65536 108_503
|
|
36
|
+
# int32 2^31 2_667_278_543
|
|
37
|
+
# uint32 2^32 7_980_081_959
|
|
38
|
+
# int64 2^63 15_508_464_536_481_899_903
|
|
39
|
+
#
|
|
40
|
+
# A literal snaps to the next zone above its absolute value.
|
|
41
|
+
# For example, a literal 100 (below int8 boundary 128) emits
|
|
42
|
+
# sentinel 167. A literal 200 (above int8, below uint8) emits
|
|
43
|
+
# sentinel 467. Values at or above 2^63 emit no sentinel as
|
|
44
|
+
# there is no higher zone to probe.
|
|
45
|
+
#
|
|
46
|
+
# Future versions of mutant will add infrastructure to explain
|
|
47
|
+
# alive mutations, including which overflow zone a surviving
|
|
48
|
+
# sentinel belongs to and what class of bug it indicates.
|
|
49
|
+
class OverflowZone
|
|
50
|
+
include Anima.new(:name, :boundary, :sentinel)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
OVERFLOW_ZONES = [
|
|
54
|
+
OverflowZone.new(name: :int8, boundary: 2**7, sentinel: 167),
|
|
55
|
+
OverflowZone.new(name: :uint8, boundary: 2**8, sentinel: 467),
|
|
56
|
+
OverflowZone.new(name: :int16, boundary: 2**15, sentinel: 55_487),
|
|
57
|
+
OverflowZone.new(name: :uint16, boundary: 2**16, sentinel: 108_503),
|
|
58
|
+
OverflowZone.new(name: :int32, boundary: 2**31, sentinel: 2_667_278_543),
|
|
59
|
+
OverflowZone.new(name: :uint32, boundary: 2**32, sentinel: 7_980_081_959),
|
|
60
|
+
OverflowZone.new(name: :int64, boundary: 2**63, sentinel: 15_508_464_536_481_899_903)
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
14
63
|
private
|
|
15
64
|
|
|
16
65
|
def dispatch
|
|
17
66
|
emit_singletons
|
|
18
67
|
emit_values
|
|
68
|
+
emit_overflow_sentinel
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def emit_overflow_sentinel
|
|
72
|
+
absolute = value.abs
|
|
73
|
+
|
|
74
|
+
OVERFLOW_ZONES.each do |zone|
|
|
75
|
+
if absolute < zone.boundary
|
|
76
|
+
emit_type(zone.sentinel)
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
end
|
|
19
80
|
end
|
|
20
81
|
|
|
21
82
|
def values
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutant
|
|
4
|
+
class Reporter
|
|
5
|
+
class CLI
|
|
6
|
+
class Printer
|
|
7
|
+
# Shared logic for printing alive mutation results
|
|
8
|
+
module AliveResults
|
|
9
|
+
ALIVE_EXPLANATION = <<~'MESSAGE'
|
|
10
|
+
Uncovered mutations detected, exiting nonzero!
|
|
11
|
+
Alive mutations require one of two actions:
|
|
12
|
+
A) Keep the mutated code: Your tests specify the correct semantics,
|
|
13
|
+
and the original code is redundant. Accept the mutation.
|
|
14
|
+
B) Add a missing test: The original code is correct, but the tests
|
|
15
|
+
do not verify the behavior the mutation removed.
|
|
16
|
+
MESSAGE
|
|
17
|
+
|
|
18
|
+
def print_alive_results(failed_subject_results)
|
|
19
|
+
failed_subject_results.each do |subject_result|
|
|
20
|
+
SubjectResult.call(display_config:, output:, object: subject_result)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end # AliveResults
|
|
24
|
+
end # Printer
|
|
25
|
+
end # CLI
|
|
26
|
+
end # Reporter
|
|
27
|
+
end # Mutant
|