Module: SimpleCov::CLI::Diff

Extended by:
CommandHelpers
Defined in:
lib/simplecov/cli/diff.rb

Overview

simplecov diff <baseline> — print per-file coverage deltas between coverage.json (--input) and a baseline coverage.json checked in alongside the suite. Only files whose coverage moved are listed; --fail-on-drop exits non-zero when any file regressed, so this composes with CI as a "coverage of this PR didn't drop" gate. Resolves the long-standing "diff coverage" feature request.

Constant Summary collapse

EPSILON =

tolerance below which a delta is considered noise

0.005
STATUS_SUFFIX =
{"added" => "(new file)", "removed" => "(removed)"}.freeze

Constants included from CommandHelpers

CommandHelpers::STATS_ROW_FORMAT

Class Method Summary collapse

Methods included from CommandHelpers

command_name, common_options, error, parse_common, stats_row

Class Method Details

.compute_deltas(current_payload, baseline_payload) ⇒ Object



87
88
89
90
91
# File 'lib/simplecov/cli/diff.rb', line 87

def compute_deltas(current_payload, baseline_payload)
  CoverageFile::CRITERIA.transform_values do |fields|
    pct_for(fields, current_payload) - pct_for(fields, baseline_payload)
  end
end

.compute_row(fname, current_payload, baseline_payload, threshold) ⇒ Object

EPSILON keeps float noise out regardless of threshold; the threshold itself is inclusive, so a file that moved exactly N% is listed under --threshold N, matching the "at least N%" the usage text promises.



78
79
80
81
82
83
84
85
# File 'lib/simplecov/cli/diff.rb', line 78

def compute_row(fname, current_payload, baseline_payload, threshold)
  deltas = compute_deltas(current_payload, baseline_payload)
  floor = threshold.abs
  return nil unless deltas.values.any? { |delta| delta.abs > EPSILON && delta.abs >= floor }

  {file: fname, status: status_for(current_payload, baseline_payload),
   line_delta: deltas[:line], branch_delta: deltas[:branch], method_delta: deltas[:method]}
end

.compute_rows(current, baseline, threshold) ⇒ Object



69
70
71
72
# File 'lib/simplecov/cli/diff.rb', line 69

def compute_rows(current, baseline, threshold)
  files = current.keys | baseline.keys
  files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
end

.coverage_drop?(rows) ⇒ Boolean

A removed file's deltas are all -baseline%, but deleting a covered file (dead code cleanup) is not a coverage regression, so removed rows never trip the --fail-on-drop gate. Drops are gated on EPSILON like row inclusion and display: a row listed for a gain in one criterion must not fail the run over float noise in another, invisible in the printed output.

Returns:

  • (Boolean)


132
133
134
135
# File 'lib/simplecov/cli/diff.rb', line 132

def coverage_drop?(rows)
  rows.reject { |row| row[:status] == "removed" }
      .any? { |row| row.values_at(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
end

.delta_parts(row, color) ⇒ Object



118
119
120
121
122
123
124
# File 'lib/simplecov/cli/diff.rb', line 118

def delta_parts(row, color)
  [
    format_delta(row[:line_delta], "lines", color),
    (format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
    (format_delta(row[:method_delta], "methods", color)  if row[:method_delta].abs > EPSILON)
  ].compact
end

.emit_text(stdout, rows, color) ⇒ Object



106
107
108
109
110
# File 'lib/simplecov/cli/diff.rb', line 106

def emit_text(stdout, rows, color)
  return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?

  rows.each { |row| stdout.puts(format_row(row, color)) }
end

.format_delta(delta, label, color) ⇒ Object

Deltas are sign-based, not threshold-based: a +5% bump is good (green) and a -5% drop is bad (red), regardless of where the absolute coverage level lands.



140
141
142
143
144
# File 'lib/simplecov/cli/diff.rb', line 140

def format_delta(delta, label, color)
  sign = delta.positive? ? "+" : ""
  text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
  SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
end

.format_row(row, color) ⇒ Object



112
113
114
115
116
# File 'lib/simplecov/cli/diff.rb', line 112

def format_row(row, color)
  line = "  #{delta_parts(row, color).join('  ')}  #{row[:file]}"
  suffix = STATUS_SUFFIX[row[:status]]
  suffix ? "#{line}  #{suffix}" : line
end

.load_coverage(path, stderr) ⇒ Object



55
56
57
58
59
60
# File 'lib/simplecov/cli/diff.rb', line 55

def load_coverage(path, stderr)
  coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
  return unless coverage

  normalize_keys(coverage)
end

.normalize_keys(coverage) ⇒ Object

Strip a leading slash so coverage.json files written before the project_filename change (keys like "/lib/foo.rb") still diff cleanly against newer reports (keys like "lib/foo.rb").



65
66
67
# File 'lib/simplecov/cli/diff.rb', line 65

def normalize_keys(coverage)
  coverage.transform_keys { |key| key.delete_prefix("/") }
end

.parse(args, stderr) ⇒ Object



38
39
40
41
42
43
44
45
# File 'lib/simplecov/cli/diff.rb', line 38

def parse(args, stderr)
  opts = parse_flags(args)
  return stderr.puts("simplecov diff: missing baseline argument") && nil if opts[:rest].empty?

  opts[:baseline] = load_coverage(opts[:rest].first, stderr) or return nil
  opts[:current]  = load_coverage(opts[:input], stderr) or return nil
  opts
end

.parse_flags(args) ⇒ Object



47
48
49
50
51
52
53
# File 'lib/simplecov/cli/diff.rb', line 47

def parse_flags(args)
  opts, rest = parse_common(args, fail_on_drop: false, threshold: 0.0) do |o, options|
    o.on("--fail-on-drop")       { options[:fail_on_drop] = true }
    o.on("--threshold N", Float) { |v| options[:threshold] = v }
  end
  opts.merge(rest: rest)
end

.pct_for(fields, payload) ⇒ Object



100
101
102
103
104
# File 'lib/simplecov/cli/diff.rb', line 100

def pct_for(fields, payload)
  return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?

  payload[fields[:percent]].to_f
end

.run(args, stdout:, stderr:) ⇒ Object



24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/simplecov/cli/diff.rb', line 24

def run(args, stdout:, stderr:, **)
  opts = parse(args, stderr)
  return 1 unless opts

  rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
  rows.sort_by! { |row| row[:line_delta] }
  if opts[:json]
    stdout.puts(JSON.pretty_generate(rows))
  else
    emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
  end
  opts[:fail_on_drop] && coverage_drop?(rows) ? 1 : 0
end

.status_for(current_payload, baseline_payload) ⇒ Object



93
94
95
96
97
98
# File 'lib/simplecov/cli/diff.rb', line 93

def status_for(current_payload, baseline_payload)
  return "added"   if baseline_payload.nil?
  return "removed" if current_payload.nil?

  "changed"
end