mutineer 1.4.0 → 1.5.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +250 -0
  3. data/README.md +198 -27
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +112 -16
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutators/array_literal.rb +0 -7
  21. data/lib/mutineer/mutators/base.rb +63 -4
  22. data/lib/mutineer/mutators/chain_link.rb +0 -7
  23. data/lib/mutineer/mutators/collection_method.rb +1 -1
  24. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  25. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  26. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  27. data/lib/mutineer/mutators/return_nil.rb +5 -11
  28. data/lib/mutineer/mutators/string_literal.rb +1 -1
  29. data/lib/mutineer/pairing.rb +222 -15
  30. data/lib/mutineer/parser.rb +12 -3
  31. data/lib/mutineer/project.rb +7 -0
  32. data/lib/mutineer/rails_worker_db.rb +1 -1
  33. data/lib/mutineer/reporter.rb +288 -52
  34. data/lib/mutineer/result.rb +37 -1
  35. data/lib/mutineer/runner.rb +133 -16
  36. data/lib/mutineer/statement_lines.rb +143 -0
  37. data/lib/mutineer/test_runners/minitest.rb +4 -2
  38. data/lib/mutineer/test_runners/rspec.rb +243 -3
  39. data/lib/mutineer/test_runners.rb +5 -3
  40. data/lib/mutineer/version.rb +1 -1
  41. data/lib/mutineer/worker_pool.rb +4 -2
  42. data/lib/mutineer.rb +1 -0
  43. metadata +8 -3
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "pathname"
4
5
  require_relative "parser"
5
6
  require_relative "project"
6
7
  require_relative "result"
8
+ require_relative "statement_lines"
7
9
  require_relative "isolation"
8
10
  require_relative "minitest_integration"
9
11
  require_relative "test_runners"
@@ -92,10 +94,11 @@ module Mutineer
92
94
  # by forking the booted parent, then select covering tests per mutant.
93
95
  coverage_map = CoverageMap.new(
94
96
  source_paths: config.sources, test_paths: config.tests,
95
- cache_dir: config.cache_dir, project_root: config.project_root,
97
+ cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
96
98
  load_paths: config.load_paths, framework: config.framework,
97
99
  boot_path: File.expand_path(config.boot, config.project_root),
98
- verbose: config.verbose
100
+ verbose: config.verbose,
101
+ capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
99
102
  ).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
100
103
  else
101
104
  # As in boot mode, and with lib first as `rake test` does.
@@ -106,8 +109,9 @@ module Mutineer
106
109
  rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
107
110
  coverage_map = CoverageMap.new(
108
111
  source_paths: config.sources, test_paths: config.tests,
109
- cache_dir: config.cache_dir, project_root: config.project_root,
110
- load_paths: config.load_paths + rel_roots, framework: config.framework
112
+ cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
113
+ load_paths: config.load_paths + rel_roots, framework: config.framework,
114
+ capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
111
115
  ).build_or_load
112
116
  end
113
117
  abort_if_unclean!(coverage_map)
@@ -138,12 +142,16 @@ module Mutineer
138
142
  bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
139
143
  on_result: ->(_r) { progress.tick }) do |subject, mutation|
140
144
  run(mutation, source_file: subject.file, coverage_map: coverage_map,
141
- subject: subject, strategy: strategy, rails: config.rails, framework: framework)
145
+ subject: subject, strategy: strategy, rails: config.rails, framework: framework,
146
+ timeout: config.timeout || Isolation::DEFAULT_TIMEOUT, matrix: config.matrix)
142
147
  end
143
148
  # The bare Results carry only status (Subjects hold live AST nodes that
144
149
  # do not marshal); reattach subject+mutation+id in the parent, in order.
145
150
  # filter_map drops nils for jobs --fail-fast left unscheduled.
146
- bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
151
+ share_tests(bare.each_with_index.filter_map do |r, i|
152
+ r = unreported_row(r) if r && config.matrix
153
+ r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2])
154
+ end)
147
155
  ensure
148
156
  sweep_orphans(dirs)
149
157
  end
@@ -151,6 +159,18 @@ module Mutineer
151
159
  [AggregateResult.new(results + ignored_results), source_map, extras]
152
160
  end
153
161
 
162
+ # A `--matrix` error that carries no {Kills} row (the worker crashed, or its
163
+ # result was lost) gets an empty, incomplete row: its tests never reported,
164
+ # so a blind test may have killed it, and the report must say so.
165
+ #
166
+ # @param result [Mutineer::Result] a worker's result.
167
+ # @return [Mutineer::Result]
168
+ def self.unreported_row(result)
169
+ return result unless result.error? && result.kills.nil?
170
+
171
+ result.with(kills: Kills.new(killed_by: [], ran: [], complete: false))
172
+ end
173
+
154
174
  # Collect every (subject, mutation, id) up front so a backend can run them.
