simplecov 1.1.1 → 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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -5
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/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 +19 -37
  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 +16 -10
  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 +37 -55
  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 +15 -9
  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 -19
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -55
  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 +56 -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 +34 -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 +57 -113
  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 -5
@@ -5,81 +5,50 @@ require_relative "parallel_adapters/parallel_tests"
5
5
  require_relative "parallel_adapters/generic"
6
6
 
7
7
  module SimpleCov
8
- # Registry + selection for parallel-test-runner adapters. An adapter
9
- # answers a small fixed set of questions on SimpleCov's behalf:
8
+ # Registry and selection for parallel-test-runner adapters. An adapter
9
+ # answers a small fixed set of questions on SimpleCov's behalf: `active?`,
10
+ # `first_worker?`, `wait_for_siblings`, and `expected_worker_count`.
10
11
  #
11
- # - `active?` are WE the runner in charge for this process?
12
- # - `first_worker?` should this process do the final-result work?
13
- # - `wait_for_siblings` block until siblings finish (optional)
14
- # - `expected_worker_count` how many workers total
12
+ # `Base` provides safe no-op defaults. `ParallelTestsAdapter` wraps the
13
+ # grosser/parallel_tests gem; `GenericAdapter` is env-var-only detection for
14
+ # runners that follow the `TEST_ENV_NUMBER` convention without shipping a
15
+ # Ruby API (#1065, #1156).
15
16
  #
16
- # `SimpleCov::ParallelAdapters::Base` provides safe no-op defaults; two
17
- # adapters ship out of the box:
18
- #
19
- # - `ParallelTestsAdapter` wraps the grosser/parallel_tests gem
20
- # (precise sync + first-process detection via the gem's own API).
21
- # - `GenericAdapter` — env-var-only detection for runners that follow
22
- # the parallel_tests `TEST_ENV_NUMBER` convention but don't ship a
23
- # Ruby API (parallel_rspec, rspec-conductor 1.0.7+, custom CI
24
- # sharding, knapsack-style splitters). See
25
- # https://github.com/simplecov-ruby/simplecov/issues/1065 and
26
- # https://github.com/simplecov-ruby/simplecov/issues/1156.
27
- #
28
- # Users can plug in additional adapters:
29
- #
30
- # SimpleCov::ParallelAdapters.register MyRunnerAdapter
31
- #
32
- # An adapter just needs to be a class responding to the four methods
33
- # above. Subclass `SimpleCov::ParallelAdapters::Base` to inherit the
34
- # no-op defaults and override only what you need (the contract methods
35
- # are defined as class methods, so plain inheritance is what carries
36
- # them through; `extend Base` won't pick them up).
17
+ # Users register their own with `ParallelAdapters.register MyAdapter`.
18
+ # Subclass `Base` to inherit the no-op defaults: the contract methods are
19
+ # class methods, so plain inheritance is what carries them through and
20
+ # `extend Base` won't pick them up.
37
21
  module ParallelAdapters
38
- module_function
22
+ extend self
39
23
 
40
- # Adapters in selection order. ParallelTestsAdapter first (most
41
- # specific — uses the gem's own API when the gem is loaded); then
42
- # GenericAdapter as the env-var fallback. User-registered adapters
43
- # are prepended (#register puts new entries at the front) so
44
- # downstream code can override the built-ins by registering a more
45
- # specific match.
24
+ # ParallelTestsAdapter first (most specific: it uses the gem's own API when
25
+ # the gem is loaded), then GenericAdapter as the env-var fallback.
26
+ # User-registered adapters are prepended.
46
27
  def adapters
47
28
  @adapters ||= [ParallelTestsAdapter, GenericAdapter]
48
29
  end
49
30
 
