mutineer 0.7.0 → 0.8.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.
@@ -8,90 +8,154 @@ module Mutineer
8
8
  # timeout — the parent SIGKILLed a child that overran its wall clock.
9
9
  # skipped — the mutated source failed to re-parse (invalid); no fork.
10
10
  # no_coverage — no test exercises the mutated line; not run, not scored.
11
- # uncapturable — the line's would-be covering test errored during capture (#9),
12
- # so coverage was lost. Excluded from the denominator exactly
13
- # like no_coverage, but reported separately: it signals a broken
14
- # harness (a test that failed to run), not a genuine coverage gap.
15
- # ignored — a known-equivalent mutant the user suppressed (#10), via an
16
- # inline `# mutineer:disable-line` comment or a `.mutineer.yml`
17
- # `ignore:` id. A pre-fork classification (never run); excluded
18
- # from the denominator so a strong file can reach 100%.
11
+ # uncapturable — the line's would-be covering test errored during capture
12
+ # (#9), so coverage was lost. Excluded from the
13
+ # denominator exactly like no_coverage, but reported
14
+ # separately: it signals a broken harness (a test that
15
+ # failed to run), not a genuine coverage gap.
16
+ # ignored — a known-equivalent mutant the user suppressed (#10), via
17
+ # an inline `# mutineer:disable-line` comment or a
18
+ # `.mutineer.yml` `ignore:` id. A pre-fork classification
19
+ # (never run); excluded from the denominator so a strong
20
+ # file can reach 100%.
19
21
  #
20
22
  # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
21
23
  # validity failure (counted separately by the reporter), error is a runtime
22
24
  # crash. Never conflate them via `details` string parsing. `no_coverage` and
23
- # `uncapturable` are pre-fork selection results (M3/#9): both excluded from the
24
- # score denominator.
25
+ # `uncapturable` are pre-fork selection results (M3/#9): both excluded from
26
+ # the score denominator.
25
27
  #
26
- # `subject`, `mutation`, and `id` are nil when the Result is built by Isolation/
27
- # Runner (which only know the outcome); the orchestrator attaches them afterwards
28
- # via `result.with(subject:, mutation:, id:)` so the Reporter can render survivor
29
- # diffs and emit the stable id. `id` is the content-based MutantId (#10).
28
+ # `subject`, `mutation`, and `id` are nil when the Result is built by
29
+ # Isolation/Runner (which only know the outcome); the orchestrator attaches
30
+ # them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
31
+ # can render survivor diffs and emit the stable id. `id` is the content-based
32
+ # MutantId (#10).
30
33
  Result = Data.define(:status, :details, :subject, :mutation, :id) do
31
- def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
32
- def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
34
+ # Builds a killed result.
35
+ #
36
+ # @return [Mutineer::Result] killed result.
37
+ def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
38
+
39
+ # Builds a survived result.
40
+ #
41
+ # @return [Mutineer::Result] survived result.
42
+ def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
43
+
44
+ # Builds an error result.
45
+ #
46
+ # @param details [String, nil] error details.
47
+ # @return [Mutineer::Result] error result.
33
48
  def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
34
- def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
49
+
50
+ # Builds a timeout result.
51
+ #
52
+ # @return [Mutineer::Result] timeout result.
53
+ def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
54
+
55
+ # Builds a skipped result.
56
+ #
57
+ # @param details [String, nil] skip details.
58
+ # @return [Mutineer::Result] skipped result.
35
59
  def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
36
- def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
37
- def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
38
- def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
39
60
 
61
+ # Builds a no_coverage result.
62
+ #
63
+ # @return [Mutineer::Result] no-coverage result.
64
+ def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
65
+
66
+ # Builds an uncapturable result.
67
+ #
68
+ # @return [Mutineer::Result] uncapturable result.
69
+ def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
70
+
71
+ # Builds an ignored result.
72
+ #
73
+ # @return [Mutineer::Result] ignored result.
74
+ def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
75
+
76
+ # @return [Boolean] true when the status is killed.
40
77
  def killed? = status == :killed
78
+ # @return [Boolean] true when the status is survived.
41
79
  def survived? = status == :survived
80
+ # @return [Boolean] true when the status is error.
42
81
  def error? = status == :error
82
+ # @return [Boolean] true when the status is timeout.
43
83
  def timeout? = status == :timeout
84
+ # @return [Boolean] true when the status is skipped.
44
85
  def skipped? = status == :skipped
