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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +39 -4
- data/lib/mutineer/baseline.rb +29 -13
- data/lib/mutineer/changed_lines.rb +29 -12
- data/lib/mutineer/cli.rb +88 -20
- data/lib/mutineer/config.rb +24 -2
- data/lib/mutineer/coverage_map.rb +86 -4
- data/lib/mutineer/isolation.rb +89 -40
- data/lib/mutineer/minitest_integration.rb +13 -9
- data/lib/mutineer/mutant_id.rb +24 -5
- data/lib/mutineer/mutation.rb +12 -6
- data/lib/mutineer/mutator_registry.rb +33 -6
- data/lib/mutineer/mutators/arithmetic.rb +8 -2
- data/lib/mutineer/mutators/base.rb +11 -3
- data/lib/mutineer/mutators/boolean_connector.rb +15 -5
- data/lib/mutineer/mutators/boolean_literal.rb +19 -6
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +7 -8
- data/lib/mutineer/mutators/condition_negation.rb +14 -5
- data/lib/mutineer/mutators/literal_mutation.rb +15 -4
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +22 -7
- data/lib/mutineer/mutators/statement_removal.rb +6 -9
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/pairing.rb +32 -13
- data/lib/mutineer/parser.rb +17 -7
- data/lib/mutineer/project.rb +73 -2
- data/lib/mutineer/reporter.rb +169 -0
- data/lib/mutineer/result.rb +100 -32
- data/lib/mutineer/runner.rb +29 -2
- data/lib/mutineer/subject.rb +10 -6
- data/lib/mutineer/test_runners/minitest.rb +5 -4
- data/lib/mutineer/test_runners/rspec.rb +10 -17
- data/lib/mutineer/test_runners.rb +9 -2
- data/lib/mutineer/version.rb +2 -1
- data/lib/mutineer/worker_pool.rb +51 -29
- data/lib/mutineer.rb +4 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8687c96cedac16e86197f3639803487657512509c45a1969449eb95b3c4b65c
|
|
4
|
+
data.tar.gz: 59588c405f16476b984fefbda003299f8373687cacd40c4569ea72edb866d0a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://rubygems.org/gems/mutineer)
|
|
4
|
+
[](https://github.com/marketplace/actions/mutineer-ruby)
|
|
5
|
+
[](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,
|
data/lib/mutineer/baseline.rb
CHANGED
|
@@ -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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
60
|
-
# side can't be compared — skip the drop check, keep the new-
|
|
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
|
|
8
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# deletion and
|
|
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
|
-
#
|
|
38
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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);
|
|
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
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -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|
|