mutineer 0.11.3 → 0.11.4
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 +22 -0
- data/README.md +2 -2
- data/lib/mutineer/reporter.rb +133 -10
- data/lib/mutineer/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85591c1b61bcdc3c8a1d39b5b657f667bf7de9039d61e05ab3e5691692f555f0
|
|
4
|
+
data.tar.gz: 76acdc054e82d9ea10dee4a63bcbb5f0f86db37dd585323f734991f413f21e61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5855d2e0a9d4e226fe0ad2034cc4d08f4b5ac5e2d34df9e92f97b9d344ea5a50c4df01bbdcb0973dc66e8ccde717baab8842935fa07a4cad1238f77b9bebf42
|
|
7
|
+
data.tar.gz: 3a5c57795de651128a3d3597b2f1776fd32ff76aeaea4edf14037a1ec32e13e72e54c38a56ef8c209f3ee1d6afc0ffbb5fd47153967803c841e64d989d80a047
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,27 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.11.4] - 2026-07-29
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`--threshold` now gates on the run being complete, not just its score** — a
|
|
13
|
+
score is `killed / (killed + survived)`, so mutants that error, time out or come
|
|
14
|
+
back uncapturable are excluded from the denominator: a broken harness *raised*
|
|
15
|
+
the score instead of lowering it. Ninety errored mutants and ten that ran (nine
|
|
16
|
+
killed) reported 90% and exited 0, so CI could not tell a complete run from a
|
|
17
|
+
mostly-broken one. Past 10% of attempted mutants producing no verdict, a positive
|
|
18
|
+
`--threshold` now exits 1 whatever the score, and the report says which states
|
|
19
|
+
broke, on every `--format`. A single bad mutant never trips it, however small the
|
|
20
|
+
run, so a `--since` PR with a handful of mutants keeps its flake tolerance (#78).
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **`no_verdict[]` in the JSON report** (`schema_version` 1.2) — every attempted
|
|
24
|
+
mutant that produced no verdict, with its `status` and, where there is one, the
|
|
25
|
+
`details` explaining the cause. `Result#details` was built and rendered in no
|
|
26
|
+
format at all, so a daemon crash reached the user as nothing but a larger errored
|
|
27
|
+
count. `summary` gains `attempted` and `no_verdict`, the two figures the
|
|
28
|
+
completeness gate is computed from (#78).
|
|
29
|
+
|
|
9
30
|
## [0.11.3] - 2026-07-29
|
|
10
31
|
|
|
11
32
|
### Fixed
|
|
@@ -297,6 +318,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
|
|
|
297
318
|
- `.mutineer.yml` configuration (CLI > config > default precedence).
|
|
298
319
|
- Byte-correct source handling for multibyte (UTF-8) sources.
|
|
299
320
|
|
|
321
|
+
[0.11.4]: https://github.com/davidteren/mutineer/releases/tag/v0.11.4
|
|
300
322
|
[0.11.3]: https://github.com/davidteren/mutineer/releases/tag/v0.11.3
|
|
301
323
|
[0.11.2]: https://github.com/davidteren/mutineer/releases/tag/v0.11.2
|
|
302
324
|
[0.11.1]: https://github.com/davidteren/mutineer/releases/tag/v0.11.1
|
data/README.md
CHANGED
|
@@ -47,7 +47,7 @@ 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 |
|
|
@@ -70,7 +70,7 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
|
|
|
70
70
|
| Code | Meaning |
|
|
71
71
|
|------|---------|
|
|
72
72
|
| `0` | Score ≥ threshold (or no threshold set) |
|
|
73
|
-
| `1` |
|
|
73
|
+
| `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
74
|
| `2` | Usage / invalid-flag error |
|
|
75
75
|
|
|
76
76
|
### Operators
|
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
|
|
@@ -42,6 +53,20 @@ module Mutineer
|
|
|
42
53
|
else
|
|
43
54
|
out.print rendered
|
|
44
55
|
end
|
|
56
|
+
|
|
57
|
+
# Both ways a run can fail the gate on completeness, said here rather than in
|
|
58
|
+
# the human renderer: --format json is the documented CI path, and a run that
|
|
59
|
+
# exits 1 must say why on every format, not only the one a person reads.
|
|
60
|
+
return unless threshold&.positive?
|
|
61
|
+
|
|
62
|
+
if @agg.mutation_score.nil? && broken_nil_score?
|
|
63
|
+
err.puts "[mutineer] nothing could be scored (#{broken_counts_detail}), so the " \
|
|
64
|
+
"--threshold gate fails. See no_verdict[] in --format json for the cause of each."
|
|
65
|
+
elsif broken_share_exceeded?
|
|
66
|
+
err.puts "[mutineer] #{no_verdict_ratio}: #{broken_counts_detail}. The score covers " \
|
|
67
|
+
"only part of the run, so the --threshold gate fails. See no_verdict[] in " \
|
|
68
|
+
"--format json for the cause of each."
|
|
69
|
+
end
|
|
45
70
|
end
|
|
46
71
|
|
|
47
72
|
# Renders the human report.
|
|
@@ -85,6 +110,11 @@ module Mutineer
|
|
|
85
110
|
return 0 # pure no_coverage / ignored / empty — gate skipped
|
|
86
111
|
end
|
|
87
112
|
|
|
113
|
+
# A score computed over a small slice of what was attempted is not this
|
|
114
|
+
# suite's score. Without this, 90 errored mutants and 10 that ran (9 killed)
|
|
115
|
+
# reports 90% and exits 0, so CI cannot tell a complete run from a broken one.
|
|
116
|
+
return 1 if broken_share_exceeded?
|
|
117
|
+
|
|
88
118
|
score >= threshold ? 0 : 1
|
|
89
119
|
end
|
|
90
120
|
|
|
@@ -106,7 +136,7 @@ module Mutineer
|
|
|
106
136
|
score = @agg.mutation_score
|
|
107
137
|
|
|
108
138
|
doc = {
|
|
109
|
-
schema_version: "1.
|
|
139
|
+
schema_version: "1.2",
|
|
110
140
|
summary: {
|
|
111
141
|
total: @agg.total, killed: killed, survived: survived,
|
|
112
142
|
no_coverage: @agg.no_coverage_count,
|
|
@@ -114,6 +144,8 @@ module Mutineer
|
|
|
114
144
|
skipped_invalid: @agg.skipped_invalid_count,
|
|
115
145
|
errored: @agg.errored_count, timeout: @agg.timeout_count,
|
|
116
146
|
ignored: @agg.ignored_count,
|
|
147
|
+
# The gate is computed from these two, so a consumer never re-derives them.
|
|
148
|
+
attempted: attempted_count, no_verdict: no_verdict_count,
|
|
117
149
|
score: score
|
|
118
150
|
},
|
|
119
151
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
@@ -123,6 +155,21 @@ module Mutineer
|
|
|
123
155
|
# Same shape as no_coverage; additive key.
|
|
124
156
|
uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
|
|
125
157
|
.sort_by { |h| [h[:file], h[:line]] },
|
|
158
|
+
# Every mutant that was attempted and produced no verdict, whatever the
|
|
159
|
+
# reason — the set the --threshold completeness gate counts. Named for the
|
|
160
|
+
# condition rather than one status, because summary.errored means :error
|
|
161
|
+
# alone and a key that reconciled with neither would be worse. `details`
|
|
162
|
+
# carries the cause where there is one. Uncapturable mutants also appear in
|
|
163
|
+
# uncapturable[]; that key keeps its lean shape for existing consumers.
|
|
164
|
+
# to_s/to_i because a pre-fork failure has no subject, so its file and line
|
|
165
|
+
# are null and would not compare against a real entry. id and status extend
|
|
166
|
+
# the key to a total order: these entries collide on (file, line) far more
|
|
167
|
+
# than survivors do — several mutants on one crashy line, every pre-fork
|
|
168
|
+
# entry on ("", 0) — and sort_by is not stable, so equal keys would leave
|
|
169
|
+
# worker finish order in the output and break the byte-stability promise.
|
|
170
|
+
no_verdict: @agg.results.select { |r| r.error? || r.timeout? || r.uncapturable? }
|
|
171
|
+
.map { |r| no_verdict_json(r) }
|
|
172
|
+
.sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:id].to_s, h[:status].to_s, h[:details].to_s] },
|
|
126
173
|
# Equivalent mutants the user suppressed: emitted with their stable id so
|
|
127
174
|
# the user can audit what is silenced (and copy ids for survivors they
|
|
128
175
|
# want to add). Excluded from the score; never in `survivors`.
|
|
@@ -134,7 +181,7 @@ module Mutineer
|
|
|
134
181
|
.sort_by { |h| h[:file] }
|
|
135
182
|
}
|
|
136
183
|
# Additive baseline-delta block, present only with --baseline. Existing
|
|
137
|
-
# consumers ignore the extra key; schema_version
|
|
184
|
+
# consumers ignore the extra key; it does not move schema_version on its own.
|
|
138
185
|
doc[:baseline] = baseline_json(baseline) if baseline
|
|
139
186
|
"#{JSON.generate(doc)}\n"
|
|
140
187
|
end
|
|
@@ -358,6 +405,31 @@ module Mutineer
|
|
|
358
405
|
}
|
|
359
406
|
end
|
|
360
407
|
|
|
408
|
+
# An entry under the JSON `no_verdict:` key: an attempted mutant with no verdict.
|
|
409
|
+
# A pre-fork failure has no subject or mutation attached, so those degrade to
|
|
410
|
+
# nulls rather than dropping the entry — the count must still reconcile with
|
|
411
|
+
# `summary.no_verdict`.
|
|
412
|
+
#
|
|
413
|
+
# @api private
|
|
414
|
+
# @param result [Mutineer::Result] an errored or timed-out result.
|
|
415
|
+
# @return [Hash] no-verdict JSON object.
|
|
416
|
+
def no_verdict_json(result)
|
|
417
|
+
file = result.subject&.file
|
|
418
|
+
line =
|
|
419
|
+
if result.mutation && file
|
|
420
|
+
source = @source_map[file] || File.read(file)
|
|
421
|
+
source.byteslice(0, result.mutation.start_offset).count("\n") + 1
|
|
422
|
+
end
|
|
423
|
+
{
|
|
424
|
+
subject: result.subject&.qualified_name,
|
|
425
|
+
file: file,
|
|
426
|
+
line: line,
|
|
427
|
+
id: result.id,
|
|
428
|
+
status: result.status.to_s,
|
|
429
|
+
details: result.details
|
|
430
|
+
}
|
|
431
|
+
end
|
|
432
|
+
|
|
361
433
|
# Writes the summary block.
|
|
362
434
|
#
|
|
363
435
|
# @param out [IO] output stream.
|
|
@@ -388,10 +460,10 @@ module Mutineer
|
|
|
388
460
|
"#{@agg.ignored_count} ignored excluded"
|
|
389
461
|
if score.nil?
|
|
390
462
|
out.puts "Mutation score: N/A (no covered mutants)"
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
463
|
+
# Only the benign case here: the gate-failure explanation is emitted once
|
|
464
|
+
# from {report}, for every format, so it cannot be said twice or only to
|
|
465
|
+
# the reader of the human report.
|
|
466
|
+
unless broken_nil_score?
|
|
395
467
|
err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
|
|
396
468
|
end
|
|
397
469
|
else
|
|
@@ -408,11 +480,58 @@ module Mutineer
|
|
|
408
480
|
(@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
|
|
409
481
|
end
|
|
410
482
|
|
|
411
|
-
#
|
|
483
|
+
# Mutants that were attempted but produced no verdict, over everything
|
|
484
|
+
# attempted. Above the limit the score describes too small a slice of the run
|
|
485
|
+
# to gate on. A few flaky mutants in a large run stay under it.
|
|
486
|
+
#
|
|
487
|
+
# @api private
|
|
488
|
+
# @return [Boolean] true when too much of the run failed to produce a verdict.
|
|
489
|
+
def broken_share_exceeded?
|
|
490
|
+
attempted = attempted_count
|
|
491
|
+
attempted.positive? && no_verdict_count > BROKEN_FLOOR &&
|
|
492
|
+
no_verdict_count > attempted * BROKEN_SHARE_LIMIT
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Mutants that were attempted and produced no verdict, whatever the reason.
|
|
496
|
+
#
|
|
497
|
+
# @api private
|
|
498
|
+
# @return [Integer] errored + timed out + uncapturable.
|
|
499
|
+
def no_verdict_count
|
|
500
|
+
@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Mutants that were actually run. Deliberately not `total`: no_coverage,
|
|
504
|
+
# skipped-invalid and ignored mutants were never attempted, so counting them
|
|
505
|
+
# would dilute the share and let a broken run slip under the limit.
|
|
506
|
+
#
|
|
507
|
+
# skipped-invalid is excluded from both sides: it means a mutant did not
|
|
508
|
+
# re-parse and was correctly never run, which is a validity outcome rather
|
|
509
|
+
# than a broken harness. Cost: an overwhelmingly-skipped run still scores on
|
|
510
|
+
# what little ran; that is our operator misbehaving and wants its own signal.
|
|
511
|
+
#
|
|
512
|
+
# @api private
|
|
513
|
+
# @return [Integer] killed + survived + no-verdict.
|
|
514
|
+
def attempted_count
|
|
515
|
+
@agg.killed_count + @agg.survived_count + no_verdict_count
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# The sentence both the verdict line and the stderr note are built from, so a
|
|
519
|
+
# user cannot read one number in the report and a different one beside it.
|
|
520
|
+
#
|
|
521
|
+
# @api private
|
|
522
|
+
# @return [String] e.g. "90 of 100 attempted mutants produced no verdict (90.0%, limit 10%)".
|
|
523
|
+
def no_verdict_ratio
|
|
524
|
+
pct = (no_verdict_count * 100.0 / attempted_count).round(1)
|
|
525
|
+
"#{no_verdict_count} of #{attempted_count} attempted mutants produced no verdict " \
|
|
526
|
+
"(#{pct}%, limit #{(BROKEN_SHARE_LIMIT * 100).round}%)"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Human-readable counts of the states that produced no verdict. Used by both
|
|
530
|
+
# the nil-score message and the completeness gate, so they agree.
|
|
412
531
|
#
|
|
413
532
|
# @api private
|
|
414
533
|
# @return [String]
|
|
415
|
-
def
|
|
534
|
+
def broken_counts_detail
|
|
416
535
|
parts = []
|
|
417
536
|
parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
|
|
418
537
|
parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
|
|
@@ -505,13 +624,17 @@ module Mutineer
|
|
|
505
624
|
score = @agg.mutation_score
|
|
506
625
|
if score.nil?
|
|
507
626
|
if broken_nil_score?
|
|
508
|
-
out.puts "FAILED: no covered mutants (#{
|
|
627
|
+
out.puts "FAILED: no covered mutants (#{broken_counts_detail}); " \
|
|
509
628
|
"threshold #{threshold}% cannot pass with a broken harness"
|
|
510
629
|
end
|
|
511
630
|
return
|
|
512
631
|
end
|
|
513
632
|
|
|
514
|
-
|
|
633
|
+
# Same rule as exit_code, or the report says PASSED on a run that exits 1 —
|
|
634
|
+
# and with --output that wrong verdict is what gets archived.
|
|
635
|
+
if broken_share_exceeded?
|
|
636
|
+
out.puts "FAILED: #{no_verdict_ratio}; #{score}% covers only part of the run"
|
|
637
|
+
elsif score >= threshold
|
|
515
638
|
out.puts "PASSED: #{score}% >= threshold #{threshold}%"
|
|
516
639
|
else
|
|
517
640
|
out.puts "FAILED: #{score}% < threshold #{threshold}%"
|
data/lib/mutineer/version.rb
CHANGED