86
+ # @return [Boolean] true when the status is no_coverage.
45
87
  def no_coverage? = status == :no_coverage
88
+ # @return [Boolean] true when the status is uncapturable.
46
89
  def uncapturable? = status == :uncapturable
90
+ # @return [Boolean] true when the status is ignored.
47
91
  def ignored? = status == :ignored
92
+
48
93
  end
49
94
 
50
95
  # Aggregates a flat list of Results into counts, the mutation score, and the
51
96
  # surviving-mutant list. The score denominator is killed + survived ONLY
52
- # (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout, and
53
- # ignored (#10 equivalent-mutant suppression) are each excluded and surfaced
54
- # separately — so suppressing every survivor reaches 100%. An empty denominator yields a nil score
55
- # (rendered "N/A"), never 0.0 — distinguishing "no testable mutants" from
56
- # "0% killed".
97
+ # (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
98
+ # and ignored (#10 equivalent-mutant suppression) are each excluded and
99
+ # surfaced separately — so suppressing every survivor reaches 100%. An empty
100
+ # denominator yields a nil score (rendered "N/A"), never 0.0 —
101
+ # distinguishing "no testable mutants" from "0% killed".
57
102
  class AggregateResult
58
103
  attr_reader :results
59
104
 
105
+ # Builds an aggregate from results.
106
+ #
107
+ # @param results [Array<Mutineer::Result>] classified results.
60
108
  def initialize(results)
61
109
  @results = results
62
110
  @by_status = results.group_by(&:status)
63
111
  end
64
112
 
113
+ # @return [Integer] killed count.
65
114
  def killed_count = count(:killed)
115
+ # @return [Integer] survived count.
66
116
  def survived_count = count(:survived)
117
+ # @return [Integer] no-coverage count.
67
118
  def no_coverage_count = count(:no_coverage)
119
+ # @return [Integer] uncapturable count.
68
120
  def uncapturable_count = count(:uncapturable)
121
+ # @return [Integer] skipped-invalid count.
69
122
  def skipped_invalid_count = count(:skipped)
123
+ # @return [Integer] errored count.
70
124
  def errored_count = count(:error)
125
+ # @return [Integer] timeout count.
71
126
  def timeout_count = count(:timeout)
127
+ # @return [Integer] ignored count.
72
128
  def ignored_count = count(:ignored)
73
129
 
74
130
  # Every generated, classified mutation. NOT the score denominator.
131
+ #
132
+ # @return [Integer] total result count.
75
133
  def total = @results.size
76
134
 
77
135
  # The score denominator (also shown to the reader).
136
+ #
137
+ # @return [Integer] killed plus survived.
78
138
  def covered_count = killed_count + survived_count
79
139
 
80
- # killed / (killed + survived) as a rounded percentage, or nil when nothing
81
- # was testable.
140
+ # Computes the mutation score.
141
+ #
142
+ # @return [Float, nil] score percentage or nil when nothing was testable.
82
143
  def mutation_score
83
144
  return nil if covered_count.zero?
84
145
 
85
146
  (killed_count.to_f / covered_count * 100).round(1)
86
147
  end
87
148
 
149
+ # Returns the surviving mutants.
150
+ #
151
+ # @return [Array<Mutineer::Result>] surviving results.
88
152
  def surviving_mutants = @results.select(&:survived?)
89
153
 
90
- # #11: split into { source_file => AggregateResult } so the Reporter (per-source
91
- # breakdown) and #13 (per-source roll-up / baseline diff) share one shape with
92
- # all the score/count methods. After Runner.execute every result carries a
93
- # subject, so the grouping is total; bare results (no subject, only in unit
94
- # tests) are skipped so file keys stay sortable strings.
154
+ # #11: split into { source_file => AggregateResult } so the Reporter
155
+ # (per-source breakdown) and #13 (per-source roll-up / baseline diff) can
156
+ # reuse the same aggregate math.
157
+ #
158
+ # @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
95
159
  def by_source
96
160
  @results.select { |r| r.subject }
97
161
  .group_by { |r| r.subject.file }
@@ -100,6 +164,10 @@ module Mutineer
100
164
 
101
165
  private
102
166
 
167
+ # Counts results for a status.
168
+ #
169
+ # @param status [Symbol] result status.
170
+ # @return [Integer] count for that status.
103
171
  def count(status) = (@by_status[status] || []).size
104
172
  end
