mutineer 0.7.0 → 0.9.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +39 -4
  4. data/lib/mutineer/baseline.rb +29 -13
  5. data/lib/mutineer/changed_lines.rb +29 -12
  6. data/lib/mutineer/cli.rb +88 -20
  7. data/lib/mutineer/config.rb +24 -2
  8. data/lib/mutineer/coverage_map.rb +86 -4
  9. data/lib/mutineer/isolation.rb +89 -40
  10. data/lib/mutineer/minitest_integration.rb +13 -9
  11. data/lib/mutineer/mutant_id.rb +24 -5
  12. data/lib/mutineer/mutation.rb +12 -6
  13. data/lib/mutineer/mutator_registry.rb +33 -6
  14. data/lib/mutineer/mutators/arithmetic.rb +8 -2
  15. data/lib/mutineer/mutators/base.rb +11 -3
  16. data/lib/mutineer/mutators/boolean_connector.rb +15 -5
  17. data/lib/mutineer/mutators/boolean_literal.rb +19 -6
  18. data/lib/mutineer/mutators/collection_method.rb +44 -0
  19. data/lib/mutineer/mutators/comparison.rb +7 -8
  20. data/lib/mutineer/mutators/condition_negation.rb +14 -5
  21. data/lib/mutineer/mutators/literal_mutation.rb +15 -4
  22. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  23. data/lib/mutineer/mutators/return_nil.rb +22 -7
  24. data/lib/mutineer/mutators/statement_removal.rb +6 -9
  25. data/lib/mutineer/mutators/string_literal.rb +34 -0
  26. data/lib/mutineer/pairing.rb +32 -13
  27. data/lib/mutineer/parser.rb +17 -7
  28. data/lib/mutineer/project.rb +73 -2
  29. data/lib/mutineer/reporter.rb +169 -0
  30. data/lib/mutineer/result.rb +100 -32
  31. data/lib/mutineer/runner.rb +29 -2
  32. data/lib/mutineer/subject.rb +10 -6
  33. data/lib/mutineer/test_runners/minitest.rb +5 -4
  34. data/lib/mutineer/test_runners/rspec.rb +10 -17
  35. data/lib/mutineer/test_runners.rb +9 -2
  36. data/lib/mutineer/version.rb +2 -1
  37. data/lib/mutineer/worker_pool.rb +51 -29
  38. data/lib/mutineer.rb +4 -0
  39. metadata +18 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae2c0b5167b5070a3afc7834ffa6d4d012267b070dd6b73f3d95affa3752c4c0
4
- data.tar.gz: 22a328c5b5446d8c937288763d5ab050869b49b58c0bee715c3ec9dbb54005fd
3
+ metadata.gz: a8687c96cedac16e86197f3639803487657512509c45a1969449eb95b3c4b65c
4
+ data.tar.gz: 59588c405f16476b984fefbda003299f8373687cacd40c4569ea72edb866d0a9
5
5
  SHA512:
