mutineer 1.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +317 -0
- data/README.md +200 -28
- data/lib/mutineer/changed_lines.rb +57 -2
- data/lib/mutineer/cli.rb +142 -92
- data/lib/mutineer/config.rb +215 -56
- data/lib/mutineer/coverage_map.rb +161 -14
- data/lib/mutineer/daemon_backend.rb +3 -0
- data/lib/mutineer/daemon_client.rb +170 -13
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/isolation.rb +91 -6
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +27 -10
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +7 -0
- data/lib/mutineer/rails_worker_db.rb +1 -1
- data/lib/mutineer/reporter.rb +288 -52
- data/lib/mutineer/result.rb +37 -1
- data/lib/mutineer/runner.rb +133 -16
- data/lib/mutineer/statement_lines.rb +143 -0
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +243 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +3 -0
- metadata +10 -3
data/lib/mutineer/runner.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
357
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|