Class: Mutineer::Reporter

Inherits:
Object
  • Object
show all
Defined in:
lib/mutineer/reporter.rb

Overview

Renders an AggregateResult: the summary block, mutation score, and per-file survivor diffs. Stream discipline: the report goes to out (stdout), diagnostics/warnings go to err (stderr), so mutineer ... > report.txt captures only the report.

source_map is { file_path => raw source string }, used to extract the containing source line for each survivor diff.

Constant Summary collapse

BROKEN_SHARE_LIMIT =

Share of attempted mutants that may produce no verdict before --threshold stops trusting the score. A handful of flaky mutants in a large run is noise; a mostly-broken run is not a score. Deliberately not a flag: no one has needed a different number yet.

0.10
BROKEN_FLOOR =

A share alone gives a small run no tolerance at all: on a --since PR that yields 8 mutants, one timeout is 12.5%. README recommends exactly that workflow, so one bad mutant never fails the gate on its own, at any size.

1

Instance Method Summary collapse

Constructor Details

#initialize(aggregate, source_map) ⇒ Reporter

Returns a new instance of Reporter.



28
29
30
31
# File 'lib/mutineer/reporter.rb', line 28

def initialize(aggregate, source_map)
  @agg = aggregate
  @source_map = source_map
end

Instance Method Details

#attempted_count ⇒ Integer (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Mutants that were actually run. Deliberately not total: no_coverage, skipped-invalid and ignored mutants were never attempted, so counting them would dilute the share and let a broken run slip under the limit.

skipped-invalid is excluded from both sides: it means a mutant did not re-parse and was correctly never run, which is a validity outcome rather than a broken harness. Cost: an overwhelmingly-skipped run still scores on what little ran; that is our operator misbehaving and wants its own signal.

Returns:

  • (Integer) —

    killed + survived + no-verdict.



540
541
542
# File 'lib/mutineer/reporter.rb', line 540

def attempted_count
  @agg.killed_count + @agg.survived_count + no_verdict_count
end

#baseline_json(delta) ⇒ Object (private)

The same delta facts the human report prints, for dashboards. new_survivors reuse the ignored_json shape (subject/file/line/operator/token/id) and sort byte-stably so output does not depend on --jobs finish order.



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/mutineer/reporter.rb', line 325

def baseline_json(delta)
  {
    regressed: delta.regressed,
    score_before: delta.score_before,
    score_after: delta.score_after,
    score_dropped: delta.score_drop,
    # Additive: false when the score-drop check was skipped (a nil score or
    # a diff-scoped side), so a consumer knows not to render the two scores
    # as a comparison.
    score_comparable: delta.score_comparable,
    new_survivors: delta.new_survivors.map { |r| ignored_json(r) }
                        .sort_by { |h| [h[:file], h[:line], h[:operator]] },
    fixed_survivors: delta.fixed_survivors.map do |h|
      { subject: h["subject"], file: h["file"], line: h["line"],
        operator: h["operator"], id: h["id"] }
    end.sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:operator].to_s] }
  }
end

#baseline_section(out, delta) ⇒ Object (private)

The --baseline delta, appended after the normal report. Names every NEW survivor (subject (file:line) operator) and the score delta when it dropped, then a one-line REGRESSION/OK verdict so CI logs show which gate fired.



592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
# File 'lib/mutineer/reporter.rb', line 592

def baseline_section(out, delta)
  out.puts
  out.puts "Baseline comparison"
  out.puts "-------------------"
  out.puts "killed #{@agg.killed_count}, #{delta.new_survivors.size} new survivors vs baseline"
  delta.new_survivors
       .sort_by { |r| [r.subject.file, r.mutation.start_offset] }
       .each do |r|
    file = r.subject.file
    source = @source_map[file] || File.read(file)
    line, = diff_for(r.mutation, source)
    out.puts "  + #{r.subject.qualified_name} (#{file}:#{line}) #{r.mutation.operator}"
  end
  out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
  # An OK verdict must not imply a check that never ran: say when the score
  # comparison was skipped (a diff-scoped side or a nil score).
  out.puts "score-drop check skipped (scores not comparable)" unless delta.score_comparable
  out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
end

#broken_counts_detail ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Human-readable counts of the states that produced no verdict. Used by both the nil-score message and the completeness gate, so they agree.

