mutineer 0.11.3 → 1.0.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 +86 -0
- data/README.md +22 -6
- data/lib/mutineer/baseline.rb +48 -10
- data/lib/mutineer/changed_lines.rb +11 -3
- data/lib/mutineer/cli.rb +13 -3
- data/lib/mutineer/config.rb +5 -0
- data/lib/mutineer/daemon_backend.rb +7 -2
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/reporter.rb +154 -15
- data/lib/mutineer/runner.rb +6 -1
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +7 -1
- data/lib/mutineer.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9104fa94a852cbd1257f9dce0af285865d98da1100849f5d1b8c5af12d6513fa
|
|
4
|
+
data.tar.gz: 4ca6421fdd7ef6ef862813cbd74481ec6babf176991c8d34301368861ea19272
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66a7b0eae5bb41331d267b4c26a0b72e9023177d89d9d21cc679e712978836c4884b8b0347fd8263f55041897c2581e7117d5ae522965efbdb9af0daf5b47fee
|
|
7
|
+
data.tar.gz: cf1ed699c65b733b717ce5ed6ee95f9f50387fb2963b0ecd5516fa43d6a18bd21e0c354f2c85bde2df56cec305446cd988697f8eb5ae5b080bdfd9d2edc0fa06
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,90 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.0.0] - 2026-09-08
|
|
10
|
+
|
|
11
|
+
The GitHub Action's PR default changes in this release, which is why it is a
|
|
12
|
+
new major: workflows pinned to `davidteren/mutineer@v0` keep the old full-scan
|
|
13
|
+
behavior; upgrading to `@v1` opts into diff-scoped PR runs (details under
|
|
14
|
+
Changed).
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **The Action reports where CI readers look**: with the default JSON format it
|
|
18
|
+
writes a score/pass-fail table (plus the baseline delta, when `baseline` is
|
|
19
|
+
set) to the job step summary, and emits one `file=…,line=…` annotation per
|
|
20
|
+
surviving mutant (up to 50; the summary lists the first 20, the full set
|
|
21
|
+
stays in the JSON report) so results land on the PR diff instead of only in a
|
|
22
|
+
collapsed log group — `error` level when the gate failed, `warning` when it
|
|
23
|
+
passed. When `output` is unset the JSON report is routed to a temp file and
|
|
24
|
+
still printed to the log, and the `report` output now exposes the report
|
|
25
|
+
path in both cases so a later step can consume the JSON without scraping
|
|
26
|
+
the log (#86).
|
|
27
|
+
- **Progress during the run**: every backend prints `[mutineer] N/M mutants
|
|
28
|
+
(P%)` to stderr at each 10% step, so a long run is never silent between
|
|
29
|
+
config resolution and the report. Stdout stays byte-exact for `--format json`
|
|
30
|
+
and `--output`. `WorkerPool#run` gains an optional `on_result:` callback for
|
|
31
|
+
this (called in the parent per reaped result) (#86).
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- **PR runs scope themselves in the GitHub Action**: on `pull_request` events
|
|
35
|
+
the `since` input now defaults to the PR base, so the action grades just the
|
|
36
|
+
diff out of the box. **Migration note (the reason for the major bump)**: a
|
|
37
|
+
PR gate that previously full-scanned now scores only the PR's changed lines,
|
|
38
|
+
so `threshold` applies to fewer mutants. Stay on `@v0` to keep the old
|
|
39
|
+
default, or pass `since: none` on `@v1` for full scans. Workflows that
|
|
40
|
+
already pass a non-empty `since` are unchanged (an explicit empty string is
|
|
41
|
+
indistinguishable from unset and picks up the new default). A PR with no
|
|
42
|
+
changed source lines (docs- or test-only) scores zero mutants and passes the
|
|
43
|
+
scoped gate vacuously; keep a full-scan baseline refresh on main as the
|
|
44
|
+
backstop. With `use-bundler: true` the caller's Gemfile picks the gem, and
|
|
45
|
+
the new default needs mutineer >= 1.0.0 (the action enforces the floor with
|
|
46
|
+
a clear error). The action scopes to the PR's exact base commit from the
|
|
47
|
+
event payload (immune to the
|
|
48
|
+
base branch advancing mid-job), falling back to a fresh fetch of the base
|
|
49
|
+
branch tip; when neither can be resolved it warns and runs without an
|
|
50
|
+
action-provided `--since` (a `.mutineer.yml` `since:` key, if any, still
|
|
51
|
+
applies). The default deliberately does NOT fire on `pull_request_target`:
|
|
52
|
+
checkout there defaults to the base branch, so auto-scoping would diff the
|
|
53
|
+
base against itself and green the gate on an empty run (#86).
|
|
54
|
+
- **`--baseline` on a diff-scoped run gates on new survivors only**: a
|
|
55
|
+
`--since` run's score covers only the changed-line mutants, a different
|
|
56
|
+
denominator from a full-run baseline, so comparing the two scores
|
|
57
|
+
manufactured false regressions (a 3/4-mutant PR at 75% "dropped" from a
|
|
58
|
+
92% whole-repo baseline with zero new survivors). With `--since`, the
|
|
59
|
+
score-drop half of the baseline gate is skipped; new-survivor detection by
|
|
60
|
+
stable id (and the reported before/after scores) are unchanged. The JSON
|
|
61
|
+
report records the scope in a new additive `summary.scoped` key
|
|
62
|
+
(`schema_version` 1.3), and the `baseline` block records `score_comparable`
|
|
63
|
+
so a consumer knows when not to render the two scores as a comparison
|
|
64
|
+
(`fixed_survivors` is likewise empty under a scoped side: an out-of-scope
|
|
65
|
+
survivor was never re-tested, so absence does not mean fixed). The reverse
|
|
66
|
+
direction is a hard guard: a scoped report is refused as a baseline (exit 2
|
|
67
|
+
with a regenerate hint), because survivors outside its diff would all read
|
|
68
|
+
as new regressions. A new `--no-since` flag disables diff scoping explicitly: a
|
|
69
|
+
typed no beats a `.mutineer.yml` `since:` key, and the action's
|
|
70
|
+
`since: none` passes it through (#86).
|
|
71
|
+
|
|
72
|
+
## [0.11.4] - 2026-07-29
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
- **`--threshold` now gates on the run being complete, not just its score** — a
|
|
76
|
+
score is `killed / (killed + survived)`, so mutants that error, time out or come
|
|
77
|
+
back uncapturable are excluded from the denominator: a broken harness *raised*
|
|
78
|
+
the score instead of lowering it. Ninety errored mutants and ten that ran (nine
|
|
79
|
+
killed) reported 90% and exited 0, so CI could not tell a complete run from a
|
|
80
|
+
mostly-broken one. Past 10% of attempted mutants producing no verdict, a positive
|
|
81
|
+
`--threshold` now exits 1 whatever the score, and the report says which states
|
|
82
|
+
broke, on every `--format`. A single bad mutant never trips it, however small the
|
|
83
|
+
run, so a `--since` PR with a handful of mutants keeps its flake tolerance (#78).
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
- **`no_verdict[]` in the JSON report** (`schema_version` 1.2) — every attempted
|
|
87
|
+
mutant that produced no verdict, with its `status` and, where there is one, the
|
|
88
|
+
`details` explaining the cause. `Result#details` was built and rendered in no
|
|
89
|
+
format at all, so a daemon crash reached the user as nothing but a larger errored
|
|
90
|
+
count. `summary` gains `attempted` and `no_verdict`, the two figures the
|
|
91
|
+
completeness gate is computed from (#78).
|
|
92
|
+
|
|
9
93
|
## [0.11.3] - 2026-07-29
|
|
10
94
|
|
|
11
95
|
### Fixed
|
|
@@ -297,6 +381,8 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
297
381
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
298
382
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
299
383
|
|
|
384
|
+
[1.0.0]: https://github.com/davidteren/mutineer/releases/tag/v1.0.0
|
|
385
|
+
[0.11.4]: https://github.com/davidteren/mutineer/releases/tag/v0.11.4
|
|
300
386
|
[0.11.3]: https://github.com/davidteren/mutineer/releases/tag/v0.11.3
|
|
301
387
|
[0.11.2]: https://github.com/davidteren/mutineer/releases/tag/v0.11.2
|
|
302
388
|
[0.11.1]: https://github.com/davidteren/mutineer/releases/tag/v0.11.1
|
data/README.md
CHANGED
|
@@ -47,11 +47,12 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
|
|
|
47
47
|
|------|---------|
|
|
48
48
|
| `--test FILE` | Test file covering the sources (repeatable) |
|
|
49
49
|
| `--operators LIST` | Comma-separated operator names (default: the Tier-1 set) |
|
|
50
|
-
| `--threshold FLOAT` | Exit 1 when the score is below FLOAT (default: 0 = off) |
|
|
50
|
+
| `--threshold FLOAT` | Exit 1 when the score is below FLOAT, or when nothing could be scored and something broke, or more than one mutant produced no verdict and they exceed 10% of those attempted (default: 0 = off) |
|
|
51
51
|
| `--only NAME` | Restrict to one fully-qualified subject, e.g. `Calculator#add` |
|
|
52
52
|
| `--framework NAME` | `minitest` (default) or `rspec`; auto-detected as rspec when most `--test` files end in `_spec.rb` |
|
|
53
53
|
| `--since REF` | Only mutate lines changed since git `REF` (e.g. `origin/main`) — ideal for PR CI |
|
|
54
|
-
| `--
|
|
54
|
+
| `--no-since` | Disable diff scoping; a typed no beats a `.mutineer.yml` `since:` key |
|
|
55
|
+
| `--baseline FILE` | Compare against a prior `--format json` run; exit 1 on new survivors / score drop (score drop is skipped under `--since`, whose score covers a different denominator; see [CI](#ci-gating)) |
|
|
55
56
|
| `--baseline-epsilon FLOAT` | Score-drop tolerance for `--baseline` (default: 0) |
|
|
56
57
|
| `--jobs N` | Parallel worker count (default: processor count; `1` under `--rails`) |
|
|
57
58
|
| `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
|
|
@@ -70,7 +71,7 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
|
|
|
70
71
|
| Code | Meaning |
|
|
71
72
|
|------|---------|
|
|
72
73
|
| `0` | Score ≥ threshold (or no threshold set) |
|
|
73
|
-
| `1` |
|
|
74
|
+
| `1` | Score below threshold, nothing could be scored and something broke, or more than one mutant produced no verdict and they exceed 10% of those attempted, a `--baseline` regression, or a runtime error |
|
|
74
75
|
| `2` | Usage / invalid-flag error |
|
|
75
76
|
|
|
76
77
|
### Operators
|
|
@@ -233,17 +234,32 @@ This repo ships a composite action (`action.yml`) that wraps the CLI for CI:
|
|
|
233
234
|
|
|
234
235
|
```yaml
|
|
235
236
|
- uses: actions/checkout@v4
|
|
236
|
-
with: { fetch-depth: 0 } # --since needs full history
|
|
237
237
|
- uses: ruby/setup-ruby@v1
|
|
238
238
|
with: { ruby-version: "3.4", bundler-cache: true }
|
|
239
|
-
- uses: davidteren/mutineer@
|
|
239
|
+
- uses: davidteren/mutineer@v1
|
|
240
240
|
with:
|
|
241
241
|
sources: app/
|
|
242
|
-
since: origin/${{ github.base_ref }}
|
|
243
242
|
baseline: .mutineer/baseline.json
|
|
244
243
|
threshold: "90"
|
|
245
244
|
```
|
|
246
245
|
|
|
246
|
+
**Default change:** on `pull_request` events (not `pull_request_target`) the
|
|
247
|
+
action scopes the run to the PR's changed lines, diffing against the PR's exact
|
|
248
|
+
base commit (fetched by the action itself when the checkout is shallow; falls
|
|
249
|
+
back to the base branch tip). Pass `since: none` for a full scan, or an
|
|
250
|
+
explicit `since:` ref (which needs `fetch-depth: 0` on checkout).
|
|
251
|
+
|
|
252
|
+
With the default JSON format the action also:
|
|
253
|
+
|
|
254
|
+
- writes a score summary to the job's step summary;
|
|
255
|
+
- annotates surviving mutants on the PR diff, up to 50 (`error` level when the
|
|
256
|
+
gate failed, `warning` when it passed);
|
|
257
|
+
- exposes the report path via the `report` output for later steps (with
|
|
258
|
+
`format: human`/`html` this needs the `output` input).
|
|
259
|
+
|
|
260
|
+
The CLI prints a progress line to the log at every 10% of the run, whatever
|
|
261
|
+
the format.
|
|
262
|
+
|
|
247
263
|
## For AI agents & pipelines
|
|
248
264
|
|
|
249
265
|
Mutineer is built for programmatic use — versioned JSON, stable mutant ids,
|
data/lib/mutineer/baseline.rb
CHANGED
|
@@ -15,12 +15,19 @@ module Mutineer
|
|
|
15
15
|
# new_survivors - current Result objects whose stable id is absent from
|
|
16
16
|
# the baseline (the regressions to name).
|
|
17
17
|
# fixed_survivors - baseline survivor hashes absent from the current run
|
|
18
|
-
# (informational, never gates).
|
|
18
|
+
# (informational, never gates). Empty when either side
|
|
19
|
+
# is diff-scoped: an out-of-scope baseline survivor was
|
|
20
|
+
# never re-tested, so absence does not mean fixed.
|
|
19
21
|
# score_drop - current score < baseline score - epsilon. nil on
|
|
20
|
-
# either side skips the check
|
|
22
|
+
# either side skips the check, and a diff-scoped run
|
|
23
|
+
# (`scoped: true`) never sets it (see #diff).
|
|
24
|
+
# score_comparable - the two scores share a denominator (neither side was
|
|
25
|
+
# diff-scoped and both are non-nil), so a consumer may
|
|
26
|
+
# render them side by side. False means the score-drop
|
|
27
|
+
# check was skipped, not that it passed.
|
|
21
28
|
# regressed - any new survivors OR a score drop.
|
|
22
29
|
Delta = Data.define(:new_survivors, :fixed_survivors,
|
|
23
|
-
:score_before, :score_after, :score_drop, :regressed)
|
|
30
|
+
:score_before, :score_after, :score_drop, :score_comparable, :regressed)
|
|
24
31
|
|
|
25
32
|
# Load a prior --format json run. Raises ConfigError (NOT exit: a data class
|
|
26
33
|
# must never kill the host) on a missing/unreadable file, unparseable JSON,
|
|
@@ -35,6 +42,15 @@ module Mutineer
|
|
|
35
42
|
unless doc.is_a?(Hash) && doc["schema_version"] && doc["survivors"].is_a?(Array)
|
|
36
43
|
raise ConfigError, "not a Mutineer JSON report: #{path}"
|
|
37
44
|
end
|
|
45
|
+
# A diff-scoped report covers only that diff's mutants: used as a
|
|
46
|
+
# baseline, every survivor outside the original diff would read as a NEW
|
|
47
|
+
# regression, and its score shares no denominator with any other run.
|
|
48
|
+
# Refuse loudly (exit 2 via the CLI) rather than gate unreliably.
|
|
49
|
+
if doc.dig("summary", "scoped") == true
|
|
50
|
+
raise ConfigError, "#{path} was written by a --since run and covers only that diff's " \
|
|
51
|
+
"mutants; regenerate the baseline from a full run " \
|
|
52
|
+
"(use --no-since if .mutineer.yml sets since:)"
|
|
53
|
+
end
|
|
38
54
|
|
|
39
55
|
new(doc)
|
|
40
56
|
rescue JSON::ParserError => e
|
|
@@ -45,39 +61,61 @@ module Mutineer
|
|
|
45
61
|
|
|
46
62
|
# Builds a baseline from a JSON document.
|
|
47
63
|
#
|
|
48
|
-
# The baseline retains the survivor document
|
|
64
|
+
# The baseline retains the survivor document, score, and scope marker from
|
|
65
|
+
# the JSON report. A report whose `summary.scoped` is true came from a
|
|
66
|
+
# `--since` run: its score covers only changed-line mutants, so later diffs
|
|
67
|
+
# must not compare a full-run score against it.
|
|
49
68
|
#
|
|
50
69
|
# @param doc [Hash] parsed JSON document.
|
|
51
70
|
def initialize(doc)
|
|
52
71
|
@survivors = doc["survivors"] || []
|
|
53
72
|
@score = doc.dig("summary", "score")
|
|
73
|
+
# Strict literal true only: a malformed value (say the STRING "false" in a
|
|
74
|
+
# hand-edited baseline) must not silently disable the score-drop gate.
|
|
75
|
+
@scoped = doc.dig("summary", "scoped") == true
|
|
54
76
|
end
|
|
55
77
|
|
|
56
78
|
# Diff a current AggregateResult against this baseline by stable survivor id.
|
|
57
79
|
# `epsilon` tolerates float jitter on the score (default 0.0 = any drop
|
|
58
80
|
# gates).
|
|
59
81
|
#
|
|
82
|
+
# `scoped: true` marks the current run as diff-scoped (`--since`): its score
|
|
83
|
+
# is computed over only the changed-line mutants, a different denominator
|
|
84
|
+
# from a full-run baseline, so comparing the two scores manufactures false
|
|
85
|
+
# regressions. A scoped diff keeps the new-survivor gate (stable ids compare
|
|
86
|
+
# fine across scopes) and still reports both scores, but never sets
|
|
87
|
+
# score_drop.
|
|
88
|
+
#
|
|
60
89
|
# @param aggregate [Mutineer::AggregateResult] current results.
|
|
61
90
|
# @param epsilon [Float] score-drop tolerance.
|
|
91
|
+
# @param scoped [Boolean] current run was diff-scoped (`--since`).
|
|
62
92
|
# @return [Mutineer::Baseline::Delta] delta summary.
|
|
63
|
-
def diff(aggregate, epsilon: 0.0)
|
|
93
|
+
def diff(aggregate, epsilon: 0.0, scoped: false)
|
|
64
94
|
current = aggregate.surviving_mutants
|
|
65
95
|
current_ids = current.map(&:id)
|
|
66
96
|
baseline_ids = @survivors.map { |h| h["id"] }
|
|
67
97
|
|
|
68
98
|
new_survivors = current.reject { |r| baseline_ids.include?(r.id) }
|
|
69
|
-
|
|
99
|
+
# Under a diff-scoped side an out-of-scope baseline survivor was never
|
|
100
|
+
# re-tested, so reporting it "fixed" would be false: empty is honest.
|
|
101
|
+
fixed = if scoped || @scoped
|
|
102
|
+
[]
|
|
103
|
+
else
|
|
104
|
+
@survivors.reject { |h| current_ids.include?(h["id"]) }
|
|
105
|
+
end
|
|
70
106
|
|
|
71
107
|
current_score = aggregate.mutation_score
|
|
72
108
|
# nil-score discipline (mirrors Reporter#exit_code): a score absent on
|
|
73
109
|
# either side cannot be compared. Skip the drop check, keep the new-
|
|
74
|
-
# survivor check.
|
|
75
|
-
|
|
76
|
-
|
|
110
|
+
# survivor check. Same when EITHER side is diff-scoped (the current run
|
|
111
|
+
# via `scoped:`, or the stored baseline via its `summary.scoped` marker):
|
|
112
|
+
# the denominators differ, so the scores are not comparable.
|
|
113
|
+
comparable = !scoped && !@scoped && !@score.nil? && !current_score.nil?
|
|
114
|
+
score_drop = comparable && current_score < @score - epsilon
|
|
77
115
|
|
|
78
116
|
Delta.new(new_survivors: new_survivors, fixed_survivors: fixed,
|
|
79
117
|
score_before: @score, score_after: current_score,
|
|
80
|
-
score_drop: score_drop,
|
|
118
|
+
score_drop: score_drop, score_comparable: comparable,
|
|
81
119
|
regressed: !new_survivors.empty? || score_drop)
|
|
82
120
|
end
|
|
83
121
|
end
|
|
@@ -57,16 +57,24 @@ module Mutineer
|
|
|
57
57
|
|
|
58
58
|
# Returns the stdout of `git -C <root> diff --unified=0 <ref> -- <file>`.
|
|
59
59
|
#
|
|
60
|
+
# A failure is warned, never silent: an empty result means "no changed
|
|
61
|
+
# lines", which under `--since` removes every mutant for the file — a green
|
|
62
|
+
# gate must not be manufactured by a broken diff without a trace.
|
|
63
|
+
#
|
|
60
64
|
# @param ref [String] git ref to diff against.
|
|
61
65
|
# @param abs_file [String] absolute path of the file being diffed.
|
|
62
66
|
# @param project_root [String] repository root for `git -C`.
|
|
63
|
-
# @return [String] diff text, or `""` on failure.
|
|
67
|
+
# @return [String] diff text, or `""` on failure (after a stderr warning).
|
|
64
68
|
def git_diff(ref, abs_file, project_root)
|
|
65
69
|
out, _err, status = Open3.capture3(
|
|
66
70
|
"git", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
|
|
67
71
|
)
|
|
68
|
-
status.success?
|
|
69
|
-
|
|
72
|
+
return out if status.success?
|
|
73
|
+
|
|
74
|
+
warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)"
|
|
75
|
+
""
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
|
|
70
78
|
""
|
|
71
79
|
end
|
|
72
80
|
end
|
data/lib/mutineer/cli.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Mutineer
|
|
|
41
41
|
--baseline-epsilon FLOAT Score-drop tolerance for --baseline (default: 0)
|
|
42
42
|
--only NAME Restrict to one fully-qualified subject
|
|
43
43
|
--since REF Only mutate lines changed since git REF (e.g. origin/main)
|
|
44
|
+
--no-since Disable diff scoping (a typed no beats a .mutineer.yml since:)
|
|
44
45
|
--jobs N Parallel worker count (default: processor count)
|
|
45
46
|
--strategy NAME reload (whole-file) or redefine (surgical); default: reload
|
|
46
47
|
--framework NAME minitest or rspec (default: auto-detect from --test names)
|
|
@@ -96,6 +97,9 @@ module Mutineer
|
|
|
96
97
|
o.on("--fail-fast") { opts[:fail_fast] = true; explicit << :fail_fast }
|
|
97
98
|
o.on("--only NAME") { |v| opts[:only] = v; explicit << :only }
|
|
98
99
|
o.on("--since REF") { |v| opts[:since] = v; explicit << :since }
|
|
100
|
+
# A typed "no" must beat a .mutineer.yml `since:` key (CLI-over-config
|
|
101
|
+
# precedence): marking :since explicit with a nil value blocks the fill.
|
|
102
|
+
o.on("--no-since") { opts[:since] = nil; explicit << :since }
|
|
99
103
|
o.on("--test FILE") { |v| (opts[:tests] ||= []) << v }
|
|
100
104
|
o.on("--operators LIST") { |v| opts[:operators] = v.split(",").map(&:strip); explicit << :operators }
|
|
101
105
|
o.on("--threshold FLOAT") do |v|
|
|
@@ -465,11 +469,17 @@ module Mutineer
|
|
|
465
469
|
|
|
466
470
|
# Diff the current run against the baseline (preflighted above) by the
|
|
467
471
|
# stable survivor id. The delta is rendered inline (human section / additive
|
|
468
|
-
# json block) and gates exit independently of --threshold.
|
|
469
|
-
|
|
472
|
+
# json block) and gates exit independently of --threshold. A --since run is
|
|
473
|
+
# scoped: its score covers a different denominator than a full-run baseline,
|
|
474
|
+
# so only the new-survivor half of the gate applies (see Baseline#diff).
|
|
475
|
+
delta = if config.baseline
|
|
476
|
+
Baseline.load(config.baseline).diff(aggregate, epsilon: config.baseline_epsilon,
|
|
477
|
+
scoped: !config.since.nil?)
|
|
478
|
+
end
|
|
470
479
|
|
|
471
480
|
reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
|
|
472
|
-
format: config.format, output: config.output, baseline: delta
|
|
481
|
+
format: config.format, output: config.output, baseline: delta,
|
|
482
|
+
scoped: !config.since.nil?)
|
|
473
483
|
|
|
474
484
|
# Warn (stderr, so it never pollutes json/html) that an external run's score
|
|
475
485
|
# is not comparable to an in-process run: no coverage narrowing (uncovered
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -182,6 +182,11 @@ module Mutineer
|
|
|
182
182
|
when "ignore" then Array(value).map(&:to_s)
|
|
183
183
|
when "baseline" then value.to_s
|
|
184
184
|
when "test_command" then value.to_s
|
|
185
|
+
when "since"
|
|
186
|
+
# false / empty normalize to nil ("no scoping"), so every consumer's
|
|
187
|
+
# nil-check (runner scoping, the report's scoped marker) agrees. A
|
|
188
|
+
# false left raw would skip scoping but still mark the report scoped.
|
|
189
|
+
value == false || value.to_s.empty? ? nil : value.to_s
|
|
185
190
|
else value
|
|
186
191
|
end
|
|
187
192
|
end
|
|
@@ -4,6 +4,7 @@ require_relative "parser"
|
|
|
4
4
|
require_relative "result"
|
|
5
5
|
require_relative "coverage_map"
|
|
6
6
|
require_relative "daemon_client"
|
|
7
|
+
require_relative "progress"
|
|
7
8
|
# No require_relative "runner" on purpose: runner.rb requires this file, and the
|
|
8
9
|
# reverse edge makes Ruby warn "circular require considered harmful" on every -w
|
|
9
10
|
# load. Runner is loaded first on every real path; requiring this file alone leaves
|
|
@@ -137,10 +138,12 @@ module Mutineer
|
|
|
137
138
|
client = DaemonClient.new(boot: boot_config(config, abs_tests),
|
|
138
139
|
app_root: config.project_root).start
|
|
139
140
|
results = []
|
|
141
|
+
progress = Progress.new(jobs.size)
|
|
140
142
|
begin
|
|
141
143
|
jobs.each_with_index do |job, i|
|
|
142
144
|
r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
143
145
|
results << r
|
|
146
|
+
progress.tick
|
|
144
147
|
break if config.fail_fast && r.survived?
|
|
145
148
|
end
|
|
146
149
|
ensure
|
|
@@ -159,8 +162,9 @@ module Mutineer
|
|
|
159
162
|
# @api private
|
|
160
163
|
# @return [Array<Mutineer::Result>] one result per input job, in input order.
|
|
161
164
|
def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
162
|
-
results
|
|
163
|
-
|
|
165
|
+
results = Array.new(jobs.size)
|
|
166
|
+
progress = Progress.new(jobs.size)
|
|
167
|
+
queue = Queue.new
|
|
164
168
|
jobs.each_index { |i| queue << i }
|
|
165
169
|
|
|
166
170
|
# Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
|
|
@@ -189,6 +193,7 @@ module Mutineer
|
|
|
189
193
|
break
|
|
190
194
|
end
|
|
191
195
|
results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
196
|
+
progress.tick
|
|
192
197
|
end
|
|
193
198
|
rescue DaemonBootError
|
|
194
199
|
# The daemon gave up for good. Stop feeding the other workers rather
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Coarse run progress on stderr: one line per 10% step of completed mutants
|
|
5
|
+
# (which means every completion on a run smaller than ten). Shared by every
|
|
6
|
+
# backend so a long run is never silent between config resolution and the
|
|
7
|
+
# report. Thread-safe so the daemon's parallel workers can share one instance.
|
|
8
|
+
# Writes only to stderr, keeping `--format json` stdout and `--output` files
|
|
9
|
+
# byte-exact per the Reporter's stream contract.
|
|
10
|
+
class Progress
|
|
11
|
+
# Builds a progress counter for a run of `total` mutants.
|
|
12
|
+
#
|
|
13
|
+
# @param total [Integer] number of mutants that will run.
|
|
14
|
+
# @param io [IO] destination stream (defaults to stderr; injectable for tests).
|
|
15
|
+
def initialize(total, io: $stderr)
|
|
16
|
+
@total = total
|
|
17
|
+
@done = 0
|
|
18
|
+
@last_step = 0
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@io = io
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Records one completed mutant and prints when a 10% boundary is crossed.
|
|
24
|
+
# A zero-total counter never prints (guards the division, and an empty run
|
|
25
|
+
# has nothing to report).
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def tick
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
return if @total.zero?
|
|
31
|
+
|
|
32
|
+
@done += 1
|
|
33
|
+
step = (@done * 10) / @total
|
|
34
|
+
return if step <= @last_step
|
|
35
|
+
|
|
36
|
+
@last_step = step
|
|
37
|
+
@io.puts "[mutineer] #{@done}/#{@total} mutants (#{(@done * 100) / @total}%)"
|
|
38
|
+
end
|
|
39
|
+
rescue IOError, Errno::EPIPE
|
|
40
|
+
# Progress is best-effort: a closed stderr (piped consumer went away) must
|
|
41
|
+
# never kill the run — in the daemon's worker threads a raise here would
|
|
42
|
+
# propagate through Thread#join and abort scoring.
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -14,6 +14,17 @@ module Mutineer
|
|
|
14
14
|
# `source_map` is { file_path => raw source string }, used to extract the
|
|
15
15
|
# containing source line for each survivor diff.
|
|
16
16
|
class Reporter
|
|
17
|
+
# Share of attempted mutants that may produce no verdict before `--threshold`
|
|
18
|
+
# stops trusting the score. A handful of flaky mutants in a large run is noise;
|
|
19
|
+
# a mostly-broken run is not a score. Deliberately not a flag: no one has needed
|
|
20
|
+
# a different number yet.
|
|
21
|
+
BROKEN_SHARE_LIMIT = 0.10
|
|
22
|
+
|
|
23
|
+
# A share alone gives a small run no tolerance at all: on a `--since` PR that
|
|
24
|
+
# yields 8 mutants, one timeout is 12.5%. README recommends exactly that
|
|
25
|
+
# workflow, so one bad mutant never fails the gate on its own, at any size.
|
|
26
|
+
BROKEN_FLOOR = 1
|
|
27
|
+
|
|
17
28
|
def initialize(aggregate, source_map)
|
|
18
29
|
@agg = aggregate
|
|
19
30
|
@source_map = source_map
|
|
@@ -21,11 +32,14 @@ module Mutineer
|
|
|
21
32
|
|
|
22
33
|
# Single entry point. Branches on `format` ("human" | "json" | "html") and
|
|
23
34
|
# routes the rendered report to `output` (a file, with a stderr confirmation)
|
|
24
|
-
# or to `out`. Diagnostics always go to `err`.
|
|
25
|
-
|
|
35
|
+
# or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
|
|
36
|
+
# (`--since`) run; the JSON report records it so a consumer (or a later
|
|
37
|
+
# `--baseline` load) knows the score covers only the changed-line mutants.
|
|
38
|
+
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
|
|
39
|
+
baseline: nil, scoped: false)
|
|
26
40
|
rendered =
|
|
27
41
|
if format == "json"
|
|
28
|
-
json_report(baseline)
|
|
42
|
+
json_report(baseline, scoped: scoped)
|
|
29
43
|
elsif format == "html"
|
|
30
44
|
html_report
|
|
31
45
|
else
|
|
@@ -42,6 +56,20 @@ module Mutineer
|
|
|
42
56
|
else
|
|
43
57
|
out.print rendered
|
|
44
58
|
end
|
|
59
|
+
|
|
60
|
+
# Both ways a run can fail the gate on completeness, said here rather than in
|
|
61
|
+
# the human renderer: --format json is the documented CI path, and a run that
|
|
62
|
+
# exits 1 must say why on every format, not only the one a person reads.
|
|
63
|
+
return unless threshold&.positive?
|
|
64
|
+
|
|
65
|
+
if @agg.mutation_score.nil? && broken_nil_score?
|
|
66
|
+
err.puts "[mutineer] nothing could be scored (#{broken_counts_detail}), so the " \
|
|
67
|
+
"--threshold gate fails. See no_verdict[] in --format json for the cause of each."
|
|
68
|
+
elsif broken_share_exceeded?
|
|
69
|
+
err.puts "[mutineer] #{no_verdict_ratio}: #{broken_counts_detail}. The score covers " \
|
|
70
|
+
"only part of the run, so the --threshold gate fails. See no_verdict[] in " \
|
|
71
|
+
"--format json for the cause of each."
|
|
72
|
+
end
|
|
45
73
|
end
|
|
46
74
|
|
|
47
75
|
# Renders the human report.
|
|
@@ -85,6 +113,11 @@ module Mutineer
|
|
|
85
113
|
return 0 # pure no_coverage / ignored / empty — gate skipped
|
|
86
114
|
end
|
|
87
115
|
|
|
116
|
+
# A score computed over a small slice of what was attempted is not this
|
|
117
|
+
# suite's score. Without this, 90 errored mutants and 10 that ran (9 killed)
|
|
118
|
+
# reports 90% and exits 0, so CI cannot tell a complete run from a broken one.
|
|
119
|
+
return 1 if broken_share_exceeded?
|
|
120
|
+
|
|
88
121
|
score >= threshold ? 0 : 1
|
|
89
122
|
end
|
|
90
123
|
|
|
@@ -96,8 +129,10 @@ module Mutineer
|
|
|
96
129
|
#
|
|
97
130
|
# @api private
|
|
98
131
|
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
132
|
+
# @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
|
|
133
|
+
# covers only the changed-line mutants (additive `summary.scoped` key).
|
|
99
134
|
# @return [String] JSON text.
|
|
100
|
-
def json_report(baseline = nil)
|
|
135
|
+
def json_report(baseline = nil, scoped: false)
|
|
101
136
|
killed = @agg.killed_count
|
|
102
137
|
survived = @agg.survived_count
|
|
103
138
|
# null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
@@ -106,7 +141,7 @@ module Mutineer
|
|
|
106
141
|
score = @agg.mutation_score
|
|
107
142
|
|
|
108
143
|
doc = {
|
|
109
|
-
schema_version: "1.
|
|
144
|
+
schema_version: "1.3",
|
|
110
145
|
summary: {
|
|
111
146
|
total: @agg.total, killed: killed, survived: survived,
|
|
112
147
|
no_coverage: @agg.no_coverage_count,
|
|
@@ -114,7 +149,13 @@ module Mutineer
|
|
|
114
149
|
skipped_invalid: @agg.skipped_invalid_count,
|
|
115
150
|
errored: @agg.errored_count, timeout: @agg.timeout_count,
|
|
116
151
|
ignored: @agg.ignored_count,
|
|
117
|
-
|
|
152
|
+
# The gate is computed from these two, so a consumer never re-derives them.
|
|
153
|
+
attempted: attempted_count, no_verdict: no_verdict_count,
|
|
154
|
+
score: score,
|
|
155
|
+
# Additive: true when the run was diff-scoped (--since). The score then
|
|
156
|
+
# covers only the changed-line mutants, so it is not comparable to a
|
|
157
|
+
# full-run score; Baseline#diff reads this to skip the score-drop gate.
|
|
158
|
+
scoped: scoped
|
|
118
159
|
},
|
|
119
160
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
120
161
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
@@ -123,6 +164,21 @@ module Mutineer
|
|
|
123
164
|
# Same shape as no_coverage; additive key.
|
|
124
165
|
uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
|
|
125
166
|
.sort_by { |h| [h[:file], h[:line]] },
|
|
167
|
+
# Every mutant that was attempted and produced no verdict, whatever the
|
|
168
|
+
# reason — the set the --threshold completeness gate counts. Named for the
|
|
169
|
+
# condition rather than one status, because summary.errored means :error
|
|
170
|
+
# alone and a key that reconciled with neither would be worse. `details`
|
|
171
|
+
# carries the cause where there is one. Uncapturable mutants also appear in
|
|
172
|
+
# uncapturable[]; that key keeps its lean shape for existing consumers.
|
|
173
|
+
# to_s/to_i because a pre-fork failure has no subject, so its file and line
|
|
174
|
+
# are null and would not compare against a real entry. id and status extend
|
|
175
|
+
# the key to a total order: these entries collide on (file, line) far more
|
|
176
|
+
# than survivors do — several mutants on one crashy line, every pre-fork
|
|
177
|
+
# entry on ("", 0) — and sort_by is not stable, so equal keys would leave
|
|
178
|
+
# worker finish order in the output and break the byte-stability promise.
|
|
179
|
+
no_verdict: @agg.results.select { |r| r.error? || r.timeout? || r.uncapturable? }
|
|
180
|
+
.map { |r| no_verdict_json(r) }
|
|
181
|
+
.sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:id].to_s, h[:status].to_s, h[:details].to_s] },
|
|
126
182
|
# Equivalent mutants the user suppressed: emitted with their stable id so
|
|
127
183
|
# the user can audit what is silenced (and copy ids for survivors they
|
|
128
184
|
# want to add). Excluded from the score; never in `survivors`.
|
|
@@ -134,7 +190,7 @@ module Mutineer
|
|
|
134
190
|
.sort_by { |h| h[:file] }
|
|
135
191
|
}
|
|
136
192
|
# Additive baseline-delta block, present only with --baseline. Existing
|
|
137
|
-
# consumers ignore the extra key; schema_version
|
|
193
|
+
# consumers ignore the extra key; it does not move schema_version on its own.
|
|
138
194
|
doc[:baseline] = baseline_json(baseline) if baseline
|
|
139
195
|
"#{JSON.generate(doc)}\n"
|
|
140
196
|
end
|
|
@@ -259,6 +315,10 @@ module Mutineer
|
|
|
259
315
|
score_before: delta.score_before,
|
|
260
316
|
score_after: delta.score_after,
|
|
261
317
|
score_dropped: delta.score_drop,
|
|
318
|
+
# Additive: false when the score-drop check was skipped (a nil score or
|
|
319
|
+
# a diff-scoped side), so a consumer knows not to render the two scores
|
|
320
|
+
# as a comparison.
|
|
321
|
+
score_comparable: delta.score_comparable,
|
|
262
322
|
new_survivors: delta.new_survivors.map { |r| ignored_json(r) }
|
|
263
323
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
|
264
324
|
fixed_survivors: delta.fixed_survivors.map do |h|
|
|
@@ -358,6 +418,31 @@ module Mutineer
|
|
|
358
418
|
}
|
|
359
419
|
end
|
|
360
420
|
|
|
421
|
+
# An entry under the JSON `no_verdict:` key: an attempted mutant with no verdict.
|
|
422
|
+
# A pre-fork failure has no subject or mutation attached, so those degrade to
|
|
423
|
+
# nulls rather than dropping the entry — the count must still reconcile with
|
|
424
|
+
# `summary.no_verdict`.
|
|
425
|
+
#
|
|
426
|
+
# @api private
|
|
427
|
+
# @param result [Mutineer::Result] an errored or timed-out result.
|
|
428
|
+
# @return [Hash] no-verdict JSON object.
|
|
429
|
+
def no_verdict_json(result)
|
|
430
|
+
file = result.subject&.file
|
|
431
|
+
line =
|
|
432
|
+
if result.mutation && file
|
|
433
|
+
source = @source_map[file] || File.read(file)
|
|
434
|
+
source.byteslice(0, result.mutation.start_offset).count("\n") + 1
|
|
435
|
+
end
|
|
436
|
+
{
|
|
437
|
+
subject: result.subject&.qualified_name,
|
|
438
|
+
file: file,
|
|
439
|
+
line: line,
|
|
440
|
+
id: result.id,
|
|
441
|
+
status: result.status.to_s,
|
|
442
|
+
details: result.details
|
|
443
|
+
}
|
|
444
|
+
end
|
|
445
|
+
|
|
361
446
|
# Writes the summary block.
|
|
362
447
|
#
|
|
363
448
|
# @param out [IO] output stream.
|
|
@@ -388,10 +473,10 @@ module Mutineer
|
|
|
388
473
|
"#{@agg.ignored_count} ignored excluded"
|
|
389
474
|
if score.nil?
|
|
390
475
|
out.puts "Mutation score: N/A (no covered mutants)"
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
476
|
+
# Only the benign case here: the gate-failure explanation is emitted once
|
|
477
|
+
# from {report}, for every format, so it cannot be said twice or only to
|
|
478
|
+
# the reader of the human report.
|
|
479
|
+
unless broken_nil_score?
|
|
395
480
|
err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
|
|
396
481
|
end
|
|
397
482
|
else
|
|
@@ -408,11 +493,58 @@ module Mutineer
|
|
|
408
493
|
(@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
|
|
409
494
|
end
|
|
410
495
|
|
|
411
|
-
#
|
|
496
|
+
# Mutants that were attempted but produced no verdict, over everything
|
|
497
|
+
# attempted. Above the limit the score describes too small a slice of the run
|
|
498
|
+
# to gate on. A few flaky mutants in a large run stay under it.
|
|
499
|
+
#
|
|
500
|
+
# @api private
|
|
501
|
+
# @return [Boolean] true when too much of the run failed to produce a verdict.
|
|
502
|
+
def broken_share_exceeded?
|
|
503
|
+
attempted = attempted_count
|
|
504
|
+
attempted.positive? && no_verdict_count > BROKEN_FLOOR &&
|
|
505
|
+
no_verdict_count > attempted * BROKEN_SHARE_LIMIT
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Mutants that were attempted and produced no verdict, whatever the reason.
|
|
509
|
+
#
|
|
510
|
+
# @api private
|
|
511
|
+
# @return [Integer] errored + timed out + uncapturable.
|
|
512
|
+
def no_verdict_count
|
|
513
|
+
@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Mutants that were actually run. Deliberately not `total`: no_coverage,
|
|
517
|
+
# skipped-invalid and ignored mutants were never attempted, so counting them
|
|
518
|
+
# would dilute the share and let a broken run slip under the limit.
|
|
519
|
+
#
|
|
520
|
+
# skipped-invalid is excluded from both sides: it means a mutant did not
|
|
521
|
+
# re-parse and was correctly never run, which is a validity outcome rather
|
|
522
|
+
# than a broken harness. Cost: an overwhelmingly-skipped run still scores on
|
|
523
|
+
# what little ran; that is our operator misbehaving and wants its own signal.
|
|
524
|
+
#
|
|
525
|
+
# @api private
|
|
526
|
+
# @return [Integer] killed + survived + no-verdict.
|
|
527
|
+
def attempted_count
|
|
528
|
+
@agg.killed_count + @agg.survived_count + no_verdict_count
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# The sentence both the verdict line and the stderr note are built from, so a
|
|
532
|
+
# user cannot read one number in the report and a different one beside it.
|
|
533
|
+
#
|
|
534
|
+
# @api private
|
|
535
|
+
# @return [String] e.g. "90 of 100 attempted mutants produced no verdict (90.0%, limit 10%)".
|
|
536
|
+
def no_verdict_ratio
|
|
537
|
+
pct = (no_verdict_count * 100.0 / attempted_count).round(1)
|
|
538
|
+
"#{no_verdict_count} of #{attempted_count} attempted mutants produced no verdict " \
|
|
539
|
+
"(#{pct}%, limit #{(BROKEN_SHARE_LIMIT * 100).round}%)"
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Human-readable counts of the states that produced no verdict. Used by both
|
|
543
|
+
# the nil-score message and the completeness gate, so they agree.
|
|
412
544
|
#
|
|
413
545
|
# @api private
|
|
414
546
|
# @return [String]
|
|
415
|
-
def
|
|
547
|
+
def broken_counts_detail
|
|
416
548
|
parts = []
|
|
417
549
|
parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
|
|
418
550
|
parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
|
|
@@ -458,6 +590,9 @@ module Mutineer
|
|
|
458
590
|
out.puts " + #{r.subject.qualified_name} (#{file}:#{line}) #{r.mutation.operator}"
|
|
459
591
|
end
|
|
460
592
|
out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
|
|
593
|
+
# An OK verdict must not imply a check that never ran: say when the score
|
|
594
|
+
# comparison was skipped (a diff-scoped side or a nil score).
|
|
595
|
+
out.puts "score-drop check skipped (scores not comparable)" unless delta.score_comparable
|
|
461
596
|
out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
|
|
462
597
|
end
|
|
463
598
|
|
|
@@ -505,13 +640,17 @@ module Mutineer
|
|
|
505
640
|
score = @agg.mutation_score
|
|
506
641
|
if score.nil?
|
|
507
642
|
if broken_nil_score?
|
|
508
|
-
out.puts "FAILED: no covered mutants (#{
|
|
643
|
+
out.puts "FAILED: no covered mutants (#{broken_counts_detail}); " \
|
|
509
644
|
"threshold #{threshold}% cannot pass with a broken harness"
|
|
510
645
|
end
|
|
511
646
|
return
|
|
512
647
|
end
|
|
513
648
|
|
|
514
|
-
|
|
649
|
+
# Same rule as exit_code, or the report says PASSED on a run that exits 1 —
|
|
650
|
+
# and with --output that wrong verdict is what gets archived.
|
|
651
|
+
if broken_share_exceeded?
|
|
652
|
+
out.puts "FAILED: #{no_verdict_ratio}; #{score}% covers only part of the run"
|
|
653
|
+
elsif score >= threshold
|
|
515
654
|
out.puts "PASSED: #{score}% >= threshold #{threshold}%"
|
|
516
655
|
else
|
|
517
656
|
out.puts "FAILED: #{score}% < threshold #{threshold}%"
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "coverage_map"
|
|
|
10
10
|
require_relative "changed_lines"
|
|
11
11
|
require_relative "mutator_registry"
|
|
12
12
|
require_relative "worker_pool"
|
|
13
|
+
require_relative "progress"
|
|
13
14
|
require_relative "mutant_id"
|
|
14
15
|
require_relative "file_swap"
|
|
15
16
|
require_relative "external_backend"
|
|
@@ -121,7 +122,9 @@ module Mutineer
|
|
|
121
122
|
begin
|
|
122
123
|
framework = config.framework
|
|
123
124
|
stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
|
|
124
|
-
|
|
125
|
+
progress = Progress.new(jobs.size)
|
|
126
|
+
bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
|
|
127
|
+
on_result: ->(_r) { progress.tick }) do |subject, mutation|
|
|
125
128
|
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
126
129
|
subject: subject, strategy: strategy, rails: config.rails, framework: framework)
|
|
127
130
|
end
|
|
@@ -205,11 +208,13 @@ module Mutineer
|
|
|
205
208
|
timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
|
|
206
209
|
|
|
207
210
|
results = []
|
|
211
|
+
progress = Progress.new(jobs.size)
|
|
208
212
|
begin
|
|
209
213
|
jobs.each do |subject, mutation, id|
|
|
210
214
|
r = run_external(subject, mutation, config.test_command, abs_tests,
|
|
211
215
|
timeout: timeout, verbose: config.verbose)
|
|
212
216
|
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
217
|
+
progress.tick
|
|
213
218
|
break if config.fail_fast && r.survived? # stop at the first survivor
|
|
214
219
|
end
|
|
215
220
|
ensure
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer/worker_pool.rb
CHANGED
|
@@ -28,10 +28,15 @@ module Mutineer
|
|
|
28
28
|
# @param stop_when [Proc, nil] called with each collected Result; when it
|
|
29
29
|
# returns truthy, no further items are scheduled and the run drains and
|
|
30
30
|
# returns early (--fail-fast). Unscheduled slots stay nil.
|
|
31
|
+
# @param on_result [Proc, nil] called in the parent with each collected
|
|
32
|
+
# Result as it is reaped, in finish order (progress reporting); its
|
|
33
|
+
# return value is ignored. Must be fast and non-blocking: it runs on the
|
|
34
|
+
# pool's single reap thread, so a slow callback stalls draining the other
|
|
35
|
+
# in-flight children's pipes (the #4 deadlock discipline).
|
|
31
36
|
# @yieldparam item [Array] one work item.
|
|
32
37
|
# @return [Array<Mutineer::Result>] results in input order (nil for any item
|
|
33
38
|
# left unscheduled by an early stop).
|
|
34
|
-
def run(items, stop_when: nil)
|
|
39
|
+
def run(items, stop_when: nil, on_result: nil)
|
|
35
40
|
results = Array.new(items.size)
|
|
36
41
|
queue = (0...items.size).to_a
|
|
37
42
|
running = {} # pid => [index, read_io, buffer]
|
|
@@ -40,6 +45,7 @@ module Mutineer
|
|
|
40
45
|
until queue.empty? && running.empty?
|
|
41
46
|
fill(items, queue, running) { |*args| yield(*args) } unless stopping
|
|
42
47
|
result = reap(results, running)
|
|
48
|
+
on_result&.call(result) if result
|
|
43
49
|
if !stopping && stop_when && result && stop_when.call(result)
|
|
44
50
|
stopping = true
|
|
45
51
|
queue.clear # schedule no more; let in-flight workers drain
|
data/lib/mutineer.rb
CHANGED
|
@@ -27,6 +27,7 @@ require_relative "mutineer/mutators/regex_literal"
|
|
|
27
27
|
require_relative "mutineer/mutators/collection_method"
|
|
28
28
|
require_relative "mutineer/mutator_registry"
|
|
29
29
|
require_relative "mutineer/worker_pool"
|
|
30
|
+
require_relative "mutineer/progress"
|
|
30
31
|
require_relative "mutineer/runner"
|
|
31
32
|
require_relative "mutineer/reporter"
|
|
32
33
|
require_relative "mutineer/baseline"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutineer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/mutineer/mutators/string_literal.rb
|
|
96
96
|
- lib/mutineer/pairing.rb
|
|
97
97
|
- lib/mutineer/parser.rb
|
|
98
|
+
- lib/mutineer/progress.rb
|
|
98
99
|
- lib/mutineer/project.rb
|
|
99
100
|
- lib/mutineer/rails_worker_db.rb
|
|
100
101
|
- lib/mutineer/reporter.rb
|