mutineer 0.11.2 → 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.
@@ -7,19 +7,30 @@ require_relative "mutation"
7
7
 
8
8
  module Mutineer
9
9
  # Renders an AggregateResult: the summary block, mutation score, and per-file
10
- # survivor diffs. Stream discipline (R14): the report goes to `out` (stdout),
10
+ # survivor diffs. Stream discipline: the report goes to `out` (stdout),
11
11
  # diagnostics/warnings go to `err` (stderr), so `mutineer ... > report.txt`
12
12
  # captures only the report.
13
13
  #
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
20
31
  end
21
32
 
22
- # Single entry point (R20/R21). Branches on `format` ("human" | "json") and
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
35
  # or to `out`. Diagnostics always go to `err`.
25
36
  def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil)
@@ -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,15 +110,19 @@ 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
 
91
121
  private
92
122
 
93
- # Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
94
- # by (file, line, operator) so output is byte-stable regardless of --jobs
95
- # worker finish order (R22).
96
- # Renders the JSON report.
123
+ # Canonical machine-readable schema. survivors/no_coverage are sorted by
124
+ # (file, line, operator) so output is byte-stable regardless of --jobs
125
+ # worker finish order.
97
126
  #
98
127
  # @api private
99
128
  # @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
@@ -101,13 +130,13 @@ module Mutineer
101
130
  def json_report(baseline = nil)
102
131
  killed = @agg.killed_count
103
132
  survived = @agg.survived_count
104
- # C8: null (not 0.0) on an empty denominator, matching the nil-vs-0.0
133
+ # null (not 0.0) on an empty denominator, matching the nil-vs-0.0
105
134
  # discipline in AggregateResult; and the SAME rounding as the human report
106
135
  # (one run must not yield two scores by --format).
107
136
  score = @agg.mutation_score
108
137
 
109
138
  doc = {
110
- schema_version: "1.1",
139
+ schema_version: "1.2",
111
140
  summary: {
112
141
  total: @agg.total, killed: killed, survived: survived,
113
142
  no_coverage: @agg.no_coverage_count,
@@ -115,37 +144,54 @@ module Mutineer
115
144
  skipped_invalid: @agg.skipped_invalid_count,
116
145
  errored: @agg.errored_count, timeout: @agg.timeout_count,
117
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,
118
149
  score: score
119
150
  },
120
151
  survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
121
152
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
122
153
  no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
123
154
  .sort_by { |h| [h[:file], h[:line]] },
124
- # #9: same shape as no_coverage; additive key.
155
+ # Same shape as no_coverage; additive key.
125
156
  uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
126
157
  .sort_by { |h| [h[:file], h[:line]] },
127
- # #10: equivalent mutants the user suppressed emitted with their stable
128
- # id so the user can audit what is silenced (and copy ids for survivors
129
- # they want to add). Excluded from the score; never in `survivors`.
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] },
173
+ # Equivalent mutants the user suppressed: emitted with their stable id so
174
+ # the user can audit what is silenced (and copy ids for survivors they
175
+ # want to add). Excluded from the score; never in `survivors`.
130
176
  ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
131
177
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
132
- # #11: per-source breakdown (additive; #13 consumes it). Sorted by file so
178
+ # Per-source breakdown (additive; baseline consumes it). Sorted by file so
133
179
  # output is byte-stable. Reuses AggregateResult via by_source.
134
180
  per_source: @agg.by_source.map { |file, agg| per_source_json(file, agg) }
135
181
  .sort_by { |h| h[:file] }
136
182
  }
137
- # #13: additive baseline-delta block, present only with --baseline. Existing
138
- # consumers ignore the extra key; schema_version stays 1.1.
183
+ # Additive baseline-delta block, present only with --baseline. Existing
184
+ # consumers ignore the extra key; it does not move schema_version on its own.
139
185
  doc[:baseline] = baseline_json(baseline) if baseline
140
186
  "#{JSON.generate(doc)}\n"
141
187
  end
142
188
 
143
- # #23: one self-contained HTML file (inline CSS, no external assets) the
144
- # overall score + summary counts, a per-source table, and every surviving
145
- # mutant with its stable id and diff. All source/diff/identifier text is
146
- # HTML-escaped (CGI.escapeHTML) so a `<`/`>` in source can never break the
147
- # markup. Reuses survivor_json/per_source_json so one run yields one set of
148
- # facts regardless of --format.
189
+ # One self-contained HTML file (inline CSS, no external assets): the overall
190
+ # score + summary counts, a per-source table, and every surviving mutant with
191
+ # its stable id and diff. All source/diff/identifier text is HTML-escaped
192
+ # (CGI.escapeHTML) so a `<`/`>` in source can never break the markup. Reuses
193
+ # survivor_json/per_source_json so one run yields one set of facts regardless
194
+ # of --format.
149
195
  def html_report
150
196
  score = @agg.mutation_score
151
197
  survivors = @agg.surviving_mutants.map { |r| survivor_json(r) }
@@ -251,9 +297,9 @@ module Mutineer
251
297
  # HTML-escapes any text destined for the document (stdlib CGI).
