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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b671af802d8c032a7b9fa3e48eac7d63478930f6077ce58793007c4e8aa98de6
4
- data.tar.gz: ef1ef88c9b6af5a0c634d0e9f7bf68ce9644e8c5d5a48002a5b3de1c1a4b7ffe
3
+ metadata.gz: 85591c1b61bcdc3c8a1d39b5b657f667bf7de9039d61e05ab3e5691692f555f0
4
+ data.tar.gz: 76acdc054e82d9ea10dee4a63bcbb5f0f86db37dd585323f734991f413f21e61
5
5
  SHA512:
6
- metadata.gz: 69ab5b4e78163653f838afba995273f156ad51d15b4613d8bd40f1d4f0571423018e5b1a8ffd59160405e81e9d2aca26fce08deba802ee896be3fd37254ff887
7
- data.tar.gz: 98e011da927ed5108fec748628a9bb0a69d74e03cdf3b1f2d395b5082e38f3478ad45bbb5caa58fa7099ecb7d787086cdaebef20828fe5b670591c4fa8e0b5bf
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` | Survivors below threshold, or a runtime error |
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
@@ -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.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 stays 1.1.
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
- if broken_nil_score?
392
- err.puts "[mutineer] no covered mutations (#{broken_nil_score_detail}); " \
393
- "threshold gate fails under a positive --threshold (broken harness)."
394
- else
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
- # Human-readable counts for a broken nil-score run.
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 broken_nil_score_detail
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 (#{broken_nil_score_detail}); " \
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
- if score >= threshold
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}%"
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.11.3"
5
+ VERSION = "0.11.4"
6
6
  end
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.11.3
4
+ version: 0.11.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren