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
@@ -1,22 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCov
4
- # ANSI colorization for stderr diagnostics. Thresholds mirror the
5
- # HTML formatter (>= 90 green, >= 75 yellow, otherwise red) so a
6
- # team's mental model of "what's the cutoff" is the same whether
7
- # they're reading the terminal output or the HTML report.
8
- #
9
- # Color precedence, highest first:
10
- #
11
- # - `SimpleCov.color = true` / `false` (programmatic override, wins
12
- # over everything; default is `:auto` which falls through)
13
- # - `NO_COLOR` env var (any non-empty value) → off (see no-color.org)
14
- # - `FORCE_COLOR` env var (any non-empty value) → on
15
- # - `stream.tty?` fallback
16
- #
17
- # `NO_COLOR` wins over `FORCE_COLOR` if both env vars are set.
4
+ # ANSI colorization for stderr diagnostics. Thresholds mirror the HTML
5
+ # formatter so a team's mental model of "what's the cutoff" is the same in
6
+ # the terminal and in the report.
18
7
  module Color
19
- GREEN_THRESHOLD = 90
8
+ GREEN_THRESHOLD = 90
20
9
  YELLOW_THRESHOLD = 75
21
10
 
22
11
  ANSI = {
@@ -26,51 +15,44 @@ module SimpleCov
26
15
  reset: "\e[0m"
27
16
  }.freeze
28
17
 
29
- module_function
18
+ extend self
30
19
 
31
- # `stream` is the IO that the colorized text is destined for. The
32
- # formatter writes to stderr, so that's the default. CLI subcommands
33
- # that print to stdout should pass `$stdout` so a redirected pipe
34
- # doesn't get ANSI sequences. See the module-level comment for
35
- # precedence.
20
+ # Precedence, highest first: an explicit `SimpleCov.color` true/false (the
21
+ # default `:auto` falls through), then `NO_COLOR`, then `FORCE_COLOR`, then
22
+ # `stream.tty?`. `NO_COLOR` wins over `FORCE_COLOR` when both are set.
23
+ #
24
+ # `stream` is the IO the colorized text is destined for. CLI subcommands
25
+ # that print to stdout should pass `$stdout` so a redirected pipe doesn't
26
+ # get ANSI sequences.
27
+ #
28
+ # `SimpleCov.color` only exists once the full library is loaded, and the
29
+ # standalone CLI loads this file without it, so a missing config reads as
30
+ # its `:auto` default. A parallel runner can also close a worker's stdio
31
+ # before its at_exit hooks run, and a closed stream is not a tty.
36
32
  def enabled?(stream = $stderr)
37
- # `SimpleCov.color` only exists once the full library is loaded.
38
- # The standalone CLI (`exe/simplecov`) loads `simplecov/color`
39
- # without `simplecov` itself to stay lightweight, so treat a
40
- # missing config the same as its `:auto` default: fall through to
41
- # the env vars and tty check below.
42
33
  config = SimpleCov.color if SimpleCov.respond_to?(:color)
43
34
  return config if [true, false].include?(config)
44
35
  return false if env_set?("NO_COLOR")
45
- return true if env_set?("FORCE_COLOR")
36
+ return true if env_set?("FORCE_COLOR")
46
37
 
47
38
  stream.tty?
48
39
  rescue IOError
49
- # A parallel runner can close a worker's stdio before its at_exit
50
- # hooks run (rspec-conductor does). A closed stream is not a tty.
51
40
  false
52
41
  end
53
42
 
54
43
  def for_percent(percent)
55
- return :green if percent >= GREEN_THRESHOLD
44
+ return :green if percent >= GREEN_THRESHOLD
56
45
  return :yellow if percent >= YELLOW_THRESHOLD
57
46
 
58
47
  :red
59
48
  end
60
49
 
61
- # Wrap `text` in the ANSI sequence for `color` (a key of ANSI).
62
- # Returns the bare text if color is disabled. The `enabled:`
63
- # keyword lets callers (e.g., CLI subcommands honoring `--no-color`)
64
- # override the auto-detection without touching env vars.
65
50
  def colorize(text, color, enabled: enabled?)
