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
@@ -1,22 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require_relative "result_merger/legacy_format_adapter"
4
+ require_relative "result_merger/resultset_file"
5
+ require_relative "result_merger/resultset_run_identity"
6
+ require_relative "result_merger/resultset_store"
7
+ require_relative "result_merger/contexts"
8
+ require_relative "result_merger/unloaded_files"
4
9
 
5
10
  module SimpleCov
6
11
  #
7
- # Singleton that is responsible for caching, loading and merging
8
- # SimpleCov::Results into a single result for coverage analysis based
9
- # upon multiple test suites.
12
+ # Caching, loading and merging `SimpleCov::Result`s into a single result for
13
+ # coverage analysis based upon multiple test suites.
10
14
  #
11
15
  module ResultMerger
16
+ extend ResultsetRunIdentity
17
+
12
18
  class << self
13
- # The path to the .resultset.json cache file
14
19
  def resultset_path
15
- File.join(SimpleCov.coverage_path, ".resultset.json")
16
- end
17
-
18
- def resultset_writelock
19
- File.join(SimpleCov.coverage_path, ".resultset.json.lock")
20
+ ResultsetStore.resultset_path
20
21
  end
21
22
 
22
23
  def merge_and_store(*file_paths, ignore_timeout: false)
@@ -26,168 +27,163 @@ module SimpleCov
26
27
  end
27
28
 
28
29
  def merge_results(*file_paths, ignore_timeout: false)
29
- # It is intentional here that files are only read in and parsed one at a time.
30
- #
31
- # In big CI setups you might deal with 100s of CI jobs and each one producing Megabytes
32
- # of data. Reading them all in easily produces Gigabytes of memory consumption which
33
- # we want to avoid.
34
- #
35
- # For similar reasons a SimpleCov::Result is only created in the end as that'd create
36
- # even more data especially when it also reads in all source files.
37
- initial_memo = valid_results(file_paths.shift, ignore_timeout: ignore_timeout)
38
-
39
- command_names, coverage = file_paths.reduce(initial_memo) do |memo, file_path|
40
- merge_coverage(memo, valid_results(file_path, ignore_timeout: ignore_timeout))
41
- end
42
-
43
- create_result(command_names, coverage)
44
- end
45
-
46
- def valid_results(file_path, ignore_timeout: false)
47
- results = parse_file(file_path)
48
- merge_valid_results(results, ignore_timeout: ignore_timeout)
30
+ # Tracked paths and test maps are collected as each resultset is parsed,
31
+ # so files are still read and discarded one at a time (#1250).
32
+ tracked_files = Set.new
33
+ context_maps = ContextMap::Union.new
34
+ command_names, coverage = absorb_results(file_paths, ignore_timeout: ignore_timeout,
35
+ &entry_collector(tracked_files, context_maps))
36
+ create_result(command_names, coverage, tracked_files: tracked_files, contexts: context_maps.map)
49
37
  end
50
38
 
51
- def parse_file(path)
52
- data = read_file(path)
53
- parse_json(data)
39
+ # One block over the entries each resultset contributes to the merge,
40
+ # feeding every merge-side accumulator in a single pass: the tracked
41
+ # paths and the per-test map union (#1250).
42
+ def entry_collector(tracked_files, context_maps)
43
+ collectors = [UnloadedFiles.collector(tracked_files), context_maps.collector]
44
+ ->(surviving) { collectors.each { |collector| collector.call(surviving) } }
54
45
  end
55
46
 
56
- def read_file(path)
57
- return unless File.exist?(path)
58
-
59
- data = File.read(path)
60
- return if data.nil? || data.length < 2
61
-
62
- data
47
+ #
48
+ # Reads every resultset and folds it into one merged coverage, stopping
49
+ # short of building a `SimpleCov::Result`.
50
+ #
51
+ # Files are only read in and parsed one at a time on purpose. In big CI
52
+ # setups you might deal with 100s of jobs each producing megabytes of
53
+ # data, and reading them all in easily produces gigabytes of memory
54
+ # consumption. For the same reason a `Result` is only created at the end.
55
+ #
56
+ # One accumulator absorbs the whole run, rather than folding each file
57
+ # into the merged-so-far pairwise: the pairwise form rebuilt every file's
58
+ # coverage once per resultset, which is what made merging a large
59
+ # parallel run's results the dominant cost of `collate`.
60
+ #
61
+ # `file_paths` is only ever iterated, so a caller that wants to observe
62
+ # the merge as it goes can hand in any Enumerable. `benchmarks/collate`
63
+ # passes an Enumerator that reports progress.
64
+ #
65
+ def absorb_results(file_paths, ignore_timeout: false, &on_parse)
66
+ Combine::CoverageAccumulator.fold(
67
+ file_paths.lazy.map { |file_path| valid_results(file_path, ignore_timeout: ignore_timeout, &on_parse) }
68
+ )
63
69
  end
