simplecov 0.22.0 → 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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +173 -875
  4. data/exe/simplecov +8 -0
  5. data/lib/minitest/simplecov_plugin.rb +18 -7
  6. data/lib/simplecov/atomic_file.rb +67 -0
  7. data/lib/simplecov/autostart.rb +9 -0
  8. data/lib/simplecov/baseline/parser.rb +58 -0
  9. data/lib/simplecov/baseline.rb +170 -0
  10. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +148 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +86 -0
  17. data/lib/simplecov/cli/command_helpers.rb +91 -0
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +83 -0
  21. data/lib/simplecov/cli/coverage_file.rb +109 -0
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +115 -0
  26. data/lib/simplecov/cli/dotfile.rb +156 -0
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +115 -0
  31. data/lib/simplecov/cli/open.rb +45 -0
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/report.rb +91 -0
  38. data/lib/simplecov/cli/run.rb +35 -0
  39. data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
  40. data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
  41. data/lib/simplecov/cli/serve.rb +88 -0
  42. data/lib/simplecov/cli/show/annotator.rb +87 -0
  43. data/lib/simplecov/cli/show/sweep.rb +36 -0
  44. data/lib/simplecov/cli/show.rb +133 -0
  45. data/lib/simplecov/cli/status/facts.rb +87 -0
  46. data/lib/simplecov/cli/status.rb +93 -0
  47. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  48. data/lib/simplecov/cli/tests.rb +148 -0
  49. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  50. data/lib/simplecov/cli/uncovered.rb +127 -0
  51. data/lib/simplecov/cli/usage.rb +153 -0
  52. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  53. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  54. data/lib/simplecov/cli/watch/poller.rb +45 -0
  55. data/lib/simplecov/cli/watch/session.rb +134 -0
  56. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  57. data/lib/simplecov/cli/watch.rb +85 -0
  58. data/lib/simplecov/cli.rb +114 -0
  59. data/lib/simplecov/color.rb +65 -0
  60. data/lib/simplecov/combine/branches_combiner.rb +44 -21
  61. data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
  62. data/lib/simplecov/combine/identity_interner.rb +27 -0
  63. data/lib/simplecov/combine/interned_counts.rb +28 -0
  64. data/lib/simplecov/combine/lines_combiner.rb +35 -29
  65. data/lib/simplecov/combine/methods_combiner.rb +48 -0
  66. data/lib/simplecov/combine/results_combiner.rb +9 -49
  67. data/lib/simplecov/combine.rb +0 -24
  68. data/lib/simplecov/command_guesser.rb +99 -42
  69. data/lib/simplecov/configuration/baseline.rb +31 -0
  70. data/lib/simplecov/configuration/coverage.rb +184 -0
  71. data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
  72. data/lib/simplecov/configuration/deprecations.rb +21 -0
  73. data/lib/simplecov/configuration/eval_coverage.rb +37 -0
  74. data/lib/simplecov/configuration/filters.rb +131 -0
  75. data/lib/simplecov/configuration/formatting.rb +129 -0
  76. data/lib/simplecov/configuration/groups.rb +48 -0
  77. data/lib/simplecov/configuration/history.rb +44 -0
  78. data/lib/simplecov/configuration/ignored_entries.rb +72 -0
  79. data/lib/simplecov/configuration/merging.rb +197 -0
  80. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  81. data/lib/simplecov/configuration/production.rb +23 -0
  82. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  83. data/lib/simplecov/configuration/thresholds.rb +150 -0
  84. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  85. data/lib/simplecov/configuration.rb +100 -443
  86. data/lib/simplecov/configuration_error.rb +5 -0
  87. data/lib/simplecov/context_map/union.rb +81 -0
  88. data/lib/simplecov/context_map.rb +187 -0
  89. data/lib/simplecov/coverage_json.rb +21 -0
  90. data/lib/simplecov/coverage_statistics.rb +24 -28
  91. data/lib/simplecov/coverage_violations.rb +254 -0
  92. data/lib/simplecov/current_run.rb +61 -0
  93. data/lib/simplecov/defaults.rb +20 -28
  94. data/lib/simplecov/deprecation.rb +63 -0
  95. data/lib/simplecov/directive/erb.rb +44 -0
  96. data/lib/simplecov/directive/haml.rb +33 -0
  97. data/lib/simplecov/directive/indented_template.rb +37 -0
  98. data/lib/simplecov/directive/slim.rb +35 -0
  99. data/lib/simplecov/directive/template.rb +29 -0
  100. data/lib/simplecov/directive.rb +152 -0
  101. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  102. data/lib/simplecov/exit_codes/check.rb +39 -0
  103. data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
  104. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
  105. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  106. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  107. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
  110. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
  111. data/lib/simplecov/exit_codes.rb +26 -0
  112. data/lib/simplecov/exit_handling.rb +163 -0
  113. data/lib/simplecov/file_list.rb +63 -48
  114. data/lib/simplecov/filter.rb +98 -42
  115. data/lib/simplecov/formatter/base.rb +100 -0
  116. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  117. data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
  118. data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
  119. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
  120. data/lib/simplecov/formatter/html_formatter.rb +88 -0
  121. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
  122. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  123. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
  124. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
  125. data/lib/simplecov/formatter/json_formatter.rb +40 -0
  126. data/lib/simplecov/formatter/multi_formatter.rb +8 -10
  127. data/lib/simplecov/formatter/simple_formatter.rb +12 -15
  128. data/lib/simplecov/formatter.rb +21 -1
  129. data/lib/simplecov/group_names.rb +33 -0
  130. data/lib/simplecov/history.rb +115 -0
  131. data/lib/simplecov/last_run.rb +16 -5
  132. data/lib/simplecov/lines_classifier.rb +67 -19
  133. data/lib/simplecov/load_global_config.rb +12 -4
  134. data/lib/simplecov/parallel_adapters/base.rb +50 -0
  135. data/lib/simplecov/parallel_adapters/generic.rb +33 -0
  136. data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
  137. data/lib/simplecov/parallel_adapters.rb +54 -0
  138. data/lib/simplecov/parallel_coordination.rb +124 -0
  139. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  140. data/lib/simplecov/parallel_result_merger.rb +171 -0
  141. data/lib/simplecov/process.rb +34 -12
  142. data/lib/simplecov/production/error.rb +11 -0
  143. data/lib/simplecov/production/file_sink.rb +130 -0
  144. data/lib/simplecov/production.rb +249 -0
  145. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  146. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  147. data/lib/simplecov/profiles/rails.rb +13 -10
  148. data/lib/simplecov/profiles/root_filter.rb +2 -5
  149. data/lib/simplecov/profiles/strict.rb +22 -0
  150. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  151. data/lib/simplecov/profiles.rb +26 -19
  152. data/lib/simplecov/report_deferral.rb +45 -0
  153. data/lib/simplecov/report_stamp.rb +25 -0
  154. data/lib/simplecov/result/filter_config.rb +19 -0
  155. data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
  156. data/lib/simplecov/result/serialization.rb +43 -0
  157. data/lib/simplecov/result/source_file_builder.rb +46 -0
  158. data/lib/simplecov/result.rb +123 -47
  159. data/lib/simplecov/result_adapter.rb +130 -11
  160. data/lib/simplecov/result_merger/contexts.rb +22 -0
  161. data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
  162. data/lib/simplecov/result_merger/resultset_file.rb +67 -0
  163. data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
  164. data/lib/simplecov/result_merger/resultset_store.rb +62 -0
  165. data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
  166. data/lib/simplecov/result_merger.rb +120 -124
  167. data/lib/simplecov/result_processing.rb +188 -0
  168. data/lib/simplecov/run_identity.rb +75 -0
  169. data/lib/simplecov/simulate_coverage.rb +72 -17
  170. data/lib/simplecov/source_file/branch.rb +8 -35
  171. data/lib/simplecov/source_file/branch_builder.rb +95 -0
  172. data/lib/simplecov/source_file/builder_context.rb +28 -0
  173. data/lib/simplecov/source_file/line.rb +17 -32
  174. data/lib/simplecov/source_file/line_builder.rb +37 -0
  175. data/lib/simplecov/source_file/method.rb +54 -0
  176. data/lib/simplecov/source_file/method_builder.rb +51 -0
  177. data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
  178. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  179. data/lib/simplecov/source_file/source_loader.rb +89 -0
  180. data/lib/simplecov/source_file/statistics.rb +76 -0
  181. data/lib/simplecov/source_file.rb +64 -260
  182. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
  183. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
  184. data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
  185. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
  186. data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
  187. data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
  188. data/lib/simplecov/static_coverage_extractor.rb +78 -0
  189. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  190. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  191. data/lib/simplecov/test_tracker/delta.rb +69 -0
  192. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  193. data/lib/simplecov/test_tracker.rb +162 -0
  194. data/lib/simplecov/unloaded_file_injector.rb +62 -0
  195. data/lib/simplecov/useless_results_remover.rb +17 -7
  196. data/lib/simplecov/version.rb +1 -1
  197. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  198. data/lib/simplecov/view_coverage.rb +55 -0
  199. data/lib/simplecov-html.rb +5 -0
  200. data/lib/simplecov.rb +143 -394
  201. data/lib/simplecov_json_formatter.rb +5 -0
  202. data/man/simplecov.1 +354 -0
  203. data/sig/simplecov.rbs +1710 -0
  204. metadata +183 -61
  205. data/CHANGELOG.md +0 -191
  206. data/doc/alternate-formatters.md +0 -71
  207. data/doc/commercial-services.md +0 -25
  208. data/doc/editor-integration.md +0 -18
  209. data/lib/simplecov/combine/files_combiner.rb +0 -24
  210. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "version"