66
51
  return text unless enabled
67
52
 
68
53
  "#{ANSI.fetch(color)}#{text}#{ANSI.fetch(:reset)}"
69
54
  end
70
55
 
71
- # Render `percent` as a fixed "NN.NN%" string colored by which
72
- # threshold band it falls into. Callers that want a different
73
- # rendering of the number can pass the pre-rendered `text`.
74
56
  def colorize_percent(percent, text = nil, enabled: enabled?)
75
57
  colorize(text || format("%.2f%%", percent), for_percent(percent), enabled: enabled)
76
58
  end
@@ -6,49 +6,29 @@ require_relative "interned_counts"
6
6
 
7
7
  module SimpleCov
8
8
  module Combine
9
- #
10
- # Combine different branch coverage results on single file.
11
- #
12
- # Should be called through `CoverageAccumulator`.
13
9
  module BranchesCombiner
14
- module_function
10
+ extend self
15
11
 
16
- #
17
- # Return merged branches or the existed branch if other is missing.
18
- #
19
- # Branches inside files are always same if they exist, the difference only in coverage count.
20
- # Branch coverage report for any conditional case is built from hash, it's key is a condition and
21
- # it's body is a hash << keys from condition and value is coverage rate >>.
22
- # ex: branches => { [:if, 3, 8, 6, 8, 36] =>
23
- # {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... }
24
- # We create copy of result and update it values depending on the combined branches coverage values.
25
- #
26
- # @return [Hash]
27
- #
28
12
  def combine(coverage_a, coverage_b)
29
13
  merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
30
14
  materialize(absorb(merged, coverage_b))
31
15
  end
32
16
 
33
17
  # Folds `coverage` into `target`, an interned table keyed by condition
34
- # identity. Kept interned (rather than turned back into tuple keys per
35
- # merge) so a fold over N resultsets interns each condition once
36
- # instead of re-interning the whole accumulated table N times.
37
- #
38
- # @return [Hash] `target`
18
+ # identity. Kept interned rather than turned back into tuple keys per merge,
19
+ # so a fold over N resultsets interns each condition once instead of
20
+ # re-interning the whole accumulated table N times.
39
21
  def absorb(target, coverage)
40
22
  return target unless coverage
41
23
 
42
24
  coverage.each do |condition, branches_inside|
43
25
  entry = target[identities[condition]] ||= new_condition(condition)
44
- InternedCounts.absorb_counts(entry[1], branches_inside, identities)
26
+ InternedCounts.absorb_counts(entry.fetch(1), branches_inside, identities)
45
27
  end
46
28
 
47
29
  target
48
30
  end
49
31
 
50
- # Split out so the empty arm table has somewhere to carry its type
51
- # annotation. Only allocated the first time a condition is seen.
52
32
  def new_condition(condition)
53
33
  arms = {} #: Hash[untyped, untyped]
54
34
  [condition, arms]
@@ -56,20 +36,16 @@ module SimpleCov
56
36
 
57
37
  # Turns an interned table back into the tuple-keyed hash the rest of
58
38
  # SimpleCov reads. Done once, at the end of a fold.
59
- #
60
- # @return [Hash]
61
39
  def materialize(target)
62
40
  target.values.to_h { |condition, branches| [condition, branches.values.to_h] }
63
41
  end
64
42
 
65
- # Bounded by the project's branch count, like `RubyDataParser`'s parse
66
- # cache.
67
43
  def identities
68
44
  @identities ||= IdentityInterner.build { |tuple| tuple_identity(tuple) }
69
45
  end
70
46
 
71
- # Branches match on source span, whatever ids the recording processes
72
- # handed them (issue #1233).
47
+ # Branches match on source span, whatever ids the recording processes handed
48
+ # them (#1233).
73
49
  def tuple_identity(tuple)
74
50
  type, _id, start_line, start_column, end_line, end_column = SourceFile::RubyDataParser.call(tuple)
75
51
  [type, start_line, start_column, end_line, end_column].freeze
@@ -8,52 +8,22 @@ module SimpleCov
8
8
  module Combine
9
9
  #
10
10
  # Folds any number of resultsets' coverage into one, absorbing them one
