simplecov 0.22.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +173 -875
  4. data/exe/simplecov +8 -0
  5. data/lib/minitest/simplecov_plugin.rb +18 -7
  6. data/lib/simplecov/atomic_file.rb +67 -0
  7. data/lib/simplecov/autostart.rb +9 -0
  8. data/lib/simplecov/baseline/parser.rb +58 -0
  9. data/lib/simplecov/baseline.rb +170 -0
  10. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +148 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +86 -0
  17. data/lib/simplecov/cli/command_helpers.rb +91 -0
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +83 -0
  21. data/lib/simplecov/cli/coverage_file.rb +109 -0
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +115 -0
  26. data/lib/simplecov/cli/dotfile.rb +156 -0
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +115 -0
  31. data/lib/simplecov/cli/open.rb +45 -0
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/report.rb +91 -0
  38. data/lib/simplecov/cli/run.rb +35 -0
  39. data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
  40. data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
  41. data/lib/simplecov/cli/serve.rb +88 -0
  42. data/lib/simplecov/cli/show/annotator.rb +87 -0
  43. data/lib/simplecov/cli/show/sweep.rb +36 -0
  44. data/lib/simplecov/cli/show.rb +133 -0
  45. data/lib/simplecov/cli/status/facts.rb +87 -0
  46. data/lib/simplecov/cli/status.rb +93 -0
  47. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  48. data/lib/simplecov/cli/tests.rb +148 -0
  49. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  50. data/lib/simplecov/cli/uncovered.rb +127 -0
  51. data/lib/simplecov/cli/usage.rb +153 -0
  52. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  53. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  54. data/lib/simplecov/cli/watch/poller.rb +45 -0
  55. data/lib/simplecov/cli/watch/session.rb +134 -0
  56. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  57. data/lib/simplecov/cli/watch.rb +85 -0
  58. data/lib/simplecov/cli.rb +114 -0
  59. data/lib/simplecov/color.rb +65 -0
  60. data/lib/simplecov/combine/branches_combiner.rb +44 -21
  61. data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
  62. data/lib/simplecov/combine/identity_interner.rb +27 -0
  63. data/lib/simplecov/combine/interned_counts.rb +28 -0
  64. data/lib/simplecov/combine/lines_combiner.rb +35 -29
  65. data/lib/simplecov/combine/methods_combiner.rb +48 -0
  66. data/lib/simplecov/combine/results_combiner.rb +9 -49
  67. data/lib/simplecov/combine.rb +0 -24
  68. data/lib/simplecov/command_guesser.rb +99 -42
  69. data/lib/simplecov/configuration/baseline.rb +31 -0
  70. data/lib/simplecov/configuration/coverage.rb +184 -0
  71. data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
  72. data/lib/simplecov/configuration/deprecations.rb +21 -0
  73. data/lib/simplecov/configuration/eval_coverage.rb +37 -0
  74. data/lib/simplecov/configuration/filters.rb +131 -0
  75. data/lib/simplecov/configuration/formatting.rb +129 -0
  76. data/lib/simplecov/configuration/groups.rb +48 -0
  77. data/lib/simplecov/configuration/history.rb +44 -0
  78. data/lib/simplecov/configuration/ignored_entries.rb +72 -0
  79. data/lib/simplecov/configuration/merging.rb +197 -0
  80. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  81. data/lib/simplecov/configuration/production.rb +23 -0
  82. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  83. data/lib/simplecov/configuration/thresholds.rb +150 -0
  84. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  85. data/lib/simplecov/configuration.rb +100 -443
  86. data/lib/simplecov/configuration_error.rb +5 -0
  87. data/lib/simplecov/context_map/union.rb +81 -0
  88. data/lib/simplecov/context_map.rb +187 -0
  89. data/lib/simplecov/coverage_json.rb +21 -0
  90. data/lib/simplecov/coverage_statistics.rb +24 -28
  91. data/lib/simplecov/coverage_violations.rb +254 -0
  92. data/lib/simplecov/current_run.rb +61 -0
  93. data/lib/simplecov/defaults.rb +20 -28
  94. data/lib/simplecov/deprecation.rb +63 -0
  95. data/lib/simplecov/directive/erb.rb +44 -0
  96. data/lib/simplecov/directive/haml.rb +33 -0
  97. data/lib/simplecov/directive/indented_template.rb +37 -0
  98. data/lib/simplecov/directive/slim.rb +35 -0
  99. data/lib/simplecov/directive/template.rb +29 -0
  100. data/lib/simplecov/directive.rb +152 -0
  101. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  102. data/lib/simplecov/exit_codes/check.rb +39 -0
  103. data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
  104. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
  105. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  106. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  107. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
  110. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
  111. data/lib/simplecov/exit_codes.rb +26 -0
  112. data/lib/simplecov/exit_handling.rb +163 -0
  113. data/lib/simplecov/file_list.rb +63 -48
  114. data/lib/simplecov/filter.rb +98 -42
  115. data/lib/simplecov/formatter/base.rb +100 -0
  116. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  117. data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
  118. data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
  119. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
  120. data/lib/simplecov/formatter/html_formatter.rb +88 -0
  121. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
  122. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  123. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
  124. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
  125. data/lib/simplecov/formatter/json_formatter.rb +40 -0
  126. data/lib/simplecov/formatter/multi_formatter.rb +8 -10
  127. data/lib/simplecov/formatter/simple_formatter.rb +12 -15
  128. data/lib/simplecov/formatter.rb +21 -1
  129. data/lib/simplecov/group_names.rb +33 -0
  130. data/lib/simplecov/history.rb +115 -0
  131. data/lib/simplecov/last_run.rb +16 -5
  132. data/lib/simplecov/lines_classifier.rb +67 -19
  133. data/lib/simplecov/load_global_config.rb +12 -4
  134. data/lib/simplecov/parallel_adapters/base.rb +50 -0
  135. data/lib/simplecov/parallel_adapters/generic.rb +33 -0
  136. data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
  137. data/lib/simplecov/parallel_adapters.rb +54 -0
  138. data/lib/simplecov/parallel_coordination.rb +124 -0
  139. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  140. data/lib/simplecov/parallel_result_merger.rb +171 -0
  141. data/lib/simplecov/process.rb +34 -12
  142. data/lib/simplecov/production/error.rb +11 -0
  143. data/lib/simplecov/production/file_sink.rb +130 -0
  144. data/lib/simplecov/production.rb +249 -0
  145. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  146. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  147. data/lib/simplecov/profiles/rails.rb +13 -10
  148. data/lib/simplecov/profiles/root_filter.rb +2 -5
  149. data/lib/simplecov/profiles/strict.rb +22 -0
  150. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  151. data/lib/simplecov/profiles.rb +26 -19
  152. data/lib/simplecov/report_deferral.rb +45 -0
  153. data/lib/simplecov/report_stamp.rb +25 -0
  154. data/lib/simplecov/result/filter_config.rb +19 -0
  155. data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
  156. data/lib/simplecov/result/serialization.rb +43 -0
  157. data/lib/simplecov/result/source_file_builder.rb +46 -0
  158. data/lib/simplecov/result.rb +123 -47
  159. data/lib/simplecov/result_adapter.rb +130 -11
  160. data/lib/simplecov/result_merger/contexts.rb +22 -0
  161. data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
  162. data/lib/simplecov/result_merger/resultset_file.rb +67 -0
  163. data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
  164. data/lib/simplecov/result_merger/resultset_store.rb +62 -0
  165. data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
  166. data/lib/simplecov/result_merger.rb +120 -124
  167. data/lib/simplecov/result_processing.rb +188 -0
  168. data/lib/simplecov/run_identity.rb +75 -0
  169. data/lib/simplecov/simulate_coverage.rb +72 -17
  170. data/lib/simplecov/source_file/branch.rb +8 -35
  171. data/lib/simplecov/source_file/branch_builder.rb +95 -0
  172. data/lib/simplecov/source_file/builder_context.rb +28 -0
  173. data/lib/simplecov/source_file/line.rb +17 -32
  174. data/lib/simplecov/source_file/line_builder.rb +37 -0
  175. data/lib/simplecov/source_file/method.rb +54 -0
  176. data/lib/simplecov/source_file/method_builder.rb +51 -0
  177. data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
  178. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  179. data/lib/simplecov/source_file/source_loader.rb +89 -0
  180. data/lib/simplecov/source_file/statistics.rb +76 -0
  181. data/lib/simplecov/source_file.rb +64 -260
  182. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
  183. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
  184. data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
  185. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
  186. data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
  187. data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
  188. data/lib/simplecov/static_coverage_extractor.rb +78 -0
  189. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  190. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  191. data/lib/simplecov/test_tracker/delta.rb +69 -0
  192. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  193. data/lib/simplecov/test_tracker.rb +162 -0
  194. data/lib/simplecov/unloaded_file_injector.rb +62 -0
  195. data/lib/simplecov/useless_results_remover.rb +17 -7
  196. data/lib/simplecov/version.rb +1 -1
  197. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  198. data/lib/simplecov/view_coverage.rb +55 -0
  199. data/lib/simplecov-html.rb +5 -0
  200. data/lib/simplecov.rb +143 -394
  201. data/lib/simplecov_json_formatter.rb +5 -0
  202. data/man/simplecov.1 +354 -0
  203. data/sig/simplecov.rbs +1710 -0
  204. metadata +183 -61
  205. data/CHANGELOG.md +0 -191
  206. data/doc/alternate-formatters.md +0 -71
  207. data/doc/commercial-services.md +0 -25
  208. data/doc/editor-integration.md +0 -18
  209. data/lib/simplecov/combine/files_combiner.rb +0 -24
  210. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parallel_result_merger/worker_payload"
