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.
Files changed (200) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -5
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/annotations.rb +166 -0
  13. data/lib/simplecov/cli/badge/svg.rb +70 -0
  14. data/lib/simplecov/cli/badge.rb +62 -0
  15. data/lib/simplecov/cli/clean.rb +23 -24
  16. data/lib/simplecov/cli/command_helpers.rb +55 -19
  17. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  18. data/lib/simplecov/cli/completions.rb +83 -0
  19. data/lib/simplecov/cli/coverage.rb +25 -28
  20. data/lib/simplecov/cli/coverage_file.rb +58 -14
  21. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  22. data/lib/simplecov/cli/dead_code.rb +139 -0
  23. data/lib/simplecov/cli/diff/output.rb +45 -0
  24. data/lib/simplecov/cli/diff.rb +35 -67
  25. data/lib/simplecov/cli/dotfile.rb +83 -38
  26. data/lib/simplecov/cli/git.rb +41 -0
  27. data/lib/simplecov/cli/history/output.rb +135 -0
  28. data/lib/simplecov/cli/history.rb +67 -0
  29. data/lib/simplecov/cli/merge.rb +34 -39
  30. data/lib/simplecov/cli/open.rb +12 -15
  31. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  32. data/lib/simplecov/cli/patch/output.rb +151 -0
  33. data/lib/simplecov/cli/patch.rb +175 -0
  34. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  35. data/lib/simplecov/cli/ratchet.rb +83 -0
  36. data/lib/simplecov/cli/report.rb +18 -24
  37. data/lib/simplecov/cli/run.rb +8 -9
  38. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  39. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  40. data/lib/simplecov/cli/serve.rb +27 -24
  41. data/lib/simplecov/cli/show/annotator.rb +87 -0
  42. data/lib/simplecov/cli/show/sweep.rb +36 -0
  43. data/lib/simplecov/cli/show.rb +133 -0
  44. data/lib/simplecov/cli/status/facts.rb +87 -0
  45. data/lib/simplecov/cli/status.rb +93 -0
  46. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  47. data/lib/simplecov/cli/tests.rb +148 -0
  48. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  49. data/lib/simplecov/cli/uncovered.rb +58 -34
  50. data/lib/simplecov/cli/usage.rb +153 -0
  51. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  52. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  53. data/lib/simplecov/cli/watch/poller.rb +45 -0
  54. data/lib/simplecov/cli/watch/session.rb +134 -0
  55. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  56. data/lib/simplecov/cli/watch.rb +85 -0
  57. data/lib/simplecov/cli.rb +43 -92
  58. data/lib/simplecov/color.rb +19 -37
  59. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  60. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  61. data/lib/simplecov/combine/identity_interner.rb +10 -13
  62. data/lib/simplecov/combine/interned_counts.rb +7 -9
  63. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  64. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  65. data/lib/simplecov/combine/results_combiner.rb +4 -21
  66. data/lib/simplecov/combine.rb +0 -6
  67. data/lib/simplecov/command_guesser.rb +20 -34
  68. data/lib/simplecov/configuration/baseline.rb +31 -0
  69. data/lib/simplecov/configuration/coverage.rb +91 -79
  70. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  71. data/lib/simplecov/configuration/deprecations.rb +21 -0
  72. data/lib/simplecov/configuration/eval_coverage.rb +7 -11
  73. data/lib/simplecov/configuration/filters.rb +40 -73
  74. data/lib/simplecov/configuration/formatting.rb +74 -83
  75. data/lib/simplecov/configuration/groups.rb +19 -13
  76. data/lib/simplecov/configuration/history.rb +44 -0
  77. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  78. data/lib/simplecov/configuration/merging.rb +94 -62
  79. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  80. data/lib/simplecov/configuration/production.rb +23 -0
  81. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  82. data/lib/simplecov/configuration/thresholds.rb +33 -62
  83. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  84. data/lib/simplecov/configuration.rb +70 -76
  85. data/lib/simplecov/configuration_error.rb +5 -0
  86. data/lib/simplecov/context_map/union.rb +81 -0
  87. data/lib/simplecov/context_map.rb +187 -0
  88. data/lib/simplecov/coverage_json.rb +2 -5
  89. data/lib/simplecov/coverage_statistics.rb +14 -24
  90. data/lib/simplecov/coverage_violations.rb +160 -65
  91. data/lib/simplecov/current_run.rb +61 -0
  92. data/lib/simplecov/defaults.rb +4 -23
  93. data/lib/simplecov/deprecation.rb +45 -27
  94. data/lib/simplecov/directive/erb.rb +44 -0
  95. data/lib/simplecov/directive/haml.rb +33 -0
  96. data/lib/simplecov/directive/indented_template.rb +37 -0
  97. data/lib/simplecov/directive/slim.rb +35 -0
  98. data/lib/simplecov/directive/template.rb +29 -0
  99. data/lib/simplecov/directive.rb +47 -62
  100. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  101. data/lib/simplecov/exit_codes/check.rb +17 -11
  102. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  103. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  104. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  105. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  106. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  107. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  109. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  110. data/lib/simplecov/exit_codes.rb +16 -10
  111. data/lib/simplecov/exit_handling.rb +94 -59
  112. data/lib/simplecov/file_list.rb +20 -51
  113. data/lib/simplecov/filter.rb +43 -66
  114. data/lib/simplecov/formatter/base.rb +37 -55
  115. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  116. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  117. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  118. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  119. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  120. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  121. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  122. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  123. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  124. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  125. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  126. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  127. data/lib/simplecov/formatter.rb +14 -10
  128. data/lib/simplecov/group_names.rb +13 -12
  129. data/lib/simplecov/history.rb +115 -0
  130. data/lib/simplecov/last_run.rb +6 -9
  131. data/lib/simplecov/lines_classifier.rb +31 -16
  132. data/lib/simplecov/load_global_config.rb +7 -7
  133. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  134. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  135. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  136. data/lib/simplecov/parallel_adapters.rb +24 -55
  137. data/lib/simplecov/parallel_coordination.rb +39 -49
  138. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  139. data/lib/simplecov/parallel_result_merger.rb +67 -126
  140. data/lib/simplecov/process.rb +14 -32
  141. data/lib/simplecov/production/error.rb +11 -0
  142. data/lib/simplecov/production/file_sink.rb +130 -0
  143. data/lib/simplecov/production.rb +249 -0
  144. data/lib/simplecov/profiles/rails.rb +4 -15
  145. data/lib/simplecov/profiles/root_filter.rb +0 -4
  146. data/lib/simplecov/profiles/strict.rb +11 -21
  147. data/lib/simplecov/profiles.rb +11 -33
  148. data/lib/simplecov/report_deferral.rb +11 -15
  149. data/lib/simplecov/report_stamp.rb +6 -9
  150. data/lib/simplecov/result/filter_config.rb +19 -0
  151. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  152. data/lib/simplecov/result/serialization.rb +43 -0
  153. data/lib/simplecov/result/source_file_builder.rb +7 -12
  154. data/lib/simplecov/result.rb +74 -117
  155. data/lib/simplecov/result_adapter.rb +56 -68
  156. data/lib/simplecov/result_merger/contexts.rb +22 -0
  157. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  158. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  159. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  160. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  161. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  162. data/lib/simplecov/result_merger.rb +59 -58
  163. data/lib/simplecov/result_processing.rb +92 -122
  164. data/lib/simplecov/run_identity.rb +16 -18
  165. data/lib/simplecov/simulate_coverage.rb +34 -50
  166. data/lib/simplecov/source_file/branch.rb +8 -33
  167. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  168. data/lib/simplecov/source_file/builder_context.rb +13 -13
  169. data/lib/simplecov/source_file/line.rb +13 -33
  170. data/lib/simplecov/source_file/line_builder.rb +9 -15
  171. data/lib/simplecov/source_file/method.rb +3 -7
  172. data/lib/simplecov/source_file/method_builder.rb +6 -15
  173. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  174. data/lib/simplecov/source_file/skip_chunks.rb +29 -29
  175. data/lib/simplecov/source_file/source_loader.rb +45 -35
  176. data/lib/simplecov/source_file/statistics.rb +20 -9
  177. data/lib/simplecov/source_file.rb +21 -51
  178. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +55 -139
  179. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  180. data/lib/simplecov/static_coverage_extractor/method_collector.rb +9 -26
  181. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  182. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  183. data/lib/simplecov/static_coverage_extractor/visitor.rb +45 -65
  184. data/lib/simplecov/static_coverage_extractor.rb +43 -75
  185. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  186. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  187. data/lib/simplecov/test_tracker/delta.rb +69 -0
  188. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  189. data/lib/simplecov/test_tracker.rb +162 -0
  190. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  191. data/lib/simplecov/useless_results_remover.rb +7 -6
  192. data/lib/simplecov/version.rb +1 -1
  193. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  194. data/lib/simplecov/view_coverage.rb +55 -0
  195. data/lib/simplecov-html.rb +1 -0
  196. data/lib/simplecov.rb +57 -113
  197. data/lib/simplecov_json_formatter.rb +1 -0
  198. data/man/simplecov.1 +354 -0
  199. data/sig/simplecov.rbs +528 -595
  200. 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.is_a?(Hash)
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.message
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
- # Holds the individual data of a coverage result.
5
- #
6
- # This is uniform across coverage criteria as they all have:
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
- # Requires only covered, missed and strength to be initialized.
37
- #
38
- # Other values are computed by this class.
39
- def initialize(covered:, missed:, omitted: 0, total_strength: 0.0, percent: nil)
40
- @covered = covered
41
- @missed = missed
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
- private
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.to_f / total
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 = percent_for(result, criterion) or next
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
- # @return [Array<Hash>] {:criterion, :expected, :actual}
21
- # Tolerance: `percent_for` floors the actual percent to two decimal
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 = percent_for(result, criterion) or next
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
- # @return [Array<Hash>] {:criterion, :expected, :actual, :filename, :project_filename}
33
- #
34
- # `defaults` is the criterion-keyed Hash applied to every file.
35
- # `overrides` is an ordered Hash<pattern, criterion_thresholds> of per-path
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 { |criterion, expected| file_minimum_violation(file, criterion, expected) }
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
- # @return [Array<Hash>] {:criterion, :maximum, :actual} where `actual`
55
- # is the observed drop (in percentage points) vs. the last run.
56
- def maximum_drop(result, thresholds, last_run: SimpleCov::LastRun.read)
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, last_run)
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
- private
87
+ private
66
88
 