105
173
  end
@@ -32,6 +32,9 @@ module Mutineer
32
32
  # The parent process `require`s each source file so its classes exist; forked
33
33
  # children inherit them, so a covering test file's own require_relative of the
34
34
  # source is a no-op and does not clobber the mutated `load` (spec §7).
35
+ #
36
+ # @param config [Mutineer::Config] run configuration.
37
+ # @return [Array(Mutineer::AggregateResult, Hash<String, String>)] aggregate and source map.
35
38
  def self.execute(config)
36
39
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
37
40
 
@@ -215,6 +218,11 @@ module Mutineer
215
218
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
216
219
  end
217
220
 
221
+ # Removes stale mutant tempfiles from the given directories.
222
+ #
223
+ # @api private
224
+ # @param dirs [Array<String>] directories to sweep.
225
+ # @return [void]
218
226
  def self.sweep_orphans(dirs)
219
227
  dirs.each do |dir|
220
228
  Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f|
@@ -223,6 +231,17 @@ module Mutineer
223
231
  end
224
232
  end
225
233
 
234
+ # Runs a single mutation through isolation.
235
+ #
236
+ # @param mutation [Mutineer::Mutation] mutation to run.
237
+ # @param source_file [String] source file path.
238
+ # @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
239
+ # @param subject [Mutineer::Subject, nil] subject for surgical strategy.
240
+ # @param strategy [String] mutation strategy.
241
+ # @param timeout [Integer] child timeout in seconds.
242
+ # @param rails [Boolean] whether Rails reconnect handling is enabled.
243
+ # @param framework [String] test framework name.
244
+ # @return [Mutineer::Result] mutant result.
226
245
  def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
227
246
  timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
228
247
  source = File.read(source_file)
@@ -257,6 +276,10 @@ module Mutineer
257
276
  end
258
277
  end
259
278
 
279
+ # Reconnects ActiveRecord in a forked child when available.
280
+ #
281
+ # @api private
282
+ # @return [void]
260
283
  def self.reconnect_active_record
261
284
  return unless defined?(ActiveRecord::Base)
262
285
 
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # One discoverable method: its location, namespace context, and the live
5
- # Prism::DefNode mutators walk. Struct (not Data) because def_node is a live
6
- # AST node value-equality would be hollow, so we don't promise it.
4
+ # One discoverable method and its AST node.
5
+ #
6
+ # Location, namespace context, and the live Prism::DefNode are kept together
7
+ # because mutators walk the def node directly.
7
8
  Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, keyword_init: true) do
8
- # e.g. "Billing::Invoice#total", "Billing::Invoice.build".
9
- # Top-level (empty namespace) -> "#name" (no :: prefix).
9
+ # Returns the fully-qualified subject name.
10
+ #
11
+ # @return [String] namespaced method name like `Billing::Invoice#total`.
10
12
  def qualified_name
11
13
  namespace.join("::") + (singleton ? "." : "#") + name.to_s
12
14
  end
13
15
 
14
- # nil for empty methods (def empty; end).
16
+ # Returns the body location for the subject, if any.
17
+ #
18
+ # @return [Prism::Location, nil] body location or nil for empty methods.
15
19
  def body_loc
16
20
  def_node.body&.location
17
21
  end
@@ -4,11 +4,12 @@ require_relative "../minitest_integration"
4
4
 
5
5
  module Mutineer
6
6
  module TestRunners
7
- # Uniform wrapper over the existing MinitestIntegration impl so the runner
8
- # selection (TestRunners.for) has one method shape across frameworks.
9
- # MinitestIntegration stays the implementation — all its behaviour (autorun
10
- # neutralisation, Runnable.reset, load, Minitest.run -> 0/1) is preserved.
7
+ # Thin wrapper around the shared Minitest integration runner.
11
8
  module Minitest
9
+ # Runs the given Minitest files.
10
+ #
11
+ # @param test_files [String, Array<String>] one file or many files.
12
+ # @return [Integer] 0 on success, 1 on failure.
12
13
  def self.run(test_files) = MinitestIntegration.run(test_files)
13
14
  end
14
15
  end
@@ -8,31 +8,21 @@ module Mutineer
8
8
  class FrameworkUnavailable < StandardError; end
9
9
 
10
10
  module TestRunners
11
- # Child-process-only RSpec runner, mirroring MinitestIntegration's contract:
12
- # run the given spec files and return 0 (all passed) / 1 (any failure).
11
+ # Child-process-only RSpec runner.
13
12
  #
