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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +34 -1
- data/lib/mutineer/baseline.rb +29 -13
- data/lib/mutineer/changed_lines.rb +29 -12
- data/lib/mutineer/cli.rb +57 -0
- data/lib/mutineer/config.rb +21 -0
- 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 +21 -3
- 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/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/return_nil.rb +22 -7
- data/lib/mutineer/mutators/statement_removal.rb +6 -9
- 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 +55 -0
- data/lib/mutineer/result.rb +100 -32
- data/lib/mutineer/runner.rb +23 -0
- 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 +34 -21
- data/lib/mutineer.rb +1 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfadc70b171d8778c8afa95021c2989ad33ab2880200a22886fec274e358efcc
|
|
4
|
+
data.tar.gz: f6f6c91f6e5fffc0a2d05d93b0981df165d6d5b2df1e1434949c7dea1c0f70a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://rubygems.org/gems/mutineer)
|
|
4
|
+
[](https://github.com/marketplace/actions/mutineer-ruby)
|
|
5
|
+
[](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,
|
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
|
|
|
@@ -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 = {}
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -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.
|
|
193
|
-
|
|
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
|
-
|
|
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
|