11
- # at a time.
12
- #
13
- # This replaces a pairwise `reduce` over the resultsets, which rebuilt
14
- # the entire accumulated structure on every one of its N-1 steps: a
15
- # fresh outer file hash, a fresh lines array for every file, and a fresh
16
- # branch / method table for every file whose keys were re-interned from
17
- # their tuples each time. On a 160-worker run over ~1,800 files that is
18
- # ~290,000 whole-file rebuilds to produce ~1,800 files of output.
19
- #
20
- # The accumulated side is private to the fold, so it can be updated in
21
- # place instead, and the interned tables only have to be turned back into
22
- # tuple-keyed hashes once, at the end.
23
- #
24
- # Resultsets are absorbed one at a time rather than taken as a list, so
25
- # `ResultMerger.merge_results` can keep reading and discarding them one
26
- # file at a time — reading 100s of CI jobs' worth of coverage into memory
27
- # at once is what that method is careful not to do.
11
+ # at a time so that a caller reading resultsets off disk never holds more
12
+ # than one in memory, and so the accumulated side can be updated in place
13
+ # rather than rebuilt once per resultset.
28
14
  #
29
15
  class CoverageAccumulator
30
- # A file some process actually loaded has at least one executed line;
31
- # a simulated (never-loaded) file's lines are all `nil` or `0`. This is
32
- # the signal the merge reconciles synthesized tuples on, and the one
33
- # `ResultMerger` re-derives a merged result's not-loaded set from, so it
34
- # lives here rather than being spelled out at each site.
16
+ # A file some process actually loaded has at least one executed line; a
17
+ # simulated (never-loaded) file's lines are all `nil` or `0`.
35
18
  #
36
- # `Array()` plus the `Numeric` test rather than a bare
37
- # `any?(&:positive?)` because this reads straight off a parsed
38
- # resultset, which is external input: a file written by another
39
- # SimpleCov version, or hand-edited, can carry anything under
40
- # "lines" — a Hash coerces to pairs, a String to itself. Keeping a
41
- # malformed entry to a wrong answer instead of a NoMethodError out
42
- # of the middle of a merge is the point.
19
+ # `Array()` plus the `Numeric` test rather than `any?(&:positive?)`
20
+ # because this reads straight off a parsed resultset, which is external
21
+ # input and can carry anything under "lines".
43
22
  def self.executed?(lines)
44
23
  counts = Array(lines) #: Array[untyped]
45
24
  counts.any? { |count| count.is_a?(Numeric) && count.positive? }
46
25
  end
47
26
 
48
- #
49
- # Folds `[command_names, coverage]` pairs into one merged coverage.
50
- #
51
- # `pairs` is only ever iterated, so a caller that reads resultsets off
52
- # disk can hand in a lazy enumerable and never hold more than one in
53
- # memory — which is what `ResultMerger.merge_results` is careful about.
54
- #
55
- # @return [Array] the concatenated command names and the merged coverage
56
- #
57
27
  def self.fold(pairs)
58
28
  accumulator = new
59
29
  command_names = [] #: Array[String]
@@ -67,8 +37,6 @@ module SimpleCov
67
37
  end
68
38
 
69
39
  def initialize
70
- @files = {} #: Hash[String, untyped]
71
- @absorbed = false
72
40
  # Whether a criterion is enabled can't change mid-fold, so read it once
73
41
  # here rather than once per file. `branch_coverage?` reaches
74
42
  # `Coverage.supported?`, and a large parallel run merges thousands of
@@ -77,48 +45,31 @@ module SimpleCov
77
45
  @method_coverage = SimpleCov.method_coverage?
78
46
  end
79
47
 
80
- #
81
- # Folds one resultset's coverage (filename => per-file coverage) into
82
- # the accumulator. A `nil` coverage is ignored — a resultset that
83
- # carried nothing contributes nothing.
84
- #
85
- # @return [CoverageAccumulator] self, so absorbs can be chained
86
- #
87
48
  def absorb(coverage)
88
49
  return self unless coverage
89
50
 
90
- @absorbed = true
51
+ files = (@files ||= {}) #: Hash[String, untyped]
91
52
  coverage.each do |filename, file_coverage|
