simplecov 1.1.0 → 1.2.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +165 -57
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/badge/svg.rb +70 -0
  13. data/lib/simplecov/cli/badge.rb +62 -0
  14. data/lib/simplecov/cli/clean.rb +23 -24
  15. data/lib/simplecov/cli/command_helpers.rb +55 -19
  16. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  17. data/lib/simplecov/cli/completions.rb +83 -0
  18. data/lib/simplecov/cli/coverage.rb +25 -28
  19. data/lib/simplecov/cli/coverage_file.rb +58 -14
  20. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  21. data/lib/simplecov/cli/dead_code.rb +139 -0
  22. data/lib/simplecov/cli/diff/output.rb +45 -0
  23. data/lib/simplecov/cli/diff.rb +35 -67
  24. data/lib/simplecov/cli/dotfile.rb +83 -38
  25. data/lib/simplecov/cli/git.rb +41 -0
  26. data/lib/simplecov/cli/history/output.rb +135 -0
  27. data/lib/simplecov/cli/history.rb +67 -0
  28. data/lib/simplecov/cli/merge.rb +34 -39
  29. data/lib/simplecov/cli/open.rb +12 -15
  30. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  31. data/lib/simplecov/cli/patch/output.rb +135 -0
  32. data/lib/simplecov/cli/patch.rb +169 -0
  33. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  34. data/lib/simplecov/cli/ratchet.rb +83 -0
  35. data/lib/simplecov/cli/report.rb +18 -24
  36. data/lib/simplecov/cli/run.rb +8 -9
  37. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  38. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  39. data/lib/simplecov/cli/serve.rb +27 -24
  40. data/lib/simplecov/cli/show/annotator.rb +87 -0
  41. data/lib/simplecov/cli/show/sweep.rb +36 -0
  42. data/lib/simplecov/cli/show.rb +133 -0
  43. data/lib/simplecov/cli/status/facts.rb +87 -0
  44. data/lib/simplecov/cli/status.rb +93 -0
  45. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  46. data/lib/simplecov/cli/tests.rb +148 -0
  47. data/lib/simplecov/cli/uncovered/misses.rb +37 -0
  48. data/lib/simplecov/cli/uncovered.rb +70 -32
  49. data/lib/simplecov/cli/usage.rb +152 -0
  50. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  51. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  52. data/lib/simplecov/cli/watch/poller.rb +45 -0
  53. data/lib/simplecov/cli/watch/session.rb +134 -0
  54. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  55. data/lib/simplecov/cli/watch.rb +85 -0
  56. data/lib/simplecov/cli.rb +43 -92
  57. data/lib/simplecov/color.rb +21 -35
  58. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  59. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  60. data/lib/simplecov/combine/identity_interner.rb +10 -13
  61. data/lib/simplecov/combine/interned_counts.rb +7 -9
  62. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  63. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  64. data/lib/simplecov/combine/results_combiner.rb +4 -21
  65. data/lib/simplecov/combine.rb +0 -6
  66. data/lib/simplecov/command_guesser.rb +20 -34
  67. data/lib/simplecov/configuration/baseline.rb +31 -0
  68. data/lib/simplecov/configuration/coverage.rb +91 -79
  69. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  70. data/lib/simplecov/configuration/deprecations.rb +21 -0
  71. data/lib/simplecov/configuration/eval_coverage.rb +6 -10
  72. data/lib/simplecov/configuration/filters.rb +40 -73
  73. data/lib/simplecov/configuration/formatting.rb +74 -83
  74. data/lib/simplecov/configuration/groups.rb +19 -13
  75. data/lib/simplecov/configuration/history.rb +44 -0
  76. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  77. data/lib/simplecov/configuration/merging.rb +94 -62
  78. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  79. data/lib/simplecov/configuration/production.rb +23 -0
  80. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  81. data/lib/simplecov/configuration/thresholds.rb +33 -62
  82. data/lib/simplecov/configuration/view_coverage.rb +38 -0
  83. data/lib/simplecov/configuration.rb +70 -76
  84. data/lib/simplecov/configuration_error.rb +5 -0
  85. data/lib/simplecov/context_map/union.rb +81 -0
  86. data/lib/simplecov/context_map.rb +187 -0
  87. data/lib/simplecov/coverage_json.rb +2 -5
  88. data/lib/simplecov/coverage_statistics.rb +14 -24
  89. data/lib/simplecov/coverage_violations.rb +157 -64
  90. data/lib/simplecov/current_run.rb +61 -0
  91. data/lib/simplecov/defaults.rb +4 -23
  92. data/lib/simplecov/deprecation.rb +45 -27
  93. data/lib/simplecov/directive.rb +46 -62
  94. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  95. data/lib/simplecov/exit_codes/check.rb +17 -11
  96. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  97. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  98. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  99. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  100. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  101. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  102. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  103. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  104. data/lib/simplecov/exit_codes.rb +17 -7
  105. data/lib/simplecov/exit_handling.rb +94 -59
  106. data/lib/simplecov/file_list.rb +20 -51
  107. data/lib/simplecov/filter.rb +43 -66
  108. data/lib/simplecov/formatter/base.rb +38 -51
  109. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  110. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  111. data/lib/simplecov/formatter/html_formatter/public/index.html +17 -10
  112. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  113. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  114. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  115. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  116. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  117. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  118. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  119. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  120. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  121. data/lib/simplecov/formatter.rb +14 -10
  122. data/lib/simplecov/group_names.rb +13 -12
  123. data/lib/simplecov/history.rb +115 -0
  124. data/lib/simplecov/last_run.rb +6 -9
  125. data/lib/simplecov/lines_classifier.rb +31 -16
  126. data/lib/simplecov/load_global_config.rb +7 -7
  127. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  128. data/lib/simplecov/parallel_adapters/generic.rb +7 -18
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -53
  131. data/lib/simplecov/parallel_coordination.rb +39 -49
  132. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  133. data/lib/simplecov/parallel_result_merger.rb +67 -126
  134. data/lib/simplecov/process.rb +14 -32
  135. data/lib/simplecov/production/error.rb +11 -0
  136. data/lib/simplecov/production/file_sink.rb +130 -0
  137. data/lib/simplecov/production.rb +249 -0
  138. data/lib/simplecov/profiles/rails.rb +1 -12
  139. data/lib/simplecov/profiles/root_filter.rb +0 -4
  140. data/lib/simplecov/profiles/strict.rb +9 -19
  141. data/lib/simplecov/profiles.rb +11 -33
  142. data/lib/simplecov/report_deferral.rb +11 -15
  143. data/lib/simplecov/report_stamp.rb +6 -9
  144. data/lib/simplecov/result/filter_config.rb +19 -0
  145. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  146. data/lib/simplecov/result/serialization.rb +43 -0
  147. data/lib/simplecov/result/source_file_builder.rb +7 -12
  148. data/lib/simplecov/result.rb +68 -117
  149. data/lib/simplecov/result_adapter.rb +58 -68
  150. data/lib/simplecov/result_merger/contexts.rb +22 -0
  151. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  152. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  153. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  154. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  155. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  156. data/lib/simplecov/result_merger.rb +59 -58
  157. data/lib/simplecov/result_processing.rb +74 -119
  158. data/lib/simplecov/run_identity.rb +16 -18
  159. data/lib/simplecov/simulate_coverage.rb +35 -50
  160. data/lib/simplecov/source_file/branch.rb +8 -33
  161. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  162. data/lib/simplecov/source_file/builder_context.rb +6 -13
  163. data/lib/simplecov/source_file/line.rb +13 -33
  164. data/lib/simplecov/source_file/line_builder.rb +9 -15
  165. data/lib/simplecov/source_file/method.rb +3 -7
  166. data/lib/simplecov/source_file/method_builder.rb +6 -15
  167. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  168. data/lib/simplecov/source_file/skip_chunks.rb +19 -28
  169. data/lib/simplecov/source_file/source_loader.rb +45 -35
  170. data/lib/simplecov/source_file/statistics.rb +10 -9
  171. data/lib/simplecov/source_file.rb +21 -51
  172. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
  173. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  174. data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
  175. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  176. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  177. data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
  178. data/lib/simplecov/static_coverage_extractor.rb +46 -62
  179. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  180. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  181. data/lib/simplecov/test_tracker/delta.rb +69 -0
  182. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  183. data/lib/simplecov/test_tracker.rb +162 -0
  184. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  185. data/lib/simplecov/useless_results_remover.rb +7 -6
  186. data/lib/simplecov/version.rb +1 -1
  187. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  188. data/lib/simplecov/view_coverage.rb +55 -0
  189. data/lib/simplecov-html.rb +1 -0
  190. data/lib/simplecov.rb +59 -114
  191. data/lib/simplecov_json_formatter.rb +1 -0
  192. data/man/simplecov.1 +349 -0
  193. data/sig/simplecov.rbs +523 -595
  194. metadata +72 -7
  195. data/schemas/coverage-v1.0.schema.json +0 -306
  196. data/schemas/coverage.schema.json +0 -306
