simplecov 1.0.3 → 1.1.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1439
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +36 -12
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +6 -3
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +5 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  76. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  77. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  78. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  79. data/lib/simplecov/result_merger.rb +69 -42
  80. data/lib/simplecov/result_processing.rb +82 -43
  81. data/lib/simplecov/run_identity.rb +77 -0
  82. data/lib/simplecov/simulate_coverage.rb +35 -11
  83. data/lib/simplecov/source_file/method.rb +7 -1
  84. data/lib/simplecov/source_file/ruby_data_parser.rb +6 -2
  85. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  86. data/lib/simplecov/source_file/source_loader.rb +23 -7
  87. data/lib/simplecov/source_file/statistics.rb +24 -16
  88. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  89. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  90. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  91. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  92. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  93. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  94. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  95. data/lib/simplecov/version.rb +1 -1
  96. data/lib/simplecov.rb +11 -5
  97. data/schemas/coverage-v1.0.schema.json +2 -2
  98. data/schemas/coverage.schema.json +2 -2
  99. data/sig/simplecov.rbs +200 -66
  100. metadata +28 -14
  101. data/doc/alternate-formatters.md +0 -66
  102. data/doc/commercial-services.md +0 -25
  103. data/doc/editor-integration.md +0 -18
  104. data/lib/simplecov/combine/files_combiner.rb +0 -70
  105. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  106. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  107. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "json"
4
+ require_relative "atomic_file"
5
5
 
6
6
  module SimpleCov
7
7
  # Reads and writes coverage/.last_run.json — the previous run's coverage
@@ -18,17 +18,24 @@ module SimpleCov
18
18
  json = File.read(last_run_path)
19
19
  return nil if json.strip.empty?
20
20
 
21
- JSON.parse(json, symbolize_names: true)
21
+ parsed = JSON.parse(json, symbolize_names: true)
22
+ # The maximum_coverage_drop check digs into a Hash, so anything
23
+ # else in a corrupt or hand-edited file counts as "no previous
24
+ # run" rather than crashing the at_exit hook.
25
+ parsed.is_a?(Hash) ? parsed : invalid_last_run
26
+ rescue JSON::ParserError
27
+ invalid_last_run
22
28
  end
23
29
 
24
- # Write to a process-private temp file, then atomically rename, so a
25
- # concurrent reader (e.g. another parallel-tests worker checking
26
- # MaximumCoverageDrop) never sees a half-written file.
27
30
  def write(json)
28
- FileUtils.mkdir_p(SimpleCov.coverage_path)
29
- temp_path = "#{last_run_path}.#{Process.pid}.tmp"
30
- File.open(temp_path, "w") { |f| f.puts JSON.pretty_generate(json) }
31
- File.rename(temp_path, last_run_path)
31
+ AtomicFile.write(last_run_path, "#{JSON.pretty_generate(json)}\n")
32
+ end
33
+
34
+ private
35
+
36
+ def invalid_last_run
37
+ warn "[SimpleCov]: Warning! Parsing JSON content of .last_run.json failed, ignoring the previous run"
38
+ nil
32
39
  end
33
40
  end
34
41
  end
@@ -13,6 +13,16 @@ module SimpleCov
13
13
  COMMENT_LINE = /^\s*#/
14
14
  WHITESPACE_OR_COMMENT_LINE = Regexp.union(WHITESPACE_LINE, COMMENT_LINE)
15
15
 
16
+ # The leading `^(\s*)#` anchor is load-bearing beyond matching the marker:
17
+ # `classify_line` only reaches this for lines that already passed
18
+ # `whitespace_line?`, which is sound only while every marker is also a
19
+ # comment. Loosening this to match a trailing `x = 1 # :nocov:` would stop
20
+ # the toggle firing. `lines_classifier_spec.rb` pins the implication.
21
+ #
22
+ # The `/o` flag freezes the interpolated nocov token at this
23
+ # process's first classification; a `nocov_token` configured after
24
+ # coverage has classified a file would be silently ignored. Sound
25
+ # today because configuration always precedes classification.
16
26
  def self.no_cov_line
17
27
  /^(\s*)#(\s*)(:#{SimpleCov.current_nocov_token}:)/o
18
28
  end
@@ -34,21 +44,32 @@ module SimpleCov
34
44
  def classify(lines)
35
45
  lines = lines.to_a
36
46
  directive_disabled = directive_disabled_line_set(lines)
37
- skipping = false
47
+ # The `:nocov:` skip state lives in a one-slot box owned by this
48
+ # call, not in an ivar, so one classifier instance can serve
49
+ # concurrent `classify` calls without the toggles interleaving.
50
+ skip_state = [false]
38
51
 
39
52
  lines.map.with_index(1) do |line, line_number|
40
- skipping = !skipping if self.class.no_cov_line?(line)
41
- not_relevant_line?(line, line_number, skipping, directive_disabled) ? NOT_RELEVANT : RELEVANT
53
+ classify_line(line, line_number, directive_disabled, skip_state)
42
54
  end
43
55
  end
44
56
 
45
57
  private
46
58
 
