mutant 0.16.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f7a495dc61f610e15c0b43ee64726016fa2feb694ce948e9554478f15c60c03
4
- data.tar.gz: 11c45dd1db15519c9647ac2b13a8c794ddacb3a4a396e90ea2c6accfb6e71dd0
3
+ metadata.gz: 752a1938022756ae4d788946052f227269843741bafc857cb9be24bd94b5a272
4
+ data.tar.gz: b7f0ec5bc7a221c9503c923d20781a061826826acb3caf605a4601e77a808bb5
5
5
  SHA512:
6
- metadata.gz: 8c52e9fa61e9b8ad827cd02f37469654201115c06251330a7eb695a9909b79d355fa9c9d555002d96650589c36d71046436462736754e7a33438f2b03c11bf37
7
- data.tar.gz: 7a1a190d3e17f1bfc3bee1584023b6f7756b97d2b79ffa6f42a93ce2b70c0d323e29b0bd7bd4010204d3bbac3a26a82deaa04163c4fc83044d1cb799ecd5d4a8
6
+ metadata.gz: ae4b675e810a9b94f340e9888722eb1a72b78f8bcb008dd0dec44919a3f5d4ef3d440f117c4c159db59528a6362b3b53ea6e0ec808833798d7f9f4ca1c0ab633
7
+ data.tar.gz: a95c6b2b83be6d672284bc6139cd8f37fb29a1a02e5babc3a5c304b6a6eeba143b37d68b6dee39c5de6355c93800435443e9abdf68e4ffd301cdb60b334d9dd5
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.16.3
1
+ 0.17.0
@@ -12,6 +12,15 @@ module Mutant
12
12
  module Bootstrap
13
13
  include Adamantium, Anima.new(:config, :parser, :world)
14
14
 
15
+ UNMATCHED_CONTEXT_MAP = <<~'MESSAGE'
16
+ The coverage recording names no test that this suite runs, so it cannot
17
+ say which tests cover a subject.
18
+
19
+ Record it again from the project root, with the suite mutant runs. A suite
20
+ that starts simplecov unconditionally rewrites the recording every time
21
+ mutant runs it, so start simplecov only when measuring coverage.
22
+ MESSAGE
23
+
15
24
  SEMANTICS_MESSAGE_FORMAT =
16
25
  "%<message>s. Fix your lib to follow normal ruby semantics!\n" \
17
26
  '{Module,Class}#name should return resolvable constant name as String or nil'
@@ -77,18 +86,58 @@ module Mutant
77
86
  env.record(__method__) do
78
87
  hooks = env.hooks
79
88
  hooks.run(:setup_integration_pre)
80
- Integration.setup(env).fmap do |integration|
81
- env.with(
82
- integration:,
83
- mutations:,
84
- selector: Selector::Expression.new(integration:),
85
- subjects: selected_subjects
86
- ).tap { hooks.run(:setup_integration_post) }
89
+ Integration.setup(env).bind do |integration|
90
+ setup_selector(env:, integration:).fmap do |selector|
91
+ env.with(
92
+ integration:,
93
+ mutations:,
94
+ selector:,
95
+ subjects: selected_subjects
96
+ ).tap { hooks.run(:setup_integration_post) }
97
+ end
87
98
  end
88
99
  end
89
100
  end
90
101
  private_class_method :setup_integration
91
102
 
103
+ # The selector the configured strategy asks for
104
+ #
105
+ # The default is to use the coverage recording whenever there is a usable
106
+ # one and the expressions otherwise, so a project that records per test
107
+ # coverage gets the better selection without asking for it. Naming
108
+ # `context_map` explicitly turns every reason the recording cannot be used
109
+ # into a failed run, rather than a silent downgrade to a selection the user
110
+ # did not ask for.
111
+ #
112
+ # @return [Either<String, Selector>]
113
+ def self.setup_selector(env:, integration:)
114
+ selection = env.config.selection
115
+ fallback = Selector::Expression.new(integration:)
116
+
117
+ return Either::Right.new(fallback) if selection.expression?
118
+
119
+ result = env.record(__method__) do
120
+ ContextMap::Loader
121
+ .call(path: env.world.pathname.new(selection.effective_path), world: env.world)
122
+ .bind { |context_map| context_map_selector(context_map:, fallback:, integration:) }
123
+ end
124
+
125
+ selection.context_map? ? result : Either::Right.new(result.from_right { fallback })
126
+ end
127
+ private_class_method :setup_selector
128
+
129
+ # A recording that names no test of this suite was taken somewhere else, and
130
+ # answering selection from it would report every mutation alive without ever
131
+ # running a test that could have killed it.
132
+ #
133
+ # @return [Either<String, Selector>]
134
+ def self.context_map_selector(context_map:, fallback:, integration:)
135
+ selector = Selector::ContextMap.new(context_map:, fallback:, integration:)
136
+
137
+ selector.unmatched? ? Either::Left.new(UNMATCHED_CONTEXT_MAP) : Either::Right.new(selector)
138
+ end
139
+ private_class_method :context_map_selector
140
+
92
141
  # rubocop:enable Metrics/MethodLength