92
- @files[filename] = merge_file(@files[filename], file_coverage)
53
+ files[filename] = merge_file(files[filename], file_coverage)
93
54
  end
94
55
 
95
56
  self
96
57
  end
97
58
 
98
- #
99
- # The merged coverage, or `nil` when nothing was absorbed at all — the
100
- # caller needs to tell "no results" apart from "results that cover
101
- # nothing", and only the former means there is no report to build.
102
- #
103
- # A file only one resultset carried is returned exactly as it came in,
104
- # untouched: with nothing to merge it into, copying it would only cost
105
- # memory.
106
- #
107
- # @return [Hash, nil]
108
- #
59
+ # `nil` until the first absorb, so callers can tell "no results" apart
60
+ # from "results that cover nothing": only the former means there is no
61
+ # report to build.
109
62
  def result
110
- return nil unless @absorbed
111
-
112
- @files.transform_values do |entry|
113
- entry.is_a?(MergedFile) ? entry.to_h : entry
63
+ @files&.transform_values do |entry|
64
+ case entry
65
+ when MergedFile then entry.to_h
66
+ else entry
67
+ end
114
68
  end
115
69
  end
116
70
 
117
- private
71
+ private
118
72
 
119
- # The first sighting of a file is kept as-is; the second promotes it to
120
- # a `MergedFile` that owns its state, and every later one folds into
121
- # that same object.
122
73
  def merge_file(existing, file_coverage)
123
74
  case existing
124
75
  when nil then file_coverage
@@ -128,34 +79,25 @@ module SimpleCov
128
79
  end
129
80
 
130
81
  #
131
- # One file's accumulated coverage, against state this object owns
132
- # outright rather than rebuilt for each merged pair.
82
+ # One file's accumulated coverage.
83
+ #
84
+ # A criterion is carried for this file when the data carries it, not when
85
+ # this process happens to measure it: `simplecov merge` never ran
86
+ # SimpleCov.start at all, and dropping a table it did not ask for would
87
+ # lose branch data the producers did measure. A nil table means nobody
88
+ # measured that criterion, an empty one means it was measured and the
89
+ # file has none.
133
90
  #
134
91
  class MergedFile
135
- # A criterion is carried for this file when the data carries it, not
136
- # when this process happens to measure it. A merge runs on behalf of
137
- # the processes that produced the resultsets and does not necessarily
138
- # share their configuration: `simplecov merge` never ran SimpleCov.start
139
- # at all, and dropping a table it did not ask for would lose branch data
140
- # the producers did measure. A nil table means nobody measured that
141
- # criterion; an empty one means it was measured and the file has none.
142
92
  def initialize(coverage, branches:, methods:)
143
93
  @branch_coverage = branches
144
94
  @method_coverage = methods
145
- @lines = coverage["lines"]&.dup
146
- # Branch coverage always reports a table, even an empty one, so
147
- # `SourceFile` can tell "no branches in this file" from "branch
148
- # coverage was off". Method coverage stays `nil` until some resultset
149
- # actually carries methods.
150
- @branches = BranchesCombiner.absorb({}, coverage["branches"]) if branches || coverage["branches"]
151
- @methods = MethodsCombiner.absorb(nil, coverage["methods"]) if methods || coverage["methods"]
95
+ @lines = LinesCombiner.merge_into(nil, coverage["lines"])
96
+ branches_table = coverage["branches"]
97
+ @branches = BranchesCombiner.absorb(new_table, branches_table) if branches || branches_table
98
+ @methods = MethodsCombiner.absorb(nil, coverage["methods"])
152
99
  end
153
100
 
154
- #
155
- # Folds one more resultset's coverage for this file in.
156
- #
157
- # @return [MergedFile] self
158
- #
159
101
  def absorb(coverage)
160
102
  reconcile_synthesized(coverage)
161
103
  @lines = LinesCombiner.merge_into(@lines, coverage["lines"])
@@ -169,33 +111,26 @@ module SimpleCov
169
111
  merged
170
112
  end
171
113
 
172
- private
114
+ private
173
115
 
174
116
  def new_table
175
117
  {} #: Hash[untyped, untyped]
