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.
@@ -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 % {
@@ -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
@@ -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:,
@@ -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
- RESULTS_DIR = '.mutant/results'
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(RESULTS_DIR)
23
+ dir = env.world.pathname.new(SessionFiles::DIRECTORY)
16
24
  dir.mkpath
17
25
 
18
- path = dir.join("#{SESSION_ID}.json")
19
- path.write(json)
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(