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,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
#
|
|
5
|
+
# Records which context covered which line: a list of context ids and, per
|
|
6
|
+
# source file, a bitmap of covered lines for each context that touched the
|
|
7
|
+
# file. A context is any labeled region of execution, and the vocabulary is
|
|
8
|
+
# deliberately the general one (coverage.py calls the same idea dynamic
|
|
9
|
+
# contexts), so the stored format doesn't bake simplecov's current use into
|
|
10
|
+
# a name.
|
|
11
|
+
#
|
|
12
|
+
# The naive shape of this data, a list of ids on every line, is
|
|
13
|
+
# O(contexts x lines) strings. Context ids are interned instead, and each
|
|
14
|
+
# context's covered lines within a file are a single Integer bitmap with bit
|
|
15
|
+
# N set when line N+1 was executed, which packs a thousand-line file into
|
|
16
|
+
# ~125 bytes and makes the union of two recordings a bitwise OR.
|
|
17
|
+
#
|
|
18
|
+
# Serialized into `.resultset.json` as `{"version" => 1, "contexts" =>
|
|
19
|
+
# [...ids...], "files" => {path => {context index => bitmap as hex}}}`. The
|
|
20
|
+
# format is tolerated, not trusted: `.from_hash` returns nil for anything
|
|
21
|
+
# malformed or future-versioned, which the merge treats the same as a map
|
|
22
|
+
# that was never recorded.
|
|
23
|
+
#
|
|
24
|
+
class ContextMap
|
|
25
|
+
# Bumped on any change an older reader could misread, so `.from_hash` can
|
|
26
|
+
# treat a future format as absent instead of answering from it wrongly.
|
|
27
|
+
VERSION = 1
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@contexts = [] #: Array[String]
|
|
31
|
+
@indices = {} #: Hash[String, Integer]
|
|
32
|
+
@files = {} #: Hash[String, Hash[Integer, Integer]]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `lines_by_file` maps a source path to the bitmap of lines the context
|
|
36
|
+
# executed there. The id is interned even when the delta is empty: the
|
|
37
|
+
# context ran, and keeping it distinguishes "covered nothing of its own"
|
|
38
|
+
# from "never recorded".
|
|
39
|
+
def record(context_id, lines_by_file)
|
|
40
|
+
index = intern(context_id)
|
|
41
|
+
lines_by_file.each do |path, bitmap|
|
|
42
|
+
next if bitmap.zero?
|
|
43
|
+
|
|
44
|
+
table = (@files[path] ||= {})
|
|
45
|
+
table[index] = (table[index] || 0) | bitmap
|
|
46
|
+
end
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Sorted for stable output, since recording order varies between runs and
|
|
51
|
+
# merges. The path is resolved against `SimpleCov.root`, so callers can
|
|
52
|
+
# pass either an absolute or a project-relative one.
|
|
53
|
+
#
|
|
54
|
+
# A line number below 1 needs no guard of its own: it shifts the probe bit
|
|
55
|
+
# right off the end of the bitmap, leaving a mask no line can match.
|
|
56
|
+
def covering(path, line)
|
|
57
|
+
table = @files[File.expand_path(path, SimpleCov.root)]
|
|
58
|
+
return [] unless table
|
|
59
|
+
|
|
60
|
+
bit = 1 << (line - 1)
|
|
61
|
+
table.filter_map { |index, bitmap| @contexts.fetch(index) if bitmap.anybits?(bit) }.sort
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def contexts
|
|
65
|
+
@contexts.dup
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def empty?
|
|
69
|
+
@contexts.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Other's contexts are re-interned, so maps recorded by different processes
|
|
73
|
+
# merge by id, and a context both sides saw contributes one entry.
|
|
74
|
+
def absorb(other)
|
|
75
|
+
remap = other.interned_contexts.map { |context_id| intern(context_id) }
|
|
76
|
+
other.file_tables.each do |path, table|
|
|
77
|
+
target = (@files[path] ||= {})
|
|
78
|
+
table.each do |index, bitmap|
|
|
79
|
+
key = remap.fetch(index)
|
|
80
|
+
target[key] = (target[key] || 0) | bitmap
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# `only:` restricts the files to a given set of paths, so the map follows
|
|
87
|
+
# the same universe as the coverage it sits beside. The context list is
|
|
88
|
+
# never restricted: which contexts ran is true regardless of which files
|
|
89
|
+
# survived filtering.
|
|
90
|
+
def to_h(only: nil)
|
|
91
|
+
# Selected rather than sliced: `only` is a Set, and splatting one into
|
|
92
|
+
# `slice` is a call `to_a` answers for, which makes dropping the `to_a` a
|
|
93
|
+
# mutation nothing can observe.
|
|
94
|
+
tables = only ? @files.select { |path, _table| only.include?(path) } : @files # rubocop:disable Style/HashSlice
|
|
95
|
+
serialized = tables.transform_values { |table| serialize_table(table) }
|
|
96
|
+
{"version" => VERSION, "contexts" => @contexts.dup, "files" => serialized}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Index string => hex bitmap, the wire encoding `to_h` writes.
|
|
100
|
+
# coverage.json shares the resultset's encoding through this, so the format
|
|
101
|
+
# has one owner.
|
|
102
|
+
def serialized_bitmaps_for(path)
|
|
103
|
+
serialize_table(@files[File.expand_path(path, SimpleCov.root)] || {})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @api private
|
|
107
|
+
def intern(context_id)
|
|
108
|
+
@indices[context_id] ||= begin
|
|
109
|
+
@contexts << context_id
|
|
110
|
+
@contexts.size - 1
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class << self
|
|
115
|
+
# nil when the data is not a well-formed map of this format version.
|
|
116
|
+
# All-or-nothing on purpose: a partially salvaged map would answer
|
|
117
|
+
# `covering` queries with silent gaps, and the merge already drops an
|
|
118
|
+
# absent map consistently.
|
|
119
|
+
def from_hash(data)
|
|
120
|
+
# `eql?` rather than `==`: a version written as 1.0 is not this format,
|
|
121
|
+
# and reading it as if it were is what the version gate exists to stop.
|
|
122
|
+
return nil unless data.instance_of?(Hash) && data["version"].eql?(VERSION)
|
|
123
|
+
|
|
124
|
+
contexts = data["contexts"]
|
|
125
|
+
files = data["files"]
|
|
126
|
+
return nil unless contexts.instance_of?(Array) && contexts.all?(String) && files.instance_of?(Hash)
|
|
127
|
+
|
|
128
|
+
build(contexts, files)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def build(contexts, files)
|
|
134
|
+
map = new
|
|
135
|
+
contexts.each { |context_id| map.intern(context_id) }
|
|
136
|
+
files.each do |path, table|
|
|
137
|
+
return nil unless path.instance_of?(String) && table.instance_of?(Hash)
|
|
138
|
+
return nil unless absorb_table(map, contexts, path, table)
|
|
139
|
+
end
|
|
140
|
+
map
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def absorb_table(map, contexts, path, table)
|
|
144
|
+
table.all? do |index, encoded|
|
|
145
|
+
position = parse_index(index, contexts.size)
|
|
146
|
+
bitmap = parse_bitmap(encoded)
|
|
147
|
+
map.record(contexts.fetch(position), path => bitmap) if position && bitmap
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A serialized context index is a decimal string pointing into the entry's
|
|
152
|
+
# own context list. An out-of-range one makes the whole map malformed.
|
|
153
|
+
def parse_index(index, size)
|
|
154
|
+
return nil unless index.instance_of?(String) && index.match?(/\A\d+\z/)
|
|
155
|
+
|
|
156
|
+
value = index.to_i
|
|
157
|
+
value if value < size
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parse_bitmap(encoded)
|
|
161
|
+
return nil unless encoded.instance_of?(String) && encoded.match?(/\A\h+\z/)
|
|
162
|
+
|
|
163
|
+
encoded.to_i(16)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# Sorted by index: recording and merge order vary run to run, and a
|
|
170
|
+
# deterministic serialization keeps stored resultsets diffable.
|
|
171
|
+
def serialize_table(table)
|
|
172
|
+
table.sort.to_h { |index, bitmap| [index.to_s, bitmap.to_s(16)] }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
protected
|
|
176
|
+
|
|
177
|
+
# Live state, not copies, which is why these are protected rather than
|
|
178
|
+
# public.
|
|
179
|
+
def interned_contexts
|
|
180
|
+
@contexts
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def file_tables
|
|
184
|
+
@files
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module SimpleCov
|
|
6
|
-
# Shared parser for coverage.json consumers. It enforces the encoding and
|
|
7
|
-
# stable outermost shape while leaving command- or viewer-specific fields to
|
|
8
|
-
# their respective callers.
|
|
9
6
|
module CoverageJSON
|
|
10
7
|
class Error < StandardError; end
|
|
11
8
|
|
|
@@ -14,11 +11,11 @@ module SimpleCov
|
|
|
14
11
|
raise Error, "input is not valid UTF-8" unless source.valid_encoding?
|
|
15
12
|
|
|
16
13
|
document = JSON.parse(source)
|
|
17
|
-
return document if document.
|
|
14
|
+
return document if document.instance_of?(Hash)
|
|
18
15
|
|
|
19
16
|
raise Error, "top-level value must be an object"
|
|
20
17
|
rescue JSON::ParserError => e
|
|
21
|
-
raise Error, e
|
|
18
|
+
raise Error, e
|
|
22
19
|
end
|
|
23
20
|
end
|
|
24
21
|
end
|
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SimpleCov
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# * total - how many things to cover there are (total relevant loc/branches)
|
|
9
|
-
# * covered - how many of the coverables are hit
|
|
10
|
-
# * missed - how many of the coverables are missed
|
|
11
|
-
# * omitted - how many lines cannot be covered (blank lines/comments); only meaningful for line coverage
|
|
12
|
-
# * percent - percentage as covered/missed
|
|
13
|
-
# * strength - average hits per coverable unit (will not exist for one shot lines format)
|
|
4
|
+
# One coverage criterion's numbers, uniform across criteria: total, covered,
|
|
5
|
+
# missed, omitted (only meaningful for line coverage), percent, and strength,
|
|
6
|
+
# the average hits per coverable unit, which the oneshot-lines format has no
|
|
7
|
+
# way to produce.
|
|
14
8
|
class CoverageStatistics
|
|
15
9
|
attr_reader :total, :covered, :missed, :omitted, :strength, :percent
|
|
16
10
|
|
|
17
|
-
# Seed for the reduce in `.from`: covered, missed, omitted, strength.
|
|
18
11
|
ZERO_STATS = [0, 0, 0, 0.0].freeze #: [Integer, Integer, Integer, Float]
|
|
19
12
|
|
|
13
|
+
# Strength is remultiplied by loc because files have different strengths and
|
|
14
|
+
# line counts, giving them a different weight in the total.
|
|
20
15
|
def self.from(coverage_statistics)
|
|
21
16
|
sum_covered, sum_missed, sum_omitted, sum_total_strength =
|
|
22
17
|
coverage_statistics.reduce(ZERO_STATS) do |(covered, missed, omitted, total_strength), stats|
|
|
@@ -24,8 +19,6 @@ module SimpleCov
|
|
|
24
19
|
covered + stats.covered,
|
|
25
20
|
missed + stats.missed,
|
|
26
21
|
omitted + stats.omitted,
|
|
27
|
-
# gotta remultiply with loc because files have different strength and loc
|
|
28
|
-
# giving them a different "weight" in total
|
|
29
22
|
total_strength + (stats.strength * stats.total)
|
|
30
23
|
]
|
|
31
24
|
end
|
|
@@ -33,19 +26,16 @@ module SimpleCov
|
|
|
33
26
|
new(covered: sum_covered, missed: sum_missed, omitted: sum_omitted, total_strength: sum_total_strength)
|
|
34
27
|
end
|
|
35
28
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@
|
|
41
|
-
@
|
|
42
|
-
@omitted = omitted
|
|
43
|
-
@total = covered + missed
|
|
44
|
-
@percent = percent || compute_percent(covered, missed, total)
|
|
29
|
+
def initialize(covered:, missed:, omitted: 0, total_strength: 0, percent: nil)
|
|
30
|
+
@covered = covered
|
|
31
|
+
@missed = missed
|
|
32
|
+
@omitted = omitted
|
|
33
|
+
@total = covered + missed
|
|
34
|
+
@percent = percent || compute_percent(covered, missed, total)
|
|
45
35
|
@strength = compute_strength(total_strength, total)
|
|
46
36
|
end
|
|
47
37
|
|
|
48
|
-
|
|
38
|
+
private
|
|
49
39
|
|
|
50
40
|
def compute_percent(covered, missed, total)
|
|
51
41
|
return 100.0 if missed.zero?
|
|
@@ -56,7 +46,7 @@ module SimpleCov
|
|
|
56
46
|
def compute_strength(total_strength, total)
|
|
57
47
|
return 0.0 if total.zero?
|
|
58
48
|
|
|
59
|
-
total_strength.
|
|
49
|
+
total_strength.fdiv(total)
|
|
60
50
|
end
|
|
61
51
|
end
|
|
62
52
|
end
|
|
@@ -1,48 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SimpleCov
|
|
4
|
-
# Computes coverage threshold violations for a given result. Shared by
|
|
5
|
-
# the exit-code checks and the JSON formatter's `errors` section.
|
|
6
|
-
#
|
|
7
|
-
# Each method returns an array of violation hashes. All percents are
|
|
8
|
-
# rounded via `SimpleCov.round_coverage` so downstream consumers don't
|
|
9
|
-
# need to round again.
|
|
10
4
|
module CoverageViolations
|
|
11
5
|
class << self
|
|
12
|
-
# @return [Array<Hash>] {:criterion, :expected, :actual}
|
|
13
6
|
def minimum_overall(result, thresholds)
|
|
14
7
|
thresholds.filter_map do |criterion, expected|
|
|
15
|
-
actual =
|
|
8
|
+
actual = floored_percent_for(result, criterion) or next
|
|
16
9
|
{criterion: criterion, expected: expected, actual: actual} if actual < expected
|
|
17
10
|
end
|
|
18
11
|
end
|
|
19
12
|
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# places (matching the existing minimum-coverage behavior), so an
|
|
23
|
-
# actual of e.g. 95.4287 is treated as 95.42 — meaning a maximum of
|
|
24
|
-
# 95.42 still passes. See issue #187 for the rationale.
|
|
13
|
+
# An actual of 95.4287 floors to 95.42, so a maximum of 95.42 passes
|
|
14
|
+
# (#187).
|
|
25
15
|
def maximum_overall(result, thresholds)
|
|
26
16
|
thresholds.filter_map do |criterion, expected|
|
|
27
|
-
actual =
|
|
17
|
+
actual = floored_percent_for(result, criterion) or next
|
|
28
18
|
{criterion: criterion, expected: expected, actual: actual} if actual > expected
|
|
29
19
|
end
|
|
30
20
|
end
|
|
31
21
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
# overrides; for each file, defaults are merged with every matching override
|
|
37
|
-
# (later wins per criterion, overrides win over defaults).
|
|
38
|
-
def minimum_by_file(result, defaults, overrides = {})
|
|
22
|
+
# A file and criterion with a `baseline` floor is exempt here: the baseline
|
|
23
|
+
# check holds it to its own floor instead, which is the fall-through the
|
|
24
|
+
# ratchet workflow rests on (#1268).
|
|
25
|
+
def minimum_by_file(result, defaults, overrides = {}, baseline: nil)
|
|
39
26
|
result.files.flat_map do |file|
|
|
40
27
|
effective = effective_per_file_thresholds(file, defaults, overrides)
|
|
41
|
-
effective.filter_map
|
|
28
|
+
effective.filter_map do |criterion, expected|
|
|
29
|
+
next if baseline&.covers?(file.project_filename, criterion)
|
|
30
|
+
|
|
31
|
+
file_minimum_violation(file, criterion, expected)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A baseline violation needs the file below its floor on both axes: a
|
|
37
|
+
# percent under the floor's, and (when the floor records one) more misses
|
|
38
|
+
# than it allows. The missed count is what keeps percent movement from
|
|
39
|
+
# pure edits out of the answer.
|
|
40
|
+
def baseline(result, baseline)
|
|
41
|
+
return [] unless baseline
|
|
42
|
+
|
|
43
|
+
result.files.flat_map do |file|
|
|
44
|
+
entry = baseline.entry_for(file.project_filename)
|
|
45
|
+
next [] unless entry
|
|
46
|
+
|
|
47
|
+
entry.filter_map { |criterion, floor| baseline_violation(file, criterion, floor) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def maximum_missed(result, caps)
|
|
52
|
+
caps.filter_map do |criterion, maximum|
|
|
53
|
+
actual = missed_for(result, criterion) or next
|
|
54
|
+
{criterion: criterion, maximum: maximum, actual: actual} if actual > maximum
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def maximum_missed_by_file(result, defaults, overrides = {}, baseline: nil)
|
|
59
|
+
result.files.flat_map do |file|
|
|
60
|
+
effective = effective_per_file_thresholds(file, defaults, overrides)
|
|
61
|
+
effective.filter_map do |criterion, maximum|
|
|
62
|
+
next if baseline&.covers?(file.project_filename, criterion)
|
|
63
|
+
|
|
64
|
+
file_missed_cap_violation(file, criterion, maximum)
|
|
65
|
+
end
|
|
42
66
|
end
|
|
43
67
|
end
|
|
44
68
|
|
|
45
|
-
# @return [Array<Hash>] {:group_name, :criterion, :expected, :actual}
|
|
46
69
|
def minimum_by_group(result, thresholds)
|
|
47
70
|
thresholds.flat_map do |group_name, minimums|
|
|
48
71
|
group = lookup_group(result, group_name)
|
|
@@ -51,50 +74,38 @@ module SimpleCov
|
|
|
51
74
|
end
|
|
52
75
|
end
|
|
53
76
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return [] unless last_run
|
|
77
|
+
def maximum_drop(result, thresholds, last_run: nil, mode: SimpleCov.drop_baseline)
|
|
78
|
+
baseline = drop_baseline_percents(mode, last_run)
|
|
79
|
+
return [] unless baseline
|
|
58
80
|
|
|
59
81
|
thresholds.filter_map do |criterion, maximum|
|
|
60
|
-
actual = compute_drop(criterion, result,
|
|
82
|
+
actual = compute_drop(criterion, result, baseline)
|
|
61
83
|
{criterion: criterion, maximum: maximum, actual: actual} if actual && actual > maximum
|
|
62
84
|
end
|
|
63
85
|
end
|
|
64
86
|
|
|
65
|
-
|
|
87
|
+
private
|
|
66
88
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
# module doesn't emit branch data). The config-time
|
|
73
|
-
# `raise_if_criterion_disabled` check still catches the genuine
|
|
74
|
-
# "forgot to enable the criterion" mistake before we ever get here.
|
|
75
|
-
def percent_for(stats_source, criterion)
|
|
89
|
+
# Answers nil, and the caller silently skips, when the criterion was
|
|
90
|
+
# configured but not measured by the runtime (`branch: 100` under the
|
|
91
|
+
# "strict" profile on JRuby, say). The config-time
|
|
92
|
+
# `raise_if_criterion_disabled` check catches the genuine mistake earlier.
|
|
93
|
+
def floored_percent_for(stats_source, criterion)
|
|
76
94
|
stats = stats_source.coverage_statistics[SimpleCov.coverage_statistics_key(criterion)]
|
|
77
95
|
round(stats.percent) if stats
|
|
78
96
|
end
|
|
79
97
|
|
|
80
|
-
# Walk the overrides in declaration order, merging each one that matches
|
|
81
|
-
# the file's project path into the running effective threshold (so the
|
|
82
|
-
# most-specific or latest-declared override wins per criterion). Returns
|
|
83
|
-
# the defaults Hash unchanged when nothing matches.
|
|
84
98
|
def effective_per_file_thresholds(file, defaults, overrides)
|
|
85
|
-
return defaults if overrides.empty?
|
|
86
|
-
|
|
87
99
|
path = file.project_filename
|
|
88
100
|
overrides.reduce(defaults) do |acc, (pattern, criterion_thresholds)|
|
|
89
101
|
path_matches?(path, pattern) ? acc.merge(criterion_thresholds) : acc
|
|
90
102
|
end
|
|
91
103
|
end
|
|
92
104
|
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# so no dead `else` branch is needed here.
|
|
105
|
+
# `==` and `eql?` are indistinguishable for the String operands, so that
|
|
106
|
+
# mutation is equivalent. The override examples pin every arm instead.
|
|
107
|
+
# mutant:disable
|
|
108
|
+
# mutant:disable
|
|
98
109
|
def path_matches?(project_filename, pattern)
|
|
99
110
|
return project_filename.match?(pattern) if pattern.is_a?(Regexp)
|
|
100
111
|
return project_filename.start_with?(pattern) if pattern.end_with?("/")
|
|
@@ -102,8 +113,33 @@ module SimpleCov
|
|
|
102
113
|
project_filename == pattern
|
|
103
114
|
end
|
|
104
115
|
|
|
116
|
+
def missed_for(stats_source, criterion)
|
|
117
|
+
stats = stats_source.coverage_statistics[SimpleCov.coverage_statistics_key(criterion)]
|
|
118
|
+
stats&.missed
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def file_missed_cap_violation(file, criterion, maximum)
|
|
122
|
+
actual = missed_for(file, criterion) or return
|
|
123
|
+
return unless actual > maximum
|
|
124
|
+
|
|
125
|
+
{criterion: criterion, maximum: maximum, actual: actual,
|
|
126
|
+
filename: file.filename, project_filename: file.project_filename}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def baseline_violation(file, criterion, floor)
|
|
130
|
+
stats = file.coverage_statistics[SimpleCov.coverage_statistics_key(criterion)]
|
|
131
|
+
return unless stats
|
|
132
|
+
|
|
133
|
+
actual = round(stats.percent)
|
|
134
|
+
return unless actual < floor.percent
|
|
135
|
+
return if floor.missed && stats.missed <= floor.missed
|
|
136
|
+
|
|
137
|
+
{criterion: criterion, expected: floor.percent, allowed_missed: floor.missed, actual: actual,
|
|
138
|
+
actual_missed: stats.missed, filename: file.filename, project_filename: file.project_filename}
|
|
139
|
+
end
|
|
140
|
+
|
|
105
141
|
def file_minimum_violation(file, criterion, expected)
|
|
106
|
-
actual =
|
|
142
|
+
actual = floored_percent_for(file, criterion) or return
|
|
107
143
|
return unless actual < expected
|
|
108
144
|
|
|
109
145
|
{
|
|
@@ -117,37 +153,96 @@ module SimpleCov
|
|
|
117
153
|
|
|
118
154
|
def group_minimum_violations(group_name, group, minimums)
|
|
119
155
|
minimums.filter_map do |criterion, expected|
|
|
120
|
-
actual =
|
|
156
|
+
actual = floored_percent_for(group, criterion) or next
|
|
121
157
|
{group_name: group_name, criterion: criterion, expected: expected, actual: actual} if actual < expected
|
|
122
158
|
end
|
|
123
159
|
end
|
|
124
160
|
|
|
125
|
-
# The misconfiguration notice is enforcement output, not a Ruby
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
161
|
+
# The misconfiguration notice is enforcement output, not a Ruby warning: it
|
|
162
|
+
# must survive `-W0` and `Warning.warn` hooks, and honor `print_errors`. A
|
|
163
|
+
# configured group that matched no files is absent from `result.groups`
|
|
164
|
+
# too, but has nothing to gate rather than nothing to look up.
|
|
129
165
|
def lookup_group(result, group_name)
|
|
130
166
|
group = result.groups[group_name]
|
|
131
|
-
if group.nil? && SimpleCov.print_errors
|
|
167
|
+
if group.nil? && SimpleCov.print_errors && !result.configured_group?(group_name)
|
|
132
168
|
ExitCodes.print_error "minimum_coverage_by_group: no group named '#{group_name}' exists. " \
|
|
133
|
-
"Available groups: #{result.groups.keys.join(
|
|
169
|
+
"Available groups: #{result.groups.keys.join(", ")}"
|
|
134
170
|
end
|
|
135
171
|
group
|
|
136
172
|
end
|
|
137
173
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
174
|
+
def drop_baseline_percents(mode, last_run)
|
|
175
|
+
case mode
|
|
176
|
+
when :median then median_baseline
|
|
177
|
+
when :branch then branch_baseline
|
|
178
|
+
else last_run_baseline(last_run || LastRun.read)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A hand-edited .last_run.json can carry any value type, and `LastRun.read`
|
|
183
|
+
# only vouches for the top level being a Hash. `:covered_percent` is the
|
|
184
|
+
# pre-criteria file format's spelling of the line percent.
|
|
185
|
+
def last_run_baseline(last_run)
|
|
186
|
+
return nil unless last_run.is_a?(Hash)
|
|
187
|
+
|
|
188
|
+
previous = (_ = last_run)[:result]
|
|
189
|
+
return nil unless previous.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
previous = _ = previous
|
|
192
|
+
{line: previous[:line] || previous[:covered_percent],
|
|
193
|
+
branch: previous[:branch], method: previous[:method]}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def median_baseline
|
|
197
|
+
totals = history_totals
|
|
198
|
+
baseline = {} #: Hash[Symbol, untyped]
|
|
199
|
+
%i[line branch method].each do |criterion|
|
|
200
|
+
values = totals.map { |entry| entry[criterion.to_s] }.grep(Numeric)
|
|
201
|
+
baseline[criterion] = median(values) unless values.empty?
|
|
202
|
+
end
|
|
203
|
+
baseline
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def history_totals
|
|
207
|
+
History.read.filter_map do |entry|
|
|
208
|
+
entry.fetch("totals") if entry.is_a?(Hash) && entry["totals"].is_a?(Hash)
|
|
143
209
|
end
|
|
144
|
-
|
|
145
|
-
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def median(values)
|
|
213
|
+
sorted = values.sort
|
|
214
|
+
mid = sorted.length / 2
|
|
215
|
+
sorted.length.odd? ? sorted.fetch(mid) : (sorted.fetch(mid - 1) + sorted.fetch(mid)).fdiv(2)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def branch_baseline
|
|
219
|
+
branch, = History.git_info
|
|
220
|
+
return nil unless branch
|
|
221
|
+
|
|
222
|
+
entry = History.read.reverse_each.find { |candidate| branch_entry?(candidate, branch) }
|
|
223
|
+
return nil unless entry
|
|
224
|
+
|
|
225
|
+
totals = {} #: Hash[Symbol, untyped]
|
|
226
|
+
entry.fetch("totals").each { |key, value| totals[key.to_sym] = value }
|
|
227
|
+
totals
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# `==` and `eql?` are indistinguishable for the String operands, so that
|
|
231
|
+
# mutation is equivalent. The branch-baseline examples pin every clause.
|
|
232
|
+
# mutant:disable
|
|
233
|
+
# mutant:disable
|
|
234
|
+
def branch_entry?(candidate, branch)
|
|
235
|
+
candidate.is_a?(Hash) && candidate["branch"] == branch && candidate["totals"].is_a?(Hash)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def compute_drop(criterion, result, baseline)
|
|
239
|
+
stats_key = SimpleCov.coverage_statistics_key(criterion)
|
|
240
|
+
last_coverage_percent = baseline[stats_key]
|
|
146
241
|
# Treat a non-numeric percent like a missing one instead of
|
|
147
242
|
# raising out of the at_exit hook.
|
|
148
243
|
return unless last_coverage_percent.is_a?(Numeric)
|
|
149
244
|
|
|
150
|
-
current =
|
|
245
|
+
current = floored_percent_for(result, criterion) or return
|
|
151
246
|
(last_coverage_percent - current).floor(10)
|
|
152
247
|
end
|
|
153
248
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
# The state one measured run accumulates in one process: the pid and start
|
|
5
|
+
# time that identify it, the result it computes, the flags the merge
|
|
6
|
+
# coordination reads, and the serial the parent hands each forked
|
|
7
|
+
# subprocess. A new run is a new object, which is what lets anything needing
|
|
8
|
+
# a clean slate begin one instead of unsetting state by hand.
|
|
9
|
+
class CurrentRun
|
|
10
|
+
attr_accessor :pid, :process_start_time, :result
|
|
11
|
+
attr_writer :collating_result
|
|
12
|
+
|
|
13
|
+
# The result, the coordination flags and the identity belong to the run that
|
|
14
|
+
# ends; the fork genealogy belongs to the process and carries over. A child
|
|
15
|
+
# that restarts tracking must not forget it was forked, or it would produce
|
|
16
|
+
# the final report itself, and a parent must not recount from zero, or its
|
|
17
|
+
# next children's names would collide with its first ones (#1171).
|
|
18
|
+
def successor
|
|
19
|
+
following = self.class.new
|
|
20
|
+
following.subprocess_serial = @subprocess_serial
|
|
21
|
+
following.mark_forked_subprocess! if forked_subprocess?
|
|
22
|
+
following
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Answers exactly false while no result was ever stored, which is how "never
|
|
26
|
+
# computed" stays distinct from "cleared to nil".
|
|
27
|
+
def result?
|
|
28
|
+
instance_variable_defined?(:@result) && (_ = @result)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def collating_result?
|
|
32
|
+
!!@collating_result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reading a mark that was never set answers nil, which is the answer wanted.
|
|
36
|
+
def forked_subprocess?
|
|
37
|
+
!!@forked_subprocess
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mark_forked_subprocess!
|
|
41
|
+
@forked_subprocess = true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A monotonically increasing serial the parent assigns to each forked
|
|
45
|
+
# subprocess. The default `at_fork` builds the worker's command_name from
|
|
46
|
+
# this rather than the OS pid: the serial sequence is the same from one run
|
|
47
|
+
# to the next, so a re-run overwrites the previous run's resultset entries
|
|
48
|
+
# instead of writing uniquely-named ones that pile up (#1171).
|
|
49
|
+
def subprocess_serial
|
|
50
|
+
@subprocess_serial ||= 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def next_subprocess_serial!
|
|
54
|
+
@subprocess_serial = subprocess_serial + 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
protected
|
|
58
|
+
|
|
59
|
+
attr_writer :subprocess_serial
|
|
60
|
+
end
|
|
61
|
+
end
|