50
- # Register a custom adapter. Newly registered adapters are inserted
51
- # at the front of the selection list so a custom adapter for a
52
- # specific runner takes precedence over the built-in ParallelTests
53
- # and Generic adapters.
54
- #
55
- # class MyRunnerAdapter < SimpleCov::ParallelAdapters::Base
56
- # def self.active? = ENV["MY_RUNNER_PID"]
57
- # def self.first_worker? = ENV["MY_RUNNER_PID"].to_i == 1
58
- # def self.expected_worker_count = ENV["MY_RUNNER_WORKERS"].to_i
59
- # end
60
- #
61
- # SimpleCov::ParallelAdapters.register MyRunnerAdapter
31
+ # Newly registered adapters are inserted at the front of the selection
32
+ # list, so a custom adapter for a specific runner takes precedence over the
33
+ # built-ins.
62
34
  def register(adapter)
63
35
  reset_current!
64
36
  adapters.unshift(adapter) unless adapters.include?(adapter)
65
37
  adapter
66
38
  end
67
39
 
68
- # The adapter SimpleCov should consult for this process the first
69
- # registered adapter whose `active?` returns true. Returns nil when
70
- # no adapter is active (i.e., we're not running under any recognized
71
- # parallel test runner), in which case the caller should treat the
72
- # process as single-worker.
40
+ # The first registered adapter whose `active?` answers true, or nil when
41
+ # none is, in which case the caller treats the process as single-worker.
73
42
  def current
74
- return @current if defined?(@current)
43
+ return @current if instance_variable_defined?(:@current)
75
44
 
76
45
  @current = adapters.find(&:active?)
77
46
  end
78
47
 
79
- # Clear the memoized `current` selection. Primarily for tests that
80
- # mutate env vars between examples; production runs are single-shot.
48
+ # Primarily for tests that mutate env vars between examples; production
49
+ # runs are single-shot.
81
50
  def reset_current!
82
- remove_instance_variable(:@current) if defined?(@current)
51
+ remove_instance_variable(:@current) if instance_variable_defined?(:@current)
83
52
  end
84
53
  end
85
54
  end
@@ -10,13 +10,12 @@ module SimpleCov
10
10
  class << self
11
11
  # @api private
12
12
  def final_result_process?
13
- adapter = SimpleCov::ParallelAdapters.current
14
- # No recognized parallel-test adapter. A subprocess forked while
15
- # coverage was running is never the final reporter the process that
16
- # spawned it merges every slice and produces the report. Without this,
17
- # fork-based runners that don't set TEST_ENV_NUMBER (e.g. Minitest's
18
- # `parallelize`) have every worker produce the final report and its
19
- # warnings. See issue #1171.
13
+ adapter = ParallelAdapters.current
14
+ # A subprocess forked while coverage was running is never the final
15
+ # reporter: the process that spawned it merges every slice and produces
16
+ # the report. Without this, fork-based runners that don't set
17
+ # TEST_ENV_NUMBER (Minitest's `parallelize`) have every worker produce
18
+ # the final report and its warnings (#1171).
20
19
  return !forked_subprocess? unless adapter
21
20
 
22
21
  adapter.first_worker?
@@ -24,53 +23,43 @@ module SimpleCov
24
23
 
25
24
  # @api private
26
25
  def wait_for_other_processes
27
- adapter = SimpleCov::ParallelAdapters.current
26
+ adapter = ParallelAdapters.current
28
27
  return unless adapter && final_result_process?
29
28
 
30
- # Native synchronization first (adapters that wrap a runner with a
31
- # real "wait" primitive parallel_tests'
32
- # `wait_for_other_processes_to_finish` — implement this; adapters
33
- # without a native API no-op and rely on the polling fallback below).
29
+ # Adapters that wrap a runner with a real "wait" primitive implement
30
+ # this; the rest no-op and rely on the polling fallback below.
34
31
  adapter.wait_for_siblings
35
32
 
36
33
  # The native wait can return before sibling at_exit handlers finish
37
- # writing resultsets, and adapters without a native wait have
38
- # nothing else. Either way, poll the resultset cache until all
39
- # expected workers have reported or a timeout is reached. Capture
40
- # the outcome so `ready_to_process_results?` can suppress min/max
41
- # threshold checks against a partial total.
34
+ # writing resultsets, and adapters without a native wait have nothing
35
+ # else. Either way, poll the resultset cache until all expected workers
36
+ # have reported or a timeout is reached. The outcome is captured so
37
+ # threshold checks can be suppressed against a partial total.
42
38
  @parallel_results_complete =