64
70
 
65
- def parse_json(content)
66
- return {} unless content
67
-
68
- JSON.parse(content) || {}
69
- rescue StandardError
70
- warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
71
- {}
71
+ # Yields the surviving entries before they are reduced.
72
+ def valid_results(file_path, ignore_timeout: false, &on_parse)
73
+ merge_valid_results(ResultsetFile.parse(file_path), ignore_timeout: ignore_timeout, &on_parse)
72
74
  end
73
75
 
76
+ # Yields the entries that survived the merge timeout, so a caller that
77
+ # wants to observe what a resultset carried sees only what is being
78
+ # merged. An expired entry contributes nothing, tracked paths included.
74
79
  def merge_valid_results(results, ignore_timeout: false)
75
- results = results.select { |_command_name, data| within_merge_timeout?(data) } unless ignore_timeout
80
+ results = drop_expired_results(results) unless ignore_timeout
81
+ yield results if block_given?
76
82
 
77
83
  command_plus_coverage = results.map do |command_name, data|
78
- [[command_name], adapt_result(data.fetch("coverage"))]
84
+ [[command_name], LegacyFormatAdapter.call(data.fetch("coverage"))]
79
85
  end
80
86
 
81
- # one file itself _might_ include multiple test runs
82
87
  merge_coverage(*command_plus_coverage)
83
88
  end
84
89
 
90
+ def drop_expired_results(results)
91
+ fresh, expired = results.partition { |_command_name, data| within_merge_timeout?(data) }
92
+ return results if expired.empty?
93
+
94
+ warn_about_expired_results(expired.map(&:first))
95
+ fresh.to_h
96
+ end
97
+
85
98
  def within_merge_timeout?(data)
86
- time_since_result_creation(data) < SimpleCov.merge_timeout
99
+ (Time.now - Time.at(data.fetch("timestamp"))) < SimpleCov.merge_timeout
87
100
  end
88
101
 
89
- def time_since_result_creation(data)
90
- Time.now - Time.at(data.fetch("timestamp"))
102
+ def warn_about_expired_results(expired_command_names)
103
+ # Ordinary parallel workers only store their own slice; the selected
104
+ # final process performs this merge.
105
+ return unless SimpleCov.print_errors
106
+
107
+ warn "[SimpleCov]: Excluded #{expired_command_names.size} result(s) older than " \
108
+ "merge_timeout (#{SimpleCov.merge_timeout}s) from the merged report: " \
109
+ "#{expired_command_names.sort.join(", ")}. " \
110
+ "Increase SimpleCov.merge_timeout to include them."
91
111
  end
92
112
 
93
- def create_result(command_names, coverage)
113
+ # `tracked_files:` has no default: only the caller knows what the
114
+ # contributing processes were told to track, and quietly reading an
115
+ # omission as "nothing was tracked" would drop every never-loaded
116
+ # file from the merged report.
117
+ def create_result(command_names, coverage, tracked_files:, contexts: nil)
94
118
  return nil unless coverage
95
119
 
96
- command_name = command_names.reject(&:empty?).sort.join(", ")
97
- SimpleCov::Result.new(coverage, command_name: command_name)
120
+ # Deduped: a CI matrix collates one resultset per worker, and joining
121
+ # every run's name verbatim rendered "RSpec" a hundred times over in the
122
+ # report footer (#1284).
123
+ distinct_names = command_names.reject(&:empty?).uniq.sort
124
+ coverage, injected = UnloadedFiles.inject(coverage, tracked_files)
125
+ # The merged result is the authoritative one users actually see, so it's
126
+ # the one that warns about source files dropped because they no longer
127
+ # exist on disk (#980). The per-process slices stay quiet to avoid one
128
+ # warning per worker.
129
+ Result.new(
130
+ coverage,
131
+ command_name: distinct_names.join(", "), contexts: contexts, report: true,
132
+ not_loaded_files: UnloadedFiles.never_executed(coverage) | injected,
133
+ tracked_files: tracked_files
134
+ ).tap { |result| result.command_names = distinct_names }
98
135
  end
