simplecov 0.22.0 → 1.1.1

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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ # The merge step's half of unloaded-file handling: the policy of when
6
+ # to inject and which criteria simulated files carry. The mechanism —
7
+ # expanding globs and simulating each file — is
8
+ # `SimpleCov::UnloadedFileInjector`, an easy name to confuse with this
9
+ # one.
10
+ #
11
+ # Injection moved here from the individual processes because only the union
12
+ # of what they all loaded says what was really never loaded. Doing it per
13
+ # process meant N workers simulated the same file up to N times and the
14
+ # merge threw all but one away. See #1250.
15
+ module UnloadedFiles
16
+ module_function
17
+
18
+ # The union of what every contributing process was told to track. Absent
19
+ # from resultsets written before this was recorded, in which case those
20
+ # processes injected their own unloaded files and the data is already in
21
+ # the coverage hash.
22
+ # A collector for the merge to hand `merge_valid_results`, gathering the
23
+ # tracked paths of every resultset that survives the merge timeout.
24
+ def collector(into)
25
+ ->(surviving) { into.merge(tracked_in(surviving)) }
26
+ end
27
+
28
+ def tracked_in(resultset)
29
+ resultset.each_with_object(Set.new) do |(_command_name, data), set|
30
+ set.merge(Array(data["tracked_files"]))
31
+ end
32
+ end
33
+
34
+ # Concurrent workers sharing a command name may have been told to track
35
+ # different sets, so keep both rather than letting the later write win.
36
+ def carry_tracked(entry, existing, incoming)
37
+ tracked = Array(existing["tracked_files"]) | Array(incoming["tracked_files"])
38
+ tracked.empty? ? entry : entry.merge("tracked_files" => tracked)
39
+ end
40
+
41
+ # Simulate each tracked file the merged coverage doesn't already carry.
42
+ # The paths come from the resultsets rather than this process's own
43
+ # configuration, which a standalone `collate` would not have: it never ran
44
+ # `SimpleCov.start`, so it has no `cover` glob to expand. Idempotent, so
45
+ # resultsets from an older SimpleCov (or from a `merging false` process)
46
+ # that already carry their unloaded files pass through untouched.
47
+ def inject(coverage, tracked_files)
48
+ SimpleCov.inject_unloaded_files(
49
+ coverage, tracked_files.to_a,
50
+ synthesize: carries?(coverage, "branches") || carries?(coverage, "methods"),
51
+ lines: carries?(coverage, "lines")
52
+ )
53
+ end
54
+
55
+ # A simulated file should have the same shape as the files it is being
56
+ # merged alongside, so the criteria come from the merged data rather than
57
+ # from this process's configuration, which is not necessarily the
58
+ # configuration any contributing process measured under: `simplecov merge`
59
+ # never ran `SimpleCov.start` at all. Giving injected files fewer tables
60
+ # than their neighbours is what inflates the percentage #1059 fixed.
61
+ #
62
+ # Falls back to the configuration when there is nothing to be consistent
63
+ # with, which is a merge of resultsets that carried no files.
64
+ def carries?(coverage, criterion)
65
+ return SimpleCov.public_send(CRITERION_PREDICATES.fetch(criterion)) if coverage.empty?
66
+
67
+ coverage.any? { |_filename, file_coverage| file_coverage[criterion] }
68
+ end
69
+
70
+ CRITERION_PREDICATES = {
71
+ "lines" => :line_coverage?,
72
+ "branches" => :branch_coverage?,
73
+ "methods" => :method_coverage?
74
+ }.freeze
75
+ private_constant :CRITERION_PREDICATES
76
+
77
+ # Which files no contributing process ever loaded. Injection reports the
78
+ # ones it added, but a file can also arrive already simulated from a
79
+ # resultset this merge didn't inject into, so the merged line counts are
80
+ # still consulted, using the same signal
81
+ # `Combine::CoverageAccumulator` reconciles synthesized tuples on.
82
+ #
83
+ # A file is judged only when it has a relevant line. A branch-only or
84
+ # method-only run reports no line data at all for the files it loaded,
85
+ # and a loaded file with no executable lines (a comment-only constants
86
+ # stub, say) reports every line as `nil` — either would otherwise read
87
+ # as "never executed" and mark a genuinely loaded file not loaded,
88
+ # turning its branch and method coverage into #902's 0%.
89
+ # `SimulateCoverage` omits lines under those criteria too and gives a
90
+ # simulated file a `0` on every relevant line, so genuinely unloaded
91
+ # files still carry judgeable data, and a simulated file with none is
92
+ # already flagged by injection reporting it.
93
+ def never_executed(coverage)
94
+ coverage.each_with_object(Set.new) do |(filename, file_coverage), set|
95
+ counts = Array(file_coverage["lines"]) #: Array[Integer?]
96
+ next unless counts.any? { |count| !count.nil? }
97
+
98
+ set << filename unless Combine::CoverageAccumulator.executed?(file_coverage["lines"])
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -1,6 +1,10 @@
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/unloaded_files"
4
8
 