176
118
  end
177
119
 
178
120
  # When exactly one side of the merge was actually executed, its branch
179
- # and method tuples are authoritative and the other side's are
180
- # dropped. A simulated entry (SimulateCoverage backfills
181
- # tracked-but-unloaded files) synthesizes those tuples statically, so
182
- # a location that drifts from what Coverage emits would otherwise be
183
- # unioned in by position and survive as a phantom, permanently-missed
184
- # branch (see #1233). This contains any such drift to denominator
185
- # inflation for files no process loaded, rather than a false miss on a
186
- # covered file. Lines are never dropped: a simulated file's line shape
187
- # is correct and carries the unloaded-file denominator (#1059).
188
- #
189
- # The accumulated side's executed-ness is re-read on every absorb
190
- # because folding in an executed resultset can flip it, exactly as it
191
- # would have flipped between two steps of the pairwise fold.
121
+ # and method tuples are authoritative and the other side's are dropped.
122
+ # SimulateCoverage synthesizes those tuples statically, so a location
123
+ # that drifts from what Coverage emits would otherwise be unioned in by
124
+ # position and survive as a phantom, permanently-missed branch (#1233).
125
+ # Lines are never dropped: a simulated file's line shape is correct and
126
+ # carries the unloaded-file denominator (#1059).
192
127
  def reconcile_synthesized(coverage)
193
- return absorb_tuples(coverage) unless judgeable?(@lines) && judgeable?(coverage["lines"])
128
+ incoming_lines = coverage["lines"]
129
+ return absorb_tuples(coverage) unless lines_measured?(@lines) && lines_measured?(incoming_lines)
194
130
 
195
- accumulated_executed = executed?(@lines)
196
- incoming_executed = executed?(coverage["lines"])
131
+ incoming_executed = executed?(incoming_lines)
197
132
 
198
- if accumulated_executed == incoming_executed
133
+ if executed?(@lines).equal?(incoming_executed)
199
134
  absorb_tuples(coverage)
200
135
  elsif incoming_executed
201
136
  replace_tuples(coverage)
@@ -204,58 +139,30 @@ module SimpleCov
204
139
  end
205
140
  end
206
141
 
207
- # Both sides agree on whether they ran: union their tuples. A criterion
208
- # only the incoming side carries comes into play here, because its
209
- # tuples survive. It deliberately does not come into play on the path
210
- # that drops them: promoting there would advertise an empty table
211
- # sourced from data this merge just discarded, which claims the file
212
- # has no branches when what is true is that nobody measured them.
213
142
  def absorb_tuples(coverage)
214
- @branches = BranchesCombiner.absorb(@branches || new_table, coverage["branches"]) if
215
- @branches || coverage["branches"]
216
- @methods = MethodsCombiner.absorb(@methods, coverage["methods"]) if @methods || coverage["methods"]
143
+ branches_table = coverage["branches"]
144
+ @branches = BranchesCombiner.absorb(@branches || new_table, branches_table) if branches_table
145
+ @methods = MethodsCombiner.absorb(@methods, coverage["methods"])
217
146
  end
218
147
 
219
- # Only the accumulated side ran, so the incoming tuples are synthesized
220
- # and contribute nothing. When the discarded side carried a methods
221
- # table, a measuring process still gets one — the empty table stands in
222
- # for the blanked side, which is what a pair against a blank one used
223
- # to produce for a file that has no methods yet. When no side has
224
- # carried methods at all, the table stays nil (the rule `initialize`
225
- # documents), the same answer the union path gives.
226
148
  def drop_incoming_tuples(coverage)
227
149
  @methods ||= {} if @method_coverage && coverage["methods"] #: Hash[untyped, untyped]
228
150
  end
229
151
 
230
- # Only the incoming side ran, so what's accumulated is synthesized:
231
- # start its tuples over from the executed side's. The authoritative
232
- # side decides which criteria the file carries, not just their tuples.
233
- # Keeping a table in play that only the dropped side carried would make
234
- # the answer depend on which side was seen first.
235
152
  def replace_tuples(coverage)
236
153
  @branches = authoritative_table(BranchesCombiner, coverage["branches"], @branch_coverage)