99
136
 
100
137
  def merge_coverage(*results)
101
138
  return [[""], nil] if results.empty?
102
- return results.first if results.size == 1
103
-
104
- results.reduce do |(memo_command, memo_coverage), (command, coverage)|
105
- # timestamp is dropped here, which is intentional (we merge it, it gets a new time stamp as of now)
106
- merged_coverage = Combine.combine(Combine::ResultsCombiner, memo_coverage, coverage)
107
- merged_command = memo_command + command
108
139
 
109
- [merged_command, merged_coverage]
110
- end
140
+ # Destructured rather than counted: the single-result fast path then
141
+ # hands back the very result it checked for, not a second lookup.
142
+ only, *rest = results
143
+ rest.empty? ? only : Combine::CoverageAccumulator.fold(results)
111
144
  end
112
145
 
113
- #
114
- # Gets all SimpleCov::Results stored in resultset, merges them and produces a new
115
- # SimpleCov::Result with merged coverage data and the command_name
116
- # for the result consisting of a join on all source result's names
117
146
  def merged_result
118
- # conceptually this is just doing `merge_results(resultset_path)`
119
- # it's more involved to make syre `synchronize_resultset` is only used around reading
120
- resultset_hash = read_resultset
121
- command_names, coverage = merge_valid_results(resultset_hash)
122
-
123
- create_result(command_names, coverage)
147
+ tracked_files = Set.new
148
+ context_maps = ContextMap::Union.new
149
+ command_names, coverage = merge_valid_results(read_resultset, &entry_collector(tracked_files, context_maps))
150
+ create_result(command_names, coverage, tracked_files: tracked_files, contexts: context_maps.map)
124
151
  end
125
152
 
126
153
  def read_resultset
127
- resultset_content =
128
- synchronize_resultset do
129
- read_file(resultset_path)
130
- end
131
-
132
- parse_json(resultset_content)
154
+ content = synchronize_resultset { ResultsetFile.read(resultset_path) }
155
+ ResultsetFile.decode(content)
133
156
  end
134
157
 
135
- # Saves the given SimpleCov::Result in the resultset cache
136
158
  def store_result(result)
137
159
  synchronize_resultset do
138
- # Ensure we have the latest, in case it was already cached
139
160
  new_resultset = read_resultset
140
161
 
141
- # A single result only ever has one command_name, see `SimpleCov::Result#to_hash`
162
+ # A single result only ever has one command_name, see `Result#to_hash`.
142
163
  command_name, data = result.to_hash.first
143
- new_resultset[command_name] = data
144
- File.open(resultset_path, "w+") do |f_|
145
- f_.puts JSON.pretty_generate(new_resultset)
146
- end
147
- end
148
- true
149
- end
150
-
151
- # Ensure only one process is reading or writing the resultset at any
152
- # given time
153
- def synchronize_resultset
154
- # make it reentrant
155
- return yield if defined?(@resultset_locked) && @resultset_locked
156
-
157
- begin
158
- @resultset_locked = true
159
- File.open(resultset_writelock, "w+") do |f|
160
- f.flock(File::LOCK_EX)
161
- yield
162
- end
163
- ensure
164
- @resultset_locked = false
165
- end
166
- end
164
+ new_resultset[command_name] = merged_entry(new_resultset[command_name], data)
167
165
 
168
- # We changed the format of the raw result data in simplecov, as people are likely
169
- # to have "old" resultsets lying around (but not too old so that they're still
170
- # considered we can adapt them).
171
- # See https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747
172
- def adapt_result(result)
173
- if pre_simplecov_0_18_result?(result)
174
- adapt_pre_simplecov_0_18_result(result)
175
- else
176
- result
166
+ ResultsetStore.write(new_resultset)
177
167
  end
168
+ true
178
169
  end