14
- # rspec-core is required LAZILY here, never at load time, so Mutineer keeps
15
- # zero runtime gem deps; a missing rspec raises a clear Mutineer error.
16
- #
17
- # No `rescue` around the run itself: Isolation.run's fork block is the single
18
- # exception boundary (any exception there -> exit 2), keeping the 0/1 contract.
13
+ # Mirrors MinitestIntegration's contract: run the given spec files and
14
+ # return 0 (all passed) or 1 (any failure).
19
15
  module RSpec
20
- # `spec_files` is one path or an Array of paths (coverage selection passes
21
- # the covering subset). All are loaded+run in a single RSpec invocation.
16
+ # Runs the given RSpec files.
17
+ #
18
+ # @param spec_files [String, Array<String>] one file or many files.
19
+ # @return [Integer] 0 on success, 1 on failure.
22
20
  def self.run(spec_files)
23
21
  require_rspec!
24
22
 
25
- # rspec/autorun (if a spec_helper required it) installs an at_exit run;
26
- # neutralise it like Minitest.autorun so it never double-fires.
27
23
  ::RSpec::Core::Runner.disable_autorun!
28
-
29
- # Drop any RSpec world/configuration inherited across runs in one process
30
- # (e.g. successive forks of a booted parent) so examples never accumulate.
31
24
  ::RSpec.reset
32
25
 
33
- # Silence RSpec's formatter output (and any stray puts/deprecations) so it
34
- # never pollutes Mutineer's report streams. The formatter writes to the
35
- # passed IO; $stdout/$stderr are redirected to catch everything else.
36
26
  sink = StringIO.new
37
27
  orig_out = $stdout
38
28
  orig_err = $stderr
@@ -48,6 +38,9 @@ module Mutineer
48
38
  status.zero? ? 0 : 1
49
39
  end
50
40
 
41
+ # Requires rspec-core from the project under test.
42
+ #
43
+ # @api private
51
44
  def self.require_rspec!
52
45
  require "rspec/core"
53
46
  rescue LoadError
@@ -4,9 +4,16 @@ require_relative "test_runners/minitest"
4
4
  require_relative "test_runners/rspec"
5
5
 
6
6
  module Mutineer
7
- # Picks the test-framework runner. Each runner responds to `.run(files) -> 0/1`
8
- # (0 = all passed, 1 = any failure) and is called only inside a forked child.
7
+ # Picks the test-framework runner.
8
+ #
9
+ # Each runner responds to `.run(files) -> 0/1` (0 = all passed, 1 = any
10
+ # failure) and is called only inside a forked child.
9
11
  module TestRunners
12
+ # Returns the runner module for a framework name.
13
+ #
14
+ # @param framework [String, nil] framework name or nil.
15
+ # @return [Module] `Mutineer::TestRunners::Minitest` or `::RSpec`.
16
+ # @raise [Mutineer::ConfigError] when the framework is unknown.
10
17
  def self.for(framework)
11
18
  case framework
12
19
  when "rspec" then RSpec
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- VERSION = "0.7.0"
4
+ # Current Mutineer release version.
5
+ VERSION = "0.8.0"
5
6
  end
@@ -3,21 +3,30 @@
3
3
  require_relative "result"
4
4
 
5
5
  module Mutineer
6
- # Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at once;
7
- # each child runs the block on one work item, marshals its Result to a private
8
- # pipe, and exits. The parent reaps any finished child with Process.wait2(-1),
9
- # opening exactly one slot per reap, then refills. Results are returned in the
10
- # SAME ORDER as `items` regardless of finish order, so verdicts are identical to
11
- # a serial run (R4) and downstream output is stable.
6
+ # Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at
7
+ # once; each child runs the block on one work item, marshals its Result to a
8
+ # private pipe, and exits. The parent reaps any finished child with
9
+ # Process.wait2(-1), opening exactly one slot per reap, then refills.
10
+ # Results are returned in the SAME ORDER as `items` regardless of finish
11
+ # order, so verdicts are identical to a serial run (R4) and downstream output
12
+ # is stable.
12
13
  #
13
14
  # The block is run inside the child via `yield(*items[i])`; whatever it
14
- # returns (a Result) is the marshaled payload. Per-mutant timeout is handled one
15
- # level down by Isolation (KTD2) — the pool adds no separate wall clock.
15
+ # returns (a Result) is the marshaled payload. Per-mutant timeout is handled
16
+ # one level down by Isolation (KTD2) — the pool adds no separate wall clock.
16
17
  class WorkerPool