4
+
5
+ module SimpleCov
6
+ #
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.
10
+ #
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.
14
+ #
15
+ module ParallelResultMerger
16
+ extend self
17
+
18
+ def merge_and_store(*file_paths, processes:, ignore_timeout: false)
19
+ return ResultMerger.merge_and_store(*file_paths, ignore_timeout: ignore_timeout) if processes < 2
20
+
21
+ result = merge_results(*file_paths, processes: processes, ignore_timeout: ignore_timeout)
22
+ ResultMerger.store_result(result) if result
23
+ result
24
+ end
25
+
26
+ def merge_results(*file_paths, processes:, ignore_timeout: false)
27
+ tracked_files = Set.new
28
+ context_maps = ContextMap::Union.new
29
+ pair = absorb_results(file_paths, processes: processes, ignore_timeout: ignore_timeout,
30
+ tracked_files: tracked_files, context_maps: context_maps)
31
+ return ResultMerger.merge_results(*file_paths, ignore_timeout: ignore_timeout) unless pair
32
+
33
+ command_names, coverage = pair
34
+ ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files, contexts: context_maps.map)
35
+ end
36
+
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)
44
+ return nil if processes < 2 || file_paths.size < 2
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.
48
+ return nil unless Process.respond_to?(:fork)
49
+
50
+ fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files,
51
+ context_maps: context_maps)
52
+ end
53
+
54
+ def chunk(file_paths, processes)
55
+ groups = [processes, file_paths.size].min
56
+ base, remainder = file_paths.size.divmod(groups)
57
+ remaining = file_paths.dup
58
+
59
+ Array.new(groups) { |index| remaining.shift(base + ((index < remainder) ? 1 : 0)) }
60
+ end
61
+
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:)
67
+ workers = spawn_workers(chunks, ignore_timeout: ignore_timeout)
68
+ payloads = collect_payloads(workers)
69
+ return nil unless payloads
70
+
71
+ payloads.each { |payload| WorkerPayload.absorb(payload, tracked_files, context_maps) }
72
+ ResultMerger.merge_coverage(*payloads.map { |payload| WorkerPayload.pair(payload) })
73
+ end
74
+
75
+ def spawn_workers(chunks, ignore_timeout:)
76
+ workers = [] #: Array[Hash[Symbol, untyped]]
77
+ chunks.each do |chunk|
78
+ workers << spawn_worker(chunk, ignore_timeout: ignore_timeout)
79
+ rescue
80
+ abandon(workers)
81
+ raise
82
+ end
83
+ workers
84
+ end
85
+
86
+ def abandon(workers)
87
+ workers.each do |worker|
88
+ worker.fetch(:reader).close
89
+ succeeded?(worker.fetch(:pid))
90
+ end
91
+ end
92
+
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.
96
+ def spawn_worker(chunk, ignore_timeout:)
97
+ reader, writer = IO.pipe
98
+ pid = fork { run_in_child(reader, writer, chunk, ignore_timeout) }
99
+ {pid: pid, reader: reader}
100
+ ensure
101
+ # @type var writer: IO?
102
+ # @type var reader: IO?
103
+ # @type var pid: Integer?
104
+ writer&.close
105
+ reader&.close unless pid
106
+ end
107
+
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.
111
+ def run_in_child(reader, writer, chunk, ignore_timeout)
112
+ reader.close
113
+ exit!(run_worker(chunk, writer, ignore_timeout: ignore_timeout))
114
+ end
115
+
116
+ # Kept free of the exit itself so it can be exercised in-process.
117
+ def run_worker(chunk, writer, ignore_timeout:)
118
+ Marshal.dump(WorkerPayload.build(chunk, ignore_timeout: ignore_timeout), writer)
119
+ writer.close
120
+ 0
121
+ rescue => e
122
+ warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e}" if SimpleCov.print_errors
123
+ 1
124
+ end
125
+
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)
129
+ payloads = drain(workers)
130
+ failed = workers.count { |worker| !succeeded?(worker.fetch(:pid)) }
131
+ return warn_about_failed_workers(failed, workers.size) unless failed.zero? && payloads.all?
132
+
133
+ payloads
134
+ ensure
135
+ workers.each { |worker| worker.fetch(:reader).close }
136
+ end
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.
142
+ def drain(workers)
143
+ workers.map { |worker| Thread.new { read_payload(worker.fetch(:reader)) } }.map(&:value)
144
+ end
145
+
146
+ def read_payload(reader)
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.
150
+ Marshal.load(reader) # rubocop:disable Security/MarshalLoad
151
+ rescue
152
+ nil
153
+ end
154
+
155
+ def succeeded?(pid)
156
+ _pid, status = Process.wait2(pid)
157
+ status.success?
158
+ rescue SystemCallError
159
+ # Errno::ECHILD: nothing left to reap, so there is no status to judge
160
+ # this worker's slice by and we have to assume it did not finish.
161
+ false
162
+ end
163
+
164
+ def warn_about_failed_workers(failed, total)
165
+ return unless SimpleCov.print_errors
166
+
167
+ warn "[SimpleCov]: parallel merge did not complete (#{failed} of #{total} workers failed); " \
168
+ "merging the resultsets in this process instead."
169
+ end
170
+ end
171
+ end
@@ -1,19 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Process
3
+ module SimpleCov
4
4
  class << self