43
39
  wait_for_parallel_results(adapter.expected_worker_count, native_wait: adapter.native_wait?)
44
40
  end
45
41
 
46
- # @api private true when every sibling reported its resultset
47
- # before the wait deadline. Defaults to true outside a parallel
48
- # run (when `wait_for_other_processes` is a no-op).
42
+ # @api private -- true when every sibling reported its resultset before the
43
+ # wait deadline. Defaults to true outside a parallel run.
49
44
  def parallel_results_complete?
50
- defined?(@parallel_results_complete) ? @parallel_results_complete : true
45
+ instance_variable_defined?(:@parallel_results_complete) ? @parallel_results_complete : true
51
46
  end
52
47
 
53
- # @api private seconds the resultset count must hold steady, after a
48
+ # @api private -- seconds the resultset count must hold steady, after a
54
49
  # native wait, before we accept fewer than `expected` workers as final.
55
50
  PARALLEL_RESULTS_SETTLE = 0.5
56
51
  private_constant :PARALLEL_RESULTS_SETTLE
57
52
 
58
- # @api private returns true when the reporting worker has every
59
- # resultset it's going to get, false on timeout. Single-process runs
60
- # (expected <= 1) short-circuit to true with no waiting.
53
+ # @api private -- true when the reporting worker has every resultset it's
54
+ # going to get, false on timeout. Single-process runs short-circuit.
61
55
  #
62
- # Normally we poll until `expected` (= PARALLEL_TEST_GROUPS) workers have
63
- # reported or `SimpleCov.parallel_wait_timeout` elapses; raise that setting
64
- # when a slow worker routinely finishes well after the others.
65
- #
66
- # When a native wait already confirmed every sibling PROCESS exited
67
- # (`native_wait`), no further resultset will appear, so a count below
68
- # `expected` just means some workers produced none — e.g. parallel_test
69
- # groups that got no spec file on a machine with more cores than files.
70
- # Once the count then holds steady for `PARALLEL_RESULTS_SETTLE` we accept
71
- # it as final rather than blocking for the whole timeout. Without a native
72
- # wait (GenericAdapter) we can't tell an idle worker from a slow one, so we
73
- # keep waiting the full timeout.
56
+ # Normally we poll until `expected` workers have reported or
57
+ # `SimpleCov.parallel_wait_timeout` elapses. When a native wait already
58
+ # confirmed every sibling process exited, no further resultset will appear,
59
+ # so a count below `expected` just means some workers produced none, and
60
+ # once the count holds steady for `PARALLEL_RESULTS_SETTLE` we accept it as
61
+ # final. Without a native wait we can't tell an idle worker from a slow
62
+ # one, so we keep waiting the full timeout.
74
63
  def wait_for_parallel_results(expected, native_wait: false)
75
64
  return true unless expected > 1 # simplecov:disable branch — only false in real parallel runs
76
65
 
@@ -87,29 +76,30 @@ module SimpleCov
87
76
  end
88
77
 
89
78
  def current_parallel_worker_count
90
- resultset = SimpleCov::ResultMerger.read_resultset
91
- SimpleCov::ResultMerger.worker_identities_for_run(resultset, SimpleCov.run_id, SimpleCov.process_start_time).size
79
+ resultset = ResultMerger.read_resultset
80
+ ResultMerger.worker_identities_for_run(resultset, run_id, process_start_time).size
92
81
  end
93
82
 
94
- # Track whether the resultset count has held steady (and positive) for
95
- # `PARALLEL_RESULTS_SETTLE` seconds. `tracker` carries the last count and
96
- # the time it last changed across poll iterations.
83
+ # `tracker` carries the last count and the time it last changed across poll
84
+ # iterations. Answered through an if/else rather than an early return
85
+ # because falling through after a climb would read the clock a second time
86
+ # and answer the same `false` anyway.
97
87
  def resultset_count_settled?(tracker, count)
98
- if count > tracker[:count]
88
+ if count > tracker.fetch(:count)
99
89
  tracker[:count] = count
100
90
  tracker[:since] = monotonic_time