@@ -2,22 +2,18 @@
2
2
 
3
3
  module SimpleCov
4
4
  module ResultMerger
5
- # We changed the format of the raw result data in simplecov, as people
6
- # are likely to have "old" resultsets lying around (but not too old so
7
- # that they're still considered we can adapt them). See
8
- # https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747
9
5
  module LegacyFormatAdapter
10
- module_function
6
+ extend self
11
7
 
12
8
  def call(result)
13
9
  pre_0_18?(result) ? upgrade(result) : result
14
10
  end
15
11
 
16
- # Pre-0.18 coverage data pointed from file directly to an array of
17
- # line coverage rather than a `{"lines" => [...]}` hash.
12
+ # Pre-0.18 coverage data pointed from file directly to an array of line
13
+ # coverage rather than a `{"lines" => [...]}` hash.
18
14
  def pre_0_18?(result)
19
15
  _key, data = result.first
20
- data.is_a?(Array)
16
+ data.instance_of?(Array)
21
17
  end
22
18
 
23
19
  def upgrade(result)
@@ -4,25 +4,22 @@ require "json"
4
4
 
5
5
  module SimpleCov
6
6
  module ResultMerger
7
- # Read + parse a `.resultset.json` file with the same tolerance the
8
- # historical `ResultMerger` had: a missing or empty file quietly
9
- # returns `{}`, an unparseable one warns and returns `{}`, and parse
10
- # success returns the decoded Hash with any malformed entries
11
- # warned about and dropped.
7
+ # Read and parse a `.resultset.json` with the same tolerance the historical
8
+ # `ResultMerger` had: a missing or empty file quietly returns `{}`, an
9
+ # unparseable one warns and returns `{}`, and malformed entries inside a
10
+ # valid file are warned about and dropped.
12
11
  module ResultsetFile