5
9
  module SimpleCov
6
10
  #
@@ -9,14 +13,11 @@ module SimpleCov
9
13
  # 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,88 +27,114 @@ 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)
49
- end
50
-
51
- def parse_file(path)
52
- data = read_file(path)
53
- parse_json(data)
30
+ # Tracked paths are collected as each resultset is parsed, so files are
31
+ # still read and discarded one at a time. See #1250.
32
+ tracked_files = Set.new
33
+ command_names, coverage = absorb_results(file_paths, ignore_timeout: ignore_timeout,
34
+ &UnloadedFiles.collector(tracked_files))
35
+ create_result(command_names, coverage, tracked_files: tracked_files)
54
36
  end
55
37
 
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
38
+ #
39
+ # Reads every resultset and folds it into one merged coverage, stopping
40
+ # short of building a `SimpleCov::Result`.
41
+ #
42
+ # It is intentional here that files are only read in and parsed one at a time.
43
+ #
44
+ # In big CI setups you might deal with 100s of CI jobs and each one producing Megabytes
45
+ # of data. Reading them all in easily produces Gigabytes of memory consumption which
46
+ # we want to avoid.
47
+ #
48
+ # For similar reasons a SimpleCov::Result is only created in the end as that'd create
49
+ # even more data especially when it also reads in all source files.
50
+ #
51
+ # One accumulator absorbs the whole run, rather than folding each file
52
+ # into the merged-so-far pairwise: the pairwise form rebuilt every
53
+ # file's coverage once per resultset, which is what made merging a
54
+ # large parallel run's results the dominant cost of `collate`.
55
+ # Absorbing is still one resultset at a time, so the memory ceiling
56
+ # above is unchanged.
57
+ #
58
+ # `file_paths` is only ever iterated, so a caller that wants to observe
59
+ # the merge as it goes can hand in any Enumerable — `benchmarks/collate`
60
+ # passes an Enumerator that reports progress — rather than reimplement
61
+ # this loop and risk timing something other than what ships.
62
+ #
63
+ # @return [Array] the command names and the merged coverage
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
87
  # one file itself _might_ include multiple test runs
82
88
  merge_coverage(*command_plus_coverage)
83
89
  end
84
90
 
91
+ def drop_expired_results(results)
92
+ fresh, expired = results.partition { |_command_name, data| within_merge_timeout?(data) }
93
+ return results if expired.empty?
94
+
95
+ warn_about_expired_results(expired.map(&:first))
96
+ fresh.to_h
97
+ end
98
+
85
99
  def within_merge_timeout?(data)
86
- time_since_result_creation(data) < SimpleCov.merge_timeout
100
+ (Time.now - Time.at(data.fetch("timestamp"))) < SimpleCov.merge_timeout
87
101
  end
88
102
 
89
- def time_since_result_creation(data)
90
- Time.now - Time.at(data.fetch("timestamp"))
103
+ def warn_about_expired_results(expired_command_names)
104
+ # Respect quiet configurations and keep this consistent with every
105
+ # other SimpleCov diagnostic. Ordinary parallel workers only store
106
+ # their own slice; the selected final process performs this merge.
107
+ return unless SimpleCov.print_errors
108
+
109
+ warn "[SimpleCov]: Excluded #{expired_command_names.size} result(s) older than " \
110
+ "merge_timeout (#{SimpleCov.merge_timeout}s) from the merged report: " \
111
+ "#{expired_command_names.sort.join(', ')}. " \
112
+ "Increase SimpleCov.merge_timeout to include them."
91
113
  end
92
114
 
93
- def create_result(command_names, coverage)
115
+ def create_result(command_names, coverage, tracked_files: Set.new)
94
116
  return nil unless coverage
95
117
 
96
118
  command_name = command_names.reject(&:empty?).sort.join(", ")