101
- return false
91
+ false
92
+ else
93
+ count.positive? && (monotonic_time - tracker.fetch(:since)) >= PARALLEL_RESULTS_SETTLE
102
94
  end
103
-
104
- count.positive? && (monotonic_time - tracker[:since]) >= PARALLEL_RESULTS_SETTLE
105
95
  end
106
96
 
107
97
  def monotonic_time
108
98
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
99
  end
110
100
 
111
- # @api private true once the wait deadline has passed; warns on
112
- # the first timeout so the user knows the merged total is partial.
101
+ # @api private -- warns on the first timeout so the user knows the merged
102
+ # total is partial.
113
103
  def parallel_wait_timed_out?(deadline, expected, seen)
114
104
  return false unless monotonic_time > deadline
115
105
 
@@ -121,7 +111,7 @@ module SimpleCov
121
111
  def warn_about_incomplete_parallel_results(expected, seen)
122
112
  return unless print_errors
123
113
 
124
- warn SimpleCov::Color.colorize(
114
+ warn Color.colorize(
125
115
  "Only #{seen} of #{expected} parallel-test workers reported within " \
126
116
  "#{parallel_wait_timeout}s, so coverage totals are partial and minimum / " \
127
117
  "maximum coverage checks are skipped for this run. Increase " \
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ParallelResultMerger
5
+ # What a worker ships back over its pipe: the `[command_names, coverage]` pair
6
+ # its slice folded to, the tracked paths the slice carried, and the slice's
7
+ # context-map union. Building and folding live together so the pipe format
8
+ # has one owner. The parent needs the union across every worker of the
9
+ # tracked paths, to know what nothing loaded, and of the maps, to judge the
10
+ # all-or-nothing rule over the whole run rather than per slice.
11
+ module WorkerPayload
12
+ extend self
13
+
14
+ def build(chunk, ignore_timeout:)
15
+ tracked_files = Set.new
16
+ context_maps = ContextMap::Union.new
17
+ pair = ResultMerger.absorb_results(chunk, ignore_timeout: ignore_timeout,
18
+ &ResultMerger.entry_collector(tracked_files, context_maps))
19
+ [pair, tracked_files.to_a, context_maps]
20
+ end
21
+
22
+ def absorb(payload, tracked_files, context_maps)
23
+ _pair, tracked, maps = payload
24
+ tracked_files.merge(tracked)
25
+ context_maps.absorb_union(maps)
26
+ end
27
+
28
+ def pair(payload)
29
+ payload.first
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,35 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "parallel_result_merger/worker_payload"
4
+
3
5
  module SimpleCov
4
6
  #
5
- # Folds a list of resultset files into one merged coverage table across
6
- # forked worker processes. Drives `SimpleCov.collate(..., processes: N)`.
7
- #
8
- # `ResultMerger.absorb_results` is a fold over N independent
9
- # read-parse-combine steps, so it splits cleanly: each worker runs that
10
- # same fold over a contiguous slice of the file list and ships the pair
11
- # back over a pipe, and the parent combines the handful of per-worker
12
- # pairs it gets back. Reading and parsing the shards — where a collate
13
- # over a few hundred CI jobs spends most of its time — is what actually
14
- # parallelises.
7
+ # `ResultMerger.absorb_results` fanned out across forked workers, driving
8
+ # `SimpleCov.collate(..., processes: N)`. Each worker folds a contiguous
9
+ # slice of the file list and ships the pair back over a pipe.
15
10
  #
16
- # The slices are contiguous and merged back in order, so the fold visits
17
- # the resultsets in the order the serial fold visits them and the merged
18
- # result is identical to `SimpleCov.collate`'s, not merely equivalent.
19
- #
20
- # Every failure path returns nil rather than a partial merge, so the caller
21
- # can redo the fold serially: reporting coverage for a subset of the
22
- # resultsets would silently understate it.
11
+ # The slices are contiguous and merged back in order, so the fold visits the
12
+ # resultsets in the order the serial fold visits them and the merged result
13
+ # is identical to `SimpleCov.collate`'s, not merely equivalent.
23
14
  #
24
15
  module ParallelResultMerger
25
- module_function
26
-
27
- #
28
- # `ResultMerger.merge_and_store` across `processes` forked workers. One
29
- # worker hands straight back to `ResultMerger`, so the default `collate`
30
- # takes exactly the path it always has and never reaches this module's
31
- # machinery at all.
32
- #
16
+ extend self
17
+
33
18
  def merge_and_store(*file_paths, processes:, ignore_timeout: false)
34
19
  return ResultMerger.merge_and_store(*file_paths, ignore_timeout: ignore_timeout) if processes < 2
35
20
 
@@ -38,176 +23,132 @@ module SimpleCov
38
23
  result
39
24
  end
40
25
 
41
- #
42
- # `ResultMerger.merge_results` across `processes` forked workers, merging
43
- # in this process instead whenever the fan-out did not produce a complete
44
- # merge — a runtime that cannot fork, nothing worth splitting, or a worker
45
- # that died. The result is the same either way; only the time it took to
46
- # get there differs.
47
- #
48
26
  def merge_results(*file_paths, processes:, ignore_timeout: false)
49
27
  tracked_files = Set.new
28
+ context_maps = ContextMap::Union.new
50
29
  pair = absorb_results(file_paths, processes: processes, ignore_timeout: ignore_timeout,
51
- tracked_files: tracked_files)
52
- # A nil pair means nothing was fanned out at all, so the serial path
53
- # is exactly `ResultMerger.merge_results` — delegate rather than
54
- # re-implement its collector wiring.
30
+ tracked_files: tracked_files, context_maps: context_maps)
55
31
  return ResultMerger.merge_results(*file_paths, ignore_timeout: ignore_timeout) unless pair
56
32
 
57
33
  command_names, coverage = pair
58
- ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files)
34
+ ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files, contexts: context_maps.map)
59
35
  end