155
175
  # A mutant the user marked known-equivalent (inline disable-line comment or
156
176
  # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
@@ -199,17 +219,25 @@ module Mutineer
199
219
  ordinal = (decls[subject.def_node.location.start_offset] ||= decls.size)
200
220
  ids = MutantId.for_subject(subject, source, mutations, path: id_path, subject_ordinal: ordinal)
201
221
  legacy_ids = MutantId.legacy_for_subject(subject, source, mutations)
222
+ lines = mutations.map { |m| source.byteslice(0, m.start_offset).count("\n") + 1 }
223
+ keys = result_keys(mutations, source, lines)
224
+ # A repeat of an earlier edit on the same line is dropped (#159),
225
+ # separately among the run and the ignored mutants, so an ignored copy
226
+ # never hides a copy that should run. A dropped copy records nothing.
227
+ seen = { run: Set.new, ignored: Set.new }
202
228
  mutations.each_with_index do |mutation, i|
203
229
  id = ids[i]
204
230
  legacy = legacy_ids[i]
231
+ ignored = suppressed?(mutation.operator, lines[i], [id, legacy], disabled, ignore_set)
232
+ next unless seen[ignored ? :ignored : :run].add?(keys[i])
233
+
205
234
  id_map[id] = legacy
206
235
  # An old entry still over-matches other files even when the new id is
207
236
  # listed too, so every old-entry match is reported for migration.
208
237
  if ignore_set.include?(legacy)
209
238
  (legacy_ignore_matches[legacy] ||= []) << { id: id, file: id_path, subject: subject.qualified_name }
210
239
  end
211
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
212
- if suppressed?(mutation.operator, line, [id, legacy], disabled, ignore_set)
240
+ if ignored
213
241
  ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
214
242
  else
215
243
  jobs << [subject, mutation, id]
@@ -219,6 +247,33 @@ module Mutineer
219
247
  [jobs, ignored_results, source_map, { legacy_ignore_matches: legacy_ignore_matches, id_map: id_map }]
220
248
  end
221
249
 
250
+ # One key per mutation; two mutations share a key exactly when they are
251
+ # the same operator on the same line and give the same mutated source (one
252
+ # edit, #159). The caller drops a repeat after ids are assigned. Only a
253
+ # group of same-operator, same-line mutations can repeat, so only those
254
+ # build a key from their text: the span from the group's earliest start to
255
+ # its latest end, digested. Every other mutation gets a key of its own.
256
+ #
257
+ # @param mutations [Array<Mutineer::Mutation>] one subject's mutations.
258
+ # @param source [String] the full, unmutated source.
259
+ # @param lines [Array<Integer>] each mutation's line.
260
+ # @return [Array<Object>] one key per mutation, in order.
261
+ def self.result_keys(mutations, source, lines)
262
+ keys = Array.new(mutations.size) { |i| i }
263
+ mutations.each_index.group_by { |i| [mutations[i].operator, lines[i]] }.each_value do |group|
264
+ next if group.size < 2
265
+
266
+ from = group.map { |i| mutations[i].start_offset }.min
267
+ to = group.map { |i| mutations[i].end_offset }.max
268
+ group.each do |i|
269
+ m = mutations[i]
270
+ span = "#{source.byteslice(from...m.start_offset)}#{m.replacement}#{source.byteslice(m.end_offset...to)}"
271
+ keys[i] = [m.operator, lines[i], Digest::SHA256.digest(span)]
272
+ end
273
+ end
274
+ keys
275
+ end
276
+
222
277
  # External backend orchestration. Runs each mutant's whole-file mutation on
223
278
  # disk (crash-safe swap) and executes the user's --test-command as a subprocess
224
279
  # in the app's own runtime. Serial by construction (one shared DB, no
@@ -318,7 +373,10 @@ module Mutineer
318
373
  # Coverage-based test selection, shared by the in-process ({run}) and daemon
319
374
  # paths so both narrow identically (score parity). Returns
320
375
  # `[:run, abs_test_paths]` when some test covers the mutant's line, or
321
- # `[:verdict, Result]` (no_coverage / uncapturable) when none do.
376
+ # `[:verdict, Result]` (no_coverage / uncapturable) when none do. A line
377
+ # Ruby does not count (a later line of a multi-line statement) uses the
378
+ # tests that ran the statement that holds it ({StatementLines}), unless the
379
+ # mutant sits in code of that statement that runs only sometimes.
322
380
  #