13
- module_function
12
+ extend self
14
13
 
15
14
  def parse(path)
16
15
  data = read(path)
17
16
  decode(data)
18
17
  end
19
18
 
20
- # A missing or blank file quietly means "no results yet"; anything
21
- # else — a 1-byte truncation included flows through decode so
22
- # corruption warns instead of silently vanishing (the old
23
- # `length < 2` check swallowed exactly those). Read first and
24
- # rescue rather than check exist? (racy against a concurrent
25
- # clean).
19
+ # A missing or blank file quietly means "no results yet"; anything else, a
20
+ # 1-byte truncation included, flows through decode so corruption warns
21
+ # instead of silently vanishing. Read first and rescue rather than check
22
+ # exist?, which is racy against a concurrent clean.
26
23
  def read(path)
27
24
  data = File.read(path)
28
25
  return if data.match?(/\A\s*\z/)
@@ -32,37 +29,33 @@ module SimpleCov
32
29
  nil
33
30
  end
34
31
 
32
+ # Valid JSON need not be an object, but everything downstream iterates
33
+ # command => data pairs, so anything else gets the same tolerance as a parse
34
+ # failure. A "null" file has long meant an empty resultset.
35
35
  def decode(content)
36
36
  return {} unless content
37
37
 
38
38
  parsed = JSON.parse(content)