5
- def fork_with_simplecov(&block)
6
- if defined?(SimpleCov) && SimpleCov.running
7
- fork_without_simplecov do
8
- SimpleCov.at_fork.call(Process.pid)
9
- block.call if block_given?
10
- end
11
- else
12
- fork_without_simplecov(&block)
13
- end
5
+ # Forked children inherit at_exit state that is wrong for them:
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).
11
+ def reset_inherited_at_exit_state!
12
+ @at_exit_hook_installed = false
13
+ self.external_at_exit = false
14
14
  end
15
+ end
15
16
 
16
- alias fork_without_simplecov fork
17
- alias fork fork_with_simplecov
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.
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).
27
+ def _fork
28
+ active = defined?(SimpleCov) && Coverage.running?
29
+ SimpleCov.next_subprocess_serial! if active
30
+ pid = super
31
+ if pid.zero? && active
32
+ SimpleCov.mark_forked_subprocess!
33
+ SimpleCov.reset_inherited_at_exit_state!
34
+ SimpleCov.at_fork.call(::Process.pid)
35
+ end
36
+ pid
37
+ end
18
38
  end
19
39
  end
40
+
41
+ Process.singleton_class.prepend(SimpleCov::ProcessForkHook)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module Production
5
+ # Configuration and store-format failures. Sink errors raised from `store` are
6
+ # not wrapped in this: the runtime rescues them, warns, and retries on the
7
+ # next interval.
8
+ class Error < StandardError
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "error"
7
+
8
+ module SimpleCov
9
+ module Production
10
+ # The bundled sink: a single JSON file, union-merged under an exclusive
11
+ # lock so any number of processes on the same host can share it. This is
12
+ # the reference for the sink contract:
13
+ #
14
+ # store(coverage) # {"lib/foo.rb" => [1, 3, 12], ...} of
15
+ # # root-relative paths to sorted line numbers
16
+ #
17
+ # A sink must merge (never replace: many processes each hold only a
18
+ # slice), must be idempotent (the same lines may arrive twice), and
19
+ # signals failure by raising, which makes the runtime keep the delta and
20
+ # retry next interval.
21
+ #
22
+ # `last_seen` stamps each file with the last store that carried it.
23
+ # Oneshot clears on drain, so still-running code re-reports every interval
24
+ # and the stamp tracks real recency, not first sighting. The field is
25
+ # optional on read: a v1 store written before it existed simply has no
26
+ # recency evidence to offer.
27
+ class FileSink
28
+ # The envelope key, which is also how `read` tells a production coverage
29
+ # file from an arbitrary JSON document it must not clobber or misread.
30
+ ENVELOPE = "simplecov_production"
31
+ FORMAT_VERSION = 1
32
+
33
+ attr_reader :path
34
+
35
+ def initialize(path:)
36
+ @path = File.expand_path(path)
37
+ end
38
+
39
+ def store(coverage)
40
+ FileUtils.mkdir_p(File.dirname(path))
41
+ with_exclusive_lock do |file|
42
+ existing = self.class.parse(file.read, path)
43
+ rewrite(file, envelope(existing, coverage))
44
+ end
45
+ true
46
+ end
47
+
48
+ # Truncates any leftover tail from a previously larger document.
49
+ def rewrite(file, payload)
50
+ file.rewind
51
+ file.write(JSON.generate(payload))
52
+ file.truncate(file.pos)
53
+ end
54
+
55
+ # Raises `Error` for anything that is not a production coverage file,
56
+ # naming the path: misreading an arbitrary JSON file as empty coverage
57
+ # would quietly report every line dead.
58
+ def self.read(path)
59
+ parse(File.read(path), path)
60
+ end
61
+
62
+ # @api private -- shared by `read` and `store`'s read-modify-write. An
63
+ # empty or missing file is a fresh store. JSON answers plain hashes,
64
+ # arrays, and scalars, never a subclass of one, so the shape checks ask
65
+ # about the class itself.
66
+ def self.parse(content, path)
67
+ return {"coverage" => {}, "last_seen" => {}, "started_at" => nil} if content.match?(/\A\s*\z/)
68
+
69
+ inner = envelope_of(JSON.parse(content), path)
70
+ inner["coverage"] = {} unless inner["coverage"].instance_of?(Hash)
71
+ inner["last_seen"] = {} unless inner["last_seen"].instance_of?(Hash)
72
+ inner
73
+ rescue JSON::ParserError => e
74
+ # One line of the parser's complaint, which for some inputs quotes the
75
+ # document back and runs long.
76
+ raise Error, "#{path} is not valid JSON (#{e.message.lines.first.to_s.rstrip})"
77
+ end
78
+
79
+ # The refusal is what keeps an arbitrary JSON file from being misread as
80
+ # empty coverage.
81
+ def self.envelope_of(document, path)
82
+ inner = document[ENVELOPE] if document.instance_of?(Hash)
83
+ raise Error, "#{path} is not a SimpleCov production coverage file" unless inner.instance_of?(Hash)
84
+
85
+ inner
86
+ end
87
+
88
+ private
89
+
90
+ # Both halves of the read-modify-write happen through the one handle, so
91
+ # processes sharing the file take turns rather than overwriting each
92
+ # other. The open flags are summed, which for disjoint bits is the same
93
+ # number OR would build and leaves no spelling of the combination
94
+ # without a witness.
95
+ def with_exclusive_lock
96
+ open_file(path, File::RDWR + File::CREAT, 0o644) do |file|
97
+ file.flock(File::LOCK_EX)
98
+ yield file
99
+ end
100
+ end
101
+
102
+ # mutant:disable — Ruby 4.0 removed Kernel#open's leading-pipe
103
+ # command mode, so no test can tell `File.open` from `open` here
104
+ # any more. The explicit receiver is kept: on older rubies it is
105
+ # what refuses to run a store path as a command.
106
+ def open_file(name, mode, perm, &)
107
+ File.open(name, mode, perm, &)
108
+ end
109
+
110
+ def merge(existing, incoming)
111
+ incoming.each_with_object(existing) do |(file, lines), merged|
112
+ merged[file] = ((merged[file] || []) | lines).sort
113
+ end
114
+ end
115
+
116
+ def envelope(existing, incoming)
117
+ now = Time.now.utc.iso8601
118
+ {
119
+ ENVELOPE => {
120
+ "format_version" => FORMAT_VERSION,
121
+ "started_at" => existing["started_at"] || now,
122
+ "updated_at" => now,
123
+ "coverage" => merge(existing.fetch("coverage"), incoming).sort.to_h,
124
+ "last_seen" => existing.fetch("last_seen").merge(incoming.transform_values { now }).sort.to_h
125
+ }
126
+ }
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require_relative "production/error"
5
+
6
+ #
7
+ # Production coverage mode (#1271). Deliberately loadable on its own:
8
+ # `require "simplecov/production"` pulls in none of the reporting machinery,
9
+ # because a production process wants a measurement tap and nothing else.
10
+ # Nothing here runs unless `SimpleCov::Production.start` is called explicitly.
11
+ #
12
+ module SimpleCov
13
+ module Production
14
+ class << self
15
+ # Answers false, with a warning naming why, when it safely declines
16
+ # (already running, another Coverage owner such as a test suite, or a
17
+ # runtime without oneshot lines). Configuration mistakes raise `Error`
18
+ # instead: a typo'd interval should fail deployment, not quietly measure
19
+ # wrong.
20
+ #
21
+ # In a forking server, call this from the worker-boot hook (Puma's
22
+ # `on_worker_boot`, Unicorn's `after_fork`): the flush thread does not
23
+ # survive a fork, and `start` in the child picks the inherited
24
+ # measurement back up.
25
+ def start(root: Dir.pwd, sink: nil, flush_interval: 60, flush_jitter: nil, sample_rate: 1.0,
26
+ max_buffered_lines: 1_000_000)
27
+ validate!(flush_interval, flush_jitter, sample_rate, max_buffered_lines)
28
+ return warn_decline("SimpleCov::Production is already running") if running?
29
+ return false unless claim_coverage
30
+
31
+ configure(root, sink, flush_interval: flush_interval, flush_jitter: flush_jitter,
32
+ sample_rate: sample_rate, max_buffered_lines: max_buffered_lines)
33
+ @running = true
34
+ @pid = Process.pid
35
+ install_at_exit
36
+ spawn_flush_thread
37
+ true
38
+ end
39
+
40
+ # Both are small Integers, so identity settles it: a measurement belongs to
41
+ # the process that started it and to no other.
42
+ def running?
43
+ !!@running && @pid.equal?(Process.pid)
44
+ end
45
+
46
+ # Called by the flush thread every interval; public so a deploy hook (or a
47
+ # signal handler) can force one. An empty delta counts as accepted.
48
+ def flush
49
+ return false unless running?
50
+
51
+ @mutex.synchronize do
52
+ pull_pending
53
+ push_pending
54
+ end
55
+ end
56
+
57
+ def stop
58
+ return false unless running?
59
+
60
+ @running = false
61
+ wake_flush_thread
62
+ @thread&.join
63
+ @mutex.synchronize do
64
+ pull_pending
65
+ push_pending
66
+ Coverage.result(stop: true, clear: true)
67
+ end
68
+ true
69
+ end
70
+
71
+ # @api private -- clears module state between tests. The at_exit
72
+ # registration flag survives on purpose: one hook per process.
73
+ def reset!
74
+ @running = false
75
+ wake_flush_thread if @thread
76
+ @thread&.join
77
+ @thread = nil
78
+ @pending = {}
79
+ @pid = nil
80
+ @started_coverage = false
81
+ end
82
+
83
+ private
84
+
85
+ def validate!(flush_interval, flush_jitter, sample_rate, max_buffered_lines)
86
+ raise Error, "flush_interval must be a positive number of seconds" unless flush_interval.positive?
87
+
88
+ validate_jitter!(flush_jitter)
89
+ raise Error, "sample_rate must be within (0, 1]" unless sample_rate.positive? && sample_rate <= 1
90
+ raise Error, "max_buffered_lines must be positive" unless max_buffered_lines.positive?
91
+ return unless sample_rate < 1 && !Coverage.respond_to?(:suspend)
92
+
93
+ raise Error, "sample_rate below 1.0 needs Coverage.suspend, which this Ruby does not provide"
94
+ end
95
+
96
+ def validate_jitter!(flush_jitter)
97
+ return if flush_jitter.nil? || (flush_jitter.is_a?(Numeric) && flush_jitter >= 0)
98
+
99
+ raise Error, "flush_jitter must be a non-negative number of seconds"
100
+ end
101
+
102
+ # A running Coverage belongs to someone else, unless this very module
103
+ # started it before a fork, in which case the child inherits the
104
+ # measurement and only needs its own flush thread.
105
+ def claim_coverage
106
+ if Coverage.running?
107
+ return true if @started_coverage && @pid && !@pid.equal?(Process.pid)
108
+
109
+ return warn_decline("Coverage is already running (a test suite or another tool owns it)")
110
+ end
111
+ return warn_decline("this runtime has no oneshot lines support") unless oneshot_supported?
112
+
113
+ Coverage.start(oneshot_lines: true)
114
+ @started_coverage = true
115
+ end
116
+
117
+ def oneshot_supported?
118
+ !Coverage.respond_to?(:supported?) || Coverage.supported?(:oneshot_lines)
119
+ end
120
+
121
+ def configure(root, sink, flush_interval:, flush_jitter:, sample_rate:, max_buffered_lines:)
122
+ @root_prefix = File.expand_path(root) + File::SEPARATOR
123
+ @sink = sink || default_sink(root)
124
+ @flush_interval = flush_interval
125
+ @flush_jitter = flush_jitter || (flush_interval / 10.0)
126
+ @sample_rate = sample_rate
127
+ @max_buffered_lines = max_buffered_lines
128
+ @pending = {} #: Hash[String, Set[Integer]]
129
+ @measuring = true
130
+ @mutex = Mutex.new
131
+ @waiter = ConditionVariable.new
132
+ end
133
+
134
+ def default_sink(root)
135
+ FileSink.new(path: File.join(root, "tmp", "simplecov", "production.json"))
136
+ end
137
+
138
+ def spawn_flush_thread
139
+ thread = Thread.new { flush_loop }
140
+ thread.name = "SimpleCov::Production flusher"
141
+ @thread = thread
142
+ end
143
+
144
+ # The condition variable rather than a bare sleep is what lets `stop`
145
+ # return promptly instead of waiting out a long interval.
146
+ def flush_loop
147
+ @mutex.synchronize do
148
+ while @running
149
+ @waiter.wait(@mutex, interval_with_jitter)
150
+ cycle if @running
151
+ end
152
+ end
153
+ end
154
+
155
+ # A fresh share is drawn per wait: a fleet of workers booted together
156
+ # would otherwise contend on the shared sink at the same instant every
157
+ # interval, forever.
158
+ def interval_with_jitter
159
+ @flush_interval + (rand * @flush_jitter)
160
+ end
161
+
162
+ def wake_flush_thread
163
+ @mutex.synchronize { @waiter.signal }
164
+ end
165
+
166
+ def cycle
167
+ resample
168
+ pull_pending
169
+ push_pending
170
+ end
171
+
172
+ # Duty-cycle sampling: with `sample_rate` 0.25, roughly one interval in
173
+ # four measures and the rest are suspended, capping steady-state overhead
174
+ # while every line still gets its chance over time.
175
+ def resample
176
+ return unless @sample_rate < 1
177
+
178
+ wanted = rand < @sample_rate
179
+ if wanted && !@measuring
180
+ Coverage.resume
181
+ elsif !wanted && @measuring
182
+ Coverage.suspend
183
+ end
184
+ @measuring = wanted
185
+ end
186
+
187
+ # Clearing on read means a line executed again later re-reports, which is
188
+ # what lets a dropped buffer heal for still-hot code.
189
+ def pull_pending
190
+ Coverage.result(stop: false, clear: true).each do |path, data|
191
+ lines = data[:oneshot_lines]
192
+ next if lines.nil? || lines.empty?
193
+
194
+ relative = relativize(path)
195
+ next unless relative
196
+
197
+ (@pending[relative] ||= Set.new).merge(lines)
198
+ end
199
+ end
200
+
201
+ # On failure the buffer is kept for the next interval, bounded by the
202
+ # ceiling so a long outage cannot grow memory without limit.
203
+ def push_pending
204
+ return true if @pending.empty?
205
+
206
+ @sink.store(@pending.transform_values(&:sort))
207
+ @pending.clear
208
+ true
209
+ rescue => e
210
+ warn "[SimpleCov::Production] flush failed (#{e.class}: #{e}); retrying next interval"
211
+ enforce_ceiling
212
+ false
213
+ end
214
+
215
+ # Oneshot's re-reporting is what makes dropping safe-ish: lines still being
216
+ # executed report again after the drop, and only code that ran during the
217
+ # outage and never again is lost.
218
+ def enforce_ceiling
219
+ return if @pending.sum { |_path, lines| lines.size } <= @max_buffered_lines
220
+
221
+ @pending.clear
222
+ warn "[SimpleCov::Production] buffer ceiling (#{@max_buffered_lines} lines) exceeded " \
223
+ "while the sink was unreachable; dropping buffered coverage"
224
+ end
225
+
226
+ def relativize(path)
227
+ return nil unless path.start_with?(@root_prefix)
228
+
229
+ path[@root_prefix.length..]
230
+ end
231
+
232
+ def warn_decline(reason)
233
+ warn "[SimpleCov::Production] not starting: #{reason}"
234
+ false
235
+ end
236
+
237
+ def install_at_exit
238
+ return if @at_exit_installed
239
+
240
+ @at_exit_installed = true
241
+ at_exit do
242
+ stop
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ require_relative "production/file_sink"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "bundler_filter" do
4
- add_filter "/vendor/bundle/"
4
+ skip "/vendor/bundle/"
5
5
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "hidden_filter" do
4
- add_filter %r{^/\..*}
4
+ skip(/\A\..*/)
5
5
  end
