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

#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.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/mutineer/reporter.rb', line 102

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

#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.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/mutineer/reporter.rb', line 78

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

#report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil) ⇒ 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.



36
37
38
39
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
# File 'lib/mutineer/reporter.rb', line 36

def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil)
  rendered =
    if format == "json"
      json_report(baseline)
    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