39
- return {} if parsed.nil? # a "null" file has long meant an empty resultset
39
+ return {} if parsed.nil?
40
40
 
41
- # Valid JSON need not be an object ("[1,2]" and "\"x\"" parse
42
- # fine), but everything downstream iterates command => data
43
- # pairs, so anything else would crash out of the middle of a
44
- # merge. Same tolerance as a parse failure: warn and move on.
45
- parsed.is_a?(Hash) ? drop_malformed_entries(parsed) : invalid_resultset
46
- rescue StandardError
41
+ parsed.instance_of?(Hash) ? drop_malformed_entries(parsed) : invalid_resultset
42
+ rescue
47
43
  invalid_resultset
48
44
  end
49
45
 
50
- # Each surviving entry must have the shape every consumer relies
51
- # on: a Hash carrying a Numeric "timestamp" (the merge-timeout
52
- # check) and a Hash "coverage" (the merge fold). A truncated or
53
- # hand-edited entry would otherwise crash out of the middle of an
54
- # at_exit merge, so it gets the same warn-and-move-on treatment
55
- # as an unparseable file.
56
46
  def drop_malformed_entries(resultset)
57
47
  malformed, valid = resultset.partition { |_command_name, data| !well_formed_entry?(data) }
58
48
  return resultset if malformed.empty?
59
49
 
60
- warn "[SimpleCov]: Warning! Ignoring malformed resultset entries: #{malformed.map(&:first).sort.join(', ')}"
50
+ warn "[SimpleCov]: Warning! Ignoring malformed resultset entries: #{malformed.map(&:first).sort.join(", ")}"
61
51
  valid.to_h
62
52
  end
63
53
 
54
+ # Every consumer relies on a Hash carrying a Numeric "timestamp" and a Hash
55
+ # "coverage". A truncated or hand-edited entry would otherwise crash out of
56
+ # the middle of an at_exit merge.
64
57
  def well_formed_entry?(data)
65
- data.is_a?(Hash) && data["timestamp"].is_a?(Numeric) && data["coverage"].is_a?(Hash)
58
+ data.instance_of?(Hash) && data["timestamp"].is_a?(Numeric) && data["coverage"].instance_of?(Hash)
66
59
  end
67
60
 
68
61
  def invalid_resultset
@@ -2,15 +2,14 @@
2
2
 
3
3
  module SimpleCov
4
4
  module ResultMerger
5
- # Run/worker metadata queries used by resultset storage and parallel
6
- # coordination. Kept separate from the coverage-folding implementation.
5
+ # Run and worker metadata queries used by resultset storage and parallel
6
+ # coordination.
7
7
  module ResultsetRunIdentity
8
- # One namespaced identity per distinct worker that wrote to the
9
- # current run: `[:worker, id]` for entries that carry a worker id,
10
- # `[:legacy, command_name]` for fresh entries written without one
11
- # (an old SimpleCov writing to the same resultset). Pairs rather
12
- # than mangled strings, so a resultset mixing both kinds can never
13
- # alias a real worker id to a synthesized legacy identity.
8
+ # One namespaced identity per distinct worker that wrote to the current run:
9
+ # `[:worker, id]` for entries carrying a worker id, `[:legacy, command_name]`
10
+ # for fresh entries written without one. Pairs rather than mangled strings,
11
+ # so a resultset mixing both kinds can never alias a real worker id to a
12
+ # synthesized legacy identity.
14
13
  def worker_identities_for_run(results, run_id, started_at)