5
+ require_relative "color"
6
+ require_relative "cli/dotfile"
7
+ require_relative "cli/badge"
8
+ require_relative "cli/clean"
9
+ require_relative "cli/completions"
10
+ require_relative "cli/coverage_file"
11
+ require_relative "cli/coverage"
12
+ require_relative "cli/dead_code"
13
+ require_relative "cli/diff"
14
+ require_relative "cli/history"
15
+ require_relative "cli/merge"
16
+ require_relative "cli/open"
17
+ require_relative "cli/patch"
18
+ require_relative "cli/ratchet"
19
+ require_relative "cli/report"
20
+ require_relative "cli/run"
21
+ require_relative "cli/serve"
22
+ require_relative "cli/show"
23
+ require_relative "cli/status"
24
+ require_relative "cli/tests"
25
+ require_relative "cli/affected"
26
+ require_relative "cli/uncovered"
27
+ require_relative "cli/usage"
28
+ require_relative "cli/watch"
29
+
30
+ module SimpleCov
31
+ # Lightweight command-line front-end. Read-only subcommands consume
32
+ # JSONFormatter output, which the bundled HTMLFormatter already drops
33
+ # alongside the HTML, so no runtime hooking is needed for those. Default
34
+ # paths follow the project's `.simplecov` `coverage_dir` setting when one is
35
+ # present.
36
+ module CLI
37
+ COMMANDS = {
38
+ "coverage" => Coverage,
39
+ "show" => Show,
40
+ "run" => Run,
41
+ "open" => Open,
42
+ "report" => Report,
43
+ "status" => Status,
44
+ "history" => History,
45
+ "uncovered" => Uncovered,
46
+ "tests" => Tests,
47
+ "affected" => Affected,
48
+ "merge" => Merge,
49
+ "diff" => Diff,
50
+ "patch" => Patch,
51
+ "ratchet" => Ratchet,
52
+ "dead-code" => DeadCode,
53
+ "serve" => Serve,
54
+ "watch" => Watch,
55
+ "badge" => Badge,
56
+ "clean" => Clean,
57
+ "completions" => Completions
58
+ }.freeze
59
+
60
+ extend self
61
+
62
+ # Resolved once per process, by walking up from cwd for a `.simplecov` and
63
+ # loading it with `SimpleCov.start` neutered so it can't trigger tracking
64
+ # just because we asked it for a config value.
65
+ def coverage_dir
66
+ @coverage_dir ||= Dotfile.coverage_dir
67
+ end
68
+
69
+ def default_input
70
+ File.join(coverage_dir, "coverage.json")
71
+ end
72
+
73
+ def color_enabled?(opts, stream)
74
+ return false if opts[:no_color]
75
+
76
+ Color.enabled?(stream)
77
+ end
78
+
79
+ def default_report
80
+ File.join(coverage_dir, "index.html")
81
+ end
82
+
83
+ def default_resultset
84
+ File.join(coverage_dir, ".resultset.json")
85
+ end
86
+
87
+ def run(argv, stdout: $stdout, stderr: $stderr)
88
+ command, *rest = argv
89
+ handler = COMMANDS[command]
90
+ return dispatch(handler, command, rest, stdout: stdout, stderr: stderr) if handler
91
+ return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
92
+ return stdout.puts("simplecov #{VERSION}") || 0 if %w[version --version -v].include?(command)
93
+
94
+ stderr.puts("simplecov: unknown command #{command.inspect}", usage)
95
+ 1
96
+ end
97
+
98
+ # One rescue covers every subcommand's OptionParser, so a typo'd flag becomes
99
+ # a one-line error and exit status 1 instead of a backtrace.
100
+ def dispatch(handler, command, rest, stdout:, stderr:)
101
+ handler.run(rest, stdout: stdout, stderr: stderr)
102
+ rescue CommandHelpers::HelpRequested
103
+ stdout.puts(Usage.for(self, command))
104
+ 0
105
+ rescue OptionParser::ParseError => e
106
+ stderr.puts("simplecov #{command}: #{e} (run `simplecov help` for usage)")
107
+ 1
108
+ end
109
+
110
+ def usage
111
+ Usage.text(self)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
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.
7
+ module Color
8
+ GREEN_THRESHOLD = 90
9
+ YELLOW_THRESHOLD = 75
10
+
11
+ ANSI = {
12
+ red: "\e[31m",
13
+ yellow: "\e[33m",
14
+ green: "\e[32m",
15
+ reset: "\e[0m"
16
+ }.freeze
17
+
18
+ extend self
19
+
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.
32
+ def enabled?(stream = $stderr)
33
+ config = SimpleCov.color if SimpleCov.respond_to?(:color)
34
+ return config if [true, false].include?(config)
35
+ return false if env_set?("NO_COLOR")
36
+ return true if env_set?("FORCE_COLOR")
37
+
38
+ stream.tty?
39
+ rescue IOError
40
+ false
41
+ end
42
+
43
+ def for_percent(percent)
44
+ return :green if percent >= GREEN_THRESHOLD
45
+ return :yellow if percent >= YELLOW_THRESHOLD
46
+
47
+ :red
48
+ end
49
+
50
+ def colorize(text, color, enabled: enabled?)
51
+ return text unless enabled
52
+
53
+ "#{ANSI.fetch(color)}#{text}#{ANSI.fetch(:reset)}"
54
+ end
55
+
56
+ def colorize_percent(percent, text = nil, enabled: enabled?)
57
+ colorize(text || format("%.2f%%", percent), for_percent(percent), enabled: enabled)
58
+ end
59
+
60
+ def env_set?(name)
61
+ value = ENV.fetch(name, nil)
62
+ value && !value.empty?
63
+ end
64
+ end
65
+ end
@@ -1,31 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../source_file/ruby_data_parser"
4
+ require_relative "identity_interner"
5
+ require_relative "interned_counts"
6
+
3
7
  module SimpleCov