60
36
 
61
- #
62
- # `ResultMerger.absorb_results` across at most `processes` forked
63
- # workers: same arguments, same `[command_names, coverage]` return.
64
- #
65
- # The tracked paths a worker's slice carried come back with its payload
66
- # rather than through a collector block, since the block a serial absorb
67
- # takes would be mutating a Set in the wrong process.
68
- #
69
- # @return [Array(Array<String>, Hash), nil] the pair
70
- # `ResultMerger.create_result` consumes, or nil when the work could not
71
- # be fanned out and the caller should merge in this process instead.
72
- #
73
- def absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new)
74
- # One worker folds the whole list anyway, and one file is a fold of
75
- # one — in both cases the fork and the round trip are pure overhead.
37
+ # Answers nil when the work could not be fanned out and the caller should
38
+ # merge in this process instead. The tracked paths and context-map union a
39
+ # worker's slice carried come back with its payload rather than through a
40
+ # collector block, since that block would be mutating state in the wrong
41
+ # process.
42
+ def absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new,
43
+ context_maps: ContextMap::Union.new)
76
44
  return nil if processes < 2 || file_paths.size < 2
77
- # The portable feature test, and the one the rest of the ecosystem uses.
78
- # CRuby leaves `fork` undefined on Windows; JRuby and TruffleRuby cannot
79
- # fork on the JVM and deliberately answer false here so libraries can
80
- # detect that without rescuing an exception — TruffleRuby's compatibility
81
- # guide names this as the correct check. They do still define
82
- # `Kernel#fork` and raise `NotImplementedError` from it, so probing that
83
- # instead would answer true and send them down the fan-out.
45
+ # JRuby cannot fork on the JVM and deliberately answers false here, but
46
+ # still defines `Kernel#fork` and raises `NotImplementedError` from it,
47
+ # so probing that instead would send it down the fan-out.
84
48
  return nil unless Process.respond_to?(:fork)
85
49
 
86
- fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files)
50
+ fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files,
51
+ context_maps: context_maps)
87
52
  end
88
53
 
89
- # Contiguous slices whose sizes differ by at most one, so no worker is
90
- # left folding twice its share while the others idle. There are never
91
- # more slices than files: asking for more processes than there are
92
- # resultsets just gives one resultset per process.
93
54
  def chunk(file_paths, processes)