Returns:

  • (String)


560
561
562
563
564
565
566
# File 'lib/mutineer/reporter.rb', line 560

def broken_counts_detail
  parts = []
  parts << "#{@agg.errored_count} errored" if @agg.errored_count.positive?
  parts << "#{@agg.timeout_count} timeout" if @agg.timeout_count.positive?
  parts << "#{@agg.uncapturable_count} uncapturable" if @agg.uncapturable_count.positive?
  parts.join(", ")
end

#broken_nil_score? ⇒ Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

True when nil score is due to errors/timeouts/uncapturable (gate must fail).

Returns:

  • (Boolean)


504
505
506
507
# File 'lib/mutineer/reporter.rb', line 504

def broken_nil_score?
  @agg.total.positive? &&
    (@agg.errored_count + @agg.timeout_count + @agg.uncapturable_count).positive?
end

#broken_share_exceeded? ⇒ Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Mutants that were attempted but produced no verdict, over everything attempted. Above the limit the score describes too small a slice of the run to gate on. A few flaky mutants in a large run stay under it.

Returns:

  • (Boolean) —

    true when too much of the run failed to produce a verdict.



515
516
517
518
519
# File 'lib/mutineer/reporter.rb', line 515

def broken_share_exceeded?
  attempted = attempted_count
  attempted.positive? && no_verdict_count > BROKEN_FLOOR &&
    no_verdict_count > attempted * BROKEN_SHARE_LIMIT
end

#diff_for(m, source) ⇒ Object (private)

Builds a line-aligned diff for a mutation whose byte range may span several lines (e.g. statement-removal of a multi-line statement). Returns the mutation's 1-based start line, the full original line-block it touches, the spliced mutated block, and a single-line token label for the header.



403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/mutineer/reporter.rb', line 403

def diff_for(m, source)
  # Byte math: Prism offsets are byte offsets; byteindex/byterindex/
  # byteslice keep line splicing correct for multibyte sources.
  line_begin = m.start_offset.zero? ? 0 : (source.byterindex("\n", m.start_offset - 1) || -1) + 1
  line_end   = source.byteindex("\n", m.end_offset) || source.bytesize
  before = source.byteslice(line_begin...m.start_offset)
  after  = source.byteslice(m.end_offset...line_end)
  original_block = source.byteslice(line_begin...line_end)
  mutated_block  = "#{before}#{m.replacement}#{after}"
  start_line  = source.byteslice(0, m.start_offset).count("\n") + 1
  token       = source.byteslice(m.start_offset...m.end_offset).gsub(/\s+/, " ").strip
  token       = "#{token[0, 47]}..." if token.length > 50
  [start_line, original_block, mutated_block, token]
end

#esc(text) ⇒ Object (private)

HTML-escapes any text destined for the document (stdlib CGI).



320
# File 'lib/mutineer/reporter.rb', line 320

def esc(text) = CGI.escapeHTML(text.to_s)

#exit_code(threshold:) ⇒ Object

0 pass / 1 below threshold or untestable-with-errors. Usage errors (exit 2) are the CLI's job. When the score is nil (nothing killed or survived), pure no_coverage / all-ignored / empty still skip the gate; if any mutant was errored, timed out, or uncapturable, fail the gate so a broken harness cannot green CI under --threshold.



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/mutineer/reporter.rb', line 107

def exit_code(threshold:)
  return 0 if threshold.nil? || threshold <= 0

  score = @agg.mutation_score
  if score.nil?
    broken = @agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
    return 1 if @agg.total.positive? && broken.positive?

    return 0 # pure no_coverage / ignored / empty — gate skipped
  end

  # A score computed over a small slice of what was attempted is not this
  # suite's score. Without this, 90 errored mutants and 10 that ran (9 killed)
  # reports 90% and exits 0, so CI cannot tell a complete run from a broken one.
  return 1 if broken_share_exceeded?

  score >= threshold ? 0 : 1
end

#html_report ⇒ Object (private)

One self-contained HTML file (inline CSS, no external assets): the overall score + summary counts, a per-source table, and every surviving mutant with its stable id and diff. All source/diff/identifier text is HTML-escaped (CGI.escapeHTML) so a </> in source can never break the markup. Reuses survivor_json/per_source_json so one run yields one set of facts regardless of --format.



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/mutineer/reporter.rb', line 217