252
298
  def esc(text) = CGI.escapeHTML(text.to_s)
253
299
 
254
- # #13: the same delta facts the human report prints, for dashboards. new_survivors
300
+ # The same delta facts the human report prints, for dashboards. new_survivors
255
301
  # reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
256
- # byte-stably so output doesn't depend on --jobs finish order.
302
+ # byte-stably so output does not depend on --jobs finish order.
257
303
  def baseline_json(delta)
258
304
  {
259
305
  regressed: delta.regressed,
@@ -300,7 +346,7 @@ module Mutineer
300
346
  file: file,
301
347
  line: start_line,
302
348
  operator: m.operator.to_s,
303
- # #10: the stable, copy-pasteable id (next to the human-readable token) so a
349
+ # The stable, copy-pasteable id (next to the human-readable token) so a
304
350
  # user can paste it straight into .mutineer.yml `ignore:`.
305
351
  id: result.id,
306
352
  token: token,
@@ -308,7 +354,7 @@ module Mutineer
308
354
  }
309
355
  end
310
356
 
311
- # An entry under the JSON `ignored:` key what the user already suppressed.
357
+ # An entry under the JSON `ignored:` key: what the user already suppressed.
312
358
  def ignored_json(result)
313
359
  m = result.mutation
314
360
  file = result.subject.file
@@ -329,7 +375,7 @@ module Mutineer
329
375
  # mutation's 1-based start line, the full original line-block it touches, the
330
376
  # spliced mutated block, and a single-line token label for the header.
331
377
  def diff_for(m, source)
332
- # Byte math (C1): Prism offsets are byte offsets; byteindex/byterindex/
378
+ # Byte math: Prism offsets are byte offsets; byteindex/byterindex/
333
379
  # byteslice keep line splicing correct for multibyte sources.
334
380
  line_begin = m.start_offset.zero? ? 0 : (source.byterindex("\n", m.start_offset - 1) || -1) + 1
335
381
  line_end = source.byteindex("\n", m.end_offset) || source.bytesize
@@ -359,6 +405,31 @@ module Mutineer
359
405
  }
360
406
  end
361
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
+
362
433
  # Writes the summary block.
363
434
  #
364
435
  # @param out [IO] output stream.
@@ -370,9 +441,9 @@ module Mutineer
370
441
  out.puts format("Survived: %-6d No coverage: %d", @agg.survived_count, @agg.no_coverage_count)
371
442
  out.puts format("Skipped: %-6d Errored: %d", @agg.skipped_invalid_count,
372
443
  @agg.errored_count + @agg.timeout_count)
373
- # #9: a broken harness, not a coverage gap report it distinctly from No coverage.
444
+ # A broken harness, not a coverage gap: report it distinctly from No coverage.
374
445
  out.puts format("Uncapturable: %-6d (tests failed to run)", @agg.uncapturable_count)
375
- # #10: equivalent mutants the user suppressed; excluded from the denominator.
446
+ # Equivalent mutants the user suppressed; excluded from the denominator.
376
447
  out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
377
448
  end
378
449
 
@@ -389,10 +460,10 @@ module Mutineer
389
460
  "#{@agg.ignored_count} ignored excluded"
390
461
  if score.nil?
391
462
  out.puts "Mutation score: N/A (no covered mutants)"
392
- if broken_nil_score?
393
- err.puts "[mutineer] no covered mutations (#{broken_nil_score_detail}); " \
394
- "threshold gate fails under a positive --threshold (broken harness)."
395
- 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?
396
467
  err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
397
468
  end
398
469
  else