15
14
  results.filter_map do |command_name, data|
16
15
  next unless current_run_entry?(data, run_id, started_at)
@@ -20,13 +19,16 @@ module SimpleCov
20
19
  end.uniq
21
20
  end
22
21
 
22
+ # `eql?` rather than `==`: run ids are strings, and value equality already
23
+ # answers false for the nil run id a caller with no identity of its own
24
+ # passes.
23
25
  def current_run_entry?(entry, run_id, started_at)
24
- return false unless entry.is_a?(Hash)
26
+ return false unless entry.instance_of?(Hash)
25
27
 
26
28
  entry_run_id = entry["run_id"]
27
29
  return fresh_entry?(entry, started_at) unless entry_run_id
28
- return false unless run_id && entry_run_id == run_id
29
- return true if SimpleCov::RunIdentity.authoritative?
30
+ return false unless entry_run_id.eql?(run_id)
31
+ return true if RunIdentity.authoritative?
30
32
 
31
33
  fresh_entry?(entry, started_at)
32
34
  end
@@ -36,28 +38,24 @@ module SimpleCov
36
38
  timestamp.is_a?(Numeric) && started_at && timestamp >= started_at.to_f
37
39
  end
38
40
 
39
- # Whether `entry` was written by a runner concurrent with this
40
- # process: either it shares our run id (`current_run_entry?`), or it
41
- # was written strictly after our process started. A mismatched run
42
- # id must not defeat the timestamp check a subprocess we shelled
43
- # out to (fork+exec) generates its own random run id, and its
44
- # freshly written entry is exactly the data issue #581 protects
45
- # from being clobbered. Strict id matching is reserved for worker
46
- # counting (`worker_identities_for_run`), where admitting a stale
47
- # entry would end a sibling wait early.
41
+ # Either the entry shares our run id, or it was written strictly after our
42
+ # process started. A mismatched run id must not defeat the timestamp check: a
43
+ # subprocess we shelled out to generates its own random run id, and its
44
+ # freshly written entry is exactly the data #581 protects from being
45
+ # clobbered. Strict id matching is reserved for worker counting, where
46
+ # admitting a stale entry would end a sibling wait early.
48
47
  def concurrent_runner_entry?(entry, incoming = nil)
49
- return false unless entry.is_a?(Hash)
48
+ return false unless entry.instance_of?(Hash)
50
49
 
51
- incoming_run_id = incoming["run_id"] if incoming.is_a?(Hash)
50
+ incoming_run_id = incoming["run_id"] if incoming.instance_of?(Hash)
52
51
  started_at = SimpleCov.process_start_time
53
52
  return true if current_run_entry?(entry, incoming_run_id, started_at)
54
53
 
55
54
  written_after_start?(entry, started_at)
56
55
  end
57
56
 
58
- # Strictly after, unlike `fresh_entry?`: an entry stamped at the
59
- # exact instant we started is treated as leftover from a previous
60
- # run, so a same-second stale entry is still overwritten.
57
+ # Strictly after, unlike `fresh_entry?`: an entry stamped at the exact
58
+ # instant we started is treated as leftover from a previous run.
61
59
  def written_after_start?(entry, started_at)
62
60
  timestamp = entry["timestamp"]
63
61
  timestamp.is_a?(Numeric) && started_at && timestamp > started_at.to_f
@@ -7,14 +7,11 @@ require_relative "../atomic_file"
7
7
 
8
8
  module SimpleCov
9
9
  module ResultMerger
10
- # Reads and writes the persistent `.resultset.json` cache, including
11
- # file-lock synchronization between processes and atomic temp-file
12
- # renames so concurrent readers don't observe a truncated file.
13
10
  module ResultsetStore
14
11
  LOCK_MONITOR = Monitor.new
15
12
  private_constant :LOCK_MONITOR
16
13
 