4
8
  module Combine
5
- #
6
- # Combine different branch coverage results on single file.
7
- #
8
- # Should be called through `SimpleCov.combine`.
9
9
  module BranchesCombiner
10
- module_function
11
-
12
- #
13
- # Return merged branches or the existed brach if other is missing.
14
- #
15
- # Branches inside files are always same if they exist, the difference only in coverage count.
16
- # Branch coverage report for any conditional case is built from hash, it's key is a condition and
17
- # it's body is a hash << keys from condition and value is coverage rate >>.
18
- # ex: branches =>{ [:if, 3, 8, 6, 8, 36] => {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36]=>2}, other conditions...}
19
- # We create copy of result and update it values depending on the combined branches coverage values.
20
- #
21
- # @return [Hash]
22
- #
10
+ extend self
11
+
23
12
  def combine(coverage_a, coverage_b)
24
- coverage_a.merge(coverage_b) do |_condition, branches_inside_a, branches_inside_b|
25
- branches_inside_a.merge(branches_inside_b) do |_branch, a_count, b_count|
26
- a_count + b_count
27
- end
13
+ merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
14
+ materialize(absorb(merged, coverage_b))
15
+ end
16
+
17
+ # Folds `coverage` into `target`, an interned table keyed by condition
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.
21
+ def absorb(target, coverage)
22
+ return target unless coverage
23
+
24
+ coverage.each do |condition, branches_inside|
25
+ entry = target[identities[condition]] ||= new_condition(condition)
26
+ InternedCounts.absorb_counts(entry.fetch(1), branches_inside, identities)
28
27
  end