6
- metadata.gz: 858c634ce049e4a55ee2c94151a11df0b620edb19630d53719bf94cf6efa9212a4bd6d55c67607a1447ff42fed7804293ae3ec8c28b703b6478f2324aa5593b9
7
- data.tar.gz: 2c47217873142ba8dbdffe1adbeb3772532925a951a2d52f058ca4b5e26732877019299de6a98c5c68ad89a5d9c6f8513b06ac424806fe7258e4ef1a7e305a27
6
+ metadata.gz: edf0bff64186f9719f4de85a0672e7b6614d161aa02b87b63600ebdb2e82e744ef706fd0db29a990d159be3ed422a35b9e3fd7d505595ca585e483fe9ababce1
7
+ data.tar.gz: 1e41589e16979a227b9a479504574d939ff5f91077535723e1b3cfc87e53fa20475e1ba1c09dce910b9644bc002f631ac5001d60737a98a9940ab06ea7dbb023
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ 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.9.0] - 2026-06-30
8
+
9
+ ### Added
10
+ - **`--fail-fast`** (#21) — stop at the first surviving mutant; in-flight workers
11
+ drain, the rest are skipped. Fast red signal for PR gates.
12
+ - **`--format html`** (#23) — a single self-contained HTML report (inline CSS, no
13
+ external assets) with the score, per-source table, and a card per survivor
14
+ (subject, file:line, operator, stable id, diff).
15
+ - **String, regex, and collection-method operators** (#24, Tier-2, opt-in via
16
+ `--operators`): `string_literal`, `regex`, `collection_method`
17
+ (`map`↔`each`, `all?`↔`any?`, `first`↔`last`, `min`↔`max`, `select`↔`reject`).
18
+
19
+ ### Changed
20
+ - **`--dry-run` now honors suppression** (#22) — inline `# mutineer:disable-line`
21
+ and `.mutineer.yml` `ignore:` entries are omitted from the preview and counted
22
+ as "ignored (suppressed)", matching a real run.
23
+
24
+ ## [0.8.0] - 2026-06-30
25
+
26
+ ### Fixed
27
+ - **Singleton methods are now mutated** (#20) — `class << self` and
28
+ `module_function` methods were discovered but applied to the instance scope, so
29
+ the mutant never ran on the singleton the caller dispatches to; every such
30
+ mutant falsely survived and the file read a false 0%. `module_function` methods
31
+ are now discovered as singletons, and the redefine strategy re-opens
32
+ `class << self` so the mutation lands on the called method. (Scores for
33
+ singleton-heavy files will rise to their true values.)
34
+ - **Write-heavy Rails tests are capturable again** (#19) — capture/worker pipes
35
+ are `binmode`: a binary Marshal payload over a text-mode pipe could raise an
36
+ encoding error the child then swallowed → empty pipe → false `:uncapturable`.
37
+ That was the root cause of the residual write-heavy failures too. A child that
38
+ dies without writing now also reports how it died (exit status / signal), and
39
+ `--verbose` always surfaces a real reason. Verified on a real Rails app: all 6
40
+ previously-uncapturable interactors (incl. caxlsx + Google-client) now capture
41
+ with real scores, 0 uncapturable.
42
+
43
+ ## [0.7.1] - 2026-06-30
44
+
45
+ ### Added
46
+ - **GitHub Action** (`action.yml`, composite) wrapping the CLI for CI — gate a PR
47
+ on new survivors / score drop with `sources`, `since`, `baseline`, `threshold`,
48
+ etc. Inputs are passed via `env` (no `${{ }}` interpolation into the run script)
49
+ for command-injection safety.
50
+ - Docs site (GitHub Pages) with Open Graph / Twitter Card share image; YARD doc
51
+ comments across the library.
52
+
7
53
  ## [0.7.0] - 2026-06-30
8
54
 
9
55
  Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
@@ -129,6 +175,9 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
129
175
  - `.mutineer.yml` configuration (CLI > config > default precedence).
130
176
  - Byte-correct source handling for multibyte (UTF-8) sources.
131
177
 
178
+ [0.9.0]: https://github.com/davidteren/mutineer/releases/tag/v0.9.0
179
+ [0.8.0]: https://github.com/davidteren/mutineer/releases/tag/v0.8.0
180
+ [0.7.1]: https://github.com/davidteren/mutineer/releases/tag/v0.7.1
132
181
  [0.7.0]: https://github.com/davidteren/mutineer/releases/tag/v0.7.0
133
182
  [0.6.2]: https://github.com/davidteren/mutineer/releases/tag/v0.6.2
134
183
  [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](https://img.shields.io/badge/Socket-security%20report-0a66c2?logo=socket&logoColor=white)](https://socket.dev/rubygems/package/mutineer)
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
@@ -54,9 +56,10 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
54
56
  | `--jobs N` | Parallel worker count (default: processor count; `1` under `--rails`) |
55
57
  | `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
56
58
  | `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
57
- | `--format human\|json` | Report format (default: human) |
59
+ | `--format human\|json\|html` | Report format (default: human; `html` is a self-contained file) |
58
60
  | `--output FILE` | Write the report to FILE instead of stdout |
59
- | `--dry-run` | List candidate mutations without executing |
61
+ | `--dry-run` | List candidate mutations without executing (honors suppression) |
62
+ | `--fail-fast` | Stop at the first surviving mutant |
60
63
  | `--list-operators` | List available operators (default vs optional) and exit |
61
64
  | `--version`, `--help` | Print version / usage and exit |
62
65
 
@@ -73,7 +76,8 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
73
76
  Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
74
77
  `comparison`, `boolean_connector`, `boolean_literal`, `statement_removal`.
75
78
  Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
76
- `literal_mutation`, `condition_negation`.
79
+ `literal_mutation`, `condition_negation`, `string_literal`, `regex`,
80
+ `collection_method`.
77
81
 
78
82
  ## Rails apps
79
83
 
@@ -124,6 +128,37 @@ combines with `--threshold` (the worse of the two sets the exit code). Pass a
124
128
  directory (or several sources) to audit a whole layer in one boot — tests are
125
129
  auto-paired by convention and the report breaks down per source.
126
130
 
131
+ ### GitHub Action
132
+
133
+ This repo ships a composite action (`action.yml`) that wraps the CLI for CI:
134
+
135
+ ```yaml
136
+ - uses: actions/checkout@v4
137
+ with: { fetch-depth: 0 } # --since needs full history
138
+ - uses: ruby/setup-ruby@v1
139
+ with: { ruby-version: "3.4", bundler-cache: true }
140
+ - uses: davidteren/mutineer@v0
141
+ with:
142
+ sources: app/
143
+ since: origin/${{ github.base_ref }}
144
+ baseline: .mutineer/baseline.json
145
+ threshold: "90"
146
+ ```
147
+
148
+ ## For AI agents & pipelines
149
+
150
+ Mutineer is built for programmatic use — versioned JSON, stable mutant ids,
151
+ structured exit codes, and diff-scoped runs. See:
152
+
153
+ - **AI agents & CI recipes** — the agent inner-loop and CI-gate recipes (and how
154
+ to avoid infinite loops on equivalent mutants):
155
+ [rendered](https://davidteren.github.io/mutineer/agentic-coding.html) ·
156
+ [source](docs/agentic-coding.md)
157
+ - **JSON schema reference** — the `--format json` schema and its versioning
158
+ contract:
159
+ [rendered](https://davidteren.github.io/mutineer/json-schema.html) ·
160
+ [source](docs/json-schema.md)
161
+
127
162
  ## Configuration
128
163
 
129
164
  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
 
@@ -46,9 +47,10 @@ module Mutineer
46
47
  --boot FILE Require FILE once in the parent to boot the app env, then
47
48
  fork per mutant (Rails apps; requires --test)
48
49
  --rails Sugar for --boot config/environment --strategy redefine
49
- --format human|json Report format (default: human)
50
+ --format human|json|html Report format (default: human)
50
51
  --output FILE Write the report to FILE instead of stdout
51
52
  --dry-run List mutations without executing
53
+ --fail-fast Stop at the first surviving mutant
52
54
  --verbose Surface the real error when a fork capture fails (alias: --debug)
53
55
 
54
56
  Options:
@@ -63,6 +65,10 @@ module Mutineer
63
65
  # Deprecated internal strategy names, mapped to their canonical equivalents.
64
66
  STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
65
67
 
68
+ # Parses arguments, executes the command, and exits.
69
+ #
70
+ # @param argv [Array<String>] raw command-line arguments.
71
+ # @return [void]
66
72
  def self.start(argv)
67
73
  opts = {} # symbol => value, the CLI-provided Config fields
68
74
  explicit = Set.new # precedence keys the user typed (KTD3)
@@ -80,6 +86,7 @@ module Mutineer
80
86
  end
81
87
  o.on("--list-operators") { show_operators = true }
82
88
  o.on("--dry-run") { opts[:dry_run] = true }
89
+ o.on("--fail-fast") { opts[:fail_fast] = true }
83
90
  o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
84
91
  o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
85
92
  o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
@@ -140,6 +147,9 @@ module Mutineer
140
147
  end
141
148
  end
142
149
 
150
+ # Lists available operators.
151
+ #
152
+ # @return [void]
143
153
  def self.list_operators
144
154
  MutatorRegistry::ALL.each_key do |name|
145
155
  state = MutatorRegistry.default?(name) ? "default" : "disabled"
@@ -148,6 +158,11 @@ module Mutineer
148
158
  end
149
159
  end
150
160
 
161
+ # Runs the requested command after validation.
162
+ #
163
+ # @param config [Mutineer::Config] run configuration.
164
+ # @param explicit [Set<Symbol>] explicit CLI fields.
165
+ # @return [void]
151
166
  def self.run(config, explicit = Set.new)
152
167
  if config.sources.empty?
153
168
  warn "mutineer: run requires at least one source file"
@@ -177,6 +192,12 @@ module Mutineer
177
192
 
178
193
  # Flag validation: every flag/usage failure exits 2 (C7), consistent with the
179
194
  # taxonomy above — CI can tell "mistyped flag" from "tests too weak."
195
+ # Validates the run configuration.
196
+ #
197
+ # @api private
198
+ # @param config [Mutineer::Config] run configuration.
199
+ # @param explicit [Set<Symbol>] explicit CLI fields.
200
+ # @return [void]
180
201
  def self.validate!(config, explicit = Set.new)
181
202
  unless (0.0..100.0).cover?(config.threshold)
182
203
  warn "mutineer: --threshold must be between 0 and 100"
@@ -190,8 +211,8 @@ module Mutineer
190
211
  end
191
212
  config.jobs = jobs
192
213
 
193
- unless %w[human json].include?(config.format)
194
- warn %(mutineer: unknown format "#{config.format}". Expected: human, json)
214
+ unless %w[human json html].include?(config.format)
215
+ warn %(mutineer: unknown format "#{config.format}". Expected: human, json, html)
195
216
  exit 2
196
217
  end
197
218
 
@@ -231,6 +252,11 @@ module Mutineer
231
252
 
232
253
  # --since needs a real git repo and a resolvable ref; either failure is a
233
254
  # usage error (exit 2) so CI sees "bad invocation," not "tests too weak."
255
+ # Validates the --since ref.
256
+ #
257
+ # @api private
258
+ # @param config [Mutineer::Config] run configuration.
259
+ # @return [void]
234
260
  def self.validate_since!(config)
235
261
  _out, _err, status = Open3.capture3(
236
262
  "git", "-C", config.project_root, "rev-parse", "--verify", "--quiet",
@@ -252,6 +278,11 @@ module Mutineer
252
278
  # R5: validate path existence up front so a typo is a clean usage error (exit
253
279
  # 2), not an Errno::ENOENT backtrace from deep in the run. Flag checks run
254
280
  # first so a bad flag still reports the flag, not the missing file.
281
+ # Validates source and test paths.
282
+ #
283
+ # @api private
284
+ # @param config [Mutineer::Config] run configuration.
285
+ # @return [void]
255
286
  def self.validate_paths!(config)
256
287
  missing = (config.sources + config.tests)
257
288
  .reject { |p| File.exist?(File.expand_path(p, config.project_root)) }
@@ -268,6 +299,12 @@ module Mutineer
268
299
  # the dedicated --boot/--rails-requires-test check reports it; otherwise exit 2
269
300
  # with a usage message. The framework is re-detected from the inferred set
270
301
  # unless it was set explicitly (a spec-only project loads/reports as rspec).
302
+ # Auto-pairs sources and tests when --test is absent.
303
+ #
304
+ # @api private
305
+ # @param config [Mutineer::Config] run configuration.
306
+ # @param explicit [Set<Symbol>] explicit CLI fields.
307
+ # @return [void]
271
308
  def self.autopair!(config, explicit)
272
309
  return unless config.tests.empty?
273
310
 
@@ -293,6 +330,11 @@ module Mutineer
293
330
  # mirroring --output/--since preflight, so CI sees "bad invocation," not a
294
331
  # backtrace mid-run. Validating up front = attempting the load (it raises
295
332
  # ConfigError/SystemCallError; the actual diff reloads in execute).
333
+ # Preflights a baseline file.
334
+ #
335
+ # @api private
336
+ # @param path [String] baseline file path.
337
+ # @return [void]
296
338
  def self.preflight_baseline!(path)
297
339
  Baseline.load(path)
298
340
  rescue Mutineer::ConfigError, SystemCallError => e
@@ -300,6 +342,11 @@ module Mutineer
300
342
  exit 2
301
343
  end
302
344
 
345
+ # Preflights an output path.
346
+ #
347
+ # @api private
348
+ # @param path [String] output file path.
349
+ # @return [void]
303
350
  def self.preflight_output!(path)
304
351
  dir = File.dirname(File.expand_path(path))
305
352
  return if File.directory?(dir) && File.writable?(dir)
@@ -309,6 +356,10 @@ module Mutineer
309
356
  exit 2
310
357
  end
311
358
 
359
+ # Executes the run command.
360
+ #
361
+ # @param config [Mutineer::Config] run configuration.
362
+ # @return [void]
312
363
  def self.execute(config)
313
364
  if config.tests.empty?
314
365
  warn "mutineer: run requires at least one --test file (or use --dry-run)"
@@ -328,7 +379,7 @@ module Mutineer
328
379
 
329
380
  # #14: nudge toward the opt-in tier-2 operators (human report only — never
330
381
  # pollute JSON output).
331
- if config.format != "json" && (hint = tier2_hint(config.operators))
382
+ if !%w[json html].include?(config.format) && (hint = tier2_hint(config.operators))
332
383
  puts hint
333
384
  end
334
385
 
@@ -340,6 +391,10 @@ module Mutineer
340
391
 
341
392
  # The tier-2 operators not in the active set, as a one-line hint (or nil when
342
393
  # they're all already enabled). `active` nil means the default (Tier-1) set.
394
+ # Builds a Tier-2 operator hint.
395
+ #
396
+ # @param active [Array<String>, nil] active operator names.
397
+ # @return [String, nil] hint text or nil.
343
398
  def self.tier2_hint(active)
344
399
  active ||= MutatorRegistry::DEFAULT_NAMES
345
400
  unused = MutatorRegistry::TIER2_NAMES - active
@@ -349,11 +404,17 @@ module Mutineer
349
404
  "enable with --operators <list>."
350
405
  end
351
406
 
407
+ # Runs dry-run mode.
408
+ #
409
+ # @param config [Mutineer::Config] run configuration.
410
+ # @return [void]
352
411
  def self.dry_run(config)
353
412
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
354
413
  sources = {}
355
414
  per_operator = Hash.new(0)
356
415
  skipped = 0
416
+ ignored = 0
417
+ ignore_set = config.ignore.to_set
357
418
 
358
419
  # --since narrows the preview to changed lines too, so `--dry-run --since`
359
420
  # shows exactly what a real `--since` run would mutate.
@@ -364,29 +425,36 @@ module Mutineer
364
425
 
365
426
  Project.discover(config.sources, only: config.only).each do |subject|
366
427
  source = (sources[subject.file] ||= Parser.parse_file(subject.file).source.source)
367
- operator_classes.each do |klass|
368
- klass.new.mutations_for(subject, source).each do |mutation|
369
- unless mutation.valid?(source)
370
- skipped += 1
371
- next
372
- end
373
- if changed
374
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
375
- next unless changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
376
- end
377
- per_operator[mutation.operator] += 1
378
- original = source.byteslice(mutation.start_offset...mutation.end_offset)
379
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
380
- puts "[#{mutation.operator}] #{subject.qualified_name} " \
381
- "#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
428
+ # #22: honor suppression so the preview matches what a real run mutates.
429
+ # Mirror execute's per-subject shape (ids need the full mutation list).
430
+ disabled = Runner.suppress_map(source)
431
+ mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
432
+ ids = MutantId.for_subject(subject, source, mutations)
433
+ mutations.each_with_index do |mutation, i|
434
+ unless mutation.valid?(source)
435
+ skipped += 1
436
+ next
382
437
  end
438
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
439
+ next if changed && !changed[File.expand_path(subject.file, config.project_root)]&.include?(line)
440
+
441
+ if Runner.suppressed?(mutation.operator, line, ids[i], disabled, ignore_set)
442
+ ignored += 1
443
+ next
444
+ end
445
+
446
+ per_operator[mutation.operator] += 1
447
+ original = source.byteslice(mutation.start_offset...mutation.end_offset)
448
+ puts "[#{mutation.operator}] #{subject.qualified_name} " \
449
+ "#{subject.file}:#{line} `#{original}` -> `#{mutation.replacement}`"
383
450
  end
384
451
  end
385
452
 
386
453
  total = per_operator.values.sum
387
454
  breakdown = per_operator.map { |op, n| "#{op}: #{n}" }.join(", ")
388
455
  summary = breakdown.empty? ? "" : "#{breakdown} — "
389
- puts "#{summary}#{total} mutations (dry run, not executed); #{skipped} skipped (invalid)"
456
+ puts "#{summary}#{total} mutations (dry run, not executed); " \
457
+ "#{skipped} skipped (invalid); #{ignored} ignored (suppressed)"
390
458
  exit 0
391
459
  end
392
460
  end
@@ -25,12 +25,13 @@ module Mutineer
25
25
  :cache_dir, :project_root, :load_paths,
26
26
  :jobs, :format, :output, :strategy, :require_paths,
27
27
  :boot, :rails, :since, :framework, :verbose, :ignore,
28
- :baseline, :baseline_epsilon,
28
+ :baseline, :baseline_epsilon, :fail_fast,
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
- KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline].freeze
34
+ KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline fail_fast].freeze
34
35
 
35
36
  def initialize(**kwargs)
36
37
  super
@@ -49,6 +50,7 @@ module Mutineer
49
50
  self.verbose = false if verbose.nil?
50
51
  self.ignore ||= []
51
52
  self.baseline_epsilon ||= 0.0
53
+ self.fail_fast = false if fail_fast.nil?
52
54
  end
53
55
 
54
56
  # Walk from `start` toward `home`, returning the first .mutineer.yml path found
@@ -128,16 +130,30 @@ module Mutineer
128
130
 
129
131
  # Pick rspec when a MAJORITY of the given test files end with _spec.rb;
130
132
  # otherwise minitest. Empty/ambiguous -> minitest (the safe default).
133
+ # Detects the test framework from the file list.
134
+ #
135
+ # @param tests [Array<String>] test file paths.
136
+ # @return [String] `"rspec"` or `"minitest"`.
131
137
  def self.detect_framework(tests)
132
138
  tests = Array(tests)
133
139
  specs = tests.count { |t| t.to_s.end_with?("_spec.rb") }
134
140
  specs > tests.length / 2.0 ? "rspec" : "minitest"
135
141
  end
136
142
 
143
+ # Maps a config key to its Struct field.
144
+ #
145
+ # @param known_key [String] config key.
146
+ # @return [Symbol] struct field name.
137
147
  def self.field_for(known_key)
138
148
  known_key == "require" ? :require_paths : known_key.to_sym
139
149
  end
140
150
 
151
+ # Coerces a config value to its target type.
152
+ #
153
+ # @param known_key [String] config key.
154
+ # @param value [Object] raw YAML value.
155
+ # @param file_name [String] config file name for warnings.
156
+ # @return [Object] coerced value.
141
157
  def self.coerce(known_key, value, file_name)
142
158
  case known_key
143
159
  when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
@@ -157,6 +173,12 @@ module Mutineer
157
173
  # Drop (with a warning) operator names the registry doesn't know (R7).
158
174
  # Referenced lazily so config.rb carries no load-order dependency on the
159
175
  # registry; by the time a config is parsed at runtime, it is loaded.
176
+ # Filters out unknown operator names.
177
+ #
178
+ # @api private
179
+ # @param names [Array<String>] operator names.
180
+ # @param file_name [String] config file name for warnings.
181
+ # @return [Array<String>] known operator names.
160
182
  def self.filter_operators(names, file_name)
161
183
  known = MutatorRegistry::ALL.keys
162
184
  names.select do |n|