17
- module_function
14
+ extend self
18
15
 
19
16
  def resultset_path
20
17
  File.join(SimpleCov.coverage_path, ".resultset.json")
@@ -24,30 +21,42 @@ module SimpleCov
24
21
  File.join(SimpleCov.coverage_path, ".resultset.json.lock")
25
22
  end
26
23
 
27
- # Compact JSON, not pretty-printed: this is a machine-read cache
28
- # that every parallel worker rewrites wholesale, and on a large
29
- # project pretty printing nearly doubles the bytes written, read
30
- # back, and parsed on each of those store-merge round trips.
24
+ # Compact JSON, not pretty-printed: this is a machine-read cache that every
25
+ # parallel worker rewrites wholesale, and on a large project pretty printing
26
+ # nearly doubles the bytes written, read back, and parsed on each of those
27
+ # store-merge round trips.
31
28
  def write(resultset)
32
29
  AtomicFile.write(resultset_path, "#{JSON.generate(resultset)}\n")
33
30
  end
34
31
 
35
- # Serialize threads before taking the process-wide file lock. Nested
36
- # calls by the owning thread bypass flock so they cannot deadlock on a
37
- # second descriptor for the same lock file.
32
+ # Threads are serialized before the process-wide file lock is taken. Nested
33
+ # calls by the owning thread bypass flock so they cannot deadlock on a second
34
+ # descriptor for the same lock file.
38
35
  def synchronize(&)
39
36
  return yield if LOCK_MONITOR.mon_owned?
40
37
 
41
38
  LOCK_MONITOR.synchronize { with_flock(&) }
42
39
  end
43
40
 
44
- def with_flock
41
+ def with_flock(&)
45
42
  FileUtils.mkdir_p(SimpleCov.coverage_path)
46
- File.open(writelock_path, "w+") do |f|
47
- f.flock(File::LOCK_EX)
43
+ holding_writelock(&)
44
+ end
45
+
46
+ def holding_writelock
47
+ open_file(writelock_path, "w+") do |file|
48
+ file.flock(File::LOCK_EX)
48
49
  yield
49
50
  end
50
51
  end
52
+
53
+ # mutant:disable — Ruby 4.0 removed Kernel#open's leading-pipe
54
+ # command mode, so no test can tell `File.open` from `open` here
55
+ # any more. The explicit receiver is kept: on older rubies it is
56
+ # what refuses to run a lock path as a command.
57
+ def open_file(name, mode, &)
58
+ File.open(name, mode, &)
59
+ end
51
60
  end
52
61
  end
53
62
  end
@@ -2,25 +2,21 @@
2
2
 
3
3
  module SimpleCov
4
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.
5
+ # The merge step's half of unloaded-file handling: the policy of when to
6
+ # inject and which criteria simulated files carry. The mechanism, expanding
7
+ # globs and simulating each file, is `SimpleCov::UnloadedFileInjector`.
10
8
  #
11
9
  # Injection moved here from the individual processes because only the union
12
10
  # of what they all loaded says what was really never loaded. Doing it per
13
11
  # process meant N workers simulated the same file up to N times and the
14
- # merge threw all but one away. See #1250.
12
+ # merge threw all but one away (#1250).
15
13
  module UnloadedFiles
16
- module_function
14
+ extend self
17
15
 
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
16
  # A collector for the merge to hand `merge_valid_results`, gathering the
23
17
  # tracked paths of every resultset that survives the merge timeout.
18
+ # Absent from resultsets written before this was recorded, in which case
19
+ # those processes injected their own unloaded files already.
24
20
  def collector(into)
25
21
  ->(surviving) { into.merge(tracked_in(surviving)) }
26
22
  end
@@ -38,12 +34,11 @@ module SimpleCov
38
34
  tracked.empty? ? entry : entry.merge("tracked_files" => tracked)
39
35
  end
40
36
 
