simplecov 0.22.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ #
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.
15
+ #
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.
23
+ #
24
+ 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
+ #
33
+ def merge_and_store(*file_paths, processes:, ignore_timeout: false)
34
+ return ResultMerger.merge_and_store(*file_paths, ignore_timeout: ignore_timeout) if processes < 2
35
+
36
+ result = merge_results(*file_paths, processes: processes, ignore_timeout: ignore_timeout)
37
+ ResultMerger.store_result(result) if result
38
+ result
39
+ end
40
+
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
+ def merge_results(*file_paths, processes:, ignore_timeout: false)
49
+ tracked_files = Set.new
50
+ 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.
55
+ return ResultMerger.merge_results(*file_paths, ignore_timeout: ignore_timeout) unless pair
56
+
57
+ command_names, coverage = pair
58
+ ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files)
59
+ end
60
+
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.
76
+ 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.
84
+ return nil unless Process.respond_to?(:fork)
85
+
86
+ fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files)
87
+ end
88
+
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
+ def chunk(file_paths, processes)
94
+ groups = [processes, file_paths.size].min
95
+ base, remainder = file_paths.size.divmod(groups)
96
+ remaining = file_paths.dup
97
+
98
+ Array.new(groups) { |index| remaining.shift(base + (index < remainder ? 1 : 0)) }
99
+ end
100
+
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)
107
+ workers = spawn_workers(chunks, ignore_timeout: ignore_timeout)
108
+ payloads = collect(workers)
109
+ return nil unless payloads
110
+
111
+ payloads.each { |(_pair, tracked)| tracked_files.merge(tracked) }
112
+ ResultMerger.merge_coverage(*payloads.map(&:first))
113
+ end
114
+
115
+ def spawn_workers(chunks, ignore_timeout:)
116
+ workers = [] #: Array[Hash[Symbol, untyped]]
117
+
118
+ chunks.each do |chunk|
119
+ workers << spawn_worker(chunk, ignore_timeout: ignore_timeout)
120
+ rescue StandardError
121
+ abandon(workers)
122
+ raise
123
+ end
124
+
125
+ workers
126
+ end
127
+
128
+ def abandon(workers)
129
+ workers.each do |worker|
130
+ worker[:reader].close
131
+ succeeded?(worker[:pid])
132
+ end
133
+ end
134
+
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.
141
+ def spawn_worker(chunk, ignore_timeout:)
142
+ reader, writer = IO.pipe
143
+ pid = fork { run_in_child(reader, writer, chunk, ignore_timeout) }
144
+ {pid: pid, reader: reader}
145
+ ensure
146
+ # steep:ignore:start
147
+ writer&.close
148
+ reader&.close unless pid
149
+ # steep:ignore:end
150
+ end
151
+
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.
155
+ def run_in_child(reader, writer, chunk, ignore_timeout)
156
+ reader.close
157
+ exit!(run_worker(chunk, writer, ignore_timeout: ignore_timeout))
158
+ end
159
+
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.
166
+ 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)
171
+ writer.close
172
+ 0
173
+ rescue StandardError => e
174
+ warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e.message}" if SimpleCov.print_errors
175
+ 1
176
+ end
177
+
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)
186
+ payloads = drain(workers)
187
+ failed = workers.count { |worker| !succeeded?(worker[:pid]) }
188
+ return payloads if failed.zero? && payloads.all?
189
+
190
+ warn_about_failed_workers(failed, workers.size)
191
+ nil
192
+ ensure
193
+ workers.each { |worker| worker[:reader].close }
194
+ end
195
+
196
+ def drain(workers)
197
+ workers.map { |worker| Thread.new { read_payload(worker[:reader]) } }.map(&:value)
198
+ end
199
+
200
+ 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
208
+ Marshal.load(reader) # rubocop:disable Security/MarshalLoad
209
+ # steep:ignore:end
210
+ rescue StandardError
211
+ nil
212
+ end
213
+
214
+ def succeeded?(pid)
215
+ _pid, status = Process.wait2(pid)
216
+ status.success?
217
+ rescue SystemCallError
218
+ # Errno::ECHILD — nothing left to reap, so there is no status to judge
219
+ # this worker's slice by and we have to assume it did not finish.
220
+ false
221
+ end
222
+
223
+ def warn_about_failed_workers(failed, total)
224
+ return unless SimpleCov.print_errors
225
+
226
+ warn "[SimpleCov]: parallel merge did not complete (#{failed} of #{total} workers failed); " \
227
+ "merging the resultsets in this process instead."
228
+ end
229
+ end
230
+ end
@@ -1,19 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Process
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
+ module SimpleCov
4
15
  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