237
- # Methods stay nil until some resultset carries them (see
238
- # `initialize`), so a measuring process keeps an empty table only
239
- # when one did.
240
154
  @methods = authoritative_table(MethodsCombiner, coverage["methods"], @method_coverage && !@methods.nil?)
241
155
  end
242
156
 
243
- # The executed side's tuples when it has them. Otherwise `keep_empty`
244
- # decides whether the criterion survives as an empty table, which is
245
- # what keeps `SourceFile` able to tell "no branches here" from
246
- # "branches were not measured".
247
157
  def authoritative_table(combiner, table, keep_empty)
248
158
  return combiner.absorb(new_table, table) if table
249
159
 
250
- keep_empty ? new_table : nil
160
+ new_table if keep_empty
251
161
  end
252
162
 
253
- # Whether a side can be judged at all. Absent lines do not mean the side
254
- # never ran: a branch-only or method-only run omits them even for the
255
- # files it loaded, so treating that as synthesized would discard real
256
- # tuples. Unknown means union, which is already what happens when
257
- # neither side carries lines.
258
- def judgeable?(lines)
163
+ # Absent lines do not mean the side never ran: a branch-only or
164
+ # method-only run omits them even for the files it loaded.
165
+ def lines_measured?(lines)
259
166
  !Array(lines).empty?
260
167
  end
261
168
 
@@ -2,21 +2,18 @@
2
2
 
3
3
  module SimpleCov
4
4
  module Combine
5
+ # Builds the raw-key => interned-identity caches the tuple combiners keep. A
6
+ # key is mapped on first sight and kept: the pairwise fold would otherwise
7
+ # re-derive the accumulator's identities on every one of its merges, always
8
+ # to the same answer.
5
9
  #
6
- # Builds the raw-key => interned-identity caches the tuple combiners keep.
7
- #
8
- # The cache maps a raw key to its interned identity on first sight and
9
- # keeps it: the pairwise fold would otherwise re-derive the accumulator's
10
- # identities on every one of its merges, always to the same answer.
11
- #
12
- # Identities exist only to be `combine`'s hash keys, and `Array#hash` is
13
- # not memoized, so keying on the identity tuple itself would rehash its
14
- # elements for every key of both sides of every merge. An Integer hashes
15
- # as an immediate. Two keys share an id exactly when their identities are
16
- # equal, so the string and array forms of one key still merge together.
17
- #
10
+ # Identities exist only to be hash keys, and `Array#hash` is not memoized, so
11
+ # keying on the identity tuple itself would rehash its elements for every key
12
+ # of both sides of every merge. An Integer hashes as an immediate. Two keys
13
+ # share an id exactly when their identities are equal, so the string and
14
+ # array forms of one key still merge together.
18
15
  module IdentityInterner
19
- module_function
16
+ extend self
20
17
 
21
18
  def build
22
19
  ids = {} #: Hash[::Array[untyped], Integer]
@@ -2,17 +2,15 @@
2
2
 
3
3
  module SimpleCov
4
4
  module Combine
5
- # The inner merge loop the branches and methods combiners share:
6
- # fold `source`'s tuple => count pairs into `target`, an interned
7
- # table keyed by each tuple's merge identity and holding
8
- # `[raw_key, count]` pairs. The first-seen raw key is kept for
9
- # display; counts sum. `target` and its pairs are always built by
10
- # this loop, so updating them in place can't reach data a caller
11
- # still holds.
5
+ # The inner merge loop the branches and methods combiners share: folds
6
+ # `source`'s tuple => count pairs into `target`, an interned table keyed by
7
+ # each tuple's merge identity and holding `[raw_key, count]` pairs. The
8
+ # first-seen raw key is kept for display; counts sum. `target` and its pairs
9
+ # are always built by this loop, so updating them in place can't reach data a
10
+ # caller still holds.
12
11
  module InternedCounts
13
- module_function
12
+ extend self
14
13
 
15
- # @return [Hash] `target`
16
14
  def absorb_counts(target, source, identities)
17
15
  source.each do |key, count|
18
16
  interned = identities[key]
@@ -2,72 +2,48 @@
2
2
 