47
- def not_relevant_line?(line, line_number, skipping, directive_disabled)
48
- skipping ||
49
- self.class.no_cov_line?(line) ||
50
- directive_disabled.include?(line_number) ||
51
- self.class.whitespace_line?(line)
59
+ # A `:nocov:` marker is itself a comment, so the cheap
60
+ # whitespace-or-comment test can gate the token match: a line of real
61
+ # code cannot be a marker, and no longer pays to be checked against
62
+ # one. That matters because this runs per line of every
63
+ # tracked-but-unloaded file, once in every process of a parallel run.
64
+ def classify_line(line, line_number, directive_disabled, skip_state)
65
+ if self.class.whitespace_line?(line)
66
+ skip_state[0] = !skip_state[0] if self.class.no_cov_line?(line)
67
+ NOT_RELEVANT
68
+ elsif skip_state[0] || directive_disabled.include?(line_number)
69
+ NOT_RELEVANT
70
+ else
71
+ RELEVANT
72
+ end
52
73
  end
53
74
 
54
75
  def directive_disabled_line_set(lines)
@@ -3,8 +3,11 @@
3
3
  # `~/.simplecov` was historically resolved via a three-step fallback chain
4
4
  # (HOME, then `Etc.getpwuid.dir`, then `~$USER`) for hostile container
5
5
  # environments circa 2017. Modern CRuby/JRuby/TruffleRuby all set HOME
6
- # reliably, so trust it and skip silently when it isn't there.
7
- if ENV.fetch("HOME", nil)
6
+ # reliably, so trust it and skip silently when it isn't there. An ENV
7
+ # check rather than Dir.home: JRuby raises from Dir.home with HOME
8
+ # unset, and `File.expand_path("~")` raises for a set-but-empty HOME
9
+ # (some container/CI images), which must not break `require "simplecov"`.
10
+ unless ENV.fetch("HOME", "").empty?
8
11
  # simplecov:disable — only fires when ~/.simplecov exists, which is
9
12
  # developer-machine-dependent (we can't rely on it for the dogfood).
10
13
  global_config_path = File.join(File.expand_path("~"), ".simplecov")
@@ -55,6 +55,23 @@ module SimpleCov
55
55
  def expected_worker_count
56
56
  1
57
57
  end
58
+
59
+ # The user's explicit `SimpleCov.parallel_tests false` opt-out,
60
+ # which every adapter's `active?` honors before its own
61
+ # detection.
62
+ def forced_off?
63
+ SimpleCov.parallel_tests == false
64
+ end
65
+
66
+ # Shared implementation of the PARALLEL_TEST_GROUPS convention
67
+ # both built-in adapters follow for `expected_worker_count`.
68
+ # Unset, empty, non-numeric, and non-positive values all mean 1:
69
+ # an unparseable value must not yield 0 workers, which would end
70
+ # the sibling wait before it started.
71
+ def parallel_test_groups_count
72
+ count = Integer(ENV.fetch("PARALLEL_TEST_GROUPS", ""), exception: false)
73
+ count&.positive? ? count : 1
74
+ end
58
75
  end
59
76
  end
60
77
  end
@@ -23,7 +23,7 @@ module SimpleCov
23
23
  class GenericAdapter < Base
24
24
  class << self
25
25
  def active?
26
- return false if SimpleCov.parallel_tests == false
26
+ return false if forced_off?
27
27
 
28
28
  ENV.key?("TEST_ENV_NUMBER")
29
29
  end
@@ -36,7 +36,7 @@ module SimpleCov
36
36
  end
37
37
 
38
38
  def expected_worker_count
39
- ENV["PARALLEL_TEST_GROUPS"]&.to_i || 1
39
+ parallel_test_groups_count
40
40
  end
41
41
  end
42
42
  end
@@ -16,7 +16,7 @@ module SimpleCov
16
16
  class ParallelTestsAdapter < Base
17
17
  class << self
18
18
  def active?
19
- return false if SimpleCov.parallel_tests == false
19
+ return false if forced_off?
20
20
 
21
21
  ensure_loaded
22
22
  # !! to coerce `defined?` (returns nil or "constant") to a proper bool.
@@ -50,7 +50,7 @@ module SimpleCov
50
50
  end
51
51
 
52
52
  def expected_worker_count
53
- ENV["PARALLEL_TEST_GROUPS"]&.to_i || 1
53
+ parallel_test_groups_count
54
54
  end
55
55
 
56
56
  # Auto-require `parallel_tests` when it's installed AND the env
@@ -77,7 +77,7 @@ module SimpleCov
77
77
  deadline = monotonic_time + parallel_wait_timeout
78
78
  tracker = {count: 0, since: monotonic_time}
79
79
  loop do
80
- seen = SimpleCov::ResultMerger.read_resultset.size
80
+ seen = current_parallel_worker_count
81
81
  return true if seen >= expected
82
82
  return true if native_wait && resultset_count_settled?(tracker, seen)
83
83
  return false if parallel_wait_timed_out?(deadline, expected, seen)
@@ -86,6 +86,11 @@ module SimpleCov
86
86
  end
87
87
  end
88
88
 