323
381
  # An empty selection is `:uncapturable` (not `:no_coverage`) when the
324
382
  # mutant's enclosing method body got coverage from no *successful* capture but
@@ -335,6 +393,11 @@ module Mutineer
335
393
  def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
336
394
  line = source.byteslice(0, mutation.start_offset).count("\n") + 1
337
395
  chosen = coverage_map.tests_for(source_file, line)
396
+ if chosen.empty? && subject
397
+ # A multi-line statement has a count on one of its lines only.
398
+ lines = StatementLines.for(subject.def_node, source, mutation.start_offset)
399
+ chosen = lines.flat_map { |l| coverage_map.tests_for(source_file, l) }.uniq
400
+ end
338
401
  if chosen.empty?
339
402
  # Method BODY range, not the whole def: the def/end lines are "covered" at
340
403
  # class-load even when the body never runs (body_loc is the statements' span).
@@ -346,15 +409,18 @@ module Mutineer
346
409
  [:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
347
410
  end
348
411
 
349
- # Scan a source once into { line_number => :all | Set[operator_syms] } from
412
+ # Map each line number to :all or a set of operator symbols, using
350
413
  # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
351
414
  # sits on the same physical line as the code it silences). A bare marker
352
415
  # disables every operator on that line; `disable-line a, b` only the listed
353
416
  # operators. Block-form disable/enable ranges are intentionally not supported.
417
+ # Only a real `#` comment counts: Prism lists the comments, so the marker
418
+ # text inside a string, heredoc or regex silences nothing (#158).
354
419
  def self.suppress_map(source, file)
355
420
  map = {}
356
- source.each_line.with_index(1) do |text, line|
357
- next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
421
+ Parser.comments(source).grep(Prism::InlineComment).each do |comment|
422
+ line = comment.location.start_line
423
+ next unless (m = comment.slice.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
358
424
 
359
425
  ops = m[1]&.split(",")&.map(&:strip)&.reject(&:empty?)
360
426
  # Only spaces or commas after the marker (e.g. `disable-line -- why`)
@@ -471,9 +537,11 @@ module Mutineer
471
537
  # @param timeout [Integer] child timeout in seconds.
472
538
  # @param rails [Boolean] whether Rails reconnect handling is enabled.
473
539
  # @param framework [String] test framework name.
540
+ # @param matrix [Boolean] a `--matrix` run: run every covering test and
541
+ # attach the {Kills} row, with project-relative test files.
474
542
  # @return [Mutineer::Result] mutant result.
475
543
  def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
476
- timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
544
+ timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest", matrix: false)
477
545
  source = File.read(source_file)
478
546
  mutated = mutation.apply(source)
479
547
 
@@ -489,7 +557,7 @@ module Mutineer
489
557
 
490
558
  abs_tests = payload
491
559
 
492
- Isolation.run(timeout: timeout) do
560
+ result = Isolation.run(timeout: timeout, channel: matrix) do |channel|
493
561
  # Forking inherits the parent's live DB connection; sharing one socket
494
562
  # across processes corrupts it. Drop it so AR reconnects per child.
495
563
  reconnect_active_record if rails
@@ -498,8 +566,57 @@ module Mutineer
498
566
  else
499
567
  Isolation.apply_whole_file(mutated, source_file)
500
568
  end
501
- # One failing test already kills the mutant, so the child stops there.
502
- TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
569
+ if channel
570
+ # --matrix: every covering test runs, and each outcome goes to the parent.
571
+ TestRunners.for(framework).run(abs_tests, record_to: channel)
572
+ else
573
+ # One failing test already kills the mutant, so the child stops there.
574
+ TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
575
+ end
576
+ end
577
+ relative_kills(result, coverage_map.project_root)
578
+ end
579
+
580
+ # Rewrites the test files of a result's {Kills} row relative to the project
581
+ # root, the form the coverage map and the report use. A result without a
582
+ # row comes back unchanged.
583
+ #
584
+ # @api private
585
+ # @param result [Mutineer::Result] a mutant result.
586
+ # @param root [String] project root.
587
+ # @return [Mutineer::Result]
588
+ def self.relative_kills(result, root)
589
+ return result unless (kills = result.kills)
590
+
591
+ paths = {}
592
+ rel = lambda do |tests|
593
+ tests.map { |file, name, id| [(paths[file] ||= ProjectPath.relative(file, root)), name, id] }.uniq.sort
594
+ end
595
+ result.with(kills: kills.with(killed_by: rel.call(kills.killed_by), ran: rel.call(kills.ran)))
596
+ end
597
+
598
+ # Makes every {Kills} row refer to one shared, frozen array per test. Each
599
+ # row arrives with its own copy of every test it ran, and a large matrix
600
+ # (thousands of mutants times hundreds of tests) would otherwise hold that
601
+ # many copies. Results without a row come back unchanged.
602
+ #
603
+ # A test is its file and its id. The name is display data and can differ
604
+ # between mutants (an RSpec example worded from its matcher reads
605
+ # differently under each mutant), so every row gets the name the first
606
+ # mutant reported, and one example stays one test.
607
+ #
608
+ # @api private
609
+ # @param results [Array<Mutineer::Result>] mutant results.
610
+ # @return [Array<Mutineer::Result>]
611
+ def self.share_tests(results)
612
+ shared = {}
613
+ share = lambda do |tests|
614
+ tests.map { |file, name, id| shared[[file, id]] ||= [file, name, id].map { |s| -s }.freeze }.uniq.sort.freeze
615
+ end
616
+ results.map do |r|
617
+ next r unless (kills = r.kills)
618
+
619
+ r.with(kills: kills.with(killed_by: share.call(kills.killed_by), ran: share.call(kills.ran)))
503
620
  end
504
621
  end
505
622
 
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # The lines of the statement that holds a position in a method body.
5
+ #
6
+ # Ruby counts one line of a statement only. A position on another line of a
7
+ # multi-line statement has no count of its own, but the tests that ran the
8
+ # statement ran it. Prism marks the statements (`Node#newline?`), so the
9
+ # nearest marked ancestor of the position is the statement. The caller reads
10
+ # the counts on the lines of the statement; a line Ruby does not count has none.
11
+ module StatementLines
12
+ # @param def_node [Prism::DefNode] the method that holds the position.
13
+ # @param source [String] the source text of the file.
14
+ # @param offset [Integer] byte offset of the position.
15
+ # @return [Array<Integer>] the lines of the statement, with the body of each
16
+ # heredoc in it. Empty when no statement in the body holds the position.
17
+ # A first or last line that holds other code is left out, because Ruby
18
+ # can count that line without the statement: the `def` line counts when
19
+ # the file loads, and `x if done; y(` counts when `y` does not run.
20
+ def self.for(def_node, source, offset)
21
+ return [] unless def_node.body
22
+
23
+ path = path_to(def_node.body, offset) || []
24
+ index = path.rindex(&:newline?)
25
+ return [] unless index
26
+
27
+ statement = path[index]
28
+ # Code that runs only sometimes inside the statement: the tests that ran
29
+ # the statement need not have run it.
30
+ return [] if path[index..].each_cons(2).any? { |parent, child| sometimes?(parent, child) }
31
+
32
+ # Ruby counts the line of `x while c` or `x if c` when it checks `c`,
33
+ # also when `x` never runs. The path is the modifier, its body, statement.
34
+ return [] if index >= 2 && modifier?(path[index - 2])
35
+
36
+ location = statement.location
37
+ lines = (location.start_line..last_line(statement)).to_a
38
+ lines.delete(location.start_line) unless before(source, location).strip.empty?
39
+ after = after(source, location).strip
40
+ lines.delete(location.end_line) unless after.empty? || after.start_with?("#")
41
+ lines
42
+ end
43
+
44
+ # Nodes whose code runs only when a branch, a match or an exception picks
45
+ # it: a `when` or `in` clause (its condition or pattern), a `rescue` clause
46
+ # (its class list), an `else` branch, parameters (their defaults) and
47
+ # `defined?` (its operand never runs). A statement in their bodies is a statement of its own, found
48
+ # deeper on the path.
49
+ SOMETIMES = [Prism::WhenNode, Prism::InNode, Prism::RescueNode, Prism::ElseNode, Prism::ParametersNode,
50
+ Prism::BlockParametersNode, Prism::DefinedNode].freeze
51
+
52
+ # Whether `child` runs only sometimes when `parent` runs. Besides the
53
+ # {SOMETIMES} nodes: a branch of an `if`/`unless` inside the statement (a
54
+ # ternary, or `if c then a else b end` on the statement's lines), the
55
+ # rescue side of `x rescue y`, the pattern of `v in p` or `v => p`, the value of
56
+ # `a ||= v` or `a &&= v`, the right side of `a || b` or `a && b`, and the
57
+ # arguments and block of `x&.m(...)` and the value of `x&.m += v`, which do
58
+ # not run when `x` is nil.
59
+ #
60
+ # @param parent [Prism::Node]
61
+ # @param child [Prism::Node]
62
+ # @return [Boolean]
63
+ def self.sometimes?(parent, child)
64
+ return true if SOMETIMES.any? { |klass| child.is_a?(klass) }
65
+ return !child.equal?(parent.predicate) if parent.is_a?(Prism::IfNode) || parent.is_a?(Prism::UnlessNode)
66
+ # A pattern stops at its first part that does not match (`v in 1 | 2`).
67
+ if parent.is_a?(Prism::MatchPredicateNode) || parent.is_a?(Prism::MatchRequiredNode)
68
+ return child.equal?(parent.pattern)
69
+ end
70
+ return child.equal?(parent.rescue_expression) if parent.is_a?(Prism::RescueModifierNode)
71
+ return child.equal?(parent.right) if parent.is_a?(Prism::OrNode) || parent.is_a?(Prism::AndNode)
72
+ if parent.is_a?(Prism::CallNode) && parent.safe_navigation?
73
+ return child.equal?(parent.arguments) || child.equal?(parent.block)
74
+ end
75
+ # `a&.b += v` skips `v` when `a` is nil, like `a&.b ||= v`.
76
+ return true if parent.respond_to?(:safe_navigation?) && parent.safe_navigation? &&
77
+ parent.respond_to?(:value) && child.equal?(parent.value)
78
+
79
+ parent.class.name.end_with?("OrWriteNode", "AndWriteNode") && child.equal?(parent.value)
80
+ end
81
+
82
+ # The nodes from `node` down to the deepest one that holds the offset. A
83
+ # heredoc body lies outside the node of its opener, so a child is searched
84
+ # even when its parent does not hold the offset.
85
+ #
86
+ # @param node [Prism::Node]
87
+ # @param offset [Integer] byte offset.
88
+ # @return [Array<Prism::Node>, nil] nil when no node in the subtree holds it.
89
+ def self.path_to(node, offset)
90
+ node.compact_child_nodes.each do |child|
91
+ path = path_to(child, offset)
92
+ return [node, *path] if path
93
+ end
94
+ location = node.location
95
+ [node] if location.start_offset <= offset && offset < location.end_offset
96
+ end
97
+
98
+ # The last line of `node` and its children. A heredoc body ends below the
99
+ # node of its opener.
100
+ #
101
+ # @param node [Prism::Node]
102
+ # @return [Integer]
103
+ def self.last_line(node)
104
+ [node.location.end_line, *node.compact_child_nodes.map { |child| last_line(child) }].max
105
+ end
106
+
107
+ # Whether `node` is a modifier whose body comes before its keyword, such
108
+ # as `x while c` or `x if c`. The body of `begin ... end while c` runs once
109
+ # before the check, so it is not.
110
+ #
111
+ # @param node [Prism::Node, nil] the parent of the body that holds the statement.
112
+ # @return [Boolean]
113
+ def self.modifier?(node)
114
+ keyword =
115
+ case node
116
+ when Prism::WhileNode, Prism::UntilNode then node.keyword_loc unless node.begin_modifier?
117
+ when Prism::IfNode then node.if_keyword_loc
118
+ when Prism::UnlessNode then node.keyword_loc
119
+ end
120
+ return false if keyword.nil? || node.statements.nil?
121
+
122
+ node.statements.location.start_offset < keyword.start_offset
123
+ end
124
+
125
+ # The text before `location` on its first line.
126
+ #
127
+ # @param source [String]
128
+ # @param location [Prism::Location]
129
+ # @return [String]
130
+ def self.before(source, location)
131
+ source.byteslice(location.start_offset - location.start_column, location.start_column)
132
+ end
133
+
134
+ # The text after `location` on its last line.
135
+ #
136
+ # @param source [String]
137
+ # @param location [Prism::Location]
138
+ # @return [String]
139
+ def self.after(source, location)
140
+ source.byteslice(location.end_offset, source.bytesize).each_line.first.to_s
141
+ end
142
+ end
143
+ end
@@ -11,9 +11,11 @@ module Mutineer
11
11
  # @param test_files [String, Array<String>] one file or many files.
12
12
  # @param stop_at_first_failure [Boolean] when true, the run ends at the
13
13
  # first failing test.
14
+ # @param record_to [IO, nil] a `--matrix` run: send each test's outcome to
15
+ # this channel and run every test.
14
16
  # @return [Integer] 0 on success, 1 on failure.
15
- def self.run(test_files, stop_at_first_failure: false)
16
- MinitestIntegration.run(test_files, stop_at_first_failure: stop_at_first_failure)
17
+ def self.run(test_files, stop_at_first_failure: false, record_to: nil)
18
+ MinitestIntegration.run(test_files, stop_at_first_failure: stop_at_first_failure, record_to: record_to)
17
19
  end
18
20
  end
19
21
  end