simplecov 1.1.1 → 1.3.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/annotations.rb +166 -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 +151 -0
- data/lib/simplecov/cli/patch.rb +175 -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 +33 -0
- data/lib/simplecov/cli/uncovered.rb +58 -34
- data/lib/simplecov/cli/usage.rb +153 -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 +7 -11
- 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 +37 -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 +160 -65
- 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/erb.rb +44 -0
- data/lib/simplecov/directive/haml.rb +33 -0
- data/lib/simplecov/directive/indented_template.rb +37 -0
- data/lib/simplecov/directive/slim.rb +35 -0
- data/lib/simplecov/directive/template.rb +29 -0
- data/lib/simplecov/directive.rb +47 -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 +4 -15
- data/lib/simplecov/profiles/root_filter.rb +0 -4
- data/lib/simplecov/profiles/strict.rb +11 -21
- 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 +74 -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 +92 -122
- 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 +13 -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 +29 -29
- data/lib/simplecov/source_file/source_loader.rb +45 -35
- data/lib/simplecov/source_file/statistics.rb +20 -9
- data/lib/simplecov/source_file.rb +21 -51
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +55 -139
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +9 -26
- 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 +45 -65
- data/lib/simplecov/static_coverage_extractor.rb +43 -75
- 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 +354 -0
- data/sig/simplecov.rbs +528 -595
- metadata +79 -6
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../annotations"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
module CLI
|
|
8
|
+
module Patch
|
|
9
|
+
module Output
|
|
10
|
+
OPTIONAL_CRITERIA = %i[branch method].freeze
|
|
11
|
+
|
|
12
|
+
CRITERIA = %i[line branch method].freeze
|
|
13
|
+
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
# One diagnostic per contiguous missed range per measured criterion, in the
|
|
17
|
+
# CI host's own annotation form. Totals and the empty-change notice stay
|
|
18
|
+
# off stdout: the exit status under --minimum is the verdict.
|
|
19
|
+
def annotate(stdout, rows, kind)
|
|
20
|
+
diagnostics = rows.flat_map do |row|
|
|
21
|
+
CRITERIA.flat_map do |criterion|
|
|
22
|
+
stats = row.fetch(criterion)
|
|
23
|
+
stats ? Annotations.diagnostics(row.fetch(:file), stats.fetch(:missing), criterion) : []
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
Annotations.emit(stdout, kind, diagnostics)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def emit(stdout, rows, opts)
|
|
30
|
+
if opts.fetch(:json)
|
|
31
|
+
stdout.puts(JSON.pretty_generate(json_rows(rows)))
|
|
32
|
+
else
|
|
33
|
+
emit_text(stdout, rows, CLI.color_enabled?(opts, stdout))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def emit_text(stdout, rows, color)
|
|
38
|
+
return stdout.puts("simplecov patch: no coverable lines changed") if rows.empty?
|
|
39
|
+
|
|
40
|
+
rows.sort_by! { |row| [pct(row.fetch(:line)), row.fetch(:file)] }
|
|
41
|
+
rows.each { |row| stdout.puts(format_row(row, color)) }
|
|
42
|
+
stdout.puts(format_total(rows, color))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_row(row, color)
|
|
46
|
+
line = " #{criterion_cells(row, color).join(" ")} #{row.fetch(:file)}"
|
|
47
|
+
note = missing_note(row)
|
|
48
|
+
note.empty? ? line : "#{line} #{note}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def criterion_cells(row, color)
|
|
52
|
+
cells = [criterion_cell("lines", row.fetch(:line), color)]
|
|
53
|
+
cells << criterion_cell("branches", row.fetch(:branch), color) if measured?(row.fetch(:branch))
|
|
54
|
+
cells << criterion_cell("methods", row.fetch(:method), color) if measured?(row.fetch(:method))
|
|
55
|
+
cells
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A "lines" cell always, "branches" and "methods" cells only when the
|
|
59
|
+
# touched lines actually carried one: a hollow 0/0 cell is noise.
|
|
60
|
+
def criterion_cell(label, stats, color)
|
|
61
|
+
percent = pct(stats)
|
|
62
|
+
cell = Color.colorize(format("%6.2f%%", percent), (percent >= 100) ? :green : :red, enabled: color)
|
|
63
|
+
"#{cell} (#{stats.fetch(:covered)}/#{stats.fetch(:relevant)}) #{label}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def missing_note(row)
|
|
67
|
+
parts = [] #: Array[String]
|
|
68
|
+
parts << "missing #{ranges(row.fetch(:line).fetch(:missing))}" if row.fetch(:line).fetch(:missing).any?
|
|
69
|
+
OPTIONAL_CRITERIA.each do |criterion|
|
|
70
|
+
stats = row.fetch(criterion)
|
|
71
|
+
parts << "#{criterion} #{ranges(stats.fetch(:missing))}" if measured?(stats) && stats.fetch(:missing).any?
|
|
72
|
+
end
|
|
73
|
+
parts.join(" ")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def format_total(rows, color)
|
|
77
|
+
totals = {line: sum_stats(rows, :line), branch: sum_stats(rows, :branch), method: sum_stats(rows, :method)}
|
|
78
|
+
" Patch coverage: #{criterion_cells(totals, color).join(", ")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def json_rows(rows)
|
|
82
|
+
rows.map do |row|
|
|
83
|
+
data = {file: row.fetch(:file), line: row.fetch(:line).merge(percent: pct(row.fetch(:line)))}
|
|
84
|
+
OPTIONAL_CRITERIA.each do |criterion|
|
|
85
|
+
stats = row.fetch(criterion)
|
|
86
|
+
data[criterion] = stats.merge(percent: pct(stats)) if measured?(stats)
|
|
87
|
+
end
|
|
88
|
+
data
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Branch and method stats are nil when the report never measured them, and
|
|
93
|
+
# 0/0 when the change touched none.
|
|
94
|
+
def measured?(stats)
|
|
95
|
+
stats.is_a?(Hash) && stats.fetch(:relevant).positive?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Collapses a sorted line list into ranges: [41, 42, 43, 47] -> "41-43, 47".
|
|
99
|
+
# The show subcommand borrows this with a bare-comma separator for its
|
|
100
|
+
# greppable `path:40,52-58,71` form.
|
|
101
|
+
def ranges(numbers, separator = ", ")
|
|
102
|
+
numbers.slice_when { |prev, curr| curr > prev + 1 }.each_with_object(+"") do |run, out|
|
|
103
|
+
out << separator unless out.empty?
|
|
104
|
+
out << (run.one? ? only(run) : span(run))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# mutant:disable
|
|
109
|
+
def only(run)
|
|
110
|
+
format("%<only>d", only: run.first)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def span(run)
|
|
114
|
+
format("%<from>d-%<to>d", from: run.first, to: run.last)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def sum_stats(rows, criterion)
|
|
118
|
+
stats = rows.filter_map { |row| row.fetch(criterion) }
|
|
119
|
+
{covered: stats.sum { |stat| stat.fetch(:covered) }, relevant: stats.sum { |stat| stat.fetch(:relevant) }}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def pct(stats)
|
|
123
|
+
relevant = stats.fetch(:relevant)
|
|
124
|
+
return 100.0 if relevant.zero?
|
|
125
|
+
|
|
126
|
+
(stats.fetch(:covered).to_f / relevant * 100).round(2)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# No --minimum is report-only. With one, every measured criterion must clear
|
|
130
|
+
# the floor, and `short?` never fails an unmeasured one.
|
|
131
|
+
def gate(rows, minimum)
|
|
132
|
+
return 0 unless minimum
|
|
133
|
+
|
|
134
|
+
below = %i[line branch method].any? { |criterion| short?(sum_stats(rows, criterion), minimum) }
|
|
135
|
+
below ? 1 : 0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Cross-multiplied rather than compared as percentages so neither rounding
|
|
139
|
+
# nor float division can shift the verdict at the boundary: reading the
|
|
140
|
+
# displayed `pct` would round a 19_999/20_000 patch up to 100 and pass it
|
|
141
|
+
# against `--minimum 100`, while dividing makes 23/40 compute as 57.4999...
|
|
142
|
+
# and fail an exactly-57.5% patch. The minimum becomes an exact Rational
|
|
143
|
+
# from its own text, and `covered * 100` is an integer, so the comparison is
|
|
144
|
+
# exact. A criterion with nothing relevant never falls short.
|
|
145
|
+
def short?(stats, minimum)
|
|
146
|
+
stats.fetch(:covered) * 100 < Rational(minimum.to_s) * stats.fetch(:relevant)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "annotations"
|
|
4
|
+
require_relative "command_helpers"
|
|
5
|
+
require_relative "patch/changed_lines"
|
|
6
|
+
require_relative "patch/output"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov patch [--base REF]`: line coverage over only the lines a
|
|
11
|
+
# change touched. Where `diff` asks "did the number move" and needs a
|
|
12
|
+
# baseline artifact, `patch` asks the question a reviewer actually asks: is
|
|
13
|
+
# the code in this change tested? A project sitting at 40% cannot move its
|
|
14
|
+
# global number in one pull request, but it can insist that every line it
|
|
15
|
+
# adds is covered.
|
|
16
|
+
#
|
|
17
|
+
# Only files the report already carries are scored: a changed file
|
|
18
|
+
# SimpleCov never tracked is out of scope, and a line `LinesClassifier`
|
|
19
|
+
# considers never relevant stays out of the denominator the same way it
|
|
20
|
+
# stays out of the file total.
|
|
21
|
+
#
|
|
22
|
+
module Patch
|
|
23
|
+
extend CommandHelpers
|
|
24
|
+
|
|
25
|
+
extend self
|
|
26
|
+
|
|
27
|
+
def run(args, stdout:, stderr:, **)
|
|
28
|
+
opts = parse(args, stderr)
|
|
29
|
+
return 1 unless opts
|
|
30
|
+
|
|
31
|
+
# An omitted --base resolves through origin's HEAD, so master and trunk
|
|
32
|
+
# repositories work bare; in CI, pass the pull request's target branch
|
|
33
|
+
# explicitly.
|
|
34
|
+
opts[:base] ||= Git.default_base
|
|
35
|
+
diffed = ChangedLines.call(opts.fetch(:base), find_renames: opts.fetch(:find_renames), stderr: stderr)
|
|
36
|
+
return 1 unless diffed
|
|
37
|
+
|
|
38
|
+
rows = compute_rows(opts.fetch(:coverage), diffed, stderr)
|
|
39
|
+
kind = opts.fetch(:annotate)
|
|
40
|
+
kind ? Output.annotate(stdout, rows, kind) : Output.emit(stdout, rows, opts)
|
|
41
|
+
Output.gate(rows, opts.fetch(:minimum))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(args, stderr)
|
|
45
|
+
# No `base:` default: the run fills it in from the repository when the
|
|
46
|
+
# option is left out.
|
|
47
|
+
opts, rest = parse_common(args, find_renames: false, minimum: nil, annotate: nil) do |parser, options|
|
|
48
|
+
parser.on("--base REF") { |v| options[:base] = v }
|
|
49
|
+
parser.on("--minimum N", Float) { |v| options[:minimum] = v }
|
|
50
|
+
parser.on("--find-renames") { options[:find_renames] = true }
|
|
51
|
+
parser.on("--annotate KIND") { |v| options[:annotate] = v }
|
|
52
|
+
end
|
|
53
|
+
return unless positional_ok?(rest, stderr)
|
|
54
|
+
|
|
55
|
+
issue = Annotations.issue(opts)
|
|
56
|
+
return error_nil(stderr, issue) if issue
|
|
57
|
+
|
|
58
|
+
opts[:coverage] = CoverageFile.load_coverage(opts.fetch(:input), command: "patch", stderr: stderr) or return nil
|
|
59
|
+
opts
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A stray positional looks exactly like a ref, so a forgotten `--base`
|
|
63
|
+
# (`simplecov patch feature-x`) would otherwise diff against the default
|
|
64
|
+
# and gate the wrong change in silence.
|
|
65
|
+
def positional_ok?(rest, stderr)
|
|
66
|
+
return true if rest.empty?
|
|
67
|
+
|
|
68
|
+
error(stderr, "unexpected argument #{rest.first.inspect} (did you mean `--base #{rest.first}`?)")
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Everything below reads values that came out of a coverage.json, which
|
|
73
|
+
# carries plain objects, arrays and integers and never a subclass of one,
|
|
74
|
+
# so the shape checks ask about the class itself.
|
|
75
|
+
|
|
76
|
+
# Diff paths are exact root-relative names, so they resolve exactly against
|
|
77
|
+
# the report. The suffix fallback `CoverageFile.lookup` offers interactive
|
|
78
|
+
# commands could only ever bind a changed file the report doesn't carry to
|
|
79
|
+
# some other file's hits and score the wrong entry.
|
|
80
|
+
def compute_rows(coverage, diffed, stderr)
|
|
81
|
+
index = CoverageFile.exact_index(coverage)
|
|
82
|
+
diffed.fetch(:changes).filter_map do |path, lines|
|
|
83
|
+
payload = index[File.expand_path(path, diffed.fetch(:root))] || index[path]
|
|
84
|
+
next unless payload.instance_of?(Hash) # file the report doesn't carry -> out of scope
|
|
85
|
+
|
|
86
|
+
changed = changed_for(lines, payload)
|
|
87
|
+
warn_stale(path, payload, changed, stderr)
|
|
88
|
+
row = build_row(path, payload, changed)
|
|
89
|
+
row if scored?(row) # nothing coverable changed in this file
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# An untracked file appears in no diff, so `:all` stands in for its line
|
|
94
|
+
# numbers: every line the report knows is this change's work.
|
|
95
|
+
def changed_for(lines, payload)
|
|
96
|
+
return lines.uniq unless lines.equal?(:all)
|
|
97
|
+
|
|
98
|
+
hits = payload["lines"]
|
|
99
|
+
hits.instance_of?(Array) ? (1..hits.length).to_a : []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A changed line past the end of the report's lines array reads as
|
|
103
|
+
# never-relevant and silently drops out of the denominator, which is right
|
|
104
|
+
# for a fresh report and wrong for a stale one, so say which is likelier
|
|
105
|
+
# out loud instead of letting a `--minimum` gate pass vacuously.
|
|
106
|
+
def warn_stale(path, payload, changed, stderr)
|
|
107
|
+
hits = payload["lines"]
|
|
108
|
+
return unless hits.instance_of?(Array)
|
|
109
|
+
return if changed.empty? || changed.max <= hits.length
|
|
110
|
+
|
|
111
|
+
stderr.puts("simplecov patch: #{path} changed beyond the #{hits.length}-line entry in the " \
|
|
112
|
+
"report (is the report stale? regenerate it and rerun)")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_row(path, payload, changed)
|
|
116
|
+
{
|
|
117
|
+
file: path,
|
|
118
|
+
line: line_stats(payload["lines"], changed),
|
|
119
|
+
branch: entry_stats(payload["branches"], changed),
|
|
120
|
+
method: entry_stats(payload["methods"], changed)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A line counts only where the report gives it an Integer hit count; a
|
|
125
|
+
# never-relevant line (nil) or a `:nocov:` line ("ignored") stays out of
|
|
126
|
+
# the denominator, the same rule the file total uses.
|
|
127
|
+
def line_stats(hits, changed)
|
|
128
|
+
covered = 0
|
|
129
|
+
missing = [] #: Array[Integer]
|
|
130
|
+
return {covered: covered, relevant: 0, missing: missing} unless hits.instance_of?(Array)
|
|
131
|
+
|
|
132
|
+
changed.each do |number|
|
|
133
|
+
hit = hits.at(number - 1)
|
|
134
|
+
# nil / "ignored" (nocov) / any non-Integer -> never relevant, skipped.
|
|
135
|
+
next unless hit.instance_of?(Integer)
|
|
136
|
+
|
|
137
|
+
hit.positive? ? (covered += 1) : (missing << number)
|
|
138
|
+
end
|
|
139
|
+
{covered: covered, relevant: covered + missing.size, missing: missing.sort}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Branches and methods share the report shape (a reported line and an
|
|
143
|
+
# integer hit count), so one scorer serves both. nil when the report
|
|
144
|
+
# carries no data for the criterion, so the output and gate skip it
|
|
145
|
+
# rather than reporting a hollow 0/0. A miss is recorded at the reported
|
|
146
|
+
# line so the note points where the source is.
|
|
147
|
+
def entry_stats(entries, changed)
|
|
148
|
+
return nil unless entries.instance_of?(Array)
|
|
149
|
+
|
|
150
|
+
covered = 0
|
|
151
|
+
missing = [] #: Array[Integer]
|
|
152
|
+
each_touched(entries, changed) do |line, hits|
|
|
153
|
+
hits.positive? ? (covered += 1) : (missing << line)
|
|
154
|
+
end
|
|
155
|
+
{covered: covered, relevant: covered + missing.size, missing: missing.uniq.sort}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# "ignored" (nocov) entries and entries off the change are skipped.
|
|
159
|
+
def each_touched(entries, changed)
|
|
160
|
+
entries.each do |entry|
|
|
161
|
+
next unless entry.instance_of?(Hash)
|
|
162
|
+
|
|
163
|
+
line = entry["report_line"] || entry["start_line"]
|
|
164
|
+
hits = entry["coverage"]
|
|
165
|
+
yield line, hits if changed.include?(line) && hits.instance_of?(Integer)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def scored?(row)
|
|
170
|
+
row.fetch(:line).fetch(:relevant).positive? ||
|
|
171
|
+
Output.measured?(row.fetch(:branch)) || Output.measured?(row.fetch(:method))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Ratchet
|
|
6
|
+
module Output
|
|
7
|
+
extend CommandHelpers
|
|
8
|
+
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
def emit(stdout, opts, outcome, generated)
|
|
12
|
+
return stdout.puts(JSON.generate(json_summary(opts, outcome, generated))) if opts.fetch(:json)
|
|
13
|
+
|
|
14
|
+
verb = opts.fetch(:dry_run) ? "would write" : "wrote"
|
|
15
|
+
stdout.puts("simplecov ratchet: #{verb} #{opts.fetch(:baseline)} (#{change_summary(outcome, generated)})")
|
|
16
|
+
report_regressed(stdout, outcome.regressed)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def change_summary(outcome, generated)
|
|
20
|
+
files = outcome.baseline.entries.size
|
|
21
|
+
return "#{files} #{one?(files) ? "file" : "files"}" if generated
|
|
22
|
+
|
|
23
|
+
"#{outcome.tightened.size} tightened, #{outcome.pruned.size} pruned, #{outcome.unchanged.size} unchanged"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def report_regressed(stdout, regressed)
|
|
27
|
+
return if regressed.empty?
|
|
28
|
+
|
|
29
|
+
noun = one?(regressed.size) ? "1 file below its floor" : "#{regressed.size} files below their floors"
|
|
30
|
+
stdout.puts("simplecov ratchet: #{noun}, entries kept unchanged")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def json_summary(opts, outcome, generated)
|
|
34
|
+
{
|
|
35
|
+
written: !opts.fetch(:dry_run), path: opts.fetch(:baseline), generated: generated,
|
|
36
|
+
files: outcome.baseline.entries.size,
|
|
37
|
+
tightened: outcome.tightened, pruned: outcome.pruned,
|
|
38
|
+
regressed: outcome.regressed, unchanged: outcome.unchanged
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atomic_file"
|
|
4
|
+
require_relative "command_helpers"
|
|
5
|
+
require_relative "coverage_file"
|
|
6
|
+
require_relative "ratchet/output"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov ratchet`: rewrite the checked-in per-file coverage baseline
|
|
11
|
+
# (#1268) from the current report. Floors only ever tighten, entries for
|
|
12
|
+
# files the report no longer carries are pruned, and files without an entry
|
|
13
|
+
# are never given one, so new code stays answerable to the global thresholds
|
|
14
|
+
# rather than to a floor cut at whatever it launched with. `--init`
|
|
15
|
+
# regenerates the whole file from the current state.
|
|
16
|
+
#
|
|
17
|
+
# The baseline path follows the project's `.simplecov` `baseline_file`
|
|
18
|
+
# setting when one is present, the same way the read-only commands follow
|
|
19
|
+
# `coverage_dir`.
|
|
20
|
+
module Ratchet
|
|
21
|
+
extend CommandHelpers
|
|
22
|
+
|
|
23
|
+
extend self
|
|
24
|
+
|
|
25
|
+
def run(args, stdout:, stderr:, **)
|
|
26
|
+
require "simplecov"
|
|
27
|
+
opts = parse(args, stderr) or return 1
|
|
28
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "ratchet", stderr: stderr) or return 1
|
|
29
|
+
|
|
30
|
+
outcome, generated = compute(opts, current_floors(coverage))
|
|
31
|
+
AtomicFile.write(opts.fetch(:baseline), outcome.baseline.to_yaml) unless opts.fetch(:dry_run)
|
|
32
|
+
Output.emit(stdout, opts, outcome, generated)
|
|
33
|
+
0
|
|
34
|
+
rescue ConfigurationError => e
|
|
35
|
+
error(stderr, e)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse(args, stderr)
|
|
39
|
+
opts, rest = parse_common(args, init: false, dry_run: false) do |parser, options|
|
|
40
|
+
parser.on("--baseline PATH") { |v| options[:baseline] = v }
|
|
41
|
+
parser.on("--init") { options[:init] = true }
|
|
42
|
+
parser.on("--dry-run") { options[:dry_run] = true }
|
|
43
|
+
end
|
|
44
|
+
return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
|
|
45
|
+
|
|
46
|
+
opts[:baseline] ||= Dotfile.baseline_file
|
|
47
|
+
opts
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Only criteria the report carries for a file appear, so a line-only report
|
|
51
|
+
# leaves existing branch floors alone.
|
|
52
|
+
def current_floors(coverage)
|
|
53
|
+
initial = {} #: Baseline::current_floors
|
|
54
|
+
coverage.each_with_object(initial) do |(path, entry), floors|
|
|
55
|
+
criteria = CoverageFile::CRITERIA.filter_map do |criterion, keys|
|
|
56
|
+
percent = entry[keys.fetch(:percent)]
|
|
57
|
+
missed = entry[keys.fetch(:missed)]
|
|
58
|
+
[criterion, {percent: SimpleCov.round_coverage(percent), missed: missed}] if usable?(percent, missed)
|
|
59
|
+
end.to_h
|
|
60
|
+
floors[path] = criteria unless criteria.empty?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A report row from another tool may be missing counts; only a complete pair
|
|
65
|
+
# can become a floor.
|
|
66
|
+
def usable?(percent, missed)
|
|
67
|
+
percent.is_a?(Numeric) && missed.instance_of?(Integer)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def compute(opts, current)
|
|
71
|
+
existing = Baseline.read_if_exists(opts.fetch(:baseline)) unless opts.fetch(:init)
|
|
72
|
+
return [existing.ratchet(current), false] if existing
|
|
73
|
+
|
|
74
|
+
none = [] #: Array[String]
|
|
75
|
+
outcome = Baseline::Outcome.new(
|
|
76
|
+
baseline: Baseline.generate(current),
|
|
77
|
+
tightened: none, pruned: none, regressed: none, unchanged: none
|
|
78
|
+
)
|
|
79
|
+
[outcome, true]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/simplecov/cli/report.rb
CHANGED
|
@@ -6,46 +6,40 @@ require_relative "command_helpers"
|
|
|
6
6
|
|
|
7
7
|
module SimpleCov
|
|
8
8
|
module CLI
|
|
9
|
-
# `simplecov report [--input PATH]` — pretty-print the overall
|
|
10
|
-
# totals row plus per-group totals from a JSONFormatter
|
|
11
|
-
# coverage.json. Same numbers as the HTML report's totals row, for
|
|
12
|
-
# contexts where opening a browser isn't an option (CI logs, ssh
|
|
13
|
-
# sessions, terminal-only workflows).
|
|
14
9
|
module Report
|
|
15
10
|
extend CommandHelpers
|
|
16
11
|
|
|
17
12
|
TOTAL_LABEL = "All Files"
|
|
18
|
-
SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
|
|
13
|
+
SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze #: Array[[String, String]]
|
|
19
14
|
|
|
20
|
-
|
|
15
|
+
extend self
|
|
21
16
|
|
|
22
17
|
def run(args, stdout:, stderr:)
|
|
23
18
|
opts, = parse_common(args)
|
|
24
|
-
return 1 unless (data = load_data(opts
|
|
19
|
+
return 1 unless (data = load_data(opts.fetch(:input), stderr))
|
|
25
20
|
|
|
26
|
-
if opts
|
|
21
|
+
if opts.fetch(:json)
|
|
27
22
|
emit_json(stdout, data)
|
|
28
23
|
else
|
|
29
|
-
emit_text(stdout, data,
|
|
24
|
+
emit_text(stdout, data, CLI.color_enabled?(opts, stdout))
|
|
30
25
|
end
|
|
31
26
|
0
|
|
32
27
|
end
|
|
33
28
|
|
|
34
|
-
# Valid JSON isn't enough: a wrong-typed "total" or "groups"
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
# sibling commands print.
|
|
29
|
+
# Valid JSON isn't enough: a wrong-typed "total" or "groups" used to escape
|
|
30
|
+
# here and crash the emitters with a backtrace instead of the one-line error
|
|
31
|
+
# the sibling commands print.
|
|
38
32
|
def load_data(input, stderr)
|
|
39
33
|
data = CoverageFile.load_document(input, command: "report", stderr: stderr)
|
|
40
34
|
return unless data
|
|
41
35
|
|
|
42
36
|
none = {} #: Hash[String, untyped]
|
|
43
|
-
unless data.fetch("total", none).
|
|
37
|
+
unless data.fetch("total", none).instance_of?(Hash)
|
|
44
38
|
return CoverageFile.report_invalid(stderr, "report", input, '"total" must be an object')
|
|
45
39
|
end
|
|
46
40
|
|
|
47
41
|
groups = data.fetch("groups", none)
|
|
48
|
-
return data if groups.
|
|
42
|
+
return data if groups.instance_of?(Hash) && groups.each_value.all?(Hash)
|
|
49
43
|
|
|
50
44
|
CoverageFile.report_invalid(stderr, "report", input, '"groups" must be an object of objects')
|
|
51
45
|
end
|
|
@@ -54,7 +48,7 @@ module SimpleCov
|
|
|
54
48
|
none = {} #: Hash[String, untyped]
|
|
55
49
|
emit_totals(stdout, TOTAL_LABEL, data.fetch("total", none), color)
|
|
56
50
|
data.fetch("groups", none).each do |name, group|
|
|
57
|
-
label = name
|
|
51
|
+
label = name.eql?(TOTAL_LABEL) ? "#{name} (group)" : name
|
|
58
52
|
emit_totals(stdout, label, group, color)
|
|
59
53
|
end
|
|
60
54
|
end
|
|
@@ -66,12 +60,12 @@ module SimpleCov
|
|
|
66
60
|
end
|
|
67
61
|
|
|
68
62
|
def emit_section(stdout, display, section, color)
|
|
69
|
-
return unless section.
|
|
63
|
+
return unless section.instance_of?(Hash) && section["total"].to_i.positive?
|
|
70
64
|
|
|
71
65
|
stdout.puts(stats_row(display,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
Color.colorize_percent(section["percent"].to_f, enabled: color),
|
|
67
|
+
section["covered"],
|
|
68
|
+
section.fetch("total")))
|
|
75
69
|
end
|
|
76
70
|
|
|
77
71
|
def emit_json(stdout, data)
|
|
@@ -85,10 +79,10 @@ module SimpleCov
|
|
|
85
79
|
sections = {} #: Hash[String, untyped]
|
|
86
80
|
SECTIONS.each_with_object(sections) do |(_, key), out|
|
|
87
81
|
section = totals[key]
|
|
88
|
-
next unless section.
|
|
82
|
+
next unless section.instance_of?(Hash) && section["total"].to_i.positive?
|
|
89
83
|
|
|
90
|
-
out[key
|
|
91
|
-
"percent" => section["percent"], "covered" => section["covered"], "total" => section
|
|
84
|
+
out[key] = {
|
|
85
|
+
"percent" => section["percent"], "covered" => section["covered"], "total" => section.fetch("total")
|
|
92
86
|
}
|
|
93
87
|
end
|
|
94
88
|
end
|
data/lib/simplecov/cli/run.rb
CHANGED
|
@@ -2,29 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module SimpleCov
|
|
4
4
|
module CLI
|
|
5
|
-
# `simplecov run <command...>` — exec the given command with
|
|
6
|
-
# simplecov auto-loaded so a coverage report drops into the
|
|
7
|
-
# project's coverage/ directory at the end. Useful for projects
|
|
8
|
-
# without a test_helper that already calls SimpleCov.start (e.g.
|
|
9
|
-
# plain `bundle exec rake test` on an unconfigured library).
|
|
10
5
|
module Run
|
|
11
6
|
AUTOSTART = File.expand_path("../autostart", __dir__.to_s)
|
|
12
7
|
|
|
13
|
-
|
|
8
|
+
extend self
|
|
14
9
|
|
|
15
10
|
def run(args, stderr:, **)
|
|
16
|
-
cmd = args.first
|
|
11
|
+
cmd = args.first.eql?("--") ? args.drop(1) : args
|
|
17
12
|
if cmd.empty?
|
|
18
13
|
stderr.puts("simplecov run: missing command")
|
|
19
14
|
return 1
|
|
20
15
|
end
|
|
21
16
|
|
|
22
|
-
|
|
17
|
+
exec_command(rubyopt_env, cmd)
|
|
23
18
|
rescue Errno::ENOENT => e
|
|
24
|
-
stderr.puts("simplecov run: #{e
|
|
19
|
+
stderr.puts("simplecov run: #{e}")
|
|
25
20
|
127
|
|
26
21
|
end
|
|
27
22
|
|
|
23
|
+
def exec_command(env, cmd)
|
|
24
|
+
Kernel.exec(env, *cmd)
|
|
25
|
+
end
|
|
26
|
+
|
|
28
27
|
def rubyopt_env
|
|
29
28
|
existing = ENV["RUBYOPT"].to_s.strip
|
|
30
29
|
injection = "-r#{AUTOSTART}"
|
|
@@ -5,9 +5,6 @@ require_relative "../../coverage_json"
|
|
|
5
5
|
module SimpleCov
|
|
6
6
|
module CLI
|
|
7
7
|
module Serve
|
|
8
|
-
# Ensures the coverage directory has a renderable entry point before
|
|
9
|
-
# the HTTP server binds. Existing self-contained reports take priority
|
|
10
|
-
# over their optional JSON sidecar.
|
|
11
8
|
module ReportPreparer
|
|
12
9
|
def self.call(dir)
|
|
13
10
|
index_path = File.join(dir, "index.html")
|
|
@@ -17,11 +14,15 @@ module SimpleCov
|
|
|
17
14
|
|
|
18
15
|
return "#{dir} has no index.html or coverage.json; run your test suite first" unless File.file?(json_path)
|
|
19
16
|
|
|
17
|
+
build_index(json_path, dir)
|
|
18
|
+
rescue CoverageJSON::Error, SystemCallError => e
|
|
19
|
+
"cannot build index.html from #{json_path}: #{e}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.build_index(json_path, dir)
|
|
20
23
|
require_relative "../../formatter/html_formatter"
|
|
21
|
-
|
|
24
|
+
Formatter::HTMLFormatter.new.format_from_json(json_path, dir)
|
|
22
25
|
nil
|
|
23
|
-
rescue SimpleCov::CoverageJSON::Error, SystemCallError => e
|
|
24
|
-
"cannot build index.html from #{json_path}: #{e.message}"
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
end
|