Module: SimpleCov::CLI::Diff
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
CommandHelpers::STATS_ROW_FORMAT
Class Method Summary
collapse
-
.compute_deltas(current_payload, baseline_payload) ⇒ Object
-
.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.
-
.compute_rows(current, baseline, threshold) ⇒ Object
-
.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.
-
.delta_parts(row, color) ⇒ Object
-
.emit_text(stdout, rows, color) ⇒ Object
-
.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.
-
.format_row(row, color) ⇒ Object
-
.load_coverage(path, stderr) ⇒ Object
-
.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").
-
.parse(args, stderr) ⇒ Object
-
.parse_flags(args) ⇒ Object
-
.pct_for(fields, payload) ⇒ Object
-
.run(args, stdout:, stderr:) ⇒ Object
-
.status_for(current_payload, baseline_payload) ⇒ Object
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.
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
|
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
|
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
|