16
+ # 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.
24
+ def reset_inherited_at_exit_state!
25
+ @at_exit_hook_installed = false
26
+ self.external_at_exit = false
14
27
  end
28
+ end
15
29
 
16
- alias fork_without_simplecov fork
17
- alias fork fork_with_simplecov
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.
33
+ module ProcessForkHook
34
+ def _fork
35
+ 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
+ SimpleCov.next_subprocess_serial! if active
41
+ pid = super
42
+ 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
+ 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
+ SimpleCov.reset_inherited_at_exit_state!
52
+ SimpleCov.at_fork.call(::Process.pid)
53
+ end
54
+ pid
55
+ end
18
56
  end
19
57
  end
58
+
59
+ Process.singleton_class.prepend(SimpleCov::ProcessForkHook)
@@ -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,30 @@
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 "Controllers", "app/controllers"
10
+ group "Channels", "app/channels"
11
+ group "Models", "app/models"
12
+ group "Mailers", "app/mailers"
13
+ group "Helpers", "app/helpers"
14
+ group "Jobs", %w[app/jobs app/workers]
15
+ group "Libraries", "lib/"
16
16
 
17
- track_files "{app,lib}/**/*.rb"
17
+ # Preserve the legacy `track_files` semantics (additive disk-discovery
18
+ # without restricting the report's universe): write the ivar directly
19
+ # so loading the profile doesn't emit the public-API deprecation. Users
20
+ # migrating their own configs should prefer `cover "{app,lib}/**/*.rb"`,
21
+ # which both injects unloaded files and scopes the report to the match
22
+ # set — usually the intended behavior for a Rails project.
23
+ @tracked_files = "{app,lib}/**/*.rb"
24
+
25
+ # `parallelize(workers: ...)` forks worker processes that each run a
26
+ # slice of the suite. Without subprocess support, the workers' coverage
27
+ # is dropped on the floor and the parent records 0% for everything they
28
+ # touched. Hooking `Process._fork` makes each worker re-call
29
+ # `SimpleCov.start` with a unique command_name so the resultsets merge
30
+ # correctly.
31
+ merge_subprocesses true
18
32
  end
@@ -1,10 +1,11 @@
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
+ # Exclude all files outside of simplecov root. Shares the regex with
5
+ # SimpleCov::UselessResultsRemover so the root-prefix logic lives in one
6
+ # place; this profile is the user-facing entry point that tools like
7
+ # `SimpleCov.filtered` apply.
8
+ skip do |src|
9
+ src.filename !~ SimpleCov::UselessResultsRemover.root_regex
9
10
  end
10
11
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A "no shortfalls allowed" profile: every coverage criterion is
4
+ # enabled and held to 100%. Per
5
+ # https://github.com/simplecov-ruby/simplecov/issues/1061, this lives as
6
+ # an opt-in profile rather than the default — most projects can't
7
+ # realistically start at 100% on day one — so a team that has paid down
8
+ # the debt can flip the strict switch without re-typing the threshold
9
+ # trio every time.
10
+ #
11
+ # JRuby gracefully degrades: `enable_coverage :branch` / `:method` are
12
+ # accepted (they just add to the configured criteria set), but the
13
+ # runtime can't actually measure them, so the corresponding stats never
14
+ # materialize. `CoverageViolations` skips threshold lookups for
15
+ # criteria not in the stats, so the `:branch` / `:method` clauses below
16
+ # silently no-op on JRuby and only `:line` is enforced.
17
+ #
18
+ # `:eval` widens the universe of code held to 100% to include strings
19
+ # passed through `Kernel#eval` (typically ERB templates, when the user
20
+ # sets `ERB#filename=`). Guarded on `coverage_for_eval_supported?` so
21
+ # the profile stays quiet on Ruby < 3.2, where `enable_coverage :eval`
22
+ # would otherwise warn about the missing runtime support every time
23
+ # the profile loads.
24
+ SimpleCov.profiles.define "strict" do
25
+ enable_coverage :branch
26
+ enable_coverage :method
27
+ # simplecov:disable branch — dogfood runs on Ruby >= 3.2 only, so
28
+ # the else arm (eval coverage not supported) is unreachable from CI.
29
+ enable_coverage :eval if coverage_for_eval_supported?
30
+ # simplecov:enable
31
+ minimum_coverage line: 100, branch: 100, method: 100
32
+ end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "test_frameworks" do
4
- add_filter "/test/"
5
- add_filter "/features/"
6
- add_filter "/spec/"
7
- add_filter "/autotest/"
4
+ skip %r{\A(test|features|spec|autotest)/}
8
5
  end