18
+ # Builds a pool.
19
+ #
20
+ # @param size [Integer] desired pool size.
17
21
  def initialize(size)
18
22
  @size = [size.to_i, 1].max
19
23
  end
20
24
 
25
+ # Runs the work items through the pool.
26
+ #
27
+ # @param items [Array<Array>] work items.
28
+ # @yieldparam item [Array] one work item.
29
+ # @return [Array<Mutineer::Result>] results in input order.
21
30
  def run(items)
22
31
  results = Array.new(items.size)
23
32
  queue = (0...items.size).to_a
@@ -33,17 +42,19 @@ module Mutineer
33
42
 
34
43
  private
35
44
 
45
+ # R1: the child must ALWAYS hard-exit. If yield raises, marshal an error
46
+ # Result and exit! in `ensure` — otherwise the child unwinds normally and
47
+ # our Minitest at_exit autorun re-runs the parent suite inside the worker,
48
+ # losing the real error.
36
49
  def fill(items, queue, running)
37
50
  while running.size < @size && !queue.empty?
38
51
  idx = queue.shift
39
52
  rd, wr = IO.pipe
53
+ rd.binmode # #19: Marshal output is binary — keep the pipe byte-exact
54
+ wr.binmode
40
55
  begin
41
56
  pid = fork do
42
57
  rd.close
43
- # R1: the child must ALWAYS hard-exit. If yield raises, marshal an
44
- # error Result and exit! in `ensure` — otherwise the child unwinds
45
- # normally and our Minitest at_exit autorun re-runs the parent suite
46
- # inside the worker, losing the real error.
47
58
  payload =
48
59
  begin
49
60
  yield(*items[idx])
@@ -74,13 +85,13 @@ module Mutineer
74
85
  end
75
86
  end
76
87
 
77
- # Drain pipes with IO.select and reap a child only on EOF (#4). The old code
78
- # reaped first and read after — but a child whose payload exceeds the OS pipe
79
- # buffer (~64KB) blocks on `write` before it can exit, so it was never reaped
80
- # and the pool deadlocked. Reading concurrently keeps the pipe drained so the
81
- # child can finish and exit; EOF means it closed its write end (done writing).
82
- # We waitpid only OUR known pids (R6: never wait2(-1), which would steal the
83
- # host suite's children).
88
+ # Drain pipes with IO.select and reap a child only on EOF (#4). The old
89
+ # code reaped first and read after — but a child whose payload exceeds the
90
+ # OS pipe buffer (~64KB) blocks on `write` before it can exit, so it was
91
+ # never reaped and the pool deadlocked. Reading concurrently keeps the
92
+ # pipe drained so the child can finish and exit; EOF means it closed its
93
+ # write end (done writing). We waitpid only OUR known pids (R6: never
94
+ # wait2(-1), which would steal the host suite's children).
84
95
  def reap(results, running)
85
96
  return if running.empty?
86
97
 
@@ -104,11 +115,13 @@ module Mutineer
104
115
  end
105
116
  end
106
117
 
118
+ # R6: a partial/garbage Marshal stream (dead worker) must not crash the
119
+ # pool — degrade to an error Result.
120
+ # @param data [String] marshaled payload.
121
+ # @return [Mutineer::Result] decoded result or error result.
107
122
  def decode(data)
108
123
  return Result.error("worker produced no result") if data.empty?
109
124
 
110
- # R6: a partial/garbage Marshal stream (dead worker) must not crash the
111
- # pool — degrade to an error Result.
112
125
  Marshal.load(data)
113
126
  rescue StandardError => e
114
127
  Result.error("worker result unreadable: #{e.class}: #{e.message}")
data/lib/mutineer.rb CHANGED
@@ -29,5 +29,6 @@ require_relative "mutineer/reporter"
29
29
  require_relative "mutineer/baseline"
30
30
  require_relative "mutineer/cli"
31
31
 
32
+ # Mutineer is the top-level namespace for the mutation-testing library.
32
33
  module Mutineer
33
34
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutineer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: yard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
40
54
  description: Mutineer mutates your source one change at a time and runs your Minitest
41
55
  suite against each mutant to find tests that don't actually test anything. Prism-based,
42
56
  fork-isolated, zero runtime dependencies.