89
+ 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
92
+ end
93
+
89
94
  # Track whether the resultset count has held steady (and positive) for
90
95
  # `PARALLEL_RESULTS_SETTLE` seconds. `tracker` carries the last count and
91
96
  # the time it last changed across poll iterations.
@@ -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
@@ -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
@@ -15,6 +15,13 @@ module SimpleCov
15
15
 
16
16
  # Returns the original Coverage.result used for this instance of SimpleCov::Result
17
17
  attr_reader :original_result
18
+ # Every path the producing process was told to track, loaded or not. Carried
19
+ # into the resultset so a merge elsewhere can inject the ones nobody loaded
20
+ # without needing that process's `cover` / `track_files` config. See #1250.
21
+ attr_reader :tracked_files
22
+ # Invocation and top-level worker identities used only for parallel-result
23
+ # coordination. They do not change which fresh suites are merged.
24
+ attr_reader :run_id, :worker_id
18
25
  # Returns all files that are applicable to this result (sans filters!) as instances of
19
26
  # SimpleCov::SourceFile. Aliased as :source_files
20
27
  attr_reader :files
@@ -55,10 +62,11 @@ module SimpleCov
55
62
  # FilterConfig to opt out — useful for tests that build synthetic Results
56
63
  # and don't want the project's filters or groups applied.
57
64
  def initialize(original_result, command_name: nil, created_at: nil, not_loaded_files: Set.new,
58
- report: false, filter_config: FilterConfig.new)
65
+ tracked_files: [], run_id: nil, worker_id: nil, report: false, filter_config: FilterConfig.new)
59
66
  @original_result = original_result.freeze
60
67
  @command_name = command_name
61
68
  @created_at = created_at
69
+ initialize_coordination_metadata(tracked_files, run_id, worker_id)
62
70
  @groups_config = filter_config.groups
63
71
  builder = SourceFileBuilder.new(original_result, not_loaded_files: not_loaded_files)
64
72
  @files = builder.call
@@ -102,7 +110,13 @@ module SimpleCov
102
110
  formatter = SimpleCov.formatter
103
111
  return nil if formatter.nil?
104
112
 
105
- Formatter.instance_for(formatter).format(self)
113
+ formatted = Formatter.format(formatter, self)
114
+ # Recorded regardless of how the run ends, so a parent process's
115
+ # clobber-prevention backstop can tell a report was produced even
116
+ # when this run's checks or tests failed (unlike .last_run.json,
117
+ # which only successful runs write).
118
+ SimpleCov::ReportStamp.touch
119
+ formatted
106
120
  end
107
121
 
108
122
  # Defines when this result has been created. Defaults to Time.now
@@ -118,23 +132,32 @@ module SimpleCov
118
132
 
119
133
  # Returns a hash representation of this Result that can be used for marshalling it into JSON
120
134
  def to_hash
121
- {
122
- command_name => {
123
- "coverage" => coverage,
124
- "timestamp" => created_at.to_i
125
- }
126
- }
135
+ data = {"coverage" => coverage, "timestamp" => created_at.to_f} #: Hash[String, untyped]
136
+ data["run_id"] = run_id if run_id
137
+ data["worker_id"] = worker_id if worker_id
138
+ # Omitted when empty so a run that tracks nothing writes the shape it
139
+ # always has, and so the key only appears where it carries information.
140
+ data["tracked_files"] = tracked_files unless tracked_files.empty?
141
+ {command_name => data}
127
142
  end
128
143
 
129
144
  # Loads a SimpleCov::Result#to_hash dump
130
145
  def self.from_hash(hash)
131
146
  hash.map do |command_name, data|
132
- new(data.fetch("coverage"), command_name: command_name, created_at: Time.at(data["timestamp"]))
147
+ new(data.fetch("coverage"), command_name: command_name, created_at: Time.at(data["timestamp"]),
148
+ tracked_files: data["tracked_files"] || [], run_id: data["run_id"],
149
+ worker_id: data["worker_id"])
133
150
  end
134
151
  end
135
152
 
136
153
  private
137
154
 
155
+ def initialize_coordination_metadata(tracked_files, run_id, worker_id)
156
+ @tracked_files = tracked_files.to_a
157
+ @run_id = run_id
158
+ @worker_id = worker_id
159
+ end
160
+
138
161
  def warn_about_missing_source_files(missing, input_size)
139
162
  return if missing.empty?
140
163
 
@@ -156,8 +179,15 @@ module SimpleCov
156
179
  ).warn!
157
180
  end
158
181
 
182
+ # A live result's criterion keys are Symbols (`:lines`, `:branches`),
183
+ # while entries parsed back from `.resultset.json` carry Strings, and
184
+ # the combiners read only String keys. Serialize with String keys so a
185
+ # live result merged against a stored entry contributes its counts
186
+ # instead of being silently dropped for every shared file.
159
187
  def coverage
160
- original_result.slice(*filenames)
188
+ original_result.slice(*filenames).transform_values do |file_coverage|
189
+ file_coverage.transform_keys(&:to_s)
190
+ end
161
191
  end
162
192
 
163
193
  # Applies the given filter chain to `@files`, dropping each source