@@ -17,7 +17,7 @@ module SimpleCov
17
17
  #
18
18
  def define(name, &blk)
19
19
  name = name.to_sym
20
- raise "SimpleCov Profile '#{name}' is already defined" unless self[name].nil?
20
+ raise SimpleCov::ConfigurationError, "SimpleCov Profile '#{name}' is already defined" unless self[name].nil?
21
21
 
22
22
  self[name] = blk
23
23
  end
@@ -26,10 +26,39 @@ module SimpleCov
26
26
  # Applies the profile of given name on SimpleCov.configure
27
27
  #
28
28
  def load(name)
29
+ SimpleCov.configure(&fetch_proc(name))
30
+ end
31
+
32
+ #
33
+ # Returns the proc registered for the given profile name, autoloading
34
+ # bundled or plugin-gem profiles on first lookup. Raises if the profile
35
+ # cannot be located.
36
+ #
37
+ # Lookup order:
38
+ # 1. already registered via #define
39
+ # 2. require "simplecov/profiles/<name>" (bundled profiles)
40
+ # 3. require "simplecov-profile-<name>" (third-party plugin gems)
41
+ #
42
+ def fetch_proc(name)
29
43
  name = name.to_sym
30
- raise "Could not find SimpleCov Profile called '#{name}'" unless key?(name)
44
+ autoload_profile(name) unless key?(name)
45
+ return self[name] if key?(name)
46
+
47
+ raise SimpleCov::ConfigurationError, "Could not find SimpleCov Profile called '#{name}'"
48
+ end
49
+
50
+ private
31
51
 
32
- SimpleCov.configure(&self[name])
52
+ def autoload_profile(name)
53
+ require "simplecov/profiles/#{name}"
54
+ rescue LoadError
55
+ begin
56
+ # simplecov:disable — third-party gem fallback (no such gem in test env)
57
+ require "simplecov-profile-#{name}"
58
+ # simplecov:enable
59
+ rescue LoadError
60
+ # fall through; #fetch_proc raises the user-facing error
61
+ end
33
62
  end
34
63
  end