97
- SimpleCov::Result.new(coverage, command_name: command_name)
119
+ coverage, injected = UnloadedFiles.inject(coverage, tracked_files)
120
+ # The merged result is the authoritative one users actually see, so
121
+ # it's the one that warns about source files dropped because they no
122
+ # longer exist on disk (issue #980). The per-process slices built in
123
+ # `process_coverage_result` stay quiet to avoid one warning per worker.
124
+ SimpleCov::Result.new(
125
+ coverage,
126
+ command_name: command_name,
127
+ not_loaded_files: UnloadedFiles.never_executed(coverage) | injected,
128
+ tracked_files: tracked_files.to_a,
129
+ report: true
130
+ )
98
131
  end
99
132
 
100
133
  def merge_coverage(*results)
101
134
  return [[""], nil] if results.empty?
102
135
  return results.first if results.size == 1
103
136
 
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
-
109
- [merged_command, merged_coverage]
110
- end
137
+ Combine::CoverageAccumulator.fold(results)
111
138
  end
112
139
 
113
140
  #
@@ -115,79 +142,47 @@ module SimpleCov
115
142
  # SimpleCov::Result with merged coverage data and the command_name
116
143
  # for the result consisting of a join on all source result's names
117
144
  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)
145
+ tracked_files = Set.new
146
+ command_names, coverage = merge_valid_results(read_resultset, &UnloadedFiles.collector(tracked_files))
147
+ create_result(command_names, coverage, tracked_files: tracked_files)
124
148
  end
125
149
 
126
150
  def read_resultset
127
- resultset_content =
128
- synchronize_resultset do
129
- read_file(resultset_path)
130
- end
131
-
132
- parse_json(resultset_content)
151
+ content = synchronize_resultset { ResultsetFile.read(resultset_path) }
152
+ ResultsetFile.decode(content)
133
153
  end
134
154
 
135
155
  # Saves the given SimpleCov::Result in the resultset cache
136
- def store_result(result)
156
+ def store_result(result) # rubocop:disable Naming/PredicateMethod
137
157
  synchronize_resultset do
138
158
  # Ensure we have the latest, in case it was already cached
139
159
  new_resultset = read_resultset
140
160
 
141
161
  # A single result only ever has one command_name, see `SimpleCov::Result#to_hash`
142
162
  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
163
+ new_resultset[command_name] = merged_entry(new_resultset[command_name], data)
150
164
 
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
167
-
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
165
+ ResultsetStore.write(new_resultset)
177
166
  end
167
+ true
178
168
  end
179
169
 
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
170
+ # If an entry with the same command_name was written AFTER our process
171
+ # started, a sibling test runner (typically a subprocess our parent
172
+ # process shelled out to) wrote it. Combine coverage data rather than
173
+ # overwriting, so an empty parent-process result doesn't clobber the
174
+ # subprocess's real data. See https://github.com/simplecov-ruby/simplecov/issues/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["coverage"], incoming["coverage"])
180
+ )
181
+ UnloadedFiles.carry_tracked(merged, existing, incoming)
185
182
  end
186
183
 
187
- def adapt_pre_simplecov_0_18_result(result)
188
- result.transform_values do |line_coverage_data|
189
- {"lines" => line_coverage_data}
190
- end
184
+ def synchronize_resultset(&)
185
+ ResultsetStore.synchronize(&)
191
186
  end
192
187
  end
193
188
  end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Result-building façade: turns the raw `Coverage.result` hash into a