179
170
 
180
- # pre 0.18 coverage data pointed from file directly to an array of line coverage
181
- def pre_simplecov_0_18_result?(result)
182
- _key, data = result.first
171
+ # If an entry with the same command_name was written AFTER our process
172
+ # started, a sibling test runner wrote it. Combine coverage data rather
173
+ # than overwriting, so an empty parent-process result doesn't clobber the
174
+ # subprocess's real data (#581).
175
+ def merged_entry(existing, incoming)
176
+ return incoming unless concurrent_runner_entry?(existing, incoming)
183
177
 
184
- data.is_a?(Array)
178
+ merged = incoming.merge(
179
+ "coverage" => Combine::ResultsCombiner.combine(existing.fetch("coverage"), incoming.fetch("coverage"))
180
+ )
181
+ merged = Contexts.carry(merged, existing, incoming)
182
+ UnloadedFiles.carry_tracked(merged, existing, incoming)
185
183
  end
186
184
 
187
- def adapt_pre_simplecov_0_18_result(result)
188
- result.transform_values do |line_coverage_data|
189
- {"lines" => line_coverage_data}
190
- end
185
+ def synchronize_resultset(&)
186
+ ResultsetStore.synchronize(&)
191
187
  end
192
188
  end
193
189
  end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class << self
