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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae2c0b5167b5070a3afc7834ffa6d4d012267b070dd6b73f3d95affa3752c4c0
4
- data.tar.gz: 22a328c5b5446d8c937288763d5ab050869b49b58c0bee715c3ec9dbb54005fd
3
+ metadata.gz: bfadc70b171d8778c8afa95021c2989ad33ab2880200a22886fec274e358efcc
4
+ data.tar.gz: f6f6c91f6e5fffc0a2d05d93b0981df165d6d5b2df1e1434949c7dea1c0f70a6
5
5
  SHA512:
6
- metadata.gz: 858c634ce049e4a55ee2c94151a11df0b620edb19630d53719bf94cf6efa9212a4bd6d55c67607a1447ff42fed7804293ae3ec8c28b703b6478f2324aa5593b9
7
- data.tar.gz: 2c47217873142ba8dbdffe1adbeb3772532925a951a2d52f058ca4b5e26732877019299de6a98c5c68ad89a5d9c6f8513b06ac424806fe7258e4ef1a7e305a27
6
+ metadata.gz: 01de0a56c54a7b85fd98d92831681a258a4420b3e5b3d34ce932811259a776764c888aae13a8975632c66851798f9b4a53f343864518fd8fe608e7dddff20e07
7
+ data.tar.gz: 63c802e363a3f87a8ecd43a5bf726d2ce23af2aadf890b87b7ad6616076e9f2461876c0af5faa70c83caf93027fd9e8754bf6866c5ab25e7d2a51bd51f9f3d4c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.8.0] - 2026-06-30
8
+
9
+ ### Fixed
10
+ - **Singleton methods are now mutated** (#20) — `class << self` and
11
+ `module_function` methods were discovered but applied to the instance scope, so
12
+ the mutant never ran on the singleton the caller dispatches to; every such
13
+ mutant falsely survived and the file read a false 0%. `module_function` methods
14
+ are now discovered as singletons, and the redefine strategy re-opens
15
+ `class << self` so the mutation lands on the called method. (Scores for
16
+ singleton-heavy files will rise to their true values.)
17
+ - **Fork-capture failures are diagnosable** (#19, part 1) — capture/worker pipes
18
+ are `binmode` (a binary Marshal payload could otherwise be lost to an encoding
19
+ error and swallowed), and a child that dies without writing now reports how it
20
+ died (exit status / signal) instead of a silent "produced no result". `--verbose`
21
+ always surfaces a real reason now. (The residual write-heavy capture failure
22
+ on some real Rails suites remains under investigation — now diagnosable.)
23
+
24
+ ## [0.7.1] - 2026-06-30
25
+
26
+ ### Added
27
+ - **GitHub Action** (`action.yml`, composite) wrapping the CLI for CI — gate a PR
28
+ on new survivors / score drop with `sources`, `since`, `baseline`, `threshold`,
29
+ etc. Inputs are passed via `env` (no `${{ }}` interpolation into the run script)
30
+ for command-injection safety.
31
+ - Docs site (GitHub Pages) with Open Graph / Twitter Card share image; YARD doc
32
+ comments across the library.
33
+
7
34
  ## [0.7.0] - 2026-06-30
8
35
 
9
36
  Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
@@ -129,6 +156,8 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
129
156
  - `.mutineer.yml` configuration (CLI > config > default precedence).
130
157
  - Byte-correct source handling for multibyte (UTF-8) sources.
131
158
 
159
+ [0.8.0]: https://github.com/davidteren/mutineer/releases/tag/v0.8.0
160
+ [0.7.1]: https://github.com/davidteren/mutineer/releases/tag/v0.7.1
132
161
  [0.7.0]: https://github.com/davidteren/mutineer/releases/tag/v0.7.0
133
162
  [0.6.2]: https://github.com/davidteren/mutineer/releases/tag/v0.6.2
134
163
  [0.6.1]: https://github.com/davidteren/mutineer/releases/tag/v0.6.1
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Mutineer
2
2
 
3
- [![Socket Badge](https://badge.socket.dev/rubygems/package/mutineer/0.7.0?platform=ruby)](https://socket.dev/rubygems/package/mutineer/overview/0.7.0?platform=ruby)
3
+ [![Gem Version](https://img.shields.io/gem/v/mutineer?logo=rubygems&color=e23b3b)](https://rubygems.org/gems/mutineer)
4
+ [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Mutineer%20Ruby-2da44e?logo=githubactions&logoColor=white)](https://github.com/marketplace/actions/mutineer-ruby)
5
+ [![Socket Badge](https://badge.socket.dev/rubygems/package/mutineer/0.7.1?platform=ruby)](https://socket.dev/rubygems/package/mutineer/overview/0.7.1?platform=ruby)
4
6
 
5
7
  A clean-room mutation-testing tool for Ruby. Mutineer mutates your source one
6
8
  change at a time, runs your test suite (Minitest or RSpec) against each mutant, and reports the
@@ -124,6 +126,37 @@ combines with `--threshold` (the worse of the two sets the exit code). Pass a
124
126
  directory (or several sources) to audit a whole layer in one boot — tests are
125
127
  auto-paired by convention and the report breaks down per source.
126
128
 
129
+ ### GitHub Action
130
+
131
+ This repo ships a composite action (`action.yml`) that wraps the CLI for CI:
132
+
133
+ ```yaml
134
+ - uses: actions/checkout@v4
135
+ with: { fetch-depth: 0 } # --since needs full history
136
+ - uses: ruby/setup-ruby@v1
137
+ with: { ruby-version: "3.4", bundler-cache: true }
138
+ - uses: davidteren/mutineer@v0
139
+ with:
140
+ sources: app/
141
+ since: origin/${{ github.base_ref }}
142
+ baseline: .mutineer/baseline.json
143
+ threshold: "90"
144
+ ```
145
+
146
+ ## For AI agents & pipelines
147
+
148
+ Mutineer is built for programmatic use — versioned JSON, stable mutant ids,
149
+ structured exit codes, and diff-scoped runs. See:
150
+
151
+ - **AI agents & CI recipes** — the agent inner-loop and CI-gate recipes (and how
152
+ to avoid infinite loops on equivalent mutants):
153
+ [rendered](https://davidteren.github.io/mutineer/agentic-coding.html) ·
154
+ [source](docs/agentic-coding.md)
155
+ - **JSON schema reference** — the `--format json` schema and its versioning
156
+ contract:
157
+ [rendered](https://davidteren.github.io/mutineer/json-schema.html) ·
158
+ [source](docs/json-schema.md)
159
+
127
160
  ## Configuration
128
161
 
129
162
  Mutineer reads an optional `.mutineer.yml` from the project root (nearest one,
@@ -5,20 +5,21 @@ require_relative "config" # for Mutineer::ConfigError
5
5
 
6
6
  module Mutineer
7
7
  # #13: CI baseline/delta gating. A baseline is literally a prior
8
- # `mutineer run --format json` document (KTD-1) — no bespoke format to version.
9
- # We diff the current run against it by the #10 stable survivor id (KTD-2): a
10
- # NEW survivor (id present now, absent in the baseline) OR a score drop is a
11
- # regression the CLI turns into exit 1. Pure data stdlib `json` only, no fork,
12
- # no Rails so it's testable in isolation from a canned JSON + a hand-built
13
- # AggregateResult.
8
+ # `mutineer run --format json` document (KTD-1) — no bespoke format to
9
+ # version.
10
+ # We diff the current run against it by the #10 stable survivor id (KTD-2):
11
+ # a NEW survivor (id present now, absent in the baseline) OR a score drop is
12
+ # a regression the CLI turns into exit 1. Pure data stdlib `json` only, no
13
+ # fork, no Rails — so it's testable in isolation from a canned JSON + a hand-
14
+ # built AggregateResult.
14
15
  class Baseline
15
16
  # The verdict of diffing a current run against the baseline.
16
- # new_survivors — current Result objects whose stable id is absent from the
17
- # baseline (the regressions to name).
17
+ # new_survivors — current Result objects whose stable id is absent from
18
+ # the baseline (the regressions to name).
18
19
  # fixed_survivors — baseline survivor hashes absent from the current run
19
20
  # (informational, never gates).
20
- # score_drop — current score < baseline score - epsilon. nil on either
21
- # side skips the check (see #diff).
21
+ # score_drop — current score < baseline score - epsilon. nil on
22
+ # either side skips the check (see #diff).
22
23
  # regressed — any new survivors OR a score drop.
23
24
  Delta = Data.define(:new_survivors, :fixed_survivors,
24
25
  :score_before, :score_after, :score_drop, :regressed)
@@ -27,6 +28,10 @@ module Mutineer
27
28
  # class must never kill the host) on a missing/unreadable file, unparseable
28
29
  # JSON, or a doc that isn't a baseline shape, so the CLI maps it to exit 2
29
30
  # (usage) like every other bad-path flag.
31
+ #
32
+ # @param path [String] baseline JSON file path.
33
+ # @return [Mutineer::Baseline] baseline object.
34
+ # @raise [Mutineer::ConfigError] when the file is missing or invalid.
30
35
  def self.load(path)
31
36
  doc = JSON.parse(File.read(path))
32
37
  unless doc.is_a?(Hash) && doc["schema_version"] && doc["survivors"].is_a?(Array)
@@ -40,13 +45,23 @@ module Mutineer
40
45
 
41
46
  attr_reader :score
42
47
 
48
+ # Builds a baseline from a JSON document.
49
+ #
50
+ # The baseline retains the survivor document and score from the JSON report.
51
+ #
52
+ # @param doc [Hash] parsed JSON document.
43
53
  def initialize(doc)
44
54
  @survivors = doc["survivors"] || []
45
55
  @score = doc.dig("summary", "score")
46
56
  end
47
57
 
48
58
  # Diff a current AggregateResult against this baseline by stable survivor id.
49
- # `epsilon` tolerates float jitter on the score (default 0.0 = any drop gates).
59
+ # `epsilon` tolerates float jitter on the score (default 0.0 = any drop
60
+ # gates).
61
+ #
62
+ # @param aggregate [Mutineer::AggregateResult] current results.
63
+ # @param epsilon [Float] score-drop tolerance.
64
+ # @return [Mutineer::Baseline::Delta] delta summary.
50
65
  def diff(aggregate, epsilon: 0.0)
51
66
  current = aggregate.surviving_mutants
52
67
  current_ids = current.map(&:id)
@@ -56,8 +71,9 @@ module Mutineer
56
71
  fixed = @survivors.reject { |h| current_ids.include?(h["id"]) }
57
72
 
58
73
  current_score = aggregate.mutation_score
59
- # nil-score discipline (mirrors Reporter#exit_code): a score absent on either
60
- # side can't be compared — skip the drop check, keep the new-survivor check.
74
+ # nil-score discipline (mirrors Reporter#exit_code): a score absent on
75
+ # either side can't be compared — skip the drop check, keep the new-
76
+ # survivor check.
61
77
  score_drop = !@score.nil? && !current_score.nil? &&
62
78
  current_score < @score - epsilon
63
79
 
@@ -4,23 +4,30 @@ require "set"
4
4
  require "open3"
5
5
 
6
6
  module Mutineer
7
- # Maps each source file to the set of NEW-side line numbers that changed since a
8
- # given git ref, by parsing `git diff --unified=0`. Used to restrict mutations
9
- # to only the diff (issue #2): on a PR you care whether the changed code is
10
- # tested, so mutating just those lines is fast and actionable.
7
+ # Maps each source file to the set of NEW-side line numbers changed since a
8
+ # git ref.
11
9
  #
12
- # git is an external tool, not a gem dependency shelling out is fine. The pure
10
+ # By parsing `git diff --unified=0`, this restricts mutations to only the
11
+ # diff (issue #2): on a PR you care whether the changed code is tested, so
12
+ # mutating just those lines is fast and actionable.
13
+ #
14
+ # git is an external tool, not a gem dependency — shelling out is fine. The
13
15
  # `parse` carries the logic; `git_diff` is injectable so it stays testable
14
16
  # without invoking git.
15
17
  module ChangedLines
18
+ # Matches unified-diff hunks and captures the new-file start/count.
16
19
  HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
17
20
 
18
21
  module_function
19
22
 
20
- # Parse `git diff --unified=0` output into the set of NEW-side line numbers
21
- # added/modified. With --unified=0 each hunk's `+c,d` block is exactly the
22
- # changed lines: c..c+d-1. `d` absent means 1 line; `d == 0` is a pure
23
- # deletion and contributes nothing.
23
+ # Parses unified diff text into the set of NEW-side line numbers.
24
+ #
25
+ # With `--unified=0` each hunk's `+c,d` block is exactly the changed lines:
26
+ # `c..c+d-1`. `d` absent means 1 line; `d == 0` is a pure deletion and
27
+ # contributes nothing.
28
+ #
29
+ # @param diff_text [String] raw `git diff --unified=0` output.
30
+ # @return [Set<Integer>] changed line numbers on the new side.
24
31
  def parse(diff_text)
25
32
  lines = Set.new
26
33
  diff_text.each_line do |row|
@@ -34,8 +41,13 @@ module Mutineer
34
41
  lines
35
42
  end
36
43
 
37
- # { absolute_file_path => Set<Integer> } of changed new-side lines per file.
38
- # `runner` is injected so tests can supply canned diff text per file.
44
+ # Builds a per-file map of changed new-side lines.
45
+ #
46
+ # @param ref [String] git ref to diff against.
47
+ # @param files [Array<String>] source files to inspect.
48
+ # @param project_root [String] repository root for `git -C`.
49
+ # @param runner [#call] injectable diff producer.
50
+ # @return [Hash<String, Set<Integer>>] absolute file path to changed lines.
39
51
  def for(ref:, files:, project_root:, runner: method(:git_diff))
40
52
  files.each_with_object({}) do |file, acc|
41
53
  abs = File.expand_path(file, project_root)
@@ -43,7 +55,12 @@ module Mutineer
43
55
  end
44
56
  end
45
57
 
46
- # stdout of `git -C <root> diff --unified=0 <ref> -- <file>`, "" on failure.
58
+ # Returns the stdout of `git -C <root> diff --unified=0 <ref> -- <file>`.
59
+ #
60
+ # @param ref [String] git ref to diff against.
61
+ # @param abs_file [String] absolute path of the file being diffed.
62
+ # @param project_root [String] repository root for `git -C`.
63
+ # @return [String] diff text, or `""` on failure.
47
64
  def git_diff(ref, abs_file, project_root)
48
65
  out, _err, status = Open3.capture3(
49
66
  "git", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
data/lib/mutineer/cli.rb CHANGED
@@ -24,6 +24,7 @@ module Mutineer
24
24
  # 2 usage / flag error (unknown subcommand, invalid flag, unknown operator,
25
25
  # out-of-range threshold)
26
26
  class CLI
27
+ # Command-line usage banner.
27
28
  BANNER = <<~USAGE
28
29
  Usage: mutineer [options] <command> [args]
29
30
 
@@ -63,6 +64,10 @@ module Mutineer
63
64
  # Deprecated internal strategy names, mapped to their canonical equivalents.
64
65
  STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
65
66
 
67
+ # Parses arguments, executes the command, and exits.
68
+ #
69
+ # @param argv [Array<String>] raw command-line arguments.
70
+ # @return [void]
66
71
  def self.start(argv)
67
72
  opts = {} # symbol => value, the CLI-provided Config fields
68
73
  explicit = Set.new # precedence keys the user typed (KTD3)
@@ -140,6 +145,9 @@ module Mutineer
140
145
  end
141
146
  end
142
147
 
148
+ # Lists available operators.
149
+ #
150
+ # @return [void]
143
151
  def self.list_operators
144
152
  MutatorRegistry::ALL.each_key do |name|
145
153
  state = MutatorRegistry.default?(name) ? "default" : "disabled"
@@ -148,6 +156,11 @@ module Mutineer
148
156
  end
149
157
  end
150
158
 
159
+ # Runs the requested command after validation.
160
+ #
161
+ # @param config [Mutineer::Config] run configuration.
162
+ # @param explicit [Set<Symbol>] explicit CLI fields.
163
+ # @return [void]
151
164
  def self.run(config, explicit = Set.new)
152
165
  if config.sources.empty?
153
166
  warn "mutineer: run requires at least one source file"
@@ -177,6 +190,12 @@ module Mutineer
177
190
 
178
191
  # Flag validation: every flag/usage failure exits 2 (C7), consistent with the
179
192
  # taxonomy above — CI can tell "mistyped flag" from "tests too weak."
193
+ # Validates the run configuration.
194
+ #
195
+ # @api private
196
+ # @param config [Mutineer::Config] run configuration.
197
+ # @param explicit [Set<Symbol>] explicit CLI fields.
198
+ # @return [void]
180
199
  def self.validate!(config, explicit = Set.new)
181
200
  unless (0.0..100.0).cover?(config.threshold)
182
201
  warn "mutineer: --threshold must be between 0 and 100"
@@ -231,6 +250,11 @@ module Mutineer
231
250
 
232
251
  # --since needs a real git repo and a resolvable ref; either failure is a
233
252
  # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
253
+ # Validates the --since ref.
254
+ #
255
+ # @api private
256
+ # @param config [Mutineer::Config] run configuration.
257
+ # @return [void]
234
258
  def self.validate_since!(config)
235
259
  _out, _err, status = Open3.capture3(
236
260
  "git", "-C", config.project_root, "rev-parse", "--verify", "--quiet",
@@ -252,6 +276,11 @@ module Mutineer
252
276
  # R5: validate path existence up front so a typo is a clean usage error (exit
253
277
  # 2), not an Errno::ENOENT backtrace from deep in the run. Flag checks run
254
278
  # first so a bad flag still reports the flag, not the missing file.
279
+ # Validates source and test paths.
280
+ #
281
+ # @api private
282
+ # @param config [Mutineer::Config] run configuration.
283
+ # @return [void]
255
284
  def self.validate_paths!(config)
256
285
  missing = (config.sources + config.tests)
257
286
  .reject { |p| File.exist?(File.expand_path(p, config.project_root)) }
@@ -268,6 +297,12 @@ module Mutineer
268
297
  # the dedicated --boot/--rails-requires-test check reports it; otherwise exit 2
269
298
  # with a usage message. The framework is re-detected from the inferred set
270
299
  # unless it was set explicitly (a spec-only project loads/reports as rspec).
300
+ # Auto-pairs sources and tests when --test is absent.
301
+ #
302
+ # @api private
303
+ # @param config [Mutineer::Config] run configuration.
304
+ # @param explicit [Set<Symbol>] explicit CLI fields.
305
+ # @return [void]
271
306
  def self.autopair!(config, explicit)
272
307
  return unless config.tests.empty?
273
308
 
@@ -293,6 +328,11 @@ module Mutineer
293
328
  # mirroring --output/--since preflight, so CI sees "bad invocation," not a
294
329
  # backtrace mid-run. Validating up front = attempting the load (it raises
295
330
  # ConfigError/SystemCallError; the actual diff reloads in execute).
331
+ # Preflights a baseline file.
332
+ #
333
+ # @api private
334
+ # @param path [String] baseline file path.
335
+ # @return [void]
296
336
  def self.preflight_baseline!(path)
297
337
  Baseline.load(path)
298
338
  rescue Mutineer::ConfigError, SystemCallError => e
@@ -300,6 +340,11 @@ module Mutineer
300
340
  exit 2
301
341
  end
302
342
 
343
+ # Preflights an output path.
344
+ #
345
+ # @api private
346
+ # @param path [String] output file path.
347
+ # @return [void]
303
348
  def self.preflight_output!(path)
304
349
  dir = File.dirname(File.expand_path(path))
305
350
  return if File.directory?(dir) && File.writable?(dir)
@@ -309,6 +354,10 @@ module Mutineer
309
354
  exit 2
310
355
  end
311
356
 
357
+ # Executes the run command.
358
+ #
359
+ # @param config [Mutineer::Config] run configuration.
360
+ # @return [void]
312
361
  def self.execute(config)
313
362
  if config.tests.empty?
314
363
  warn "mutineer: run requires at least one --test file (or use --dry-run)"
@@ -340,6 +389,10 @@ module Mutineer
340
389
 
341
390
  # The tier-2 operators not in the active set, as a one-line hint (or nil when
342
391
  # they're all already enabled). `active` nil means the default (Tier-1) set.
392
+ # Builds a Tier-2 operator hint.
393
+ #
394
+ # @param active [Array<String>, nil] active operator names.
395
+ # @return [String, nil] hint text or nil.
343
396
  def self.tier2_hint(active)
344
397
  active ||= MutatorRegistry::DEFAULT_NAMES
345
398
  unused = MutatorRegistry::TIER2_NAMES - active
@@ -349,6 +402,10 @@ module Mutineer
349
402
  "enable with --operators <list>."
350
403
  end
351
404
 
405
+ # Runs dry-run mode.
406
+ #
407
+ # @param config [Mutineer::Config] run configuration.
408
+ # @return [void]
352
409
  def self.dry_run(config)
353
410
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
354
411
  sources = {}
@@ -28,6 +28,7 @@ module Mutineer
28
28
  :baseline, :baseline_epsilon,
29
29
  keyword_init: true
30
30
  ) do
31
+ # Config file name.
31
32
  CONFIG_FILE = ".mutineer.yml"
32
33
  # Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
33
34
  KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline].freeze
@@ -128,16 +129,30 @@ module Mutineer
128
129
 
129
130
  # Pick rspec when a MAJORITY of the given test files end with _spec.rb;
130
131
  # otherwise minitest. Empty/ambiguous -> minitest (the safe default).
132
+ # Detects the test framework from the file list.
133
+ #
134
+ # @param tests [Array<String>] test file paths.
135
+ # @return [String] `"rspec"` or `"minitest"`.
131
136
  def self.detect_framework(tests)
132
137
  tests = Array(tests)
133
138
  specs = tests.count { |t| t.to_s.end_with?("_spec.rb") }
134
139
  specs > tests.length / 2.0 ? "rspec" : "minitest"
135
140
  end
136
141
 
142
+ # Maps a config key to its Struct field.
143
+ #
144
+ # @param known_key [String] config key.
145
+ # @return [Symbol] struct field name.
137
146
  def self.field_for(known_key)
138
147
  known_key == "require" ? :require_paths : known_key.to_sym
139
148
  end
140
149
 
150
+ # Coerces a config value to its target type.
151
+ #
152
+ # @param known_key [String] config key.
153
+ # @param value [Object] raw YAML value.
154
+ # @param file_name [String] config file name for warnings.
155
+ # @return [Object] coerced value.
141
156
  def self.coerce(known_key, value, file_name)
142
157
  case known_key
143
158
  when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
@@ -157,6 +172,12 @@ module Mutineer
157
172
  # Drop (with a warning) operator names the registry doesn't know (R7).
158
173
  # Referenced lazily so config.rb carries no load-order dependency on the
159
174
  # registry; by the time a config is parsed at runtime, it is loaded.
175
+ # Filters out unknown operator names.
176
+ #
177
+ # @api private
178
+ # @param names [Array<String>] operator names.
179
+ # @param file_name [String] config file name for warnings.
180
+ # @return [Array<String>] known operator names.
160
181
  def self.filter_operators(names, file_name)
161
182
  known = MutatorRegistry::ALL.keys
162
183
  names.select do |n|
@@ -117,6 +117,9 @@ module Mutineer
117
117
  self
118
118
  end
119
119
 
120
+ # Runs standalone Phase A coverage capture.
121
+ #
122
+ # @api private
120
123
  def run_phase_a
121
124
  @phase_a_ran = true
122
125
  @map = {}
@@ -160,6 +163,11 @@ module Mutineer
160
163
  # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
161
164
  def fork_capture(abs_test, abs_sources, rails)
162
165
  rd, wr = IO.pipe
166
+ # #19: Marshal output is binary — an un-binmoded pipe can raise
167
+ # Encoding::UndefinedConversionError on write, which the child's rescue then
168
+ # swallows, losing the real error and yielding a bare "no result".
169
+ rd.binmode
170
+ wr.binmode
163
171
  pid = fork do
164
172
  rd.close
165
173
  payload =
@@ -189,12 +197,30 @@ module Mutineer
189
197
  wr.close
190
198
  data = rd.read
191
199
  rd.close
192
- Process.waitpid(pid)
193
- return nil if data.empty?
200
+ _, status = Process.waitpid2(pid)
201
+ # #19: an empty pipe means the child died before writing (e.g. a hard crash,
202
+ # OOM, or a signal from the test's own subprocess handling). Report HOW it
203
+ # died (exit status / signal) as a diagnostic string so --verbose has
204
+ # something actionable instead of a silent "no result".
205
+ return "child wrote no result (#{describe_status(status)})" if data.empty?
194
206
 
195
207
  Marshal.load(data)
196
- rescue StandardError
197
- nil
208
+ rescue StandardError => e
209
+ "parent could not read capture result: #{e.class}: #{e.message}"
210
+ end
211
+
212
+ # Human description of a child Process::Status for capture diagnostics.
213
+ #
214
+ # @api private
215
+ # @param status [Process::Status] the reaped child status.
216
+ # @return [String] e.g. "killed by signal 9 (SIGKILL)" or "exit status 1".
217
+ def describe_status(status)
218
+ if status.signaled?
219
+ sig = status.termsig
220
+ "killed by signal #{sig}#{Signal.signame(sig) ? " (SIG#{Signal.signame(sig)})" : ''}"
221
+ else
222
+ "exit status #{status.exitstatus.inspect}"
223
+ end
198
224
  end
199
225
 
200
226
  # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
@@ -225,6 +251,12 @@ module Mutineer
225
251
  fail_test(test_path, "invalid coverage output: #{e.message}")
226
252
  end
227
253
 
254
+ # Records a failed coverage capture.
255
+ #
256
+ # @api private
257
+ # @param test_path [String] test file path.
258
+ # @param reason [String] failure reason.
259
+ # @return [void]
228
260
  def fail_test(test_path, reason)
229
261
  rel = relativize(test_path)
230
262
  @failed_test_files << rel
@@ -232,10 +264,20 @@ module Mutineer
232
264
  nil
233
265
  end
234
266
 
267
+ # Builds the framework-specific subprocess script.
268
+ #
269
+ # @api private
270
+ # @param test_path [String] test file path.
271
+ # @return [String] Ruby script text.
235
272
  def subprocess_script(test_path)
236
273
  @framework == "rspec" ? rspec_subprocess_script(test_path) : minitest_subprocess_script(test_path)
237
274
  end
238
275
 
276
+ # Builds the minitest subprocess script.
277
+ #
278
+ # @api private
279
+ # @param test_path [String] test file path.
280
+ # @return [String] Ruby script text.
239
281
  def minitest_subprocess_script(test_path)
240
282
  <<~RUBY
241
283
  require "coverage"
@@ -322,6 +364,13 @@ module Mutineer
322
364
  File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
323
365
  end
324
366
 
367
+ # Groups a digest with its role and paths.
368
+ #
369
+ # @api private
370
+ # @param digest [String] digest string.
371
+ # @param role [String] digest role.
372
+ # @param paths [Array<String>] paths in the digest group.
373
+ # @return [Array(String, String, Array<String>)] grouped digest data.
325
374
  def digest_group(digest, role, paths)
326
375
  paths.sort.each do |p|
327
376
  content = File.read(absolute(p))
@@ -347,8 +396,16 @@ module Mutineer
347
396
  end
348
397
  end
349
398
 
399
+ # Returns the cache path.
400
+ #
401
+ # @api private
402
+ # @return [String] cache file path.
350
403
  def cache_path = File.join(@cache_dir, "coverage.json")
351
404
 
405
+ # Reads the coverage cache.
406
+ #
407
+ # @api private
408
+ # @return [Hash, nil] cached payload.
352
409
  def read_cache
353
410
  return nil unless File.exist?(cache_path)
354
411
 
@@ -357,6 +414,10 @@ module Mutineer
357
414
  nil # corrupt cache — rebuild from scratch
358
415
  end
359
416
 
417
+ # Saves the coverage cache.
418
+ #
419
+ # @api private
420
+ # @return [void]
360
421
  def save
361
422
  FileUtils.mkdir_p(@cache_dir)
362
423
  data = { "digest" => @digest, "failed_test_files" => @failed_test_files, "map" => @map }
@@ -365,20 +426,41 @@ module Mutineer
365
426
  File.rename(tmp, cache_path) # atomic swap
366
427
  end
367
428
 
429
+ # Warns when coverage capture was incomplete.
430
+ #
431
+ # @api private
432
+ # @return [void]
368
433
  def warn_incomplete
369
434
  warn "[mutineer] cached coverage map may be incomplete; these test files " \
370
435
  "failed to contribute: #{@failed_test_files.join(', ')}"
371
436
  end
372
437
 
438
+ # Returns absolute source paths.
439
+ #
440
+ # @return [Array<String>] absolute source paths.
373
441
  def abs_source_paths = @source_paths.map { |p| absolute(p) }
442
+
443
+ # Returns absolute load paths.
444
+ #
445
+ # @return [Array<String>] absolute load paths.
374
446
  def abs_load_paths = @load_paths.map { |p| absolute(p) }
375
447
 
448
+ # Relativizes a path against the project root.
449
+ #
450
+ # @api private
451
+ # @param path [String] path to relativize.
452
+ # @return [String] relative path.
376
453
  def relativize(path)
377
454
  return path unless path.start_with?("/")
378
455
 
379
456
  path.delete_prefix("#{@project_root}/")
380
457
  end
381
458
 
459
+ # Expands a path relative to the project root.
460
+ #
461
+ # @api private
462
+ # @param path [String] path to expand.
463
+ # @return [String] absolute path.
382
464
  def absolute(path)
383
465
  File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
384
466
  end