67
- # Look up a criterion's percent on any coverage_statistics-bearing
68
- # object (Result, SourceFile, FileList). Returns nil — and the
69
- # caller silently skips — when the criterion was configured but not
70
- # actually measured by the runtime (e.g. `minimum_coverage branch:
71
- # 100` under the "strict" profile on JRuby, where the Coverage
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
- # Per-path matching for `minimum_coverage_by_file` overrides. Strings
94
- # ending in `/` are treated as directory prefixes; otherwise they must
95
- # match `project_filename` exactly. Regexps are tested via `match?`.
96
- # The configuration setter rejects anything other than String/Regexp,
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 = percent_for(file, criterion) or return
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 = percent_for(group, criterion) or next
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
- # warning: like every other enforcement message it must survive
127
- # `-W0` and `Warning.warn` hooks (see ExitCodes.print_error) and
128
- # honor the `print_errors` opt-out.
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 compute_drop(criterion, result, last_run)
139
- stats_key = SimpleCov.coverage_statistics_key(criterion)
140
- last_coverage_percent = last_run.dig(:result, stats_key)
141
- if last_coverage_percent.nil? && stats_key == :line
142
- last_coverage_percent = last_run.dig(:result, :covered_percent)
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
- # A hand-edited .last_run.json can carry any value type, and
145
- # LastRun.read only vouches for the top level being a Hash.
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 = percent_for(result, criterion) or return
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