5
+ # By default `collate` ignores the merge_timeout so all results in all
6
+ # files specified will be merged. Pass `ignore_timeout: false` to honor it.
7
+ #
8
+ # `processes:` above 1 fans the merge out across that many forked workers.
9
+ # The report is identical either way, not merely equivalent. The count is
10
+ # deliberately not clamped to the machine's core count nor gated on a
11
+ # minimum number of resultsets: what a collate job can afford is the
12
+ # caller's call. Anything below 1 is taken as 1, so an unset
13
+ # `SIMPLECOV_CONCURRENCY` needs no default of its own.
14
+ def collate(result_filenames, profile = nil, processes: ENV["SIMPLECOV_CONCURRENCY"].to_i,
15
+ ignore_timeout: true, &)
16
+ raise ArgumentError, "There are no reports to be merged" if result_filenames.empty?
17
+
18
+ initial_setup(profile, &)
19
+
20
+ current_run.result = ParallelResultMerger.merge_and_store(*result_filenames, processes: [1, processes].max,
21
+ ignore_timeout: ignore_timeout)
22
+
23
+ current_run.collating_result = true
24
+ run_exit_tasks!
25
+ ensure
26
+ current_run.collating_result = false
27
+ end
28
+
29
+ def result
30
+ return current_run.result if result?
31
+
32
+ merge = merging
33
+ collect_own_coverage(standalone: !merge)
34
+ merge_own_slice if merge
35
+ current_run.result
36
+ end
37
+
38
+ def merge_own_slice
39
+ ResultMerger.store_result(result) if result?
40
+ return unless merge_finalization_owner?
41
+
42
+ wait_for_other_processes
43
+ current_run.result = ResultMerger.merged_result
44
+ end
45
+
46
+ # `standalone` is true when no merge step follows, which makes this the
47
+ # final result: the one that reports dropped source files and injects
48
+ # unloaded files. When a merge does follow, both jobs belong to the merge.
49
+ def collect_own_coverage(standalone:)
50
+ return unless defined?(Coverage) && Coverage.running?
51
+
52
+ process_coverage_result(report: standalone, inject_unloaded: standalone)
53
+ end
54
+
55
+ def filtered(files)
56
+ result = files.to_a
57
+ filters.each do |filter|
58
+ result = result.reject { |source_file| filter.matches?(source_file) }
59
+ end
60
+ FileList.new result
61
+ end
62
+
63
+ # Files matched by no group fall into the implicit "Ungrouped" bucket. Any
64
+ # group left empty, Ungrouped included, is dropped so a profile's unused
65
+ # groups (#1293) don't pad the report as 100% covered.
66
+ def grouped(files, groups: default_groups)
67
+ return {} if GroupNames.validate!(groups.keys).empty?
68
+
69
+ grouped = groups.transform_values do |filter|
70
+ FileList.new(files.select { |source_file| filter.matches?(source_file) })
71
+ end
72
+
73
+ in_group = grouped_file_set(grouped)
74
+ grouped[GroupNames::UNGROUPED] = FileList.new(files.reject { |source_file| in_group.include?(source_file) })
75
+
76
+ grouped.reject { |_name, group_files| group_files.empty? }
77
+ end
78
+
79
+ def load_profile(name)
80
+ profiles.load(name)
81
+ end
82
+
83
+ def clear_result
84
+ current_run.result = nil
85
+ end
86
+
87
+ # @api private -- floored (#679) so the next run can compute drift.
88
+ def write_last_run(result)
89
+ LastRun.write(
90
+ result: result.coverage_statistics.transform_values { |stats| round_coverage(stats.percent) }
91
+ )
92
+ end
93
+
94
+ # @api private -- floored, to be extra strict.
95
+ def round_coverage(coverage)
96
+ coverage.floor(2)
97
+ end
98
+
99
+ # @api private -- the seam `SimpleCov::ResultMerger` injects through. Public
100
+ # only because the merge runs in another object, on behalf of processes
101
+ # whose configuration it may not share, so it supplies the paths itself.
102
+ def inject_unloaded_files(result, candidate_paths, synthesize: nil, lines: nil)
103
+ return [result, Set.new] if candidate_paths.empty?
104
+
105
+ # Synthesizing branch and method tuples means parsing every tracked file
106
+ # that wasn't loaded, about half the cost of simulating one, and nothing
107
+ # reads those tuples when neither criterion is enabled. The same goes for
108
+ # line data, which a branch-only or method-only run neither reports nor
109
+ # receives from `Coverage` for the files it loaded (#1250).
110
+ UnloadedFileInjector.call(
111
+ result, candidate_paths,
112
+ synthesize: synthesize.nil? ? branch_coverage? || method_coverage? : synthesize,
113
+ lines: lines.nil? ? line_coverage? : lines
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ def initial_setup(profile, &block)
120
+ load_profile(profile) if profile
121
+ configure(&block) if block
122
+ end
123
+
124
+ def grouped_file_set(grouped)
125
+ grouped.values.each_with_object(Set.new) { |file_list, set| set.merge(file_list) }
126
+ end
127
+
128
+ # Every path this process was told to track, whether or not it loaded them.
129
+ # Recorded on the result so a merge in another process can inject the ones
130
+ # nobody loaded without needing this process's `cover` / `track_files`
131
+ # configuration. A standalone `collate` never ran `SimpleCov.start` (#1250).
132
+ def tracked_file_paths
133
+ paths = UnloadedFileInjector.discover(
134
+ unloaded_file_discovery_globs, root: root, reject: filters.select(&:path_only?)
135
+ )
136
+ templates = paths.select { |path| Directive::Template.template?(path) }
137
+ return paths if templates.empty?
138
+
139
+ warn_templates_tracked(templates)
140
+ paths - templates
141
+ end
142
+
143
+ # Simulating a template means classifying its lines as Ruby and parsing it
144
+ # for branches, both of which produce a wrong shape, whereas `cover_views`
145
+ # compiles it and measures the real thing.
146
+ def warn_templates_tracked(templates)
147
+ names = templates.map { |path| path.delete_prefix(root).sub(%r{\A[/\\]}, "") }
148
+ warn "[SimpleCov]: `cover` matched templates it cannot simulate (#{names.join(", ")}). " \
149
+ "Add `cover_views` to measure templates."
150
+ end
151
+
152
+ # The legacy `track_files` glob (additive only) plus every string glob
153
+ # declared via `cover` (also restrictive, but the restriction lives in
154
+ # `Result#apply_cover_filters!`).
155
+ def unloaded_file_discovery_globs
156
+ [tracked_files, *cover_globs].compact
157
+ end
158
+
159
+ # `report:` and `inject_unloaded:` are false when a merge step follows.
160
+ # Only the union of every process's loaded files says what was really never
161
+ # loaded, and the merged result is the one that reports dropped source
162
+ # files, so a per-process slice would warn once per worker and simulate
163
+ # nearly the whole project only to have it merged away (#1250).
164
+ def process_coverage_result(report:, inject_unloaded: true)
165
+ # Templates nobody rendered are compiled into the running Coverage so they
166
+ # arrive at 0% below rather than being absent. Needs a live Coverage and
167
+ # ActionView, which rules out the merge point that does the same job for
168
+ # unloaded `.rb` files.
169
+ ViewCoverage.compile_unrendered
170
+ raw = UselessResultsRemover.call(Coverage.result)
171
+ adapted = ResultAdapter.call(raw)
172
+ # `Result#to_hash` serializes coverage after filtering, so a file this
173
+ # process loaded and then filtered out would otherwise be recorded as
174
+ # tracked while absent from the stored coverage, and the merge would
175
+ # simulate it back in as never-loaded (#1250).
176
+ tracked = tracked_file_paths - adapted.keys
177
+ result, not_loaded = inject_unloaded ? inject_unloaded_files(adapted, tracked) : [adapted, Set.new]
178
+ current_run.result = build_result(raw, result, not_loaded: not_loaded, tracked: tracked, report: report)
179
+ end
180
+
181
+ def build_result(raw, coverage, not_loaded:, tracked:, report:)
182
+ Result.new(
183
+ coverage, not_loaded_files: not_loaded, tracked_files: tracked, run_id: run_id,
184
+ worker_id: worker_id, contexts: test_tracker&.recorded_map(closing: raw), report: report
185
+ )
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SimpleCov
6
+ # Identifies one test invocation and each top-level parallel worker within it.
7
+ # Forked subprocesses inherit both values from their parent worker.
8
+ module RunIdentity
9
+ extend self
10
+
11
+ # Answers the run id together with its provenance: true when the id alone
12
+ # proves same-run membership (explicitly configured, derived from
13
+ # parallel_tests' per-invocation pid file, or freshly random), false when it
14
+ # was inferred from the parent pid, which consecutive runs launched by the
15
+ # same long-lived parent process share.
16
+ def generate
17
+ explicit = ENV.fetch("SIMPLECOV_RUN_ID", nil)
18
+ return [explicit, true] if explicit && !explicit.empty?
19
+
20
+ pid_file = ENV.fetch("PARALLEL_PID_FILE", nil)
21
+ return ["parallel-tests:#{File.basename(pid_file)}", true] if pid_file && !pid_file.empty?
22
+
23
+ return ["parallel-parent:#{Process.ppid}", false] if ParallelAdapters.current
24
+
25
+ [SecureRandom.uuid, true]
26
+ end
27
+
28
+ def worker_id
29
+ explicit = ENV.fetch("SIMPLECOV_WORKER_ID", nil)
30
+ return explicit if explicit && !explicit.empty?
31
+
32
+ number = ENV.fetch("TEST_ENV_NUMBER", nil)
33
+ return number.empty? ? "1" : number if number
34
+
35
+ Process.pid.to_s
36
+ end
37
+
38
+ # Decided where the id is generated, never re-inferred from the id's shape,
39
+ # so an explicit SIMPLECOV_RUN_ID that happens to look like an inferred one
40
+ # is still trusted.
41
+ def authoritative?
42
+ materialize_current
43
+ @authoritative
44
+ end
45
+
46
+ def current
47
+ materialize_current
48
+ @current
49
+ end
50
+
51
+ def materialize_current
52
+ @current, @authoritative = generate unless instance_variable_defined?(:@current)
53
+ end
54
+
55
+ def current_worker_id
56
+ @current_worker_id ||= worker_id
57
+ end
58
+
59
+ def prepare
60
+ ParallelAdapters.current
61
+ SimpleCov.run_id
62
+ SimpleCov.worker_id
63
+ end
64
+
65
+ module Accessors
66
+ def run_id
67
+ RunIdentity.current
68
+ end
69
+
70
+ def worker_id
71
+ RunIdentity.current_worker_id
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,29 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "coverage"
4
+ require_relative "static_coverage_extractor"
5
+
3
6
  module SimpleCov
4
- #
5
- # Responsible for producing file coverage metrics.
6
- #
7
7
  module SimulateCoverage
8
- module_function
8
+ extend self
9
9
 
10
10
  #
11
- # Simulate normal file coverage report on
12
- # ruby 2.5 and return similar hash with lines and branches keys
11
+ # Simulate a file coverage report for a file that was tracked but never
12
+ # required, in the same hash shape as `Coverage.result`.
13
+ #
14
+ # The line classification comes from `Coverage.line_stub`, the same
15
+ # classification the runtime would have produced if the file had been
16
+ # required, overlaid with SimpleCov's `# :nocov:` toggles and
17
+ # `# simplecov:disable line` directive ranges, which `Coverage` doesn't
18
+ # know about. This keeps "relevant lines" identical whether a file was
19
+ # loaded or just tracked (#654).
20
+ #
21
+ # Branches and methods are enumerated by static analysis. Earlier behavior
22
+ # left both as empty hashes, which made unloaded files invisible to the
23
+ # branch/method denominators while their lines did count, so a glob that
24
+ # picked up files without specs silently inflated branch% relative to
25
+ # line% (#1059). When the file can't be parsed, fall back to the old empty
26
+ # hashes.
13
27
  #
14
- # Happens when a file wasn't required but still tracked.
28
+ # Pass `synthesize: false` to skip the static analysis. Callers use it when
29
+ # neither branch nor method coverage is enabled, since nothing will read
30
+ # the tuples and the Prism parse is about half the cost of simulating a
31
+ # file (#1250).
15
32
  #
16
- # @return [Hash]
33
+ # Pass `lines: false` to omit the `"lines"` key entirely, mirroring what
34
+ # `Coverage.result` reports for a file loaded under a branch-only or
35
+ # method-only run. Emitting zeroed lines there would make a simulated file
36
+ # indistinguishable from one a sibling process actually loaded.
17
37
  #
18
- def call(absolute_path)
19
- lines = File.foreach(absolute_path)
20
-
21
- {
22
- "lines" => LinesClassifier.new.classify(lines),
23
- # we don't want to parse branches ourselves...
24
- # requiring files can have side effects and we don't want to trigger that
25
- "branches" => {}
26
- }
38
+ def call(absolute_path, synthesize: true, lines: true)
39
+ source_lines = read_lines(absolute_path)
40
+ simulated = synthesized_tuples(source_lines, synthesize)
41
+ return simulated unless lines
42
+
43
+ classified = coverage_stub(absolute_path, source_lines) ||
44
+ LinesClassifier.new.classify(source_lines)
45
+ {"lines" => classified}.merge(simulated)
46
+ end
47
+
48
+ # Empty hashes when the static analysis is skipped (nothing enabled reads
49
+ # them) or unavailable (no Prism, or the file doesn't parse).
50
+ def synthesized_tuples(source_lines, synthesize)
51
+ empty = {"branches" => {}, "methods" => {}} #: Hash[String, Hash[untyped, untyped]]
52
+ synthesized = (StaticCoverageExtractor.call(source_lines.join) if synthesize) || empty
53
+
54
+ {"branches" => synthesized.fetch("branches"), "methods" => synthesized.fetch("methods")}
55
+ end
56
+
57
+ # SystemCallError, not just ENOENT: a `track_files` glob can sweep up an
58
+ # unreadable file or a directory named like a Ruby file, and simulation must
59
+ # degrade to "empty file" rather than crash the merge or report step.
60
+ def read_lines(path)
61
+ File.readlines(path)
62
+ rescue SystemCallError
63
+ []
64
+ end
65
+
66
+ # Combines `Coverage.line_stub` (which gets multi-line statements right)
67
+ # with `LinesClassifier` (which knows about `# :nocov:` toggles and
68
+ # `# simplecov:disable line` ranges). Answers nil when `Coverage` can't
69
+ # read or parse the file, or the runtime doesn't expose `line_stub`.
70
+ def coverage_stub(path, source_lines)
71
+ return nil unless Coverage.respond_to?(:line_stub)
72
+
73
+ # Paired off rather than indexed: the two lists describe the same file
74
+ # line for line. A line the classifier has no verdict for keeps the nil
75
+ # it would have been set to.
76
+ relevance = LinesClassifier.new.classify(source_lines)
77
+ Coverage.line_stub(path).zip(relevance).map { |hits, relevant| hits if relevant }
78
+ rescue SystemCallError, SyntaxError
79
+ # SystemCallError for the same reason as read_lines: line_stub reads the
80
+ # file itself, so EACCES/EISDIR surface here too.
81
+ nil
27
82
  end
28
83
  end
29
84
  end