simplecov 1.1.1 → 1.2.0
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.
- checksums.yaml +4 -4
- data/README.md +148 -5
- data/exe/simplecov +0 -3
- data/lib/minitest/simplecov_plugin.rb +12 -9
- data/lib/simplecov/atomic_file.rb +16 -19
- data/lib/simplecov/autostart.rb +5 -7
- data/lib/simplecov/baseline/parser.rb +58 -0
- data/lib/simplecov/baseline.rb +170 -0
- data/lib/simplecov/cli/affected/changed_files.rb +57 -0
- data/lib/simplecov/cli/affected/selection.rb +117 -0
- data/lib/simplecov/cli/affected.rb +148 -0
- data/lib/simplecov/cli/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +23 -24
- data/lib/simplecov/cli/command_helpers.rb +55 -19
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +25 -28
- data/lib/simplecov/cli/coverage_file.rb +58 -14
- data/lib/simplecov/cli/dead_code/output.rb +100 -0
- data/lib/simplecov/cli/dead_code.rb +139 -0
- data/lib/simplecov/cli/diff/output.rb +45 -0
- data/lib/simplecov/cli/diff.rb +35 -67
- data/lib/simplecov/cli/dotfile.rb +83 -38
- data/lib/simplecov/cli/git.rb +41 -0
- data/lib/simplecov/cli/history/output.rb +135 -0
- data/lib/simplecov/cli/history.rb +67 -0
- data/lib/simplecov/cli/merge.rb +34 -39
- data/lib/simplecov/cli/open.rb +12 -15
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +135 -0
- data/lib/simplecov/cli/patch.rb +169 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +18 -24
- data/lib/simplecov/cli/run.rb +8 -9
- data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
- data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
- data/lib/simplecov/cli/serve.rb +27 -24
- data/lib/simplecov/cli/show/annotator.rb +87 -0
- data/lib/simplecov/cli/show/sweep.rb +36 -0
- data/lib/simplecov/cli/show.rb +133 -0
- data/lib/simplecov/cli/status/facts.rb +87 -0
- data/lib/simplecov/cli/status.rb +93 -0
- data/lib/simplecov/cli/tests/redundancy.rb +78 -0
- data/lib/simplecov/cli/tests.rb +148 -0
- data/lib/simplecov/cli/uncovered/misses.rb +37 -0
- data/lib/simplecov/cli/uncovered.rb +70 -32
- data/lib/simplecov/cli/usage.rb +152 -0
- data/lib/simplecov/cli/watch/live_report.rb +73 -0
- data/lib/simplecov/cli/watch/narrator.rb +55 -0
- data/lib/simplecov/cli/watch/poller.rb +45 -0
- data/lib/simplecov/cli/watch/session.rb +134 -0
- data/lib/simplecov/cli/watch/test_plan.rb +43 -0
- data/lib/simplecov/cli/watch.rb +85 -0
- data/lib/simplecov/cli.rb +43 -92
- data/lib/simplecov/color.rb +19 -37
- data/lib/simplecov/combine/branches_combiner.rb +7 -31
- data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
- data/lib/simplecov/combine/identity_interner.rb +10 -13
- data/lib/simplecov/combine/interned_counts.rb +7 -9
- data/lib/simplecov/combine/lines_combiner.rb +20 -44
- data/lib/simplecov/combine/methods_combiner.rb +9 -35
- data/lib/simplecov/combine/results_combiner.rb +4 -21
- data/lib/simplecov/combine.rb +0 -6
- data/lib/simplecov/command_guesser.rb +20 -34
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +91 -79
- data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +6 -10
- data/lib/simplecov/configuration/filters.rb +40 -73
- data/lib/simplecov/configuration/formatting.rb +74 -83
- data/lib/simplecov/configuration/groups.rb +19 -13
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +31 -22
- data/lib/simplecov/configuration/merging.rb +94 -62
- data/lib/simplecov/configuration/missed_caps.rb +51 -0
- data/lib/simplecov/configuration/production.rb +23 -0
- data/lib/simplecov/configuration/test_tracking.rb +48 -0
- data/lib/simplecov/configuration/thresholds.rb +33 -62
- data/lib/simplecov/configuration/view_coverage.rb +38 -0
- data/lib/simplecov/configuration.rb +70 -76
- data/lib/simplecov/configuration_error.rb +5 -0
- data/lib/simplecov/context_map/union.rb +81 -0
- data/lib/simplecov/context_map.rb +187 -0
- data/lib/simplecov/coverage_json.rb +2 -5
- data/lib/simplecov/coverage_statistics.rb +14 -24
- data/lib/simplecov/coverage_violations.rb +157 -64
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +4 -23
- data/lib/simplecov/deprecation.rb +45 -27
- data/lib/simplecov/directive.rb +46 -62
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +17 -11
- data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
- data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
- data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
- data/lib/simplecov/exit_codes.rb +16 -10
- data/lib/simplecov/exit_handling.rb +94 -59
- data/lib/simplecov/file_list.rb +20 -51
- data/lib/simplecov/filter.rb +43 -66
- data/lib/simplecov/formatter/base.rb +37 -55
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
- data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
- data/lib/simplecov/formatter/html_formatter.rb +24 -41
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
- data/lib/simplecov/formatter/json_formatter.rb +7 -13
- data/lib/simplecov/formatter/multi_formatter.rb +5 -11
- data/lib/simplecov/formatter/simple_formatter.rb +2 -8
- data/lib/simplecov/formatter.rb +14 -10
- data/lib/simplecov/group_names.rb +13 -12
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +6 -9
- data/lib/simplecov/lines_classifier.rb +31 -16
- data/lib/simplecov/load_global_config.rb +7 -7
- data/lib/simplecov/parallel_adapters/base.rb +13 -41
- data/lib/simplecov/parallel_adapters/generic.rb +7 -19
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
- data/lib/simplecov/parallel_adapters.rb +24 -55
- data/lib/simplecov/parallel_coordination.rb +39 -49
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +67 -126
- data/lib/simplecov/process.rb +14 -32
- data/lib/simplecov/production/error.rb +11 -0
- data/lib/simplecov/production/file_sink.rb +130 -0
- data/lib/simplecov/production.rb +249 -0
- data/lib/simplecov/profiles/rails.rb +1 -12
- data/lib/simplecov/profiles/root_filter.rb +0 -4
- data/lib/simplecov/profiles/strict.rb +9 -19
- data/lib/simplecov/profiles.rb +11 -33
- data/lib/simplecov/report_deferral.rb +11 -15
- data/lib/simplecov/report_stamp.rb +6 -9
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +7 -12
- data/lib/simplecov/result.rb +68 -117
- data/lib/simplecov/result_adapter.rb +56 -68
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
- data/lib/simplecov/result_merger/resultset_file.rb +20 -27
- data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
- data/lib/simplecov/result_merger/resultset_store.rb +23 -14
- data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
- data/lib/simplecov/result_merger.rb +59 -58
- data/lib/simplecov/result_processing.rb +74 -119
- data/lib/simplecov/run_identity.rb +16 -18
- data/lib/simplecov/simulate_coverage.rb +34 -50
- data/lib/simplecov/source_file/branch.rb +8 -33
- data/lib/simplecov/source_file/branch_builder.rb +28 -47
- data/lib/simplecov/source_file/builder_context.rb +6 -13
- data/lib/simplecov/source_file/line.rb +13 -33
- data/lib/simplecov/source_file/line_builder.rb +9 -15
- data/lib/simplecov/source_file/method.rb +3 -7
- data/lib/simplecov/source_file/method_builder.rb +6 -15
- data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
- data/lib/simplecov/source_file/skip_chunks.rb +19 -28
- data/lib/simplecov/source_file/source_loader.rb +45 -35
- data/lib/simplecov/source_file/statistics.rb +10 -9
- data/lib/simplecov/source_file.rb +21 -51
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
- data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
- data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
- data/lib/simplecov/static_coverage_extractor.rb +46 -62
- data/lib/simplecov/test_tracker/accessors.rb +34 -0
- data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
- data/lib/simplecov/test_tracker/delta.rb +69 -0
- data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
- data/lib/simplecov/test_tracker.rb +162 -0
- data/lib/simplecov/unloaded_file_injector.rb +18 -31
- data/lib/simplecov/useless_results_remover.rb +7 -6
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
- data/lib/simplecov/view_coverage.rb +55 -0
- data/lib/simplecov-html.rb +1 -0
- data/lib/simplecov.rb +57 -113
- data/lib/simplecov_json_formatter.rb +1 -0
- data/man/simplecov.1 +349 -0
- data/sig/simplecov.rbs +523 -595
- metadata +72 -5
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Diff
|
|
6
|
+
module Output
|
|
7
|
+
STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
|
|
8
|
+
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
def emit_text(stdout, rows, color)
|
|
12
|
+
if rows.empty?
|
|
13
|
+
stdout.puts("simplecov diff: no per-file coverage changes")
|
|
14
|
+
else
|
|
15
|
+
rows.each { |row| stdout.puts(format_row(row, color)) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def format_row(row, color)
|
|
20
|
+
line = " #{delta_parts(row, color).join(" ")} #{row.fetch(:file)}"
|
|
21
|
+
suffix = STATUS_SUFFIX[row.fetch(:status)]
|
|
22
|
+
suffix ? "#{line} #{suffix}" : line
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delta_parts(row, color)
|
|
26
|
+
[
|
|
27
|
+
format_delta(row.fetch(:line_delta), "lines", color),
|
|
28
|
+
(format_delta(row.fetch(:branch_delta), "branches", color) if row.fetch(:branch_delta).abs > EPSILON),
|
|
29
|
+
(format_delta(row.fetch(:method_delta), "methods", color) if row.fetch(:method_delta).abs > EPSILON)
|
|
30
|
+
].compact
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deltas are sign-based, not threshold-based: a +5% bump is green and a -5%
|
|
34
|
+
# drop is red, regardless of where the absolute coverage level lands. A drop
|
|
35
|
+
# carries its own minus, so the sign is a prefix a rise has and a drop has
|
|
36
|
+
# not.
|
|
37
|
+
def format_delta(delta, label, color)
|
|
38
|
+
sign = "+" if delta.positive?
|
|
39
|
+
text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
|
|
40
|
+
Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/simplecov/cli/diff.rb
CHANGED
|
@@ -3,50 +3,52 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
5
|
require_relative "command_helpers"
|
|
6
|
+
require_relative "diff/output"
|
|
6
7
|
|
|
7
8
|
module SimpleCov
|
|
8
9
|
module CLI
|
|
9
|
-
# `simplecov diff <baseline
|
|
10
|
-
# coverage.json
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# gate. Resolves the long-standing "diff coverage" feature request.
|
|
10
|
+
# `simplecov diff <baseline>`: per-file coverage deltas between coverage.json
|
|
11
|
+
# and a baseline coverage.json checked in alongside the suite. Only files
|
|
12
|
+
# whose coverage moved are listed, and `--fail-on-drop` exits non-zero when
|
|
13
|
+
# any file regressed, so this composes as a "coverage of this PR didn't drop"
|
|
14
|
+
# gate.
|
|
15
15
|
module Diff
|
|
16
16
|
extend CommandHelpers
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# Tolerance below which a delta is considered noise.
|
|
19
|
+
EPSILON = 0.005
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
module_function
|
|
21
|
+
extend self
|
|
23
22
|
|
|
24
23
|
def run(args, stdout:, stderr:, **)
|
|
25
24
|
opts = parse(args, stderr)
|
|
26
25
|
return 1 unless opts
|
|
27
26
|
|
|
28
|
-
rows = compute_rows(opts
|
|
29
|
-
rows.sort_by! { |row| row
|
|
30
|
-
if opts
|
|
27
|
+
rows = compute_rows(opts.fetch(:current), opts.fetch(:baseline), opts.fetch(:threshold))
|
|
28
|
+
rows.sort_by! { |row| row.fetch(:line_delta) }
|
|
29
|
+
if opts.fetch(:json)
|
|
31
30
|
stdout.puts(JSON.pretty_generate(rows))
|
|
32
31
|
else
|
|
33
|
-
emit_text(stdout, rows,
|
|
32
|
+
Output.emit_text(stdout, rows, CLI.color_enabled?(opts, stdout))
|
|
34
33
|
end
|
|
35
|
-
opts
|
|
34
|
+
(opts.fetch(:fail_on_drop) && coverage_drop?(rows)) ? 1 : 0
|
|
36
35
|
end
|
|
37
36
|
|
|
38
37
|
def parse(args, stderr)
|
|
39
38
|
opts = parse_flags(args)
|
|
40
|
-
|
|
39
|
+
if opts.fetch(:rest).empty?
|
|
40
|
+
stderr.puts("simplecov diff: missing baseline argument")
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
41
43
|
|
|
42
|
-
opts[:baseline] = load_coverage(opts
|
|
43
|
-
opts[:current]
|
|
44
|
+
opts[:baseline] = load_coverage(opts.fetch(:rest).first, stderr) or return nil
|
|
45
|
+
opts[:current] = load_coverage(opts.fetch(:input), stderr) or return nil
|
|
44
46
|
opts
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def parse_flags(args)
|
|
48
50
|
opts, rest = parse_common(args, fail_on_drop: false, threshold: 0.0) do |o, options|
|
|
49
|
-
o.on("--fail-on-drop")
|
|
51
|
+
o.on("--fail-on-drop") { options[:fail_on_drop] = true }
|
|
50
52
|
o.on("--threshold N", Float) { |v| options[:threshold] = v }
|
|
51
53
|
end
|
|
52
54
|
opts.merge(rest: rest)
|
|
@@ -59,9 +61,8 @@ module SimpleCov
|
|
|
59
61
|
normalize_keys(coverage)
|
|
60
62
|
end
|
|
61
63
|
|
|
62
|
-
#
|
|
63
|
-
# `project_filename` change
|
|
64
|
-
# cleanly against newer reports (keys like "lib/foo.rb").
|
|
64
|
+
# Strips a leading slash so coverage.json files written before the
|
|
65
|
+
# `project_filename` change still diff cleanly against newer reports.
|
|
65
66
|
def normalize_keys(coverage)
|
|
66
67
|
coverage.transform_keys { |key| key.delete_prefix("/") }
|
|
67
68
|
end
|
|
@@ -71,17 +72,15 @@ module SimpleCov
|
|
|
71
72
|
files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
|
|
72
73
|
end
|
|
73
74
|
|
|
74
|
-
#
|
|
75
|
-
# threshold
|
|
76
|
-
# is listed under `--threshold N`, matching the "at least N%" the
|
|
77
|
-
# usage text promises.
|
|
75
|
+
# The threshold is inclusive, so a file that moved exactly N% is listed under
|
|
76
|
+
# `--threshold N`, matching the "at least N%" the usage text promises.
|
|
78
77
|
def compute_row(fname, current_payload, baseline_payload, threshold)
|
|
79
78
|
deltas = compute_deltas(current_payload, baseline_payload)
|
|
80
79
|
floor = threshold.abs
|
|
81
80
|
return nil unless deltas.values.any? { |delta| delta.abs > EPSILON && delta.abs >= floor }
|
|
82
81
|
|
|
83
82
|
{file: fname, status: status_for(current_payload, baseline_payload),
|
|
84
|
-
line_delta: deltas
|
|
83
|
+
line_delta: deltas.fetch(:line), branch_delta: deltas.fetch(:branch), method_delta: deltas.fetch(:method)}
|
|
85
84
|
end
|
|
86
85
|
|
|
87
86
|
def compute_deltas(current_payload, baseline_payload)
|
|
@@ -91,56 +90,25 @@ module SimpleCov
|
|
|
91
90
|
end
|
|
92
91
|
|
|
93
92
|
def status_for(current_payload, baseline_payload)
|
|
94
|
-
return "added"
|
|
93
|
+
return "added" if baseline_payload.nil?
|
|
95
94
|
return "removed" if current_payload.nil?
|
|
96
95
|
|
|
97
96
|
"changed"
|
|
98
97
|
end
|
|
99
98
|
|
|
100
99
|
def pct_for(fields, payload)
|
|
101
|
-
return 0.0 unless payload.
|
|
102
|
-
|
|
103
|
-
payload[fields[:percent]].to_f
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def emit_text(stdout, rows, color)
|
|
107
|
-
return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?
|
|
100
|
+
return 0.0 unless payload.instance_of?(Hash) && payload[fields.fetch(:total)].to_i.positive?
|
|
108
101
|
|
|
109
|
-
|
|
102
|
+
payload[fields.fetch(:percent)].to_f
|
|
110
103
|
end
|
|
111
104
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def delta_parts(row, color)
|
|
119
|
-
[
|
|
120
|
-
format_delta(row[:line_delta], "lines", color),
|
|
121
|
-
(format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
|
|
122
|
-
(format_delta(row[:method_delta], "methods", color) if row[:method_delta].abs > EPSILON)
|
|
123
|
-
].compact
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# A removed file's deltas are all -baseline%, but deleting a
|
|
127
|
-
# covered file (dead code cleanup) is not a coverage regression,
|
|
128
|
-
# so removed rows never trip the --fail-on-drop gate. Drops are
|
|
129
|
-
# gated on EPSILON like row inclusion and display: a row listed
|
|
130
|
-
# for a gain in one criterion must not fail the run over float
|
|
131
|
-
# noise in another, invisible in the printed output.
|
|
105
|
+
# A removed file's deltas are all -baseline%, but deleting a covered file is
|
|
106
|
+
# not a coverage regression, so removed rows never trip the gate. Drops are
|
|
107
|
+
# gated on EPSILON like row inclusion and display, so a row listed for a gain
|
|
108
|
+
# in one criterion cannot fail the run over float noise in another.
|
|
132
109
|
def coverage_drop?(rows)
|
|
133
|
-
rows.reject { |row| row
|
|
134
|
-
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Deltas are sign-based, not threshold-based: a +5% bump is good
|
|
138
|
-
# (green) and a -5% drop is bad (red), regardless of where the
|
|
139
|
-
# absolute coverage level lands.
|
|
140
|
-
def format_delta(delta, label, color)
|
|
141
|
-
sign = delta.positive? ? "+" : ""
|
|
142
|
-
text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
|
|
143
|
-
SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
|
|
110
|
+
rows.reject { |row| row.fetch(:status).eql?("removed") }
|
|
111
|
+
.any? { |row| row.fetch_values(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
|
|
144
112
|
end
|
|
145
113
|
end
|
|
146
114
|
end
|
|
@@ -4,14 +4,13 @@ require "pathname"
|
|
|
4
4
|
|
|
5
5
|
module SimpleCov
|
|
6
6
|
module CLI
|
|
7
|
-
# Loads a project's `.simplecov` config file purely to read
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# every read-only subcommand pay for actually starting Coverage.
|
|
7
|
+
# Loads a project's `.simplecov` config file purely to read configuration
|
|
8
|
+
# from it, with `SimpleCov.start` and the at_exit hook installer neutered so
|
|
9
|
+
# the load doesn't trigger coverage tracking. Used by the CLI to default
|
|
10
|
+
# paths to whatever the project's dotfile declares, without making every
|
|
11
|
+
# read-only subcommand pay for actually starting Coverage.
|
|
13
12
|
module Dotfile
|
|
14
|
-
|
|
13
|
+
extend self
|
|
15
14
|
|
|
16
15
|
def coverage_dir
|
|
17
16
|
dotfile = find
|
|
@@ -19,37 +18,73 @@ module SimpleCov
|
|
|
19
18
|
|
|
20
19
|
with_simplecov_loaded { read_from(dotfile) }
|
|
21
20
|
rescue ScriptError, StandardError => e
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
# rest (EACCES, errors raised by the dotfile's own code, etc.).
|
|
27
|
-
warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
|
|
21
|
+
# ScriptError covers the SyntaxError a malformed dotfile raises from
|
|
22
|
+
# `load` (it is not a StandardError) along with LoadError; StandardError
|
|
23
|
+
# covers the rest.
|
|
24
|
+
warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e}"
|
|
28
25
|
"coverage"
|
|
29
|
-
# simplecov:enable
|
|
30
26
|
end
|
|
31
27
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
# Read from `.simplecov` the way `coverage_dir` is, so a project that moved
|
|
29
|
+
# its baseline doesn't need `--baseline` on every ratchet.
|
|
30
|
+
def baseline_file
|
|
31
|
+
dotfile = find
|
|
32
|
+
return Baseline::DEFAULT_FILENAME unless dotfile
|
|
33
|
+
|
|
34
|
+
with_simplecov_loaded { read_baseline_file_from(dotfile) }
|
|
35
|
+
rescue ScriptError, StandardError => e
|
|
36
|
+
warn "simplecov: failed to read baseline_file from #{dotfile}: #{e.class}: #{e}"
|
|
37
|
+
Baseline::DEFAULT_FILENAME
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Nil when there is no dotfile, the dotfile names no store, or it cannot be
|
|
41
|
+
# read: unlike the other reads there is no sensible fallback path, and the
|
|
42
|
+
# command's own missing-store error is the right answer.
|
|
43
|
+
def production_coverage
|
|
44
|
+
dotfile = find
|
|
45
|
+
return nil unless dotfile
|
|
46
|
+
|
|
47
|
+
with_simplecov_loaded { read_production_coverage_from(dotfile) }
|
|
48
|
+
rescue ScriptError, StandardError => e
|
|
49
|
+
# `warn` answers nil, which is this reader's whole fallback.
|
|
50
|
+
warn "simplecov: failed to read production_coverage from #{dotfile}: #{e.class}: #{e}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read_production_coverage_from(dotfile)
|
|
54
|
+
snapshot = SimpleCov.instance_variable_get(:@production_coverage) #: String?
|
|
55
|
+
load_with_start_neutered(dotfile)
|
|
56
|
+
SimpleCov.production_coverage
|
|
57
|
+
ensure
|
|
58
|
+
# @type var snapshot: String?
|
|
59
|
+
SimpleCov.instance_variable_set(:@production_coverage, snapshot)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_baseline_file_from(dotfile)
|
|
63
|
+
snapshot = SimpleCov.instance_variable_get(:@baseline_file) #: String?
|
|
64
|
+
load_with_start_neutered(dotfile)
|
|
65
|
+
SimpleCov.baseline_file
|
|
66
|
+
ensure
|
|
67
|
+
# @type var snapshot: String?
|
|
68
|
+
SimpleCov.instance_variable_set(:@baseline_file, snapshot)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Snapshot and restore `SimpleCov.coverage_dir` so we don't quietly clobber
|
|
72
|
+
# it in a host process that's already configured. The snapshot is
|
|
73
|
+
# intentionally narrow: a dotfile can still mutate other configuration
|
|
74
|
+
# via `SimpleCov.configure` or `SimpleCov.start { ... }` blocks.
|
|
42
75
|
def read_from(dotfile)
|
|
43
76
|
snapshot = SimpleCov.instance_variable_get(:@coverage_dir) #: String?
|
|
44
77
|
load_with_start_neutered(dotfile)
|
|
45
78
|
SimpleCov.coverage_dir
|
|
46
79
|
ensure
|
|
47
|
-
#
|
|
48
|
-
#
|
|
80
|
+
# Restored even when the load raises, so a bad dotfile doesn't leave a
|
|
81
|
+
# host process's configured dir clobbered.
|
|
49
82
|
# @type var snapshot: String?
|
|
50
83
|
SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
|
|
51
84
|
end
|
|
52
85
|
|
|
86
|
+
# The nearest `.simplecov` at or above the working directory, or nil, which
|
|
87
|
+
# is what the walk itself answers once it breaks at the filesystem root.
|
|
53
88
|
def find
|
|
54
89
|
dir = Pathname.new(Dir.pwd)
|
|
55
90
|
loop do
|
|
@@ -59,38 +94,48 @@ module SimpleCov
|
|
|
59
94
|
|
|
60
95
|
dir = dir.parent
|
|
61
96
|
end
|
|
62
|
-
nil
|
|
63
97
|
end
|
|
64
98
|
|
|
65
99
|
def with_simplecov_loaded
|
|
66
100
|
previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil) #: String?
|
|
67
|
-
previous_cli
|
|
101
|
+
previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
|
|
68
102
|
ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
|
|
69
103
|
# SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
|
|
70
|
-
# CLI-only behavior
|
|
71
|
-
#
|
|
104
|
+
# CLI-only behavior: simplecov itself sets `coverage_dir` to the dogfood
|
|
105
|
+
# path here but skips that for descendants.
|
|
72
106
|
ENV["SIMPLECOV_CLI"] = "1"
|
|
73
|
-
|
|
107
|
+
load_simplecov
|
|
74
108
|
yield
|
|
75
109
|
ensure
|
|
76
110
|
# @type var previous_no_defaults: String?
|
|
77
111
|
# @type var previous_cli: String?
|
|
78
112
|
ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
|
|
79
|
-
ENV["SIMPLECOV_CLI"]
|
|
113
|
+
ENV["SIMPLECOV_CLI"] = previous_cli
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The deferred require exists for a standalone CLI process that has not
|
|
117
|
+
# loaded simplecov yet. It must happen inside the env guard above, so a
|
|
118
|
+
# first load here skips the default profile chain.
|
|
119
|
+
def load_simplecov
|
|
120
|
+
require "simplecov"
|
|
80
121
|
end
|
|
81
122
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
123
|
+
# A constant rather than a block written at the call site, where an empty
|
|
124
|
+
# block and one answering nil are the same thing to every caller and so
|
|
125
|
+
# to every test.
|
|
126
|
+
NEUTERED = proc {}
|
|
127
|
+
|
|
128
|
+
# Load `path` with `SimpleCov.start` and the at_exit installer turned into
|
|
129
|
+
# no-ops, so a project whose dotfile calls `SimpleCov.start` doesn't
|
|
130
|
+
# trigger Coverage just because we asked for `coverage_dir`. Config
|
|
131
|
+
# inside any `SimpleCov.start { ... }` block still runs.
|
|
87
132
|
def load_with_start_neutered(path)
|
|
88
133
|
klass = SimpleCov.singleton_class
|
|
89
134
|
names = %i[start_tracking install_at_exit_hook]
|
|
90
135
|
stash = names.to_h { |name| [name, klass.instance_method(name)] } #: Hash[Symbol, UnboundMethod]
|
|
91
136
|
# define_method over an existing method emits a "method redefined"
|
|
92
137
|
# warning under $VERBOSE; the override and restore are intentional.
|
|
93
|
-
silence_verbose { names.each { |name| klass.define_method(name
|
|
138
|
+
silence_verbose { names.each { |name| klass.define_method(name, &(_ = NEUTERED)) } }
|
|
94
139
|
load path
|
|
95
140
|
ensure
|
|
96
141
|
# @type var stash: Hash[Symbol, UnboundMethod]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
# The git plumbing the change-aware subcommands share: safe capture, the
|
|
8
|
+
# repository toplevel, ref hygiene, and the default base ref.
|
|
9
|
+
module Git
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
# [stdout, first stderr line, success]. A spawn failure reads as an
|
|
13
|
+
# unsuccessful run whose stdout is nil, so callers can tell "git ran and
|
|
14
|
+
# refused" from "git never ran".
|
|
15
|
+
def capture(*argv)
|
|
16
|
+
stdout, stderr, status = Open3.capture3("git", *argv)
|
|
17
|
+
[stdout, stderr.lines.first.to_s.strip, status.success?]
|
|
18
|
+
rescue => e
|
|
19
|
+
[nil, e.message, false]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def toplevel
|
|
23
|
+
stdout, _detail, success = capture("rev-parse", "--show-toplevel")
|
|
24
|
+
(_ = stdout).chomp if success
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A git ref can never begin with "-", so one that does would be read by git
|
|
28
|
+
# as an option instead of a revision.
|
|
29
|
+
def option_like_ref?(ref)
|
|
30
|
+
ref.start_with?("-")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The branch origin's HEAD points at, so master and trunk repositories work
|
|
34
|
+
# bare, falling back to main where no origin HEAD is recorded.
|
|
35
|
+
def default_base
|
|
36
|
+
stdout, _detail, success = capture("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
|
|
37
|
+
success ? (_ = stdout).chomp.delete_prefix("origin/") : "main"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module History
|
|
6
|
+
# Renders the trend: Unicode sparklines scaled to each series' own range,
|
|
7
|
+
# with the numbers beside them carrying the absolute scale.
|
|
8
|
+
module Output
|
|
9
|
+
BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
|
|
10
|
+
|
|
11
|
+
CRITERIA_ORDER = %w[line branch method].freeze
|
|
12
|
+
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
def emit(stdout, opts, entries, color:)
|
|
16
|
+
return emit_json(stdout, opts, entries) if opts.fetch(:json)
|
|
17
|
+
return stdout.puts("simplecov history: no recorded runs in #{opts.fetch(:input)}") if entries.empty?
|
|
18
|
+
|
|
19
|
+
if opts.fetch(:file)
|
|
20
|
+
emit_file(stdout, opts, entries, color)
|
|
21
|
+
else
|
|
22
|
+
emit_totals(stdout, opts, entries, color)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def emit_totals(stdout, opts, entries, color)
|
|
27
|
+
stdout.puts("Coverage history: #{opts.fetch(:input)} (#{pluralize(entries.length, "run")})")
|
|
28
|
+
stdout.puts
|
|
29
|
+
emit_criteria_views(stdout, entries, color, "totals")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def emit_file(stdout, opts, entries, color)
|
|
33
|
+
file = opts.fetch(:file)
|
|
34
|
+
stdout.puts("Coverage history for #{file} (#{pluralize(entries.length, "run")})")
|
|
35
|
+
stdout.puts
|
|
36
|
+
emit_criteria_views(stdout, entries, color, "files", file)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def emit_criteria_views(stdout, entries, color, *path)
|
|
40
|
+
criteria = measured_criteria(entries, path)
|
|
41
|
+
emit_sparklines(stdout, entries, criteria, color, path)
|
|
42
|
+
stdout.puts
|
|
43
|
+
emit_rows(stdout, entries) { |entry| percents_cell(entry, criteria, path) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def emit_sparklines(stdout, entries, criteria, color, path)
|
|
47
|
+
width = criteria.map(&:length).max
|
|
48
|
+
criteria.each do |criterion|
|
|
49
|
+
series = entries.map { |entry| numeric(entry.dig(*path, criterion)) }
|
|
50
|
+
stdout.puts(" #{criterion.ljust(width)} #{sparkline(series)} #{trend(series, color)}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def emit_rows(stdout, entries)
|
|
55
|
+
width = entries.map { |entry| branch_of(entry).length }.max
|
|
56
|
+
entries.each do |entry|
|
|
57
|
+
commit = (entry["commit"] || "-")[0, 7].ljust(7)
|
|
58
|
+
stdout.puts(" #{entry["created_at"]} #{branch_of(entry).ljust(width)} #{commit} #{yield(entry)}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def branch_of(entry)
|
|
63
|
+
entry["branch"] || "-"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def percents_cell(entry, criteria, path)
|
|
67
|
+
cell = criteria.filter_map do |criterion|
|
|
68
|
+
value = numeric(entry.dig(*path, criterion))
|
|
69
|
+
"#{criterion} #{value}%" if value
|
|
70
|
+
end.join(" ")
|
|
71
|
+
cell.empty? ? "-" : cell
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Min-max scaled block characters, one per run, a space where the series has
|
|
75
|
+
# no value for that run. A flat series renders at mid height: direction of
|
|
76
|
+
# travel is the message, and a flat line says it plainly. A series with no
|
|
77
|
+
# numbers at all is all gaps, and answering that outright keeps the scale off
|
|
78
|
+
# a range it doesn't have.
|
|
79
|
+
def sparkline(series)
|
|
80
|
+
numeric = series.compact
|
|
81
|
+
return " " * series.length if numeric.empty?
|
|
82
|
+
|
|
83
|
+
min = numeric.min
|
|
84
|
+
span = numeric.max - min.to_f
|
|
85
|
+
series.map { |value| value.nil? ? " " : bar(value, min, span) }.join
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def bar(value, min, span)
|
|
89
|
+
span.zero? ? BARS.fetch(3) : BARS.fetch(((value - min) / span * (BARS.length - 1)).round)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def trend(series, color)
|
|
93
|
+
numeric = series.compact
|
|
94
|
+
first = numeric.first
|
|
95
|
+
last = numeric.last
|
|
96
|
+
delta = (last - first).round(2)
|
|
97
|
+
sign = "+" unless delta.negative?
|
|
98
|
+
delta_text = Color.colorize("(#{sign}#{delta})", delta.negative? ? :red : :green, enabled: color)
|
|
99
|
+
"#{first}% → #{last}% #{delta_text}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def emit_json(stdout, opts, entries)
|
|
103
|
+
return stdout.puts(JSON.generate(entries)) unless opts.fetch(:file)
|
|
104
|
+
|
|
105
|
+
rows = entries.map do |entry|
|
|
106
|
+
percents = entry.dig("files", opts.fetch(:file))
|
|
107
|
+
{created_at: entry["created_at"], branch: entry["branch"], commit: entry["commit"],
|
|
108
|
+
percents: (percents if percents.instance_of?(Hash))}
|
|
109
|
+
end
|
|
110
|
+
stdout.puts(JSON.generate(rows))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Criteria some run recorded a number for, in canonical order, so a criterion
|
|
114
|
+
# enabled midway still gets its sparkline. One no run recorded is left out:
|
|
115
|
+
# an all-gaps sparkline has no trend to read off it.
|
|
116
|
+
def measured_criteria(entries, path)
|
|
117
|
+
CRITERIA_ORDER.select do |criterion|
|
|
118
|
+
entries.any? do |entry|
|
|
119
|
+
percents = entry.dig(*path)
|
|
120
|
+
percents.instance_of?(Hash) && numeric(percents[criterion])
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def numeric(value)
|
|
126
|
+
value if value.is_a?(Numeric)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def pluralize(number, noun)
|
|
130
|
+
"#{number} #{noun}#{"s" unless number.eql?(1)}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_helpers"
|
|
4
|
+
require_relative "history/output"
|
|
5
|
+
require_relative "../history"
|
|
6
|
+
|
|
7
|
+
module SimpleCov
|
|
8
|
+
module CLI
|
|
9
|
+
# `simplecov history`: the recorded coverage trend in the terminal, a
|
|
10
|
+
# sparkline per measured criterion with the run rows beneath, and
|
|
11
|
+
# `--file PATH` for one file's trajectory instead of the totals.
|
|
12
|
+
module History
|
|
13
|
+
extend CommandHelpers
|
|
14
|
+
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
def run(args, stdout:, stderr:, **)
|
|
18
|
+
opts = parse(args, stderr) or return 1
|
|
19
|
+
entries = load_entries(opts.fetch(:input), stderr) or return 1
|
|
20
|
+
return 1 unless file_recorded?(entries, opts, stderr)
|
|
21
|
+
|
|
22
|
+
Output.emit(stdout, opts, entries, color: CLI.color_enabled?(opts, stdout))
|
|
23
|
+
0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(args, stderr)
|
|
27
|
+
opts, rest = parse_common(args, input: default_input, file: nil) do |parser, options|
|
|
28
|
+
parser.on("--file PATH") { |v| options[:file] = v }
|
|
29
|
+
end
|
|
30
|
+
return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
|
|
31
|
+
|
|
32
|
+
opts
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The history file, not coverage.json: this command reads the sibling
|
|
36
|
+
# artifact the exit tasks append to.
|
|
37
|
+
def default_input
|
|
38
|
+
File.join(CLI.coverage_dir, ".history.json")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load_entries(path, stderr)
|
|
42
|
+
document = JSON.parse(File.read(path))
|
|
43
|
+
entries = document.dig(SimpleCov::History::ENVELOPE, "entries") if document.instance_of?(Hash)
|
|
44
|
+
return entries if entries.instance_of?(Array)
|
|
45
|
+
|
|
46
|
+
error_nil(stderr, "#{path} is not a SimpleCov history file")
|
|
47
|
+
rescue Errno::ENOENT
|
|
48
|
+
error_nil(stderr, "#{path} not found (the history is recorded automatically each time a suite reports)")
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
error_nil(stderr, "#{path} is not valid JSON (#{e.message.lines.first.to_s.strip})")
|
|
51
|
+
rescue SystemCallError => e
|
|
52
|
+
error_nil(stderr, "#{path} could not be read (#{e})")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A `--file` for a path no entry recorded deserves a loud answer, not an
|
|
56
|
+
# empty sparkline that reads as "0% forever".
|
|
57
|
+
def file_recorded?(entries, opts, stderr)
|
|
58
|
+
file = opts.fetch(:file)
|
|
59
|
+
return true unless file
|
|
60
|
+
return true if entries.any? { |entry| entry.instance_of?(Hash) && entry.dig("files", file).instance_of?(Hash) }
|
|
61
|
+
|
|
62
|
+
error(stderr, "no recorded coverage for #{file} in #{opts.fetch(:input)}")
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|