94
55
  groups = [processes, file_paths.size].min
95
56
  base, remainder = file_paths.size.divmod(groups)
96
57
  remaining = file_paths.dup
97
58
 
98
- Array.new(groups) { |index| remaining.shift(base + (index < remainder ? 1 : 0)) }
59
+ Array.new(groups) { |index| remaining.shift(base + ((index < remainder) ? 1 : 0)) }
99
60
  end
100
61
 
101
- # A `fork` that fails here raises, and is left to. `absorb_results` has
102
- # already excluded the runtimes that never fork, so what remains is the OS
103
- # refusing a process we expected to get EAGAIN at RLIMIT_NPROC, ENOMEM
104
- # under memory pressure. That says something is wrong with the machine
105
- # rather than with the merge, and quietly absorbing it would hide it.
106
- def fan_out(chunks, ignore_timeout:, tracked_files: Set.new)
62
+ # A `fork` that fails here raises, and is left to: the runtimes that never
63
+ # fork are already excluded, so what remains is the OS refusing a process
64
+ # we expected to get, which says something is wrong with the machine rather
65
+ # than with the merge.
66
+ def fan_out(chunks, ignore_timeout:, tracked_files:, context_maps:)
107
67
  workers = spawn_workers(chunks, ignore_timeout: ignore_timeout)
108
- payloads = collect(workers)
68
+ payloads = collect_payloads(workers)
109
69
  return nil unless payloads
110
70
 
111
- payloads.each { |(_pair, tracked)| tracked_files.merge(tracked) }
112
- ResultMerger.merge_coverage(*payloads.map(&:first))
71
+ payloads.each { |payload| WorkerPayload.absorb(payload, tracked_files, context_maps) }
72
+ ResultMerger.merge_coverage(*payloads.map { |payload| WorkerPayload.pair(payload) })
113
73
  end
114
74
 
115
75
  def spawn_workers(chunks, ignore_timeout:)
116
76
  workers = [] #: Array[Hash[Symbol, untyped]]
117
-
118
77
  chunks.each do |chunk|
119
78
  workers << spawn_worker(chunk, ignore_timeout: ignore_timeout)
120
- rescue StandardError
79
+ rescue
121
80
  abandon(workers)
122
81
  raise
123
82
  end
124
-
125
83
  workers
126
84
  end
127
85
 
128
86
  def abandon(workers)
129
87
  workers.each do |worker|
130
- worker[:reader].close
131
- succeeded?(worker[:pid])
88
+ worker.fetch(:reader).close
89
+ succeeded?(worker.fetch(:pid))
132
90
  end
133
91
  end
134
92
 
135
- # `fork` itself can fail (EAGAIN under a process limit). The caller
136
- # cleans up the workers it knows about, but this pipe is ours: the
137
- # parent's writer end always closes, and the reader closes too when
138
- # no child was spawned to feed it. Steep cannot type body locals
139
- # inside an ensure, hence the ignore markers; the safe navigation
140
- # keeps the cleanup well-defined when `IO.pipe` itself raised.
93
+ # The caller cleans up the workers it knows about, but this pipe is ours.
94
+ # The safe navigation keeps the cleanup well-defined when `IO.pipe` itself
95
+ # raised, and Steep cannot type body locals inside an ensure.
141
96
  def spawn_worker(chunk, ignore_timeout:)
142
97
  reader, writer = IO.pipe
143
98
  pid = fork { run_in_child(reader, writer, chunk, ignore_timeout) }
144
99
  {pid: pid, reader: reader}
145
100
  ensure
146
- # steep:ignore:start
101
+ # @type var writer: IO?
102
+ # @type var reader: IO?
103
+ # @type var pid: Integer?
147
104
  writer&.close
148
105
  reader&.close unless pid
149
- # steep:ignore:end
150
106
  end
151
107
 
152
- # Everything the child does. `exit!` rather than `exit` because it must
153
- # never fall through to the collating process's inherited `at_exit`
154
- # handlers — SimpleCov's own report generation included.
108
+ # `exit!` rather than `exit` because the child must never fall through to
109
+ # the collating process's inherited `at_exit` handlers, SimpleCov's own
110
+ # report generation included.
155
111
  def run_in_child(reader, writer, chunk, ignore_timeout)