28
+
29
+ target
30
+ end
31
+
32
+ def new_condition(condition)
33
+ arms = {} #: Hash[untyped, untyped]
34
+ [condition, arms]
35
+ end
36
+
37
+ # Turns an interned table back into the tuple-keyed hash the rest of
38
+ # SimpleCov reads. Done once, at the end of a fold.
39
+ def materialize(target)
40
+ target.values.to_h { |condition, branches| [condition, branches.values.to_h] }
41
+ end
42
+
43
+ def identities
44
+ @identities ||= IdentityInterner.build { |tuple| tuple_identity(tuple) }
45
+ end
46
+
47
+ # Branches match on source span, whatever ids the recording processes handed
48
+ # them (#1233).
49
+ def tuple_identity(tuple)
50
+ type, _id, start_line, start_column, end_line, end_column = SourceFile::RubyDataParser.call(tuple)
51
+ [type, start_line, start_column, end_line, end_column].freeze
29
52
  end
30
53
  end
31
54
  end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "branches_combiner"
4
+ require_relative "lines_combiner"
5
+ require_relative "methods_combiner"
6
+
7
+ module SimpleCov
8
+ module Combine
9
+ #
10
+ # Folds any number of resultsets' coverage into one, absorbing them one
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.
14
+ #
15
+ class CoverageAccumulator
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`.
18
+ #
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".
22
+ def self.executed?(lines)
23
+ counts = Array(lines) #: Array[untyped]
24
+ counts.any? { |count| count.is_a?(Numeric) && count.positive? }
25
+ end
26
+
27
+ def self.fold(pairs)
28
+ accumulator = new
29
+ command_names = [] #: Array[String]
30
+
31
+ pairs.each do |names, coverage|
32
+ command_names.concat(names)
33
+ accumulator.absorb(coverage)
34
+ end
35
+
36
+ [command_names, accumulator.result]
37
+ end
38
+
39
+ def initialize
40
+ # Whether a criterion is enabled can't change mid-fold, so read it once
41
+ # here rather than once per file. `branch_coverage?` reaches
42
+ # `Coverage.supported?`, and a large parallel run merges thousands of
43
+ # files.
44
+ @branch_coverage = SimpleCov.branch_coverage?
45
+ @method_coverage = SimpleCov.method_coverage?
46
+ end
47
+
48
+ def absorb(coverage)
49
+ return self unless coverage
50
+
51
+ files = (@files ||= {}) #: Hash[String, untyped]
52
+ coverage.each do |filename, file_coverage|
53
+ files[filename] = merge_file(files[filename], file_coverage)
54
+ end
55
+
56
+ self
57
+ end
58
+
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.
62
+ def result
63
+ @files&.transform_values do |entry|
64
+ case entry
65
+ when MergedFile then entry.to_h
66
+ else entry
67
+ end
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def merge_file(existing, file_coverage)
74
+ case existing
75
+ when nil then file_coverage
76
+ when MergedFile then existing.absorb(file_coverage)
77
+ else MergedFile.new(existing, branches: @branch_coverage, methods: @method_coverage).absorb(file_coverage)
78
+ end
79
+ end
80
+
81
+ #
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.
90
+ #
91
+ class MergedFile
92
+ def initialize(coverage, branches:, methods:)
93
+ @branch_coverage = branches
94
+ @method_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"])
99
+ end
100
+
101
+ def absorb(coverage)
102
+ reconcile_synthesized(coverage)
103
+ @lines = LinesCombiner.merge_into(@lines, coverage["lines"])
104
+ self
105
+ end
106
+
107
+ def to_h
108
+ merged = {"lines" => @lines} #: Hash[String, untyped]
109
+ merged["branches"] = BranchesCombiner.materialize(@branches) if @branches
110
+ merged["methods"] = MethodsCombiner.materialize(@methods) if @methods
111
+ merged
112
+ end
113
+
114
+ private
115
+
116
+ def new_table
117
+ {} #: Hash[untyped, untyped]
118
+ end
119
+
120
+ # When exactly one side of the merge was actually executed, its branch
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).
127
+ def reconcile_synthesized(coverage)
128
+ incoming_lines = coverage["lines"]
129
+ return absorb_tuples(coverage) unless lines_measured?(@lines) && lines_measured?(incoming_lines)
130
+
131
+ incoming_executed = executed?(incoming_lines)
132
+
133
+ if executed?(@lines).equal?(incoming_executed)
134
+ absorb_tuples(coverage)
135
+ elsif incoming_executed
136
+ replace_tuples(coverage)
137
+ else
138
+ drop_incoming_tuples(coverage)
139
+ end
140
+ end
141
+
142
+ def absorb_tuples(coverage)
143
+ branches_table = coverage["branches"]
144
+ @branches = BranchesCombiner.absorb(@branches || new_table, branches_table) if branches_table
145
+ @methods = MethodsCombiner.absorb(@methods, coverage["methods"])
146
+ end
147
+
148
+ def drop_incoming_tuples(coverage)
149
+ @methods ||= {} if @method_coverage && coverage["methods"] #: Hash[untyped, untyped]
150
+ end
151
+
152
+ def replace_tuples(coverage)
153
+ @branches = authoritative_table(BranchesCombiner, coverage["branches"], @branch_coverage)
154
+ @methods = authoritative_table(MethodsCombiner, coverage["methods"], @method_coverage && !@methods.nil?)
155
+ end
156
+
157
+ def authoritative_table(combiner, table, keep_empty)
158
+ return combiner.absorb(new_table, table) if table
159
+
160
+ new_table if keep_empty
161
+ end
162
+
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)
166
+ !Array(lines).empty?
167
+ end
168
+
169
+ def executed?(lines)
170
+ CoverageAccumulator.executed?(lines)
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
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.
9
+ #
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.
15
+ module IdentityInterner
16
+ extend self
17
+
18
+ def build
19
+ ids = {} #: Hash[::Array[untyped], Integer]
20
+ Hash.new do |cache, key|
21
+ identity = yield(key)
22
+ cache[key] = ids[identity] ||= ids.size
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module Combine
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.
11
+ module InternedCounts
12
+ extend self
13
+
14
+ def absorb_counts(target, source, identities)
15
+ source.each do |key, count|
16
+ interned = identities[key]
17
+ entry = target[interned]
18
+ if entry
19
+ entry[1] += count
20
+ else
21
+ target[interned] = [key, count]
22
+ end
23
+ end
24
+ target
25
+ end
26
+ end
27
+ end
28
+ end
@@ -2,41 +2,47 @@
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 `SimpleCov.combine`.
9
5
  module LinesCombiner
10
- module_function
6
+ extend self
11
7
 
12
- def combine(coverage_a, coverage_b)
13
- coverage_a
14
- .zip(coverage_b)
15
- .map do |coverage_a_val, coverage_b_val|
16
- merge_line_coverage(coverage_a_val, coverage_b_val)
17
- end
18
- end
19
-
20
- # Return depends on coverage in a specific line
21
- #
22
- # @param [Integer || nil] first_val
23
- # @param [Integer || nil] second_val
24
- #
25
- # Logic:
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.
26
11
  #
27
- # => nil + 0 = nil
28
- # => nil + nil = nil
29
- # => int + int = int
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:
30
16
  #
31
- # @return [Integer || nil]
32
- def merge_line_coverage(first_val, second_val)
33
- sum = first_val.to_i + second_val.to_i
17
+ # nil + nil = nil
18
+ # nil + int = int (preserves a relevant-but-uncovered 0)
19
+ # int + int = int (sum)
20
+ def merge_into(target, source)
21
+ return target unless source
22
+ return source.dup unless target
34
23
 
35
- if sum.zero? && (first_val.nil? || second_val.nil?)
36
- nil
37
- else
38
- sum
24
+ size = source.size
25
+ target.fill(nil, target.size, size - target.size)
26
+ sum_into(target, source, size)
27
+ end
28
+
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.
32
+ #
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.
37
+ def sum_into(target, source, size)
38
+ index = 0
39
+ while index < size
40
+ if (value = source[index])
41
+ target[index] = target.at(index).to_i + value.to_i
42
+ end
43
+ index += 1
39
44
  end
45
+ target
40
46
  end
41
47
  end
42
48
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source_file/ruby_data_parser"
4
+ require_relative "identity_interner"
5
+ require_relative "interned_counts"
6
+
7
+ module SimpleCov
8
+ module Combine
9
+ module MethodsCombiner
10
+ extend self
11
+
12
+ # Method coverage maps `[class, name, start_line, start_col, end_line,
13
+ # end_col]` keys to hit counts. Keys are matched on their source identity,
14
+ # the location alone, because Ruby records one entry per defined method: the
15
+ # same `define_method` block defined onto different classes, or under
16
+ # different names, in different processes arrives with different receivers
17
+ # for the same source method, and matching on the full key would keep both,
18
+ # letting a never-called copy's 0 shadow a covered method (#1234).
19
+ def combine(coverage_a, coverage_b)
20
+ merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Integer]]
21
+ materialize(absorb(merged, coverage_b))
22
+ end
23
+
24
+ # A `nil` `target` stays `nil` until some resultset actually carries
25
+ # methods, so a merge can tell "no method data anywhere" apart from "method
26
+ # data that covers nothing".
27
+ def absorb(target, coverage)
28
+ return target unless coverage
29
+
30
+ target ||= {} #: Hash[untyped, [untyped, Integer]]
31
+ InternedCounts.absorb_counts(target, coverage, identities)
32
+ end
33
+
34
+ def materialize(target)
35
+ target.values.to_h
36
+ end
37
+
38
+ def identities
39
+ @identities ||= IdentityInterner.build { |key| source_identity(key) }
40
+ end
41
+
42
+ def source_identity(key)
43
+ _class_name, _method_name, *location = SourceFile::RubyDataParser.call(key)
44
+ location.freeze
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,59 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "coverage_accumulator"
4
+
3
5
  module SimpleCov