@@ -409,11 +480,58 @@ module Mutineer
409
480
  (@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
410
481
  end
411
482
 
412
- # 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.
413
531
  #
414
532
  # @api private
415
533
  # @return [String]
416
- def broken_nil_score_detail
534
+ def broken_counts_detail
417
535
  parts = []
418
536
  parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
419
537
  parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
@@ -421,10 +539,9 @@ module Mutineer
421
539
  parts.join(", ")
422
540
  end
423
541
 
424
- # #11: one line per source after the global summary, so a multi-source run
425
- # shows which file is weak. Omitted for a single-source run the global
426
- # summary already says everything (ponytail: no redundant one-line block).
427
- # Writes the per-source breakdown.
542
+ # One line per source after the global summary, so a multi-source run shows
543
+ # which file is weak. Omitted for a single-source run: the global summary
544
+ # already says everything (no redundant one-line block).
428
545
  #
429
546
  # @param out [IO] output stream.
430
547
  # @return [void]
@@ -443,7 +560,7 @@ module Mutineer
443
560
  end
444
561
  end
445
562
 
446
- # #13: the --baseline delta, appended after the normal report. Names every NEW
563
+ # The --baseline delta, appended after the normal report. Names every NEW
447
564
  # survivor (subject (file:line) operator) and the score delta when it dropped,
448
565
  # then a one-line REGRESSION/OK verdict so CI logs show which gate fired.
449
566
  def baseline_section(out, delta)
@@ -507,13 +624,17 @@ module Mutineer
507
624
  score = @agg.mutation_score
508
625
  if score.nil?
509
626
  if broken_nil_score?
510
- out.puts "FAILED: no covered mutants (#{broken_nil_score_detail}); " \
627
+ out.puts "FAILED: no covered mutants (#{broken_counts_detail}); " \
511
628
  "threshold #{threshold}% cannot pass with a broken harness"
512
629
  end
513
630
  return
514
631
  end
515
632
 
516
- 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
517
638
  out.puts "PASSED: #{score}% >= threshold #{threshold}%"
518
639
  else
519
640
  out.puts "FAILED: #{score}% < threshold #{threshold}%"
@@ -1,20 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # Immutable outcome of running one mutant. Seven distinct states:
5
- # killed a test failed/errored, so the mutation was caught.
6
- # survived every test passed, so the mutation went undetected.
7
- # error the child crashed (unhandled exception): exit status 2.
8
- # timeout the parent SIGKILLed a child that overran its wall clock.
9
- # skipped the mutated source failed to re-parse (invalid); no fork.
10
- # no_coverage no test exercises the mutated line; not run, not scored.
11
- # uncapturable the line's would-be covering test errored during capture
12
- # (#9), so coverage was lost. Excluded from the
13
- # denominator exactly like no_coverage, but reported
14
- # separately: it signals a broken harness (a test that
15
- # failed to run), not a genuine coverage gap.
16
- # ignored a known-equivalent mutant the user suppressed (#10), via
17
- # an inline `# mutineer:disable-line` comment or a
4
+ # Immutable outcome of running one mutant. Eight distinct states:
5
+ # killed - a test failed/errored, so the mutation was caught.
6
+ # survived - every test passed, so the mutation went undetected.
7
+ # error - the child crashed (unhandled exception): exit status 2.
8
+ # timeout - the parent SIGKILLed a child that overran its wall clock.
9
+ # skipped - the mutated source failed to re-parse (invalid); no fork.
10
+ # no_coverage - no test exercises the mutated line; not run, not scored.
11
+ # uncapturable - the line's would-be covering test errored during capture,
12
+ # so coverage was lost. Excluded from the denominator exactly
13
+ # like no_coverage, but reported separately: it signals a
14
+ # broken harness (a test that failed to run), not a genuine
15
+ # coverage gap.
16
+ # ignored - a known-equivalent mutant the user suppressed, via an
17
+ # inline `# mutineer:disable-line` comment or a
18
18
  # `.mutineer.yml` `ignore:` id. A pre-fork classification
19
19
  # (never run); excluded from the denominator so a strong
20
20
  # file can reach 100%.
@@ -22,14 +22,14 @@ module Mutineer
22
22
  # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
23
23
  # validity failure (counted separately by the reporter), error is a runtime
24
24
  # crash. Never conflate them via `details` string parsing. `no_coverage` and
25
- # `uncapturable` are pre-fork selection results (M3/#9): both excluded from
26
- # the score denominator.
25
+ # `uncapturable` are pre-fork selection results: both excluded from the score
26
+ # denominator.
27
27
  #
28
28
  # `subject`, `mutation`, and `id` are nil when the Result is built by
29
29
  # Isolation/Runner (which only know the outcome); the orchestrator attaches
30
30
  # them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
31
31
  # can render survivor diffs and emit the stable id. `id` is the content-based
32
- # MutantId (#10).
32
+ # MutantId.
33
33
  Result = Data.define(:status, :details, :subject, :mutation, :id) do
34
34
  # Builds a killed result.
35
35
  #
@@ -93,12 +93,12 @@ module Mutineer
93
93
  end
94
94
 
95
95
  # Aggregates a flat list of Results into counts, the mutation score, and the
96
- # surviving-mutant list. The score denominator is killed + survived ONLY
97
- # (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
98
- # and ignored (#10 equivalent-mutant suppression) are each excluded and
99
- # surfaced separately — so suppressing every survivor reaches 100%. An empty
100
- # denominator yields a nil score (rendered "N/A"), never 0.0
101
- # distinguishing "no testable mutants" from "0% killed".
96
+ # surviving-mutant list. The score denominator is killed + survived ONLY:
97
+ # no-coverage, uncapturable, skipped (invalid), errored, timeout, and ignored
98
+ # (equivalent-mutant suppression) are each excluded and surfaced separately,
99
+ # so suppressing every survivor reaches 100%. An empty denominator yields a
100
+ # nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
101
+ # from "0% killed".
102
102
  class AggregateResult
103
103
  attr_reader :results
104
104
 
@@ -151,9 +151,8 @@ module Mutineer
151
151
  # @return [Array<Mutineer::Result>] surviving results.
152
152
  def surviving_mutants = @results.select(&:survived?)
153
153
 
154
- # #11: split into { source_file => AggregateResult } so the Reporter
155
- # (per-source breakdown) and #13 (per-source roll-up / baseline diff) can
156
- # reuse the same aggregate math.
154
+ # Groups results by source file so the Reporter (per-source breakdown) and
155
+ # baseline diff can reuse the same aggregate math.
157
156
  #
158
157
  # @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
159
158
  def by_source