156
112
  reader.close
157
113
  exit!(run_worker(chunk, writer, ignore_timeout: ignore_timeout))
158
114
  end
159
115
 
160
- # The body of a worker: merge the slice, ship it back, and report the exit
161
- # status the child should terminate with. Kept free of the exit itself so
162
- # it can be exercised in-process.
163
- #
164
- # The slice's tracked paths travel with the pair because the parent needs
165
- # the union across every worker to know what nothing loaded.
116
+ # Kept free of the exit itself so it can be exercised in-process.
166
117
  def run_worker(chunk, writer, ignore_timeout:)
167
- tracked_files = Set.new
168
- pair = ResultMerger.absorb_results(chunk, ignore_timeout: ignore_timeout,
169
- &ResultMerger::UnloadedFiles.collector(tracked_files))
170
- Marshal.dump([pair, tracked_files.to_a], writer)
118
+ Marshal.dump(WorkerPayload.build(chunk, ignore_timeout: ignore_timeout), writer)
171
119
  writer.close
172
120
  0
173
- rescue StandardError => e
174
- warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e.message}" if SimpleCov.print_errors
121
+ rescue => e
122
+ warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e}" if SimpleCov.print_errors
175
123
  1
176
124
  end
177
125
 
178
- # Deserializes on a thread per worker so every pipe is drained while the
179
- # workers are still writing. A payload larger than the pipe buffer would
180
- # otherwise block its worker mid-write, and the parent would block
181
- # reaping a worker that can never finish.
182
- #
183
- # Returns nil if any worker failed, so the caller can fall back to the
184
- # serial fold rather than report a subset of the resultsets as the whole.
185
- def collect(workers)
126
+ # Answers nil if any worker failed, so the caller redoes the fold serially
127
+ # rather than report a subset of the resultsets as the whole.
128
+ def collect_payloads(workers)
186
129
  payloads = drain(workers)
187
- failed = workers.count { |worker| !succeeded?(worker[:pid]) }
188
- return payloads if failed.zero? && payloads.all?
130
+ failed = workers.count { |worker| !succeeded?(worker.fetch(:pid)) }
131
+ return warn_about_failed_workers(failed, workers.size) unless failed.zero? && payloads.all?
189
132
 
190
- warn_about_failed_workers(failed, workers.size)
191
- nil
133
+ payloads
192
134
  ensure
193
- workers.each { |worker| worker[:reader].close }
135
+ workers.each { |worker| worker.fetch(:reader).close }
194
136
  end
195
137
 
138
+ # A thread per worker, so every pipe is drained while the workers are still
139
+ # writing. A payload larger than the pipe buffer would otherwise block its
140
+ # worker mid-write, and the parent would block reaping a worker that can
141
+ # never finish.
196
142
  def drain(workers)
197
- workers.map { |worker| Thread.new { read_payload(worker[:reader]) } }.map(&:value)
143
+ workers.map { |worker| Thread.new { read_payload(worker.fetch(:reader)) } }.map(&:value)
198
144
  end
199
145
 
200
146
  def read_payload(reader)
201
- # The writer is a fork of this very process and the pipe never leaves
202
- # it, so this is our own data coming back through our own kernel
203
- # buffer, not input. A worker that died mid-write leaves the stream
204
- # truncated, which Marshal reports by raising rather than returning.
205
- # RBS types `Marshal.load`'s source as `_Source`, which IO satisfies
206
- # structurally but not nominally.
207
- # steep:ignore:start
147
+ # The writer is a fork of this very process and the pipe never leaves it,
148
+ # so this is our own data coming back through our own kernel buffer, not
149
+ # input.
208
150
  Marshal.load(reader) # rubocop:disable Security/MarshalLoad
209
- # steep:ignore:end
210
- rescue StandardError
151
+ rescue
211
152
  nil
212
153
  end
213
154
 
@@ -215,7 +156,7 @@ module SimpleCov
215
156
  _pid, status = Process.wait2(pid)
216
157
  status.success?