35
64
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The clobber-prevention backstop: an empty process stands down instead
4
+ # of formatting over a fresher report a sibling produced. Split from
5
+ # exit_handling.rb, which orchestrates the at_exit flow that consults
6
+ # this. See issue #581.
7
+ module SimpleCov
8
+ class << self
9
+ # Returns true when our process has no coverage data to contribute
10
+ # (after the resultset merge) and a newer report already exists on
11
+ # disk. Typically fires when `SimpleCov.start` ran in a parent
12
+ # process that shelled out to the test runner. See issue #581.
13
+ def defer_to_existing_report?
14
+ return false unless existing_report_newer_than_us?
15
+
16
+ res = result
17
+ empty = res.nil? || res.files.empty?
18
+ warn_about_deferred_report if empty
19
+ empty
20
+ end
21
+
22
+ # `.last_run.json` only exists after fully successful runs, so alone
23
+ # it left the backstop inert when the child run failed — the case
24
+ # where clobbering its report hurts most. The report stamp is
25
+ # touched by every formatting process regardless of exit status. The
26
+ # rescue covers a file vanishing mid-at_exit (`rm -rf coverage`).
27
+ def existing_report_newer_than_us?
28
+ return false unless process_start_time
29
+
30
+ [SimpleCov::LastRun.last_run_path, SimpleCov::ReportStamp.path].any? do |path|
31
+ File.mtime(path) > process_start_time
32
+ rescue SystemCallError
33
+ false
34
+ end
35
+ end
36
+
37
+ def warn_about_deferred_report
38
+ return unless print_errors
39
+
40
+ ExitCodes.print_error SimpleCov::Color.colorize(
41
+ "Skipping SimpleCov report — this process tracked no application code and a newer " \
42
+ "report already exists at #{coverage_path}. This usually means SimpleCov.start ran in a " \
43
+ "parent process (e.g. a Rakefile or Rails' Bundler.require) that shelled out to the test " \
44
+ "runner. See https://github.com/simplecov-ruby/simplecov/issues/581.",
45
+ :yellow
46
+ )
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module SimpleCov
6
+ # Touches coverage/.report_stamp whenever a report is formatted, no
7
+ # matter how the run ends. The clobber-prevention backstop
8
+ # (`SimpleCov.defer_to_existing_report?`) needs an on-disk signal that
9
+ # a fresher report exists, and `.last_run.json` can't be it alone:
10
+ # that file is only written after fully successful runs, which left
11
+ # the backstop inert exactly when the child run failed and its report
12
+ # mattered most.
13
+ module ReportStamp
14
+ class << self
15
+ def path
16
+ File.join(SimpleCov.coverage_path, ".report_stamp")
17
+ end
18
+
19
+ def touch
20
+ FileUtils.touch(path)
21
+ rescue SystemCallError
22
+ # A read-only or vanished coverage dir must not crash reporting;
23
+ # the stamp only powers the deferral heuristic.
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class Result
5
+ # When a resultset references source files that don't exist on the local
6
+ # filesystem they're silently dropped — which produces an empty `0 / 0
7
+ # (100.00%)` report that looks like success but isn't. Emit a single
8
+ # warning summarizing the drop and, when every entry was lost, point at
9
+ # the typical cause (`SimpleCov.collate` invoked from a machine or path
10
+ # different from where the resultsets were generated). See #980.
11
+ class MissingSourceFilesReporter
12
+ def initialize(missing_paths, input_size:, every_entry_dropped:)
13
+ @missing_paths = missing_paths
14
+ @input_size = input_size
15
+ @every_entry_dropped = every_entry_dropped
16
+ end
17
+
18
+ def warn!
19
+ warn SimpleCov::Color.colorize(message, :yellow)
20
+ end
21
+
22
+ def message
23
+ @every_entry_dropped ? all_missing_warning : partial_missing_warning
24
+ end
25
+
26
+ private
27
+
28
+ def all_missing_warning
29
+ "SimpleCov dropped all #{@missing_paths.size} source file(s) from the result — " \
30
+ "none of the paths in the resultset exist on this filesystem: " \
31
+ "#{summary}. If you're running `SimpleCov.collate`, the source " \
32
+ "files must be available at the same absolute paths as when the individual resultsets " \
33
+ "were generated."
34
+ end
35
+
36
+ def partial_missing_warning
37
+ "SimpleCov dropped #{@missing_paths.size} source file(s) from the result because " \
38
+ "they don't exist on this filesystem: #{summary}. They were " \
39
+ "tracked in the resultset but have since moved or been removed."
40
+ end
41
+
42
+ def summary
43
+ sample = @missing_paths.first(5).join(", ")
44
+ remaining = @missing_paths.size - 5
45
+ remaining.positive? ? "#{sample} (+#{remaining} more)" : sample
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class Result
5
+ # Constructs `SimpleCov::SourceFile` instances from a raw coverage
6
+ # hash, sorts them by filename, and surfaces filenames whose source
7
+ # is no longer present on disk so the caller can warn about the
8
+ # silent drop (see #980).
9
+ class SourceFileBuilder
10
+ attr_reader :missing_source_files
11
+
12
+ def initialize(original_result, not_loaded_files:)
13
+ @original_result = original_result
14
+ @not_loaded_files = not_loaded_files
15
+ @missing_source_files = []
16
+ end
17
+
18
+ def call
19
+ SimpleCov::FileList.new(
20
+ @original_result
21
+ .filter_map { |filename, coverage| build_source_file(filename, coverage) }
22
+ .sort_by(&:filename)
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def build_source_file(filename, coverage)
29
+ unless File.file?(filename)
30
+ @missing_source_files << filename
31
+ return
32
+ end
33
+
34
+ SimpleCov::SourceFile.new(
35
+ filename,
36
+ stringify_outer_keys(coverage),
37
+ loaded: !@not_loaded_files.include?(filename)
38
+ )
39
+ end
40
+
41
+ # `Coverage.result` returns symbol keys (`:lines`, `:branches`,
42
+ # `:methods`); resultsets loaded from disk are already string-keyed.
43
+ # SourceFile reads with strings, and handles both Array and
44
+ # stringified-Array branch/method keys via `restore_ruby_data_structure`,
45
+ # so only the outer hash needs normalizing.
46
+ def stringify_outer_keys(coverage)
47
+ coverage.transform_keys(&:to_s)
48
+ end
49
+ end
50
+ end
51
+ end