def html_report
  score = @agg.mutation_score
  survivors = @agg.surviving_mutants.map { |r| survivor_json(r) }
                  .sort_by { |h| [h[:file], h[:line], h[:operator]] }
  per_source = @agg.by_source.map { |file, agg| per_source_json(file, agg) }
                   .sort_by { |h| h[:file] }

  <<~HTML
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Mutineer Mutation Report</title>
    <style>
      body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
             margin: 2rem; color: #1b1b1b; background: #fafafa; }
      h1 { margin: 0 0 .25rem; }
      .score { font-size: 2.5rem; font-weight: 700; }
      .counts { color: #444; margin: .5rem 0 1.5rem; }
      .counts span { display: inline-block; margin-right: 1rem; white-space: nowrap; }
      table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; background: #fff; }
      th, td { border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }
      th { background: #f0f0f0; }
      td.num { text-align: right; font-variant-numeric: tabular-nums; }
      .survivor { background: #fff; border: 1px solid #ddd; border-radius: 4px;
                  padding: .75rem 1rem; margin-bottom: 1rem; }
      .survivor h3 { margin: 0 0 .25rem; font-size: 1rem; }
      .meta { color: #555; font-size: .85rem; margin-bottom: .5rem; }
      .id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
      pre.diff { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
                 background: #f6f8fa; padding: .5rem .75rem; overflow-x: auto;
                 margin: 0; border-radius: 4px; }
      .diff .add { color: #116329; }
      .diff .del { color: #82071e; }
    </style>
    </head>
    <body>
    <h1>Mutineer — Mutation Report</h1>
    <div class="score">Score: #{score.nil? ? 'N/A' : "#{score}%"}</div>
    #{summary_html}
    #{per_source_html(per_source)}
    #{survivors_html(survivors)}
    </body>
    </html>
  HTML
end

#human_report(out, err, threshold) ⇒ void

This method returns an undefined value.

Renders the human report.

Parameters:

  • out (IO) —

    output stream.

  • err (IO) —

    error stream.

  • threshold (Float) —

    score threshold.



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/mutineer/reporter.rb', line 83

def human_report(out, err, threshold)
  if @agg.total.zero?
    err.puts "No mutations generated — verify target files contain in-scope " \
             "operators and are reached by the suite."
    return
  end

  out.puts "Mutineer — Mutation Results"
  out.puts "========================="
  out.puts
  summary(out)
  out.puts
  score_line(out, err)
  per_source(out)

  survivors(out)
  verdict(out, threshold) if threshold && threshold.positive?
end

#ignored_json(result) ⇒ Object (private)

An entry under the JSON ignored: key: what the user already suppressed.



384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/mutineer/reporter.rb', line 384

def ignored_json(result)
  m = result.mutation
  file = result.subject.file
  source = @source_map[file] || File.read(file)
  start_line, _orig, _mut, token = diff_for(m, source)
  {
    subject: result.subject.qualified_name,
    file: file,
    line: start_line,
    operator: m.operator.to_s,
    token: token,
    id: result.id
  }
end

#json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 }) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Canonical machine-readable schema. survivors/no_coverage are sorted by (file, line, operator) so output is byte-stable regardless of --jobs worker finish order.

Parameters:

  • baseline (Mutineer::Baseline::Delta, nil) (defaults to: nil) —

    baseline delta.

  • scoped (Boolean) (defaults to: false) —

    the run was diff-scoped (--since), so its score covers only the changed-line mutants (additive summary.scoped key).

  • legacy_id_matches (Hash{Symbol => Integer}) (defaults to: { ignore: 0, baseline: 0 }) —

    {ignore:, baseline:}: old-format ignore entries that matched, and survivors matched in an old-format baseline only through their old id (#126).

Returns:

  • (String) —

    JSON text.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/mutineer/reporter.rb', line 140

def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
  killed = @agg.killed_count
  survived = @agg.survived_count
  # null (not 0.0) on an empty denominator, matching the nil-vs-0.0
  # discipline in AggregateResult; and the SAME rounding as the human report
  # (one run must not yield two scores by --format).
  score = @agg.mutation_score

  doc = {
    schema_version: "1.4",
    summary: {
      total: @agg.total, killed: killed, survived: survived,
      no_coverage: @agg.no_coverage_count,
      uncapturable: @agg.uncapturable_count,
      skipped_invalid: @agg.skipped_invalid_count,
      errored: @agg.errored_count, timeout: @agg.timeout_count,
      ignored: @agg.ignored_count,
      # The gate is computed from these two, so a consumer never re-derives them.
      attempted: attempted_count, no_verdict: no_verdict_count,
      score: score,
      # Additive: true when the run was diff-scoped (--since). The score then
      # covers only the changed-line mutants, so it is not comparable to a
      # full-run score; Baseline#diff reads this to skip the score-drop gate.
      scoped: scoped,
      # Additive (1.4, #126): ids hash the project-relative file path. A
      # baseline without this key stores old-format ids; Baseline#diff then
      # also matches on old ids.
      id_format: 2,
      # Additive (1.4, #126): stored ids still in the old format. `ignore` is
      # the number of old-format ignore entries that matched; `baseline` the
      # survivors matched in the baseline only through their old id.
      legacy_id_matches: legacy_id_matches
    },
    survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
                   .sort_by { |h| [h[:file], h[:line], h[:operator]] },
    no_coverage: @agg.results.select(&:no_coverage?).map { |r| no_coverage_json(r) }
                     .sort_by { |h| [h[:file], h[:line]] },
    # Same shape as no_coverage; additive key.
    uncapturable: @agg.results.select(&:uncapturable?).map { |r| no_coverage_json(r) }
                      .sort_by { |h| [h[:file], h[:line]] },
    # Every mutant that was attempted and produced no verdict, whatever the
    # reason — the set the --threshold completeness gate counts. Named for the
    # condition rather than one status, because summary.errored means :error
    # alone and a key that reconciled with neither would be worse. `details`
    # carries the cause where there is one. Uncapturable mutants also appear in
    # uncapturable[]; that key keeps its lean shape for existing consumers.
    # to_s/to_i because a pre-fork failure has no subject, so its file and line
    # are null and would not compare against a real entry. id and status extend
    # the key to a total order: these entries collide on (file, line) far more
    # than survivors do — several mutants on one crashy line, every pre-fork
    # entry on ("", 0) — and sort_by is not stable, so equal keys would leave
    # worker finish order in the output and break the byte-stability promise.
    no_verdict: @agg.results.select { |r| r.error? || r.timeout? || r.uncapturable? }
                    .map { |r| no_verdict_json(r) }
                    .sort_by { |h| [h[:file].to_s, h[:line].to_i, h[:id].to_s, h[:status].to_s, h[:details].to_s] },
    # Equivalent mutants the user suppressed: emitted with their stable id so
    # the user can audit what is silenced (and copy ids for survivors they
    # want to add). Excluded from the score; never in `survivors`.
    ignored: @agg.results.select(&:ignored?).map { |r| ignored_json(r) }
                 .sort_by { |h| [h[:file], h[:line], h[:operator]] },
    # Per-source breakdown (additive; baseline consumes it). Sorted by file so
    # output is byte-stable. Reuses AggregateResult via by_source.
    per_source: @agg.by_source.map { |file, agg| per_source_json(file, agg) }
                    .sort_by { |h| h[:file] }
  }
  # Additive baseline-delta block, present only with --baseline. Existing
  # consumers ignore the extra key; it does not move schema_version on its own.
  doc[:baseline] = baseline_json(baseline) if baseline
  "#{JSON.generate(doc)}\n"
end

#no_coverage_json(result) ⇒ Hash (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds no-coverage JSON.

Parameters:

Returns:

  • (Hash) —

    no-coverage JSON object.



423
424
425
426
427
428
429
430
431
432
# File 'lib/mutineer/reporter.rb', line 423

def no_coverage_json(result)
  m = result.mutation
  file = result.subject.file
  source = @source_map[file] || File.read(file)
  {
    subject: result.subject.qualified_name,
    file: file,
    line: source.byteslice(0, m.start_offset).count("\n") + 1
  }
end

#no_verdict_count ⇒ Integer (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Mutants that were attempted and produced no verdict, whatever the reason.

Returns:

  • (Integer) —

    errored + timed out + uncapturable.



525
526
527
# File 'lib/mutineer/reporter.rb', line 525

def no_verdict_count
  @agg.errored_count + @agg.timeout_count + @agg.uncapturable_count
end

#no_verdict_json(result) ⇒ Hash (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

An entry under the JSON no_verdict: key: an attempted mutant with no verdict. A pre-fork failure has no subject or mutation attached, so those degrade to nulls rather than dropping the entry — the count must still reconcile with summary.no_verdict.

Parameters:

Returns:

  • (Hash) —

    no-verdict JSON object.



442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/mutineer/reporter.rb', line 442

def no_verdict_json(result)
  file = result.subject&.file
  line =
    if result.mutation && file
      source = @source_map[file] || File.read(file)
      source.byteslice(0, result.mutation.start_offset).count("\n") + 1
    end
  {
    subject: result.subject&.qualified_name,
    file: file,
    line: line,
    id: result.id,
    status: result.status.to_s,
    details: result.details
  }
end

#no_verdict_ratio ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The sentence both the verdict line and the stderr note are built from, so a user cannot read one number in the report and a different one beside it.

Returns:

  • (String) —

    e.g. "90 of 100 attempted mutants produced no verdict (90.0%, limit 10%)".



549
550
551
552
553
# File 'lib/mutineer/reporter.rb', line 549

def no_verdict_ratio
  pct = (no_verdict_count * 100.0 / attempted_count).round(1)
  "#{no_verdict_count} of #{attempted_count} attempted mutants produced no verdict " \
    "(#{pct}%, limit #{(BROKEN_SHARE_LIMIT * 100).round}%)"
end

#per_source(out) ⇒ void (private)

This method returns an undefined value.

One line per source after the global summary, so a multi-source run shows which file is weak. Omitted for a single-source run: the global summary already says everything (no redundant one-line block).

Parameters:

  • out (IO) —

    output stream.



574
575
576
577
578
579
580
581
582
583
584
585
586
587
# File 'lib/mutineer/reporter.rb', line 574

def per_source(out)
  sources = @agg.by_source
  return if sources.size <= 1

  out.puts
  out.puts "Per-source"
  out.puts "----------"
  sources.sort.each do |file, agg|
    score = agg.mutation_score
    out.puts format("%s  %s  (%d killed / %d survived / %d no-cov)",
                    file, score.nil? ? "N/A" : "#{score}%",
                    agg.killed_count, agg.survived_count, agg.no_coverage_count)
  end
end

#per_source_html(per_source) ⇒ Object (private)

The per-source breakdown table.



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/mutineer/reporter.rb', line 279

def per_source_html(per_source)
  return "" if per_source.empty?

  rows = per_source.map do |h|
    score = h[:score].nil? ? "N/A" : "#{h[:score]}%"
    "<tr><td>#{esc(h[:file])}</td><td class=\"num\">#{score}</td>" \
      "<td class=\"num\">#{h[:killed]}</td><td class=\"num\">#{h[:survived]}</td>" \
      "<td class=\"num\">#{h[:no_coverage]}</td></tr>"
  end.join("\n  ")
  <<~HTML.chomp
    <h2>Per-source</h2>
    <table>
    <tr><th>File</th><th>Score</th><th>Killed</th><th>Survived</th><th>No coverage</th></tr>
      #{rows}
    </table>
  HTML
end

#per_source_json(file, agg) ⇒ Hash (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds per-source JSON.

Parameters:

Returns:

  • (Hash) —

    per-source JSON object.



350
351
352
353
354
355
356
# File 'lib/mutineer/reporter.rb', line 350

def per_source_json(file, agg)
  {
    file: file, total: agg.total,
    killed: agg.killed_count, survived: agg.survived_count,
    no_coverage: agg.no_coverage_count, score: agg.mutation_score
  }
end

#report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 }) ⇒ Object

Single entry point. Branches on format ("human" | "json" | "html") and routes the rendered report to output (a file, with a stderr confirmation) or to out. Diagnostics always go to err. scoped marks a diff-scoped (--since) run; the JSON report records it so a consumer (or a later --baseline load) knows the score covers only the changed-line mutants. legacy_id_matches ({ignore:, baseline:}) counts stored ids still in the old format (#126); only the JSON report records it (summary.legacy_id_matches).



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/mutineer/reporter.rb', line 40

def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
           baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
  rendered =
    if format == "json"
      json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches)
    elsif format == "html"
      html_report
    else
      sio = StringIO.new
      human_report(sio, err, threshold)
      baseline_section(sio, baseline) if baseline
      sio.string
    end

  if output
    abs = File.expand_path(output)
    File.write(abs, rendered)
    err.puts "Report written to #{abs}"
  else
    out.print rendered
  end

  # Both ways a run can fail the gate on completeness, said here rather than in
  # the human renderer: --format json is the documented CI path, and a run that
  # exits 1 must say why on every format, not only the one a person reads.
  return unless threshold&.positive?

  if @agg.mutation_score.nil? && broken_nil_score?
    err.puts "[mutineer] nothing could be scored (#{broken_counts_detail}), so the " \
             "--threshold gate fails. See no_verdict[] in --format json for the cause of each."
  elsif broken_share_exceeded?
    err.puts "[mutineer] #{no_verdict_ratio}: #{broken_counts_detail}. The score covers " \
             "only part of the run, so the --threshold gate fails. See no_verdict[] in " \
             "--format json for the cause of each."
  end
end

#score_line(out, err) ⇒ void (private)

This method returns an undefined value.

Writes the score line.

Parameters:

  • out (IO) —

    output stream.

  • err (IO) —

    error stream.



481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# File 'lib/mutineer/reporter.rb', line 481

def score_line(out, err)
  score = @agg.mutation_score
  excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
             "#{@agg.skipped_invalid_count} skipped, " \
             "#{@agg.errored_count + @agg.timeout_count} errored, " \
             "#{@agg.ignored_count} ignored excluded"
  if score.nil?
    out.puts "Mutation score: N/A  (no covered mutants)"
    # Only the benign case here: the gate-failure explanation is emitted once
    # from {report}, for every format, so it cannot be said twice or only to
    # the reader of the human report.
    unless broken_nil_score?
      err.puts "[mutineer] no covered mutations; mutation score is N/A and the threshold check is skipped."
    end
  else
    out.puts "Mutation score: #{score}%  (killed / (killed + survived); #{excluded})"
  end
end

#summary(out) ⇒ void (private)

This method returns an undefined value.

Writes the summary block.

Parameters:

  • out (IO) —

    output stream.



463
464
465
466
467
468
469
470
471
472
473
474
# File 'lib/mutineer/reporter.rb', line 463

def summary(out)
  out.puts "Summary"
  out.puts "-------"
  out.puts format("Total:        %-6d  Killed:        %d", @agg.total, @agg.killed_count)
  out.puts format("Survived:     %-6d  No coverage:   %d", @agg.survived_count, @agg.no_coverage_count)
  out.puts format("Skipped:      %-6d  Errored:       %d", @agg.skipped_invalid_count,
                  @agg.errored_count + @agg.timeout_count)
  # A broken harness, not a coverage gap: report it distinctly from No coverage.
  out.puts format("Uncapturable: %-6d  (tests failed to run)", @agg.uncapturable_count)
  # Equivalent mutants the user suppressed; excluded from the denominator.
  out.puts format("Ignored:      %-6d  (equivalent, suppressed)", @agg.ignored_count)
end

#summary_html ⇒ Object (private)

The summary counts block for the HTML header.



266
267
268
269
270
271
272
273
274
275
276
# File 'lib/mutineer/reporter.rb', line 266

def summary_html
  counts = {
    "total" => @agg.total, "killed" => @agg.killed_count,
    "survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
    "uncapturable" => @agg.uncapturable_count, "ignored" => @agg.ignored_count,
    "skipped" => @agg.skipped_invalid_count,
    "errored" => @agg.errored_count + @agg.timeout_count
  }
  spans = counts.map { |k, v| "<span><strong>#{v}</strong> #{esc(k)}</span>" }.join("\n  ")
  "<div class=\"counts\">\n  #{spans}\n</div>"
end

#survivor(out, file, result) ⇒ void (private)

This method returns an undefined value.

Writes one survivor entry.

Parameters:

  • out (IO) —

    output stream.

  • file (String) —

    source file path.

  • result (Mutineer::Result) —

    survivor result.



636
637
638
639
640
641
642
643
644
645
# File 'lib/mutineer/reporter.rb', line 636

def survivor(out, file, result)
  m = result.mutation
  source = @source_map[file] || File.read(file)
  start_line, original_block, mutated_block, token = diff_for(m, source)

  out.puts "  #{result.subject.qualified_name} (#{File.basename(file)}:#{start_line})"
  out.puts "  Operator: #{m.operator}  (#{token} -> #{m.replacement})"
  original_block.each_line { |l| out.puts "  - #{l.chomp}" }
  mutated_block.each_line  { |l| out.puts "  + #{l.chomp}" }
end

#survivor_json(result) ⇒ Hash (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds survivor JSON.

Parameters:

Returns:

  • (Hash) —

    survivor JSON object.



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/mutineer/reporter.rb', line 363

def survivor_json(result)
  m = result.mutation
  file = result.subject.file
  source = @source_map[file] || File.read(file)
  start_line, original_block, mutated_block, token = diff_for(m, source)
  minus = original_block.each_line.map { |l| "-#{l.chomp}" }.join("\n")
  plus  = mutated_block.each_line.map { |l| "+#{l.chomp}" }.join("\n")
  {
    subject: result.subject.qualified_name,
    file: file,
    line: start_line,
    operator: m.operator.to_s,
    # The stable, copy-pasteable id (next to the human-readable token) so a
    # user can paste it straight into .mutineer.yml `ignore:`.
    id: result.id,
    token: token,
    diff: "--- a/#{file}\n+++ b/#{file}\n@@ -#{start_line} +#{start_line} @@\n#{minus}\n#{plus}\n"
  }
end

#survivors(out) ⇒ void (private)

This method returns an undefined value.

Writes the survivors block.

Parameters:

  • out (IO) —

    output stream.



616
617
618
619
620
621
622
623
624
625
626
627
628
# File 'lib/mutineer/reporter.rb', line 616

def survivors(out)
  mutants = @agg.surviving_mutants
  return if mutants.empty?

  out.puts
  out.puts "Surviving Mutants"
  out.puts "-----------------"
  mutants.group_by { |r| r.subject.file }.sort.each do |file, group|
    out.puts
    out.puts file
    group.sort_by { |r| r.mutation.start_offset }.each { |r| survivor(out, file, r) }
  end
end

#survivors_html(survivors) ⇒ Object (private)

The surviving-mutants list, each with subject, location, operator, stable id, and a colorized diff. All text is HTML-escaped.



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/mutineer/reporter.rb', line 299

def survivors_html(survivors)
  return "<h2>Surviving Mutants</h2>\n<p>None — every covered mutant was killed.</p>" if survivors.empty?

  cards = survivors.map do |s|
    diff_lines = s[:diff].each_line.map do |line|
      cls = line.start_with?("+") ? "add" : (line.start_with?("-") ? "del" : nil)
      text = esc(line.chomp)
      cls ? "<span class=\"#{cls}\">#{text}</span>" : text
    end.join("\n")
    <<~CARD.chomp
      <div class="survivor">
      <h3>#{esc(s[:subject])}</h3>
      <div class="meta">#{esc(s[:file])}:#{s[:line]} &middot; #{esc(s[:operator])} &middot; <span class="id">#{esc(s[:id])}</span></div>
      <pre class="diff">#{diff_lines}</pre>
      </div>
    CARD
  end.join("\n")
  "<h2>Surviving Mutants</h2>\n#{cards}"
end

#verdict(out, threshold) ⇒ void (private)

This method returns an undefined value.

Writes the final verdict line.

Parameters:

  • out (IO) —

    output stream.

  • threshold (Float) —

    score threshold.



652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
# File 'lib/mutineer/reporter.rb', line 652

def verdict(out, threshold)
  score = @agg.mutation_score
  if score.nil?
    if broken_nil_score?
      out.puts "FAILED: no covered mutants (#{broken_counts_detail}); " \
               "threshold #{threshold}% cannot pass with a broken harness"
    end
    return
  end

  # Same rule as exit_code, or the report says PASSED on a run that exits 1 —
  # and with --output that wrong verdict is what gets archived.
  if broken_share_exceeded?
    out.puts "FAILED: #{no_verdict_ratio}; #{score}% covers only part of the run"
  elsif score >= threshold
    out.puts "PASSED: #{score}% >= threshold #{threshold}%"
  else
    out.puts "FAILED: #{score}% < threshold #{threshold}%"
  end
end