217
158
  rescue SystemCallError
218
- # Errno::ECHILD nothing left to reap, so there is no status to judge
159
+ # Errno::ECHILD: nothing left to reap, so there is no status to judge
219
160
  # this worker's slice by and we have to assume it did not finish.
220
161
  false
221
162
  end
@@ -1,53 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Hooks `Process._fork` (Ruby 3.1+) so child processes inherit SimpleCov's
4
- # coverage tracking when `SimpleCov.enable_for_subprocesses?` is set.
5
- #
6
- # `Process._fork` is the official extension point: `Kernel#fork`,
7
- # `Process.fork`, `IO.popen("-")`, and similar all funnel through it.
8
- # Hooking `_fork` (instead of redefining `Process.fork`) composes
9
- # correctly with other libraries doing the same — they each prepend
10
- # their own module and chain via `super`.
11
-
12
- # Reopened here for the fork-related at_exit plumbing that only matters
13
- # when subprocess support is loaded.
14
3
  module SimpleCov
15
4
  class << self
16
5
  # Forked children inherit at_exit state that is wrong for them:
17
- # @at_exit_hook_installed may describe a hook the parent already
18
- # consumed before forking (Minitest autorun runs the suite inside the
19
- # parent's at_exit, after SimpleCov's own hook has fired), and
20
- # external_at_exit may point at a Minitest.after_run deferral that is
21
- # pid-pinned to the parent. Reset both so the at_fork proc's
22
- # SimpleCov.start installs a fresh hook that actually fires at child
23
- # exit. See issue #1227.
6
+ # @at_exit_hook_installed may describe a hook the parent already consumed
7
+ # before forking (Minitest autorun runs the suite inside the parent's
8
+ # at_exit), and external_at_exit may point at a Minitest.after_run deferral
9
+ # that is pid-pinned to the parent. Resetting both is what lets the at_fork
10
+ # proc's `SimpleCov.start` install a hook that actually fires (#1227).
24
11
  def reset_inherited_at_exit_state!
25
12
  @at_exit_hook_installed = false
26
13
  self.external_at_exit = false
27
14
  end
28
15
  end
29
16
 
30
- # Prepended onto Process's singleton class so every fork direct or
31
- # via Kernel#fork / IO.popen re-runs SimpleCov's at_fork callback in
32
- # the child.
17
+ # Prepended onto Process's singleton class so every fork re-runs SimpleCov's
18
+ # at_fork callback in the child. `Process._fork` is the official extension
19
+ # point: `Kernel#fork`, `Process.fork`, and `IO.popen("-")` all funnel
20
+ # through it, and prepending composes with other libraries doing the same.
33
21
  module ProcessForkHook
22
+ # The next serial is assigned in the parent, before the fork, so the child
23
+ # inherits its own stable ordinal via copy-on-write, and the child is marked
24
+ # here independent of whatever custom at_fork block the user installed so
25
+ # `final_result_process?` can keep forked workers from each producing the
26
+ # final report (#1171, #1227).
34
27
  def _fork
35
28
  active = defined?(SimpleCov) && Coverage.running?
36
- # Assign the next serial in the PARENT, before the fork, so the child
37
- # inherits its own stable ordinal via copy-on-write. The default
38
- # at_fork uses it (instead of the child pid) to name the subprocess's
39
- # result, keeping that name identical across runs. See issue #1171.
40
29
  SimpleCov.next_subprocess_serial! if active
41
30
  pid = super
42
31
  if pid.zero? && active
43
- # Mark the child here, independent of whatever custom at_fork block
44
- # the user installed, so `final_result_process?` can keep forked
45
- # workers from each producing the final report. See issue #1171.
46
32
  SimpleCov.mark_forked_subprocess!
47
- # Without this, a child forked under Minitest autorun measures
48
- # coverage and then silently discards it: every inherited exit
49
- # route is dead in the child (see the method's comment). A custom
50
- # at_fork runs afterwards and can still override. See issue #1227.
51
33
  SimpleCov.reset_inherited_at_exit_state!
52
34
  SimpleCov.at_fork.call(::Process.pid)
53
35
  end