41
- # Simulate each tracked file the merged coverage doesn't already carry.
37
+ # Simulates each tracked file the merged coverage doesn't already carry.
42
38
  # 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.
39
+ # configuration, which a standalone `collate` would not have. Idempotent,
40
+ # so resultsets that already carry their unloaded files pass through
41
+ # untouched.
47
42
  def inject(coverage, tracked_files)
48
43
  SimpleCov.inject_unloaded_files(
49
44
  coverage, tracked_files.to_a,
@@ -54,10 +49,10 @@ module SimpleCov
54
49
 
55
50
  # A simulated file should have the same shape as the files it is being
56
51
  # 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.
52
+ # from this process's configuration, which is not necessarily what any
53
+ # contributing process measured under: `simplecov merge` never ran
54
+ # `SimpleCov.start` at all. Giving injected files fewer tables than their
55
+ # neighbours is what inflates the percentage #1059 fixed.
61
56
  #
62
57
  # Falls back to the configuration when there is nothing to be consistent
63
58
  # with, which is a merge of resultsets that carried no files.
@@ -77,25 +72,22 @@ module SimpleCov
77
72
  # Which files no contributing process ever loaded. Injection reports the
78
73
  # ones it added, but a file can also arrive already simulated from a
79
74
  # 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.
75
+ # still consulted.
82
76
  #
83
77
  # A file is judged only when it has a relevant line. A branch-only or
84
78
  # 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.
79
+ # and a loaded file with no executable lines reports every line as `nil`.
80
+ # Either would otherwise read as "never executed" and mark a genuinely
81
+ # loaded file not loaded, turning its branch and method coverage into
82
+ # #902's 0%.
93
83
  def never_executed(coverage)
94
84
  coverage.each_with_object(Set.new) do |(filename, file_coverage), set|
95
85
  counts = Array(file_coverage["lines"]) #: Array[Integer?]
96
- next unless counts.any? { |count| !count.nil? }
86
+ # A line count is an Integer or nil, and every Integer is truthy, so a
87
+ # truthy element is exactly a relevant line.
88
+ next unless counts.any?
97
89
 
98
- set << filename unless Combine::CoverageAccumulator.executed?(file_coverage["lines"])
90
+ set << filename unless Combine::CoverageAccumulator.executed?(counts)
99
91
  end
100
92
  end
101
93
  end
@@ -4,13 +4,13 @@ require_relative "result_merger/legacy_format_adapter"
4
4
  require_relative "result_merger/resultset_file"
5
5
  require_relative "result_merger/resultset_run_identity"
6
6
  require_relative "result_merger/resultset_store"
7
+ require_relative "result_merger/contexts"
7
8
  require_relative "result_merger/unloaded_files"
8
9
 
9
10
  module SimpleCov
10
11
  #
11
- # Singleton that is responsible for caching, loading and merging
12
- # SimpleCov::Results into a single result for coverage analysis based
13
- # 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.
14
14
  #
15
15
  module ResultMerger
16
16
  extend ResultsetRunIdentity
@@ -27,40 +27,40 @@ module SimpleCov
27
27
  end
28
28
 
29
29
  def merge_results(*file_paths, ignore_timeout: false)
30
- # Tracked paths are collected as each resultset is parsed, so files are
31
- # still read and discarded one at a time. See #1250.
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
32
  tracked_files = Set.new
33
+ context_maps = ContextMap::Union.new
33
34
  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)
35
+ &entry_collector(tracked_files, context_maps))
36
+ create_result(command_names, coverage, tracked_files: tracked_files, contexts: context_maps.map)
37
+ end
38
+
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) } }
36
45
  end
37
46
 
38
47
  #
39
48
  # Reads every resultset and folds it into one merged coverage, stopping
40
49
  # short of building a `SimpleCov::Result`.
41
50
  #
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.
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.
50
55
  #
51
56
  # 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
+ # 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`.
57
60
  #
58
61
  # `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