93
142
  def self.load_hooks(env)
94
143
  env.record(__method__) do
@@ -5,8 +5,9 @@ module Mutant
5
5
  class Command
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class Environment < self
8
- NAME = 'environment'
9
- SHORT_DESCRIPTION = 'Environment subcommands'
8
+ NAME = 'environment'
9
+ SHORT_DESCRIPTION = 'Environment subcommands'
10
+ SELECTION_DESCRIPTION = 'Select tests via STRATEGY: auto|expression|context_map'
10
11
 
11
12
  OPTIONS =
12
13
  %i[
@@ -14,6 +15,7 @@ module Mutant
14
15
  add_runner_options
15
16
  add_integration_options
16
17
  add_matcher_options
18
+ add_selection_options
17
19
  add_reporter_options
18
20
  add_usage_options
19
21
  ].freeze
@@ -116,6 +118,22 @@ module Mutant
116
118
  end
117
119
  end
118
120
 
121
+ def add_selection_options(parser)
122
+ parser.separator('Selection:')
123
+
124
+ parser.on('--selection STRATEGY', Config::Selection::STRATEGIES, SELECTION_DESCRIPTION) do |strategy|
125
+ selection(strategy:)
126
+ end
127
+
128
+ parser.on('--selection-path PATH', 'Read the coverage report from PATH') do |path|
129
+ selection(path:)
130
+ end
131
+ end
132
+
133
+ def selection(**attributes)
134
+ set(selection: @config.selection.with(attributes))
135
+ end
136
+
119
137
  def add_runner_options(parser)
120
138
  parser.separator('Runner:')
121
139
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ class Config
5
+ # Configuration of how tests get selected for a subject
6
+ class Selection
7
+ include Adamantium, Anima.new(:path, :strategy)
8
+
9
+ # Use the coverage recording when there is one, and the expressions
10
+ # otherwise. Mutant's default.
11
+ AUTO = 'auto'
12
+
13
+ # Select tests whose expressions match the subject's
14
+ EXPRESSION = 'expression'
15
+
16
+ # Select tests a coverage recording says executed the subject's lines
17
+ CONTEXT_MAP = 'context_map'
18
+
19
+ STRATEGIES = [AUTO, EXPRESSION, CONTEXT_MAP].freeze
20
+
21
+ # Directory coverage tools write their report into
22
+ DEFAULT_PATH = 'coverage'
23
+
24
+ UNKNOWN_STRATEGY = 'Unknown test selection strategy %s, expected one of: %s'
25
+
26
+ private_constant(:UNKNOWN_STRATEGY)
27
+
28
+ # Both attributes stay nil until set, so a value from mutant.yml is not
29
+ # overwritten by a default coming from the CLI end of the merge.
30
+ DEFAULT = new(path: nil, strategy: nil)
31
+
32
+ TRANSFORM =
33
+ Transform::Sequence.new(
34
+ steps: [
35
+ Transform::Hash.new(
36
+ optional: [
37
+ Transform::Hash::Key.new(
38
+ transform: Transform::STRING,
39
+ value: 'path'
40
+ ),
41
+ Transform::Hash::Key.new(
42
+ transform: Transform::STRING,
43
+ value: 'strategy'
44
+ )
45
+ ],
46
+ required: []
47
+ ),
48
+ Transform::Hash::Symbolize.new,
49
+ Transform::Block.capture(:selection) { |value| new(**DEFAULT.to_h, **value).validate }
50
+ ]
51
+ )
52
+
53
+ # Whether the recording is the only acceptable source of tests
54
+ #
55
+ # @return [Boolean]
56
+ def context_map? = strategy.eql?(CONTEXT_MAP)
57
+
58
+ # Whether the expressions are the only acceptable source of tests
59
+ #
60
+ # @return [Boolean]
61
+ def expression? = strategy.eql?(EXPRESSION)
62
+
63
+ # The configured recording location, or the conventional one
64
+ #
65
+ # @return [String]
66
+ def effective_path = path || DEFAULT_PATH
67
+
68
+ # Reject a strategy mutant does not implement
69
+ #
70
+ # @return [Either<String, Selection>]
71
+ def validate
72
+ return Either::Right.new(self) if strategy.nil? || STRATEGIES.include?(strategy)
73
+
74
+ Either::Left.new(UNKNOWN_STRATEGY % [strategy.inspect, STRATEGIES.join(', ')])
75
+ end
76
+
77
+ # Merge with other selection config
78
+ #
79
+ # Values from the other instance have precedence.
80
+ #
81
+ # @param [Selection] other
82
+ #
83
+ # @return [Selection]
84
+ def merge(other)
85
+ self.class.new(
86
+ path: other.path || path,
87
+ strategy: other.strategy || strategy
88
+ )
89
+ end
90
+ end # Selection
91
+ end # Config
92
+ end # Mutant
data/lib/mutant/config.rb CHANGED
@@ -22,6 +22,7 @@ module Mutant
22
22
  :mutation,