3
3
  module SimpleCov
4
4
  module Combine
5
- #
6
- # Combine two different lines coverage results on same file
7
- #
8
- # Should be called through `CoverageAccumulator`.
9
5
  module LinesCombiner
10
- module_function
6
+ extend self
11
7
 
12
- # Folds `source` into `target` rather than building a third array.
13
- # Only for a `target` the caller owns outright: a caller holding a
14
- # reference to it (e.g. the parsed `coverage` key of a resultset
15
- # hash being passed into a second merge) would see it change.
16
- # `source` is never touched.
8
+ # Folds `source` into `target` rather than building a third array, so only
9
+ # for a `target` the caller owns outright: a caller still holding a
10
+ # reference would see it change. `source` is never touched.
17
11
  #
18
- # Two runs of the same source file should agree on which lines
19
- # are coverage-relevant (`nil` for comments / whitespace, `0`+
20
- # for executable). When they don't, treat "relevant on either
21
- # side" as relevant rather than masking a real `0` as `nil`,
22
- # which would silently drop an uncovered line from the
23
- # denominator and inflate the percentage:
12
+ # Two runs of the same source file should agree on which lines are
13
+ # coverage-relevant (`nil` for comments, `0`+ for executable). When they
14
+ # don't, "relevant on either side" wins rather than masking a real `0` as
15
+ # `nil`, which would drop an uncovered line from the denominator:
24
16
  #
25
- # => nil + nil = nil
26
- # => nil + int = int (preserves a relevant-but-uncovered 0)
27
- # => int + int = int (sum)
28
- #
29
- # The loop is written out rather than dispatching a block per
30
- # element because this is the innermost loop of a merge: a
31
- # 160-worker run over ~1,800 files folds tens of millions of line
32
- # counts through it, and a call per count is a measurable share of
33
- # that.
34
- #
35
- # @return [Array] the array to keep — `target` itself once there is one
17
+ # nil + nil = nil
18
+ # nil + int = int (preserves a relevant-but-uncovered 0)
19
+ # int + int = int (sum)
36
20
  def merge_into(target, source)
37
21
  return target unless source
38
22
  return source.dup unless target
39
23
 
40
24
  size = source.size
41
- target.concat(Array.new(size - target.size)) if target.size < size
25
+ target.fill(nil, target.size, size - target.size)
42
26
  sum_into(target, source, size)
43
27
  end
44
28
 
45
- # Split out only to keep `merge_into` short; it is the same loop.
29
+ # The loop is written out rather than dispatching a block per element
30
+ # because this is the innermost loop of a merge: a 160-worker run over
31
+ # ~1,800 files folds tens of millions of line counts through it.
46
32
  #
47
- # The `Integer` tests coerce malformed counts (a "3" written by a
48
- # hand-edited or foreign resultset, a JSON float) with `to_i`, so
49
- # external input merges to a wrong answer instead of raising out of
50
- # the middle of a merge. An `is_a?` per count is far cheaper than
51
- # the block dispatch this loop exists to avoid, and the well-formed
52
- # fast path stays branch-for-branch what it was.
33
+ # Both sides go through `to_i`, which is what keeps the merge total over
34
+ # external input: a count a hand-edited or foreign resultset wrote as "3" or
35
+ # as a JSON float merges to a wrong answer instead of raising out of the
36
+ # middle of a merge. On the accumulated side it doubles as the nil arm.
53
37
  def sum_into(target, source, size)
54
38
  index = 0
55
39
  while index < size
56
40
  if (value = source[index])
57
- value = value.to_i unless value.is_a?(Integer)
58
- existing = target[index]
59
- target[index] = existing.is_a?(Integer) ? existing + value : coerce_add(existing, value)
41
+ target[index] = target.at(index).to_i + value.to_i
60
42
  end
61
43
  index += 1
62
44
  end
63
45
  target
64
46
  end
65
-
66
- # The rare arm: `existing` is nil (line not yet relevant in the
67
- # target) or malformed external input.
68
- def coerce_add(existing, value)
69
- existing.nil? ? value : existing.to_i + value
70
- end
71
47
  end
72
48
  end
73
49
  end