@@ -3,16 +3,19 @@
3
3
  SimpleCov.profiles.define "rails" do
4
4
  load_profile "test_frameworks"
5
5
 
6
- add_filter %r{^/config/}
7
- add_filter %r{^/db/}
6
+ skip %r{\Aconfig/}
7
+ skip %r{\Adb/}
8
8
 
9
- add_group "Controllers", "app/controllers"
10
- add_group "Channels", "app/channels"
11
- add_group "Models", "app/models"
12
- add_group "Mailers", "app/mailers"
13
- add_group "Helpers", "app/helpers"
14
- add_group "Jobs", %w[app/jobs app/workers]
15
- add_group "Libraries", "lib/"
9
+ group "Channels", "app/channels"
10
+ group "Controllers", "app/controllers"
11
+ group "Helpers", "app/helpers"
12
+ group "Jobs", %w[app/jobs app/workers]
13
+ group "Libraries", "lib/"
14
+ group "Mailers", "app/mailers"
15
+ group "Models", "app/models"
16
+ group "Views", "app/views"
16
17
 
17
- track_files "{app,lib}/**/*.rb"
18
+ @tracked_files = "{app,lib}/**/*.rb"
19
+
20
+ merge_subprocesses true
18
21
  end
@@ -1,10 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "root_filter" do
4
- # Exclude all files outside of simplecov root
5
- root_filter = nil
6
- add_filter do |src|
7
- root_filter ||= /\A#{Regexp.escape(SimpleCov.root + File::SEPARATOR)}/io
8
- src.filename !~ root_filter
4
+ skip do |src|
5
+ src.filename !~ SimpleCov::UselessResultsRemover.root_regex
9
6
  end
10
7
  end