23
23
  :reporter,
24
24
  :requires,
25
+ :selection,
25
26
  :usage
26
27
  )
27
28
 
@@ -89,6 +90,7 @@ module Mutant
89
90
  matcher: matcher.merge(other.matcher),
90
91
  mutation: mutation.merge(other.mutation),
91
92
  requires: requires + other.requires,
93
+ selection: selection.merge(other.selection),
92
94
  usage: other.usage.merge(usage)
93
95
  )
94
96
  end
@@ -269,6 +271,10 @@ module Mutant
269
271
  transform: Transform::STRING_ARRAY,
270
272
  value: 'requires'
271
273
  ),
274
+ Transform::Hash::Key.new(
275
+ transform: ->(value) { Selection::TRANSFORM.call(value) },
276
+ value: 'selection'
277
+ ),
272
278
  Transform::Hash::Key.new(
273
279
  transform: Usage::TRANSFORM,
274
280
  value: 'usage'
@@ -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
@@ -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 % {
data/lib/mutant/meta.rb CHANGED
@@ -25,7 +25,7 @@ module Mutant
25
25
  )
26
26
  end
27
27
 
28
- Pathname.glob(Pathname.new(__dir__).parent.parent.join('meta', '*.rb'))
28
+ Pathname.glob(Pathname.new(__dir__).parent.parent.join('meta', '**', '*.rb'))
29
29
  .sort
30
30
  .each(&method(:require))
31
31
 
@@ -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 << coverage_result(mutation_result),
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:,
@@ -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
@@ -10,13 +10,19 @@ module Mutant
10
10
 
11
11
  # Write result JSON file
12
12
  #
13
+ # Written to a temporary file first and renamed into place, so a
14
+ # concurrent reader of the session file never observes a partial
15
+ # document. This matters once the file is rewritten during a run.
16
+ #
13
17
  # @return [Pathname]
14
18
  def call
15
19
  dir = env.world.pathname.new(RESULTS_DIR)
16
20
  dir.mkpath
17
21
 
18
22
  path = dir.join("#{SESSION_ID}.json")
19
- path.write(json)
23
+ tmp_path = dir.join("#{SESSION_ID}.json.tmp")
24
+ tmp_path.write(json)
25
+ tmp_path.rename(path)
20
26
 
21
27
  path
22
28
  end
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(
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ class Selector
5
+ # Test selector backed by a per test coverage recording
6
+ #
7
+ # Selects the tests that executed the subject's own source lines, which
8
+ # finds the tests that cover a subject regardless of where they live or how
9
+ # they are named. Subjects the recording says nothing about fall back to the
10
+ # expression selector: code that only runs while the suite loads (a constant
11
+ # body, a class level DSL call) executes under no test, and reporting its
12
+ # mutations alive without running a single test would be a lie.
13
+ class ContextMap < self
14
+ include Anima.new(:context_map, :fallback, :integration)
15
+
16
+ # Name of the strategy this selector implements
17
+ #
18
+ # @return [String]
19
+ def name = 'context_map'
20
+
21
+ # Tests for subject, likeliest killer first
22
+ #
23
+ # @param [Subject] subject
24
+ #
25
+ # @return [Enumerable<Test>]
26
+ def call(subject)
27
+ reach = context_map.reach(path: subject.source_path, lines: subject.source_lines)
28
+
29
+ covering = reach.flat_map do |location, lines|
30
+ index.fetch(location, EMPTY_ARRAY).map { |test| [test, lines, context_map.footprint(location)] }
31
+ end
32
+
33
+ return fallback.call(subject) if covering.empty?
34
+
35
+ order(covering, subject)
36
+ end
37
+
38
+ # Whether the recording names no test this suite runs
39
+ #
40
+ # A recording taken from another project root, or from a suite that has
41
+ # since been renamed away, covers everything and matches nothing. Left
42
+ # unsaid it looks exactly like a suite in which no test covers anything.
43
+ #
44
+ # @return [Boolean]
45
+ def unmatched?
46
+ index.each_key.none? { |location| context_map.context_ids.include?(location) }
47
+ end
48
+
49
+ private
50
+
51
+ # A run against a mutation stops at the first failing test, so this order
52
+ # is the difference between running one test and running all of them.
53
+ #
54
+ # A test the expressions would also have picked is the subject's own unit
55
+ # test, and no measurement beats that prior, so those go first as a block.
56
+ # Within each block comes the test spending the largest share of its own
57
+ # footprint inside this subject, since a test that exists for this code
58
+ # asserts on it where a feature spec passing through may swallow the
59
+ # difference. Reach breaks the remaining ties, because a test that ran
60
+ # more of the subject's lines is likelier to have run the mutated one.
61
+ def order(covering, subject)
62
+ named, rest = covering.partition { |test, _lines, _footprint| expression_match?(subject, test) }
63
+
64
+ (by_focus(named) + by_focus(rest)).map(&:first)
65
+ end
66
+
67
+ # The test id breaks a full tie, so the order depends on the recording's
68
+ # content rather than on the order a coverage tool happened to write it.
69
+ def by_focus(covering)
70
+ covering.sort_by { |test, lines, footprint| [-Rational(lines, footprint), -lines, test.id] }
71
+ end
72
+
73
+ # The question the expression selector asks of one test, rather than the
74
+ # set it builds by asking it of every test in the suite.
75
+ def expression_match?(subject, test)
76
+ subject.match_expressions.any? do |match_expression|
77
+ test.expressions.any? { |test_expression| match_expression.prefix?(test_expression) }
78
+ end
79
+ end
80
+
81
+ # Tests by the location the recording would name them under. A location
82
+ # holds more than one test whenever a framework generates examples from
83
+ # one line, so the value is a list.
84
+ def index
85
+ integration.available_tests.each_with_object({}) do |test, index|
86
+ location = test.location or next
87
+
88
+ (index[context_map.normalize(location)] ||= []) << test
89
+ end
90
+ end
91
+ memoize :index
92
+ end # ContextMap
93
+ end # Selector
94
+ end # Mutant
@@ -6,6 +6,11 @@ module Mutant
6
6
  class Expression < self
7
7
  include Anima.new(:integration)
8
8
 
9
+ # Name of the strategy this selector implements
10
+ #
11
+ # @return [String]
12
+ def name = 'expression'
13
+
9
14
  # Tests for subject
10
15
  #
11
16
  # @param [Subject] subject
@@ -6,6 +6,11 @@ module Mutant
6
6
  class Null < self
7
7
  include Equalizer.new
8
8
 
9
+ # Name of the strategy this selector implements
10
+ #
11
+ # @return [String]
12
+ def name = 'null'
13
+
9
14
  # Tests for subject
10
15
  #
11
16
  # @param [Subject] subject
@@ -12,5 +12,10 @@ module Mutant
12
12
  # @return [Enumerable<Test>]
13
13
  abstract_method :call
14
14
 
15
+ # Name of the strategy this selector implements
16
+ #
17
+ # @return [String]
18
+ abstract_method :name
19
+
15
20
  end # Selector
16
21
  end # Mutant
data/lib/mutant/test.rb CHANGED
@@ -5,7 +5,8 @@ module Mutant
5
5
  class Test
6
6
  include Anima.new(
7
7
  :expressions,
8
- :id
8
+ :id,
9
+ :location
9
10
  )
10
11
 
11
12
  # Identification string
@@ -295,6 +295,75 @@ module Mutant
295
295
  end
296
296
  end # Key
297
297
 
298
+ # Transform reducing a hash to the keys it names
299
+ #
300
+ # A document another tool writes carries keys mutant has no use for,
301
+ # and gains more between versions. Slicing ahead of a Hash transform
302
+ # lets that transform stay strict about the keys it does know.
303
+ class Slice < Transform
304
+ include Anima.new(:keys)
305
+
306
+ # Apply transformation to input
307
+ #
308
+ # @param [Object] input
309
+ #
310
+ # @return [Either<Error, Hash>]
311
+ def call(input)
312
+ PRIMITIVE
313
+ .call(input)
314
+ .lmap(&method(:lift_error))
315
+ .fmap { |hash| hash.slice(*keys) }
316
+ end
317
+ end # Slice
318
+
319
+ # Transform a hash keyed by data via mapping its pairs over key and
320
+ # value transforms
321
+ #
322
+ # Where Hash names the keys it accepts, this accepts every key the key
323
+ # transform does.
324
+ class Map < Transform
325
+ include Anima.new(:key, :value)
326
+
327
+ DUPLICATE_MESSAGE = 'Key transform maps distinct keys onto one'
328
+
329
+ private_constant(*constants(false))
330
+
331
+ # Apply transformation to input
332
+ #
333
+ # @param [Object] input
334
+ #
335
+ # @return [Either<Error, Hash>]
336
+ def call(input)
337
+ PRIMITIVE
338
+ .call(input)
339
+ .lmap(&method(:lift_error))
340
+ .bind(&method(:run))
341
+ end
342
+
343
+ private
344
+
345
+ # rubocop:disable Metrics/MethodLength
346
+ def run(input)
347
+ output = input.to_h do |key_input, value_input|
348
+ [
349
+ coerce(key, key_input, key_input).from_right { |error| return failure(error(cause: error, input:)) },
350
+ coerce(value, key_input, value_input).from_right { |error| return failure(error(cause: error, input:)) }
351
+ ]
352
+ end
353
+
354
+ if output.size.equal?(input.size)
355
+ success(output)
356
+ else
357
+ failure(error(input:, message: DUPLICATE_MESSAGE))
358
+ end
359
+ end
360
+ # rubocop:enable Metrics/MethodLength
361
+
362
+ def coerce(transform, key, input)
363
+ Key.new(value: key, transform:).call(input)
364
+ end
365
+ end # Map
366
+
298
367
  # Apply transformation to input
299
368
  #
300
369
  # @param [Object] input
data/lib/mutant.rb CHANGED
@@ -225,13 +225,17 @@ module Mutant
225
225
  require 'mutant/timer'
226
226
  require 'mutant/integration'
227
227
  require 'mutant/integration/null'
228
+ require 'mutant/context_map'
229
+ require 'mutant/context_map/loader'
228
230
  require 'mutant/selector'
231
+ require 'mutant/selector/context_map'
229
232
  require 'mutant/selector/expression'
230
233
  require 'mutant/selector/null'
231
234
  require 'mutant/world'
232
235
  require 'mutant/hooks'
233
236
  require 'mutant/config'
234
237
  require 'mutant/config/coverage_criteria'
238
+ require 'mutant/config/selection'
235
239
  require 'mutant/cli'
236
240
  require 'mutant/cli/command'
237
241
  require 'mutant/cli/command/environment'
@@ -378,6 +382,7 @@ module Mutant
378
382
  mutation: Mutation::Config::EMPTY,
379
383
  reporter: Reporter::CLI.build(WORLD.stdout),
380
384
  requires: EMPTY_ARRAY,
385
+ selection: Config::Selection::DEFAULT,
381
386
  usage: Usage::Unknown.new
382
387
  )
383
388
  end # Config
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.3
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Markus Schirp
@@ -235,7 +235,10 @@ files:
235
235
  - lib/mutant/cli/command/util.rb
236
236
  - lib/mutant/config.rb
237
237
  - lib/mutant/config/coverage_criteria.rb
238
+ - lib/mutant/config/selection.rb
238
239
  - lib/mutant/context.rb
240
+ - lib/mutant/context_map.rb
241
+ - lib/mutant/context_map/loader.rb
239
242
  - lib/mutant/env.rb
240
243
  - lib/mutant/expression.rb
241
244
  - lib/mutant/expression/descendants.rb
@@ -391,6 +394,7 @@ files:
391
394
  - lib/mutant/segment.rb
392
395
  - lib/mutant/segment/recorder.rb
393
396
  - lib/mutant/selector.rb
397
+ - lib/mutant/selector/context_map.rb
394
398
  - lib/mutant/selector/expression.rb
395
399
  - lib/mutant/selector/null.rb
396
400
  - lib/mutant/subject.rb