4
6
  module Combine
5
- # There might be reports from different kinds of tests,
6
- # e.g. RSpec and Cucumber. We need to combine their results
7
- # into unified one. This class does that.
8
- # To unite the results on file basis, it leverages
9
- # the combine of lines and branches inside each file within given results.
10
7
  module ResultsCombiner
11
- module_function
8
+ extend self
12
9
 
13
- #
14
- # Combine process explanation
15
- # => ResultCombiner: define all present files between results and start combine on file level.
16
- # ==> FileCombiner: collect result of next combine levels lines and branches.
17
- # ===> LinesCombiner: combine lines results.
18
- # ===> BranchesCombiner: combine branches results.
19
- #
20
- # @return [Hash]
21
- #
10
+ # Callers that read their results one at a time, to keep a big CI run's
11
+ # resultsets out of memory at once, should drive a `CoverageAccumulator`
12
+ # directly instead of collecting the results to pass here.
22
13
  def combine(*results)
23
- results.reduce({}) do |combined_results, next_result|
24
- combine_result_sets(combined_results, next_result)
25
- end
26
- end
27
-
28
- #
29
- # Manage combining results on files level
30
- #
31
- # @param [Hash] combined_results
32
- # @param [Hash] result
33
- #
34
- # @return [Hash]
35
- #
36
- def combine_result_sets(combined_results, result)
37
- results_files = combined_results.keys | result.keys
38
-
39
- results_files.each_with_object({}) do |file_name, file_combination|
40
- file_combination[file_name] = combine_file_coverage(
41
- combined_results[file_name],
42
- result[file_name]
43
- )
44
- end
45
- end
46
-
47
- #
48
- # Combine two files coverage results
49
- #
50
- # @param [Hash] coverage_a
51
- # @param [Hash] coverage_b
52
- #
53
- # @return [Hash]
54
- #
55
- def combine_file_coverage(coverage_a, coverage_b)
56
- Combine.combine(Combine::FilesCombiner, coverage_a, coverage_b)
14
+ accumulator = CoverageAccumulator.new
15
+ results.each { |result| accumulator.absorb(result) }
16
+ accumulator.result || {}
57
17
  end
58
18
  end
59
19
  end