mutant 0.16.3 → 0.17.1
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/ast/structure.rb +71 -0
- data/lib/mutant/bootstrap.rb +56 -7
- data/lib/mutant/cli/command/environment.rb +20 -2
- data/lib/mutant/cli/command/session.rb +19 -11
- data/lib/mutant/config/selection.rb +92 -0
- data/lib/mutant/config.rb +6 -0
- data/lib/mutant/context_map/loader.rb +246 -0
- data/lib/mutant/context_map.rb +109 -0
- data/lib/mutant/integration.rb +1 -1
- data/lib/mutant/matcher/method/instance.rb +26 -0
- data/lib/mutant/meta.rb +1 -1
- data/lib/mutant/mutation/runner/sink.rb +27 -1
- data/lib/mutant/mutation/runner.rb +1 -0
- data/lib/mutant/mutator/node/itblock.rb +36 -0
- data/lib/mutant/reporter/cli/printer/env.rb +6 -0
- data/lib/mutant/result/json_writer.rb +38 -6
- data/lib/mutant/result/session_files.rb +51 -0
- data/lib/mutant/result.rb +1 -1
- data/lib/mutant/selector/context_map.rb +94 -0
- data/lib/mutant/selector/expression.rb +5 -0
- data/lib/mutant/selector/null.rb +5 -0
- data/lib/mutant/selector.rb +5 -0
- data/lib/mutant/subject/method/instance.rb +22 -0
- data/lib/mutant/test.rb +2 -1
- data/lib/mutant/transform.rb +69 -0
- data/lib/mutant.rb +8 -0
- metadata +8 -2
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutant
|
|
4
|
+
class ContextMap
|
|
5
|
+
# Reads simplecov's `coverage.json` into a ContextMap
|
|
6
|
+
#
|
|
7
|
+
# Every way the read can fail ends in an explanation of how to produce the
|
|
8
|
+
# recording, because the recording is the whole reason the strategy was
|
|
9
|
+
# asked for.
|
|
10
|
+
#
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
12
|
+
class Loader
|
|
13
|
+
include Anima.new(:artifact, :world)
|
|
14
|
+
|
|
15
|
+
MISSING_ARTIFACT = <<~'MESSAGE'
|
|
16
|
+
Test selection strategy `context_map` needs a per test coverage recording,
|
|
17
|
+
but none exists at:
|
|
18
|
+
|
|
19
|
+
%<path>s
|
|
20
|
+
|
|
21
|
+
Record one with simplecov 1.2.0 or newer:
|
|
22
|
+
|
|
23
|
+
# spec/spec_helper.rb, or test/test_helper.rb
|
|
24
|
+
require 'simplecov'
|
|
25
|
+
SimpleCov.start do
|
|
26
|
+
track_tests
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Run the test suite once to write the recording, then re-run mutant. The
|
|
30
|
+
default HTML formatter writes `coverage.json` beside its report, as does
|
|
31
|
+
`SimpleCov::Formatter::JSONFormatter`.
|
|
32
|
+
|
|
33
|
+
Pass `--selection-path DIRECTORY` if the recording is not under ./coverage.
|
|
34
|
+
MESSAGE
|
|
35
|
+
|
|
36
|
+
UNREADABLE_ARTIFACT = <<~'MESSAGE'
|
|
37
|
+
Unable to read the coverage recording at:
|
|
38
|
+
|
|
39
|
+
%<path>s
|
|
40
|
+
|
|
41
|
+
%<error>s
|
|
42
|
+
MESSAGE
|
|
43
|
+
|
|
44
|
+
INVALID_ARTIFACT = <<~'MESSAGE'
|
|
45
|
+
The coverage recording at:
|
|
46
|
+
|
|
47
|
+
%<path>s
|
|
48
|
+
|
|
49
|
+
is not a simplecov report mutant can read:
|
|
50
|
+
|
|
51
|
+
%<error>s
|
|
52
|
+
|
|
53
|
+
Delete it and record it again with simplecov 1.2.0 or newer.
|
|
54
|
+
MESSAGE
|
|
55
|
+
|
|
56
|
+
UNKNOWN_SCHEMA = <<~'MESSAGE'
|
|
57
|
+
The coverage recording at:
|
|
58
|
+
|
|
59
|
+
%<path>s
|
|
60
|
+
|
|
61
|
+
uses report schema version %<schema>s, which mutant does not understand.
|
|
62
|
+
Upgrade mutant, or record again with a simplecov that writes schema
|
|
63
|
+
version 1.
|
|
64
|
+
MESSAGE
|
|
65
|
+
|
|
66
|
+
NO_CONTEXTS = <<~'MESSAGE'
|
|
67
|
+
The coverage recording at:
|
|
68
|
+
|
|
69
|
+
%<path>s
|
|
70
|
+
|
|
71
|
+
has no per test data, so mutant cannot tell which tests cover a subject.
|
|
72
|
+
|
|
73
|
+
Enable tracking with simplecov 1.2.0 or newer:
|
|
74
|
+
|
|
75
|
+
SimpleCov.start do
|
|
76
|
+
track_tests
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Run the test suite once with tracking enabled, then re-run mutant.
|
|
80
|
+
MESSAGE
|
|
81
|
+
|
|
82
|
+
UNKNOWN_CONTEXT = '%<file>s refers to a context the recording does not list'
|
|
83
|
+
NOT_DIGITS = 'Expected: digits in base %<base>d but got: %<actual>s'
|
|
84
|
+
|
|
85
|
+
# An integer written as digits in a base, and nothing else
|
|
86
|
+
#
|
|
87
|
+
# Kernel#Integer would also take a sign, a prefix, whitespace or
|
|
88
|
+
# underscores, none of which the format allows.
|
|
89
|
+
def self.parse_digits(string, base:, pattern:)
|
|
90
|
+
if string.match?(pattern)
|
|
91
|
+
Either::Right.new(Integer(string, base))
|
|
92
|
+
else
|
|
93
|
+
Either::Left.new(NOT_DIGITS % { actual: string.inspect, base: })
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
private_class_method :parse_digits
|
|
97
|
+
|
|
98
|
+
CONTEXT_INDEX = Transform::Sequence.new(
|
|
99
|
+
steps: [
|
|
100
|
+
Transform::STRING,
|
|
101
|
+
Transform::Block.capture(:decimal) { |string| parse_digits(string, base: 10, pattern: /\A\d+\z/) }
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
LINE_BITMAP = Transform::Sequence.new(
|
|
106
|
+
steps: [
|
|
107
|
+
Transform::STRING,
|
|
108
|
+
Transform::Block.capture(:hexadecimal) { |string| parse_digits(string, base: 16, pattern: /\A\h+\z/) }
|
|
109
|
+
]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Per source file: the index of each test that executed it, and a bitmap
|
|
113
|
+
# of the lines it executed
|
|
114
|
+
TABLE = Transform::Hash::Map.new(key: CONTEXT_INDEX, value: LINE_BITMAP)
|
|
115
|
+
|
|
116
|
+
# The report carries far more than mutant reads, and gains keys between
|
|
117
|
+
# minor schema versions. Each hash below is sliced to the keys mutant
|
|
118
|
+
# names before the strict transform sees it.
|
|
119
|
+
#
|
|
120
|
+
# A file with no `contexts` section is one no recorded test executed, and
|
|
121
|
+
# contributes nothing rather than making the recording unreadable.
|
|
122
|
+
FILE = Transform::Sequence.new(
|
|
123
|
+
steps: [
|
|
124
|
+
Transform::Hash::Slice.new(keys: %w[contexts]),
|
|
125
|
+
Transform::Hash.new(
|
|
126
|
+
optional: [Transform::Hash::Key.new(value: 'contexts', transform: TABLE)],
|
|
127
|
+
required: []
|
|
128
|
+
)
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
META = Transform::Sequence.new(
|
|
133
|
+
steps: [
|
|
134
|
+
Transform::Hash::Slice.new(keys: %w[root schema_version]),
|
|
135
|
+
Transform::Hash.new(
|
|
136
|
+
optional: [],
|
|
137
|
+
required: [
|
|
138
|
+
Transform::Hash::Key.new(value: 'root', transform: Transform::STRING),
|
|
139
|
+
Transform::Hash::Key.new(value: 'schema_version', transform: Transform::STRING)
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
COVERAGE = Transform::Hash::Map.new(key: Transform::STRING, value: FILE)
|
|
146
|
+
|
|
147
|
+
DOCUMENT = Transform::Sequence.new(
|
|
148
|
+
steps: [
|
|
149
|
+
Transform::Hash::Slice.new(keys: %w[contexts coverage meta]),
|
|
150
|
+
Transform::Hash.new(
|
|
151
|
+
optional: [Transform::Hash::Key.new(value: 'contexts', transform: Transform::STRING_ARRAY)],
|
|
152
|
+
required: [
|
|
153
|
+
Transform::Hash::Key.new(value: 'coverage', transform: COVERAGE),
|
|
154
|
+
Transform::Hash::Key.new(value: 'meta', transform: META)
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
private_constant(*constants(false))
|
|
161
|
+
|
|
162
|
+
# Load the recording under path
|
|
163
|
+
#
|
|
164
|
+
# @return [Either<String, ContextMap>]
|
|
165
|
+
def self.call(path:, world:)
|
|
166
|
+
artifact = path.file? ? path : path.join(ARTIFACT_BASENAME)
|
|
167
|
+
|
|
168
|
+
new(artifact:, world:).call
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Load the recording
|
|
172
|
+
#
|
|
173
|
+
# @return [Either<String, ContextMap>]
|
|
174
|
+
def call
|
|
175
|
+
return error(MISSING_ARTIFACT) unless artifact.file?
|
|
176
|
+
|
|
177
|
+
read
|
|
178
|
+
.bind(&method(:parse))
|
|
179
|
+
.bind(&method(:decode))
|
|
180
|
+
.bind(&method(:from_document))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def read
|
|
186
|
+
Either
|
|
187
|
+
.wrap_error(SystemCallError) { artifact.read }
|
|
188
|
+
.lmap { |exception| unreadable(exception) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def parse(contents)
|
|
192
|
+
world.parse_json(contents).lmap { |exception| unreadable(exception) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def decode(data)
|
|
196
|
+
DOCUMENT.call(data).lmap { |error| INVALID_ARTIFACT % { error: error.compact_message, path: artifact } }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def unreadable(exception)
|
|
200
|
+
UNREADABLE_ARTIFACT % { error: exception, path: artifact }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def error(message, **arguments)
|
|
204
|
+
Either::Left.new(message % { path: artifact, **arguments })
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def from_document(document)
|
|
208
|
+
meta = document.fetch('meta')
|
|
209
|
+
schema = meta.fetch('schema_version')
|
|
210
|
+
|
|
211
|
+
return error(UNKNOWN_SCHEMA, schema: schema.inspect) unless schema.match?(SUPPORTED_SCHEMA)
|
|
212
|
+
|
|
213
|
+
contexts = document.fetch('contexts', nil) or return error(NO_CONTEXTS)
|
|
214
|
+
|
|
215
|
+
load_tables(contexts:, coverage: document.fetch('coverage'), root: meta.fetch('root'))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def load_tables(contexts:, coverage:, root:)
|
|
219
|
+
ids = contexts.map { |context| ContextMap.normalize(context, root) }
|
|
220
|
+
|
|
221
|
+
tables = coverage.each_with_object({}) do |(file, entry), tables|
|
|
222
|
+
table = entry.fetch('contexts', nil) or next
|
|
223
|
+
|
|
224
|
+
resolved = resolve(ids:, table:) or return error(INVALID_ARTIFACT, error: UNKNOWN_CONTEXT % { file: })
|
|
225
|
+
|
|
226
|
+
tables[File.expand_path(file.delete_prefix('/'), root)] = resolved
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
Either::Right.new(ContextMap.new(root:, tables:))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# nil on an index outside the recording's list. A half read recording
|
|
233
|
+
# would answer coverage questions with silent gaps, and a silent gap is
|
|
234
|
+
# a mutation reported alive that no test was ever given the chance to
|
|
235
|
+
# kill.
|
|
236
|
+
def resolve(ids:, table:)
|
|
237
|
+
table.each_with_object({}) do |(index, bitmap), resolved|
|
|
238
|
+
id = ids.at(index) or return nil
|
|
239
|
+
|
|
240
|
+
resolved[id] = resolved.fetch(id, 0) | bitmap
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end # Loader
|
|
244
|
+
# rubocop:enable Metrics/ClassLength
|
|
245
|
+
end # ContextMap
|
|
246
|
+
end # Mutant
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutant
|
|
4
|
+
# Per test coverage, read from the artifact a coverage tool wrote.
|
|
5
|
+
#
|
|
6
|
+
# Mutant does not depend on the tool that produces the recording. It reads
|
|
7
|
+
# the `contexts` sections of simplecov's `coverage.json`, which appear once
|
|
8
|
+
# `track_tests` is enabled: an interned list of test ids, and per source file
|
|
9
|
+
# a bitmap of the lines each of those tests executed.
|
|
10
|
+
#
|
|
11
|
+
# The recording answers the question the expression selector can only guess
|
|
12
|
+
# at, which tests actually executed the lines a subject is made of.
|
|
13
|
+
class ContextMap
|
|
14
|
+
include Adamantium, Anima.new(:root, :tables)
|
|
15
|
+
|
|
16
|
+
# Basename simplecov writes its report under
|
|
17
|
+
ARTIFACT_BASENAME = 'coverage.json'
|
|
18
|
+
|
|
19
|
+
# Report schema versions this reader understands
|
|
20
|
+
SUPPORTED_SCHEMA = /\A1\./
|
|
21
|
+
|
|
22
|
+
# Absolute `path:line` form of a location
|
|
23
|
+
#
|
|
24
|
+
# Test ids are recorded relative to the project root, while the
|
|
25
|
+
# integrations report locations the way their framework spells them. Both
|
|
26
|
+
# sides pass through here so that they compare as strings.
|
|
27
|
+
#
|
|
28
|
+
# @param [String] location
|
|
29
|
+
# @param [String] root
|
|
30
|
+
#
|
|
31
|
+
# @return [String]
|
|
32
|
+
def self.normalize(location, root)
|
|
33
|
+
path, separator, line = location.rpartition(':')
|
|
34
|
+
|
|
35
|
+
return location if separator.empty?
|
|
36
|
+
|
|
37
|
+
"#{File.expand_path(path, root)}:#{line}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Absolute `path:line` form of a location, against this recording's root
|
|
41
|
+
#
|
|
42
|
+
# @param [String] location
|
|
43
|
+
#
|
|
44
|
+
# @return [String]
|
|
45
|
+
def normalize(location) = self.class.normalize(location, root)
|
|
46
|
+
|
|
47
|
+
# How many of `lines` in `path` each test executed, for the tests that
|
|
48
|
+
# executed any of them
|
|
49
|
+
#
|
|
50
|
+
# The count is what ranks the tests: one that ran the whole method body is
|
|
51
|
+
# likelier to reach a mutated expression than one that grazed a guard.
|
|
52
|
+
#
|
|
53
|
+
# @param [Pathname] path
|
|
54
|
+
# @param [Range<Integer>] lines
|
|
55
|
+
#
|
|
56
|
+
# @return [Hash{String => Integer}]
|
|
57
|
+
def reach(path:, lines:)
|
|
58
|
+
table = tables.fetch(File.expand_path(path), nil)
|
|
59
|
+
|
|
60
|
+
return EMPTY_HASH unless table
|
|
61
|
+
|
|
62
|
+
# Bit N is line N+1, so the subject's lines are the bits from
|
|
63
|
+
# `lines.begin - 1` up to and including `lines.end - 1`.
|
|
64
|
+
mask = (1 << lines.end) - (1 << (lines.begin - 1))
|
|
65
|
+
|
|
66
|
+
table.each_with_object({}) do |(context, bitmap), reached|
|
|
67
|
+
covered = self.class.popcount(bitmap & mask)
|
|
68
|
+
|
|
69
|
+
reached[context] = covered unless covered.zero?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Lines a test executed across the whole project
|
|
74
|
+
#
|
|
75
|
+
# The share of that footprint falling inside a subject is how focused the
|
|
76
|
+
# test is on it. A unit test spends most of its lines there, while a
|
|
77
|
+
# feature spec touching thousands of lines spends almost none.
|
|
78
|
+
#
|
|
79
|
+
# @param [String] context
|
|
80
|
+
#
|
|
81
|
+
# @return [Integer]
|
|
82
|
+
def footprint(context) = footprints.fetch(context, 0)
|
|
83
|
+
|
|
84
|
+
# Number of set bits
|
|
85
|
+
#
|
|
86
|
+
# Ruby has no population count on Integer, and a binary string is both the
|
|
87
|
+
# idiom and fast enough at the sizes a line bitmap reaches.
|
|
88
|
+
#
|
|
89
|
+
# @param [Integer] value
|
|
90
|
+
#
|
|
91
|
+
# @return [Integer]
|
|
92
|
+
def self.popcount(value) = value.to_s(2).count('1')
|
|
93
|
+
|
|
94
|
+
# Every test id the recording knows about
|
|
95
|
+
#
|
|
96
|
+
# @return [Set<String>]
|
|
97
|
+
def context_ids = tables.each_value.flat_map(&:keys).to_set
|
|
98
|
+
memoize :context_ids
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def footprints
|
|
103
|
+
tables.each_value.with_object(Hash.new(0)) do |table, totals|
|
|
104
|
+
table.each { |context, bitmap| totals[context] += self.class.popcount(bitmap) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
memoize :footprints
|
|
108
|
+
end # ContextMap
|
|
109
|
+
end # Mutant
|
data/lib/mutant/integration.rb
CHANGED
|
@@ -95,7 +95,7 @@ module Mutant
|
|
|
95
95
|
|
|
96
96
|
def self.attempt_const_get(env)
|
|
97
97
|
integration_name = env.config.integration.name
|
|
98
|
-
constant_name = integration_name.capitalize
|
|
98
|
+
constant_name = integration_name.split(/[-_]/).map(&:capitalize).join
|
|
99
99
|
|
|
100
100
|
Either.wrap_error(NameError) { const_get(constant_name) }.lmap do |exception|
|
|
101
101
|
CONST_MESSAGE % {
|
|
@@ -18,6 +18,8 @@ module Mutant
|
|
|
18
18
|
evaluator =
|
|
19
19
|
if memoized_method?(scope, target_method.name)
|
|
20
20
|
Evaluator::Memoized
|
|
21
|
+
elsif module_function?(scope, target_method)
|
|
22
|
+
Evaluator::ModuleFunction
|
|
21
23
|
else
|
|
22
24
|
Evaluator
|
|
23
25
|
end
|
|
@@ -39,6 +41,25 @@ module Mutant
|
|
|
39
41
|
end
|
|
40
42
|
private_class_method :memoized_method?
|
|
41
43
|
|
|
44
|
+
# Test if method was exposed via module_function
|
|
45
|
+
#
|
|
46
|
+
# module_function makes the instance method private and copies it
|
|
47
|
+
# onto the singleton class, where it keeps the source location.
|
|
48
|
+
#
|
|
49
|
+
# @param [Scope] scope
|
|
50
|
+
# @param [UnboundMethod] target_method
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def self.module_function?(scope, target_method)
|
|
54
|
+
raw = scope.raw
|
|
55
|
+
method_name = target_method.name
|
|
56
|
+
|
|
57
|
+
raw.private_method_defined?(method_name) &&
|
|
58
|
+
raw.singleton_class.public_method_defined?(method_name) &&
|
|
59
|
+
raw.singleton_class.instance_method(method_name).source_location.eql?(target_method.source_location)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :module_function?
|
|
62
|
+
|
|
42
63
|
# Instance method specific evaluator
|
|
43
64
|
class Evaluator < Evaluator
|
|
44
65
|
MATCH_NODE_TYPE = :def
|
|
@@ -75,6 +96,11 @@ module Mutant
|
|
|
75
96
|
end
|
|
76
97
|
|
|
77
98
|
end # Memoized
|
|
99
|
+
|
|
100
|
+
# Evaluator specialized for module_function instance methods
|
|
101
|
+
class ModuleFunction < self
|
|
102
|
+
SUBJECT_CLASS = Subject::Method::Instance::ModuleFunction
|
|
103
|
+
end # ModuleFunction
|
|
78
104
|
end # Evaluator
|
|
79
105
|
|
|
80
106
|
private_constant(*constants(false))
|
data/lib/mutant/meta.rb
CHANGED
|
@@ -8,12 +8,16 @@ module Mutant
|
|
|
8
8
|
|
|
9
9
|
include Anima.new(:env)
|
|
10
10
|
|
|
11
|
+
# Minimum seconds between two session flushes
|
|
12
|
+
FLUSH_INTERVAL = 1.0
|
|
13
|
+
|
|
11
14
|
# Initialize object
|
|
12
15
|
#
|
|
13
16
|
# @return [undefined]
|
|
14
17
|
def initialize(*)
|
|
15
18
|
super
|
|
16
19
|
@start = env.world.timer.now
|
|
20
|
+
@last_flush_at = @start - FLUSH_INTERVAL
|
|
17
21
|
@subject_results = {}
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -48,10 +52,11 @@ module Mutant
|
|
|
48
52
|
mutation = env.mutations.fetch(response.result.mutation_index)
|
|
49
53
|
subject = mutation.subject
|
|
50
54
|
mutation_result = mutation_result(mutation, response.result)
|
|
55
|
+
coverage = coverage_result(mutation_result)
|
|
51
56
|
|
|
52
57
|
@subject_results[subject] = Result::Subject.new(
|
|
53
58
|
amount_mutations: subject.mutations.length,
|
|
54
|
-
coverage_results: previous_coverage_results(subject).dup <<
|
|
59
|
+
coverage_results: previous_coverage_results(subject).dup << coverage,
|
|
55
60
|
expression_syntax: subject.expression.syntax,
|
|
56
61
|
identification: subject.identification,
|
|
57
62
|
node: subject.node,
|
|
@@ -60,6 +65,8 @@ module Mutant
|
|
|
60
65
|
tests: env.selections.fetch(subject)
|
|
61
66
|
)
|
|
62
67
|
|
|
68
|
+
flush_session unless coverage.success?
|
|
69
|
+
|
|
63
70
|
self
|
|
64
71
|
end
|
|
65
72
|
# rubocop:enable Metrics/AbcSize
|
|
@@ -67,6 +74,25 @@ module Mutant
|
|
|
67
74
|
|
|
68
75
|
private
|
|
69
76
|
|
|
77
|
+
# Persist the session on alive mutations, so survivors are
|
|
78
|
+
# inspectable while a long run is still going, rather than only
|
|
79
|
+
# after its final write.
|
|
80
|
+
#
|
|
81
|
+
# Alive mutations can arrive far faster than once per second, and
|
|
82
|
+
# each flush serializes the whole result tree on the main process,
|
|
83
|
+
# so flushes are rate limited against the monotonic timer. A
|
|
84
|
+
# survivor that lands inside the suppressed window is picked up by
|
|
85
|
+
# the next flush, or by the final write in the runner.
|
|
86
|
+
def flush_session
|
|
87
|
+
now = env.world.timer.now
|
|
88
|
+
|
|
89
|
+
return if (now - @last_flush_at) < FLUSH_INTERVAL
|
|
90
|
+
|
|
91
|
+
@last_flush_at = now
|
|
92
|
+
|
|
93
|
+
Result::JSONWriter.new(env:, result: status).call
|
|
94
|
+
end
|
|
95
|
+
|
|
70
96
|
def coverage_result(mutation_result)
|
|
71
97
|
Result::Coverage.new(
|
|
72
98
|
mutation_result:,
|
|
@@ -19,6 +19,7 @@ module Mutant
|
|
|
19
19
|
.record(:analysis) { run_driver(reporter, async_driver(env)) }
|
|
20
20
|
.tap { |result| env.record(:report) { reporter.report(result) } }
|
|
21
21
|
.tap { |result| Result::JSONWriter.new(env:, result:).call }
|
|
22
|
+
.tap { Result::SessionFiles.new(world: env.world).prune(Result::SessionFiles::KEEP) }
|
|
22
23
|
end
|
|
23
24
|
private_class_method :run_mutation_analysis
|
|
24
25
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutant
|
|
4
|
+
class Mutator
|
|
5
|
+
class Node
|
|
6
|
+
class Itblock < self
|
|
7
|
+
|
|
8
|
+
handle(:itblock)
|
|
9
|
+
|
|
10
|
+
children :receiver, :parameter, :body
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def dispatch
|
|
15
|
+
emit_nil
|
|
16
|
+
emit_receiver_mutations(&method(:n_send?))
|
|
17
|
+
emit_body_mutations(&method(:references_it?))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def references_it?(node)
|
|
21
|
+
return true if node.eql?(s(:lvar, :it))
|
|
22
|
+
|
|
23
|
+
children = owns_it?(node) ? [node.children.first] : node.children
|
|
24
|
+
|
|
25
|
+
children.any? do |child|
|
|
26
|
+
child.instance_of?(::Parser::AST::Node) && references_it?(child)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def owns_it?(node)
|
|
31
|
+
n_block?(node) || n_itblock?(node) || n_def?(node) || n_defs?(node)
|
|
32
|
+
end
|
|
33
|
+
end # Itblock
|
|
34
|
+
end # Node
|
|
35
|
+
end # Mutator
|
|
36
|
+
end # Mutant
|
|
@@ -13,10 +13,12 @@ module Mutant
|
|
|
13
13
|
:amount_subjects,
|
|
14
14
|
:amount_all_tests,
|
|
15
15
|
:config,
|
|
16
|
+
:selector,
|
|
16
17
|
:test_subject_ratio
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
FORMATS = [
|
|
21
|
+
[:info, 'Selection: %s', :selection_name ],
|
|
20
22
|
[:info, 'Subjects: %s', :amount_subjects ],
|
|
21
23
|
[:info, 'All-Tests: %s', :amount_all_tests ],
|
|
22
24
|
[:info, 'Available-Tests: %s', :amount_available_tests],
|
|
@@ -35,6 +37,10 @@ module Mutant
|
|
|
35
37
|
__send__(report, format, __send__(value))
|
|
36
38
|
end
|
|
37
39
|
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def selection_name = selector.name
|
|
38
44
|
end # Env
|
|
39
45
|
end # Printer
|
|
40
46
|
end # CLI
|
|
@@ -2,21 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Mutant
|
|
4
4
|
module Result
|
|
5
|
-
# Write result JSON to .mutant/results
|
|
5
|
+
# Write result JSON to .mutant/results/, gzip compressed
|
|
6
6
|
class JSONWriter
|
|
7
7
|
include Anima.new(:env, :result)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# Log stored in place of the log of a covered mutation
|
|
10
|
+
EMPTY_LOG = LogCapture::String.new(content: '')
|
|
10
11
|
|
|
11
12
|
# Write result JSON file
|
|
12
13
|
#
|
|
14
|
+
# The JSON repeats the source, diff, and tests of each mutation,
|
|
15
|
+
# and compresses about twentyfold.
|
|
16
|
+
#
|
|
17
|
+
# Written to a temporary file first and renamed into place, so a
|
|
18
|
+
# concurrent reader of the session file never observes a partial
|
|
19
|
+
# document. This matters once the file is rewritten during a run.
|
|
20
|
+
#
|
|
13
21
|
# @return [Pathname]
|
|
14
22
|
def call
|
|
15
|
-
dir = env.world.pathname.new(
|
|
23
|
+
dir = env.world.pathname.new(SessionFiles::DIRECTORY)
|
|
16
24
|
dir.mkpath
|
|
17
25
|
|
|
18
|
-
path = dir.join("#{SESSION_ID}.json")
|
|
19
|
-
|
|
26
|
+
path = dir.join("#{SESSION_ID}.json.gz")
|
|
27
|
+
tmp_path = dir.join("#{SESSION_ID}.json.gz.tmp")
|
|
28
|
+
tmp_path.binwrite(Zlib.gzip(json))
|
|
29
|
+
tmp_path.rename(path)
|
|
20
30
|
|
|
21
31
|
path
|
|
22
32
|
end
|
|
@@ -35,7 +45,29 @@ module Mutant
|
|
|
35
45
|
ruby_version: RUBY_VERSION,
|
|
36
46
|
runtime: result.runtime,
|
|
37
47
|
session_id: SESSION_ID,
|
|
38
|
-
subject_results: result.subject_results
|
|
48
|
+
subject_results: result.subject_results.map(&method(:without_covered_logs))
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def without_covered_logs(subject_result)
|
|
53
|
+
subject_result.with(
|
|
54
|
+
coverage_results: subject_result.coverage_results.map(&method(:without_log))
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The log of a covered mutation is the output of the tests that
|
|
59
|
+
# killed it. The session subcommands only report alive mutations,
|
|
60
|
+
# so nothing reads it back, and on a suite whose failures print
|
|
61
|
+
# large values it is most of the session file.
|
|
62
|
+
def without_log(coverage_result)
|
|
63
|
+
return coverage_result unless coverage_result.success?
|
|
64
|
+
|
|
65
|
+
mutation_result = coverage_result.mutation_result
|
|
66
|
+
|
|
67
|
+
coverage_result.with(
|
|
68
|
+
mutation_result: mutation_result.with(
|
|
69
|
+
isolation_result: mutation_result.isolation_result.with(log: EMPTY_LOG)
|
|
70
|
+
)
|
|
39
71
|
)
|
|
40
72
|
end
|
|
41
73
|
end # JSONWriter
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutant
|
|
4
|
+
module Result
|
|
5
|
+
# The session files of past runs in .mutant/results/
|
|
6
|
+
class SessionFiles
|
|
7
|
+
include Anima.new(:world)
|
|
8
|
+
|
|
9
|
+
DIRECTORY = '.mutant/results'
|
|
10
|
+
|
|
11
|
+
# Sessions this version writes gzip compressed, and those earlier
|
|
12
|
+
# versions wrote as plain JSON. The plain ones are listed first,
|
|
13
|
+
# each kind oldest first, as the earlier versions wrote them before
|
|
14
|
+
# this one wrote any.
|
|
15
|
+
PATTERN = '*.json{,.gz}'
|
|
16
|
+
|
|
17
|
+
# Sessions a run keeps, and `mutant session gc` keeps by default
|
|
18
|
+
KEEP = 100
|
|
19
|
+
|
|
20
|
+
# The session files, oldest first
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<Pathname>]
|
|
23
|
+
def paths
|
|
24
|
+
dir = world.pathname.new(DIRECTORY)
|
|
25
|
+
|
|
26
|
+
return EMPTY_ARRAY unless dir.directory?
|
|
27
|
+
|
|
28
|
+
dir.glob(PATTERN)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Delete all but the most recent sessions
|
|
32
|
+
#
|
|
33
|
+
# A session written alongside this one by a concurrent run may
|
|
34
|
+
# have been deleted by that run already.
|
|
35
|
+
#
|
|
36
|
+
# @param [Integer] keep
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<Pathname>]
|
|
39
|
+
# the deleted session files
|
|
40
|
+
def prune(keep)
|
|
41
|
+
excess = paths.reverse.drop(keep)
|
|
42
|
+
|
|
43
|
+
excess.each do |path|
|
|
44
|
+
path.delete
|
|
45
|
+
rescue Errno::ENOENT
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end # SessionFiles
|
|
50
|
+
end # Result
|
|
51
|
+
end # Mutant
|
data/lib/mutant/result.rb
CHANGED
|
@@ -375,7 +375,7 @@ module Mutant
|
|
|
375
375
|
end
|
|
376
376
|
|
|
377
377
|
load_test = Transform::Success.new(
|
|
378
|
-
block: ->(id) { Mutant::Test.new(expressions: EMPTY_ARRAY, id:) }
|
|
378
|
+
block: ->(id) { Mutant::Test.new(expressions: EMPTY_ARRAY, id:, location: nil) }
|
|
379
379
|
)
|
|
380
380
|
|
|
381
381
|
derive_node = Transform::Success.new(
|