62
+ # the merge as it goes can hand in any Enumerable. `benchmarks/collate`
63
+ # passes an Enumerator that reports progress.
64
64
  #
65
65
  def absorb_results(file_paths, ignore_timeout: false, &on_parse)
66
66
  Combine::CoverageAccumulator.fold(
@@ -84,7 +84,6 @@ module SimpleCov
84
84
  [[command_name], LegacyFormatAdapter.call(data.fetch("coverage"))]
85
85
  end
86
86
 
87
- # one file itself _might_ include multiple test runs
88
87
  merge_coverage(*command_plus_coverage)
89
88
  end
90
89
 
@@ -101,50 +100,54 @@ module SimpleCov
101
100
  end
102
101
 
103
102
  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.
103
+ # Ordinary parallel workers only store their own slice; the selected
104
+ # final process performs this merge.
107
105
  return unless SimpleCov.print_errors
108
106
 
109
107
  warn "[SimpleCov]: Excluded #{expired_command_names.size} result(s) older than " \
110
108
  "merge_timeout (#{SimpleCov.merge_timeout}s) from the merged report: " \
111
- "#{expired_command_names.sort.join(', ')}. " \
109
+ "#{expired_command_names.sort.join(", ")}. " \
112
110
  "Increase SimpleCov.merge_timeout to include them."
113
111
  end
114
112
 
115
- def create_result(command_names, coverage, tracked_files: Set.new)
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)
116
118
  return nil unless coverage
117
119
 
118
- command_name = command_names.reject(&:empty?).sort.join(", ")
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
119
124
  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
+ # 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(
125
130
  coverage,
126
- command_name: command_name,
131
+ command_name: distinct_names.join(", "), contexts: contexts, report: true,
127
132
  not_loaded_files: UnloadedFiles.never_executed(coverage) | injected,
128
- tracked_files: tracked_files.to_a,
129
- report: true
130
- )
133
+ tracked_files: tracked_files
134
+ ).tap { |result| result.command_names = distinct_names }
131
135
  end
132
136
 
133
137
  def merge_coverage(*results)
134
138
  return [[""], nil] if results.empty?
135
- return results.first if results.size == 1
136
139
 
137
- Combine::CoverageAccumulator.fold(results)
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)
138
144
  end
139
145
 
140
- #
141
- # Gets all SimpleCov::Results stored in resultset, merges them and produces a new
142
- # SimpleCov::Result with merged coverage data and the command_name
143
- # for the result consisting of a join on all source result's names
144
146
  def merged_result
145
147
  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)
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)
148
151
  end
149
152
 
150
153
  def read_resultset
@@ -152,13 +155,11 @@ module SimpleCov
152
155
  ResultsetFile.decode(content)
153
156
  end
154
157
 
155
- # Saves the given SimpleCov::Result in the resultset cache
156
- def store_result(result) # rubocop:disable Naming/PredicateMethod
158
+ def store_result(result)
157
159
  synchronize_resultset do
158
- # Ensure we have the latest, in case it was already cached
159
160
  new_resultset = read_resultset
160
161
 
161
- # 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`.
162
163
  command_name, data = result.to_hash.first
163
164
  new_resultset[command_name] = merged_entry(new_resultset[command_name], data)
164
165
 
@@ -168,16 +169,16 @@ module SimpleCov
168
169
  end
169
170
 
170
171
  # 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.
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
175
  def merged_entry(existing, incoming)
176
176
  return incoming unless concurrent_runner_entry?(existing, incoming)
177
177
 
178
178
  merged = incoming.merge(
179
- "coverage" => Combine::ResultsCombiner.combine(existing["coverage"], incoming["coverage"])
179
+ "coverage" => Combine::ResultsCombiner.combine(existing.fetch("coverage"), incoming.fetch("coverage"))
180
180
  )
181
+ merged = Contexts.carry(merged, existing, incoming)
181
182
  UnloadedFiles.carry_tracked(merged, existing, incoming)
182
183
  end
183
184