4
+ # `SimpleCov::Result`, applies filters and groups, drives merging
5
+ # across test suites via `SimpleCov::ResultMerger`, and exposes the
6
+ # `collate` entry point for stitching disparate resultsets together.
7
+ module SimpleCov
8
+ class << self
9
+ #
10
+ # Collate a series of SimpleCov result files into a single SimpleCov output.
11
+ #
12
+ # See README for usage. By default `collate` ignores the merge_timeout
13
+ # so all results in all files specified will be merged. Pass
14
+ # `ignore_timeout: false` to honor it.
15
+ #
16
+ # `processes:` above 1 fans the merge out across that many forked worker
17
+ # processes, for a collate big enough that reading and parsing the
18
+ # resultsets dominates it. The report is identical either way, not merely
19
+ # equivalent — the workers visit the resultsets in the order the
20
+ # single-process merge visits them — and one process never forks at all.
21
+ # The count is deliberately not clamped to the machine's core count nor
22
+ # gated on some minimum number of resultsets: how many processes a collate
23
+ # job can afford is the caller's call, not SimpleCov's. Anything below 1 is
24
+ # taken as 1, so a count computed from arithmetic that can reach zero needs
25
+ # no guarding. Defaults to `SIMPLECOV_CONCURRENCY`, or 1 when that is
26
+ # unset. See `SimpleCov::ParallelResultMerger`.
27
+ #
28
+ def collate(result_filenames, profile = nil, processes: ENV.fetch("SIMPLECOV_CONCURRENCY", 1).to_i,
29
+ ignore_timeout: true, &)
30
+ raise ArgumentError, "There are no reports to be merged" if result_filenames.empty?
31
+
32
+ initial_setup(profile, &)
33
+
34
+ # Use the ResultMerger to produce a single, merged result, ready to use.
35
+ @result = ParallelResultMerger.merge_and_store(*result_filenames, processes: [1, processes].max,
36
+ ignore_timeout: ignore_timeout)
37
+
38
+ @collating_result = true
39
+ run_exit_tasks!
40
+ ensure
41
+ @collating_result = false
42
+ end
43
+
44
+ #
45
+ # Returns the result for the current coverage run. With merging enabled,
46
+ # every process stores its own slice, but only the finalization owner reads
47
+ # and caches the merged result.
48
+ #
49
+ def result
50
+ return @result if result?
51
+
52
+ use_merging = merging
53
+
54
+ collect_own_coverage(standalone: !use_merging)
55
+
56
+ # If we're using merging of results, store the current result
57
+ # first (if there is one), then merge the results and return those
58
+ if use_merging
59
+ SimpleCov::ResultMerger.store_result(@result) if result?
60
+ return @result unless merge_finalization_owner?
61
+
62
+ wait_for_other_processes
63
+ @result = SimpleCov::ResultMerger.merged_result
64
+ end
65
+
66
+ @result
67
+ end
68
+
69
+ # Build this process's slice of the coverage result. `standalone` is true
70
+ # when no merge step follows, which makes this the final result: it is the
71
+ # one that reports dropped source files, and the one that injects unloaded
72
+ # files. When a merge does follow, both jobs belong to the merged result.
73
+ def collect_own_coverage(standalone:)
74
+ return unless defined?(Coverage) && Coverage.running?
75
+
76
+ process_coverage_result(report: standalone, inject_unloaded: standalone)
77
+ end
78
+
79
+ # Returns nil if the result has not been computed, otherwise the result.
80
+ def result?
81
+ defined?(@result) && @result
82
+ end
83
+
84
+ # @api private — true while `SimpleCov.collate` is running its finalizer.
85
+ def collating_result?
86
+ defined?(@collating_result) && @collating_result
87
+ end
88
+
89
+ # Applies the configured filters to the given array of SimpleCov::SourceFile items
90
+ def filtered(files)
91
+ result = files.to_a.dup
92
+ filters.each do |filter|
93
+ result = result.reject { |source_file| filter.matches?(source_file) }
94
+ end
95
+ SimpleCov::FileList.new result
96
+ end
97
+
98
+ # Bin the given source files by group filter. `groups:` defaults to
99
+ # `SimpleCov.groups`; pass a Hash explicitly to bin against a
100
+ # different group config (e.g., the snapshot a Result captured at
101
+ # construction). Files matched by no group fall into the implicit
102
+ # "Ungrouped" bucket.
103
+ def grouped(files, groups: SimpleCov.groups)
104
+ return {} if GroupNames.validate!(groups.keys).empty?
105
+
106
+ grouped = groups.transform_values do |filter|
107
+ SimpleCov::FileList.new(files.select { |source_file| filter.matches?(source_file) })
108
+ end
109
+
110
+ in_group = grouped_file_set(grouped)
111
+ ungrouped = files.reject { |source_file| in_group.include?(source_file) }
112
+ grouped[GroupNames::UNGROUPED] = SimpleCov::FileList.new(ungrouped) if ungrouped.any?
113
+
114
+ grouped
115
+ end
116
+
117
+ # Applies the profile of given name on SimpleCov configuration
118
+ def load_profile(name)
119
+ profiles.load(name)
120
+ end
121
+
122
+ # Clear out the previously cached .result. Primarily useful in testing.
123
+ def clear_result
124
+ @result = nil
125
+ end
126
+
127
+ # @api private — persist the per-criterion coverage percentages
128
+ # rounded down (see #679) so the next run can compute drift.
129
+ def write_last_run(result)
130
+ SimpleCov::LastRun.write(
131
+ result: result.coverage_statistics.transform_values { |stats| round_coverage(stats.percent) }
132
+ )
133
+ end
134
+
135
+ # @api private — round down to two decimals to be extra strict.
136
+ def round_coverage(coverage)
137
+ coverage.floor(2)
138
+ end
139
+
140
+ # Add simulated coverage for each of `candidate_paths` the result doesn't
141
+ # already carry, and return it with the set of paths added.
142
+ #
143
+ # @api private — the seam `SimpleCov::ResultMerger` injects through. Public
144
+ # only because the merge runs in another object, on behalf of processes
145
+ # whose configuration it may not share, so it supplies the paths itself.
146
+ def inject_unloaded_files(result, candidate_paths, synthesize: nil, lines: nil)
147
+ return [result, Set.new] if candidate_paths.empty?
148
+
149
+ # Synthesizing branch and method tuples means parsing every tracked file
150
+ # that wasn't loaded, which is about half the cost of simulating one.
151
+ # Nothing reads those tuples when neither criterion is enabled —
152
+ # `Combine::CoverageAccumulator` only combines them per criterion, and the
153
+ # statistics drop them the same way — so skip the parse. The same goes
154
+ # for line data, which a branch-only or method-only run neither reports
155
+ # nor receives from `Coverage` for the files it loaded. See #1250.
156
+ UnloadedFileInjector.call(
157
+ result, candidate_paths,
158
+ synthesize: synthesize.nil? ? branch_coverage? || method_coverage? : synthesize,
159
+ lines: lines.nil? ? line_coverage? : lines
160
+ )
161
+ end
162
+
163
+ private
164
+
165
+ def initial_setup(profile, &block)
166
+ load_profile(profile) if profile
167
+ configure(&block) if block
168
+ end
169
+
170
+ def grouped_file_set(grouped)
171
+ grouped.values.each_with_object(Set.new) { |file_list, set| set.merge(file_list) }
172
+ end
173
+
174
+ # Every path this process was told to track, whether or not it loaded them.
175
+ # Recorded on the result (and from there into the resultset) so that a merge
176
+ # in another process can inject the ones nobody loaded without needing this
177
+ # process's `cover` / `track_files` configuration. A standalone `collate`
178
+ # never ran `SimpleCov.start` and so has none of its own. See #1250.
179
+ def tracked_file_paths
180
+ UnloadedFileInjector.discover(
181
+ unloaded_file_discovery_globs, root: root, reject: filters.select(&:path_only?)
182
+ )
183
+ end
184
+
185
+ # Globs to expand on disk when injecting unloaded files into the
186
+ # result. Combines the legacy `track_files` glob (additive only)
187
+ # with every string glob declared via `cover` (also restrictive,
188
+ # but the restriction lives in `Result#apply_cover_filters!`).
189
+ def unloaded_file_discovery_globs
190
+ [tracked_files, *cover_globs].compact
191
+ end
192
+
193
+ # Run all the steps that handle processing the raw coverage result.
194
+ # `report:` is true only when this slice is the final result (merging
195
+ # off); with merging on the merged result reports dropped source files,
196
+ # so the per-process slice stays quiet to avoid one warning per worker.
197
+ #
198
+ # `inject_unloaded:` is likewise false when a merge step follows. Only the
199
+ # union of every process's loaded files says what was really never loaded,
200
+ # so injecting here means each worker simulates nearly the whole project
201
+ # and all but one of those passes is merged away. See #1250.
202
+ def process_coverage_result(report:, inject_unloaded: true)
203
+ raw = SimpleCov::UselessResultsRemover.call(Coverage.result)
204
+ adapted = SimpleCov::ResultAdapter.call(raw)
205
+ # What this process was told to track and did not load. Subtracting what
206
+ # it loaded matters because `Result#to_hash` serializes coverage after
207
+ # filtering: a file this process loaded and then filtered out would
208
+ # otherwise be recorded as tracked while absent from the stored coverage,
209
+ # and the merge would simulate it back in as never-loaded. See #1250.
210
+ tracked = tracked_file_paths - adapted.keys
211
+ result, not_loaded = inject_unloaded ? inject_unloaded_files(adapted, tracked) : [adapted, Set.new]
212
+ @result = SimpleCov::Result.new(
213
+ result, not_loaded_files: not_loaded, tracked_files: tracked,
214
+ run_id: SimpleCov.run_id, worker_id: SimpleCov.worker_id, report: report
215
+ )
216
+ end
217
+ end
218
+ end