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
@@ -5,9 +5,10 @@ require "json"
5
5
  module SimpleCov
6
6
  module ResultMerger
7
7
  # Read + parse a `.resultset.json` file with the same tolerance the
8
- # historical `ResultMerger` had: missing file returns `{}`, an empty
9
- # or unparseable file warns and returns `{}`, parse success returns
10
- # the decoded Hash.
8
+ # historical `ResultMerger` had: a missing or empty file quietly
9
+ # returns `{}`, an unparseable one warns and returns `{}`, and parse
10
+ # success returns the decoded Hash with any malformed entries
11
+ # warned about and dropped.
11
12
  module ResultsetFile
12
13
  module_function
13
14
 
@@ -16,20 +17,55 @@ module SimpleCov
16
17
  decode(data)
17
18
  end
18
19
 
20
+ # A missing or blank file quietly means "no results yet"; anything
21
+ # else — a 1-byte truncation included — flows through decode so
22
+ # corruption warns instead of silently vanishing (the old
23
+ # `length < 2` check swallowed exactly those). Read first and
24
+ # rescue rather than check exist? (racy against a concurrent
25
+ # clean).
19
26
  def read(path)
20
- return unless File.exist?(path)
21
-
22
27
  data = File.read(path)
23
- return if data.nil? || data.length < 2
28
+ return if data.match?(/\A\s*\z/)
24
29
 
25
30
  data
31
+ rescue Errno::ENOENT
32
+ nil
26
33
  end
27
34
 
28
35
  def decode(content)
29
36
  return {} unless content
30
37
 
31
- JSON.parse(content) || {}
38
+ parsed = JSON.parse(content)
39
+ return {} if parsed.nil? # a "null" file has long meant an empty resultset
40
+
41
+ # Valid JSON need not be an object ("[1,2]" and "\"x\"" parse
42
+ # fine), but everything downstream iterates command => data
43
+ # pairs, so anything else would crash out of the middle of a
44
+ # merge. Same tolerance as a parse failure: warn and move on.
45
+ parsed.is_a?(Hash) ? drop_malformed_entries(parsed) : invalid_resultset
32
46
  rescue StandardError
47
+ invalid_resultset
48
+ end
49
+
50
+ # Each surviving entry must have the shape every consumer relies
51
+ # on: a Hash carrying a Numeric "timestamp" (the merge-timeout
52
+ # check) and a Hash "coverage" (the merge fold). A truncated or
53
+ # hand-edited entry would otherwise crash out of the middle of an
54
+ # at_exit merge, so it gets the same warn-and-move-on treatment
55
+ # as an unparseable file.
56
+ def drop_malformed_entries(resultset)
57
+ malformed, valid = resultset.partition { |_command_name, data| !well_formed_entry?(data) }
58
+ return resultset if malformed.empty?
59
+
60
+ warn "[SimpleCov]: Warning! Ignoring malformed resultset entries: #{malformed.map(&:first).sort.join(', ')}"
61
+ valid.to_h
62
+ end
63
+
64
+ def well_formed_entry?(data)
65
+ data.is_a?(Hash) && data["timestamp"].is_a?(Numeric) && data["coverage"].is_a?(Hash)
66
+ end
67
+
68
+ def invalid_resultset
33
69
  warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
34
70
  {}
35
71
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ # Run/worker metadata queries used by resultset storage and parallel
6
+ # coordination. Kept separate from the coverage-folding implementation.
7
+ module ResultsetRunIdentity
8
+ # One namespaced identity per distinct worker that wrote to the
9
+ # current run: `[:worker, id]` for entries that carry a worker id,
10
+ # `[:legacy, command_name]` for fresh entries written without one
11
+ # (an old SimpleCov writing to the same resultset). Pairs rather
12
+ # than mangled strings, so a resultset mixing both kinds can never
13
+ # alias a real worker id to a synthesized legacy identity.
14
+ def worker_identities_for_run(results, run_id, started_at)
15
+ results.filter_map do |command_name, data|
16
+ next unless current_run_entry?(data, run_id, started_at)
17
+
18
+ worker_id = data["worker_id"]
19
+ worker_id.to_s.empty? ? [:legacy, command_name] : [:worker, worker_id.to_s]
20
+ end.uniq
21
+ end
22
+
23
+ def current_run_entry?(entry, run_id, started_at)
24
+ return false unless entry.is_a?(Hash)
25
+
26
+ entry_run_id = entry["run_id"]
27
+ return fresh_entry?(entry, started_at) unless entry_run_id
28
+ return false unless run_id && entry_run_id == run_id
29
+ return true if SimpleCov::RunIdentity.authoritative?
30
+
31
+ fresh_entry?(entry, started_at)
32
+ end
33
+
34
+ def fresh_entry?(entry, started_at)
35
+ timestamp = entry["timestamp"]
36
+ timestamp.is_a?(Numeric) && started_at && timestamp >= started_at.to_f
37
+ end
38
+
39
+ # Whether `entry` was written by a runner concurrent with this
40
+ # process: either it shares our run id (`current_run_entry?`), or it
41
+ # was written strictly after our process started. A mismatched run
42
+ # id must not defeat the timestamp check — a subprocess we shelled
43
+ # out to (fork+exec) generates its own random run id, and its
44
+ # freshly written entry is exactly the data issue #581 protects
45
+ # from being clobbered. Strict id matching is reserved for worker
46
+ # counting (`worker_identities_for_run`), where admitting a stale
47
+ # entry would end a sibling wait early.
48
+ def concurrent_runner_entry?(entry, incoming = nil)
49
+ return false unless entry.is_a?(Hash)
50
+
51
+ incoming_run_id = incoming["run_id"] if incoming.is_a?(Hash)
52
+ started_at = SimpleCov.process_start_time
53
+ return true if current_run_entry?(entry, incoming_run_id, started_at)
54
+
55
+ written_after_start?(entry, started_at)
56
+ end
57
+
58
+ # Strictly after, unlike `fresh_entry?`: an entry stamped at the
59
+ # exact instant we started is treated as leftover from a previous
60
+ # run, so a same-second stale entry is still overwritten.
61
+ def written_after_start?(entry, started_at)
62
+ timestamp = entry["timestamp"]
63
+ timestamp.is_a?(Numeric) && started_at && timestamp > started_at.to_f
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "monitor"
6
+ require_relative "../atomic_file"
5
7
 
6
8
  module SimpleCov
7
9
  module ResultMerger
@@ -9,6 +11,9 @@ module SimpleCov
9
11
  # file-lock synchronization between processes and atomic temp-file
10
12
  # renames so concurrent readers don't observe a truncated file.
11
13
  module ResultsetStore
14
+ LOCK_MONITOR = Monitor.new
15
+ private_constant :LOCK_MONITOR
16
+
12
17
  module_function
13
18
 
14
19
  def resultset_path
@@ -19,23 +24,21 @@ module SimpleCov
19
24
  File.join(SimpleCov.coverage_path, ".resultset.json.lock")
20
25
  end
21
26
 
27
+ # Compact JSON, not pretty-printed: this is a machine-read cache
28
+ # that every parallel worker rewrites wholesale, and on a large
29
+ # project pretty printing nearly doubles the bytes written, read
30
+ # back, and parsed on each of those store-merge round trips.
22
31
  def write(resultset)
23
- FileUtils.mkdir_p(SimpleCov.coverage_path)
24
- temp_path = "#{resultset_path}.#{Process.pid}.tmp"
25
- File.open(temp_path, "w") { |f| f.puts JSON.pretty_generate(resultset) }
26
- File.rename(temp_path, resultset_path)
32
+ AtomicFile.write(resultset_path, "#{JSON.generate(resultset)}\n")
27
33
  end
28
34
 
29
- # Ensure only one process is reading or writing the resultset at
30
- # any given time. Reentrant: the lock is acquired once per outer
31
- # call no matter how deeply nested.
35
+ # Serialize threads before taking the process-wide file lock. Nested
36
+ # calls by the owning thread bypass flock so they cannot deadlock on a
37
+ # second descriptor for the same lock file.
32
38
  def synchronize(&)
33
- return yield if @locked
39
+ return yield if LOCK_MONITOR.mon_owned?
34
40
 
35
- @locked = true
36
- with_flock(&)
37
- ensure
38
- @locked = false
41
+ LOCK_MONITOR.synchronize { with_flock(&) }
39
42
  end
40
43
 
41
44
  def with_flock
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ # The merge step's half of unloaded-file handling: the policy of when
6
+ # to inject and which criteria simulated files carry. The mechanism —
7
+ # expanding globs and simulating each file — is
8
+ # `SimpleCov::UnloadedFileInjector`, an easy name to confuse with this
9
+ # one.
10
+ #
11
+ # Injection moved here from the individual processes because only the union
12
+ # of what they all loaded says what was really never loaded. Doing it per
13
+ # process meant N workers simulated the same file up to N times and the
14
+ # merge threw all but one away. See #1250.
15
+ module UnloadedFiles
16
+ module_function
17
+
18
+ # The union of what every contributing process was told to track. Absent
19
+ # from resultsets written before this was recorded, in which case those
20
+ # processes injected their own unloaded files and the data is already in
21
+ # the coverage hash.
22
+ # A collector for the merge to hand `merge_valid_results`, gathering the
23
+ # tracked paths of every resultset that survives the merge timeout.
24
+ def collector(into)
25
+ ->(surviving) { into.merge(tracked_in(surviving)) }
26
+ end
27
+
28
+ def tracked_in(resultset)
29
+ resultset.each_with_object(Set.new) do |(_command_name, data), set|
30
+ set.merge(Array(data["tracked_files"]))
31
+ end
32
+ end
33
+
34
+ # Concurrent workers sharing a command name may have been told to track
35
+ # different sets, so keep both rather than letting the later write win.
36
+ def carry_tracked(entry, existing, incoming)
37
+ tracked = Array(existing["tracked_files"]) | Array(incoming["tracked_files"])
38
+ tracked.empty? ? entry : entry.merge("tracked_files" => tracked)
39
+ end
40
+
41
+ # Simulate each tracked file the merged coverage doesn't already carry.
42
+ # The paths come from the resultsets rather than this process's own
43
+ # configuration, which a standalone `collate` would not have: it never ran
44
+ # `SimpleCov.start`, so it has no `cover` glob to expand. Idempotent, so
45
+ # resultsets from an older SimpleCov (or from a `merging false` process)
46
+ # that already carry their unloaded files pass through untouched.
47
+ def inject(coverage, tracked_files)
48
+ SimpleCov.inject_unloaded_files(
49
+ coverage, tracked_files.to_a,
50
+ synthesize: carries?(coverage, "branches") || carries?(coverage, "methods"),
51
+ lines: carries?(coverage, "lines")
52
+ )
53
+ end
54
+
55
+ # A simulated file should have the same shape as the files it is being
56
+ # merged alongside, so the criteria come from the merged data rather than
57
+ # from this process's configuration, which is not necessarily the
58
+ # configuration any contributing process measured under: `simplecov merge`
59
+ # never ran `SimpleCov.start` at all. Giving injected files fewer tables
60
+ # than their neighbours is what inflates the percentage #1059 fixed.
61
+ #
62
+ # Falls back to the configuration when there is nothing to be consistent
63
+ # with, which is a merge of resultsets that carried no files.
64
+ def carries?(coverage, criterion)
65
+ return SimpleCov.public_send(CRITERION_PREDICATES.fetch(criterion)) if coverage.empty?
66
+
67
+ coverage.any? { |_filename, file_coverage| file_coverage[criterion] }
68
+ end
69
+
70
+ CRITERION_PREDICATES = {
71
+ "lines" => :line_coverage?,
72
+ "branches" => :branch_coverage?,
73
+ "methods" => :method_coverage?
74
+ }.freeze
75
+ private_constant :CRITERION_PREDICATES
76
+
77
+ # Which files no contributing process ever loaded. Injection reports the
78
+ # ones it added, but a file can also arrive already simulated from a
79
+ # resultset this merge didn't inject into, so the merged line counts are
80
+ # still consulted, using the same signal
81
+ # `Combine::CoverageAccumulator` reconciles synthesized tuples on.
82
+ #
83
+ # A file is judged only when it has a relevant line. A branch-only or
84
+ # method-only run reports no line data at all for the files it loaded,
85
+ # and a loaded file with no executable lines (a comment-only constants
86
+ # stub, say) reports every line as `nil` — either would otherwise read
87
+ # as "never executed" and mark a genuinely loaded file not loaded,
88
+ # turning its branch and method coverage into #902's 0%.
89
+ # `SimulateCoverage` omits lines under those criteria too and gives a
90
+ # simulated file a `0` on every relevant line, so genuinely unloaded
91
+ # files still carry judgeable data, and a simulated file with none is
92
+ # already flagged by injection reporting it.
93
+ def never_executed(coverage)
94
+ coverage.each_with_object(Set.new) do |(filename, file_coverage), set|
95
+ counts = Array(file_coverage["lines"]) #: Array[Integer?]
96
+ next unless counts.any? { |count| !count.nil? }
97
+
98
+ set << filename unless Combine::CoverageAccumulator.executed?(file_coverage["lines"])
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -2,7 +2,9 @@
2
2
 
3
3
  require_relative "result_merger/legacy_format_adapter"
4
4
  require_relative "result_merger/resultset_file"
5
+ require_relative "result_merger/resultset_run_identity"
5
6
  require_relative "result_merger/resultset_store"
7
+ require_relative "result_merger/unloaded_files"
6
8
 
7
9
  module SimpleCov
8
10
  #
@@ -11,6 +13,8 @@ module SimpleCov
11
13
  # upon multiple test suites.
12
14
  #
13
15
  module ResultMerger
16
+ extend ResultsetRunIdentity
17
+
14
18
  class << self
15
19
  def resultset_path
16
20
  ResultsetStore.resultset_path
@@ -23,29 +27,58 @@ module SimpleCov
23
27
  end
24
28
 
25
29
  def merge_results(*file_paths, ignore_timeout: false)
26
- # It is intentional here that files are only read in and parsed one at a time.
27
- #
28
- # In big CI setups you might deal with 100s of CI jobs and each one producing Megabytes
29
- # of data. Reading them all in easily produces Gigabytes of memory consumption which
30
- # we want to avoid.
31
- #
32
- # For similar reasons a SimpleCov::Result is only created in the end as that'd create
33
- # even more data especially when it also reads in all source files.
34
- initial_memo = valid_results(file_paths.shift, ignore_timeout: ignore_timeout)
35
-
36
- command_names, coverage = file_paths.reduce(initial_memo) do |memo, file_path|
37
- merge_coverage(memo, valid_results(file_path, ignore_timeout: ignore_timeout))
38
- end
30
+ # Tracked paths are collected as each resultset is parsed, so files are
31
+ # still read and discarded one at a time. See #1250.
32
+ tracked_files = Set.new
33
+ command_names, coverage = absorb_results(file_paths, ignore_timeout: ignore_timeout,
34
+ &UnloadedFiles.collector(tracked_files))
35
+ create_result(command_names, coverage, tracked_files: tracked_files)
36
+ end
39
37
 
40
- create_result(command_names, coverage)
38
+ #
39
+ # Reads every resultset and folds it into one merged coverage, stopping
40
+ # short of building a `SimpleCov::Result`.
41
+ #
42
+ # It is intentional here that files are only read in and parsed one at a time.
43
+ #
44
+ # In big CI setups you might deal with 100s of CI jobs and each one producing Megabytes
45
+ # of data. Reading them all in easily produces Gigabytes of memory consumption which
46
+ # we want to avoid.
47
+ #
48
+ # For similar reasons a SimpleCov::Result is only created in the end as that'd create
49
+ # even more data especially when it also reads in all source files.
50
+ #
51
+ # One accumulator absorbs the whole run, rather than folding each file
52
+ # into the merged-so-far pairwise: the pairwise form rebuilt every
53
+ # file's coverage once per resultset, which is what made merging a
54
+ # large parallel run's results the dominant cost of `collate`.
55
+ # Absorbing is still one resultset at a time, so the memory ceiling
56
+ # above is unchanged.
57
+ #
58
+ # `file_paths` is only ever iterated, so a caller that wants to observe
59
+ # the merge as it goes can hand in any Enumerable — `benchmarks/collate`
60
+ # passes an Enumerator that reports progress — rather than reimplement
61
+ # this loop and risk timing something other than what ships.
62
+ #
63
+ # @return [Array] the command names and the merged coverage
64
+ #
65
+ def absorb_results(file_paths, ignore_timeout: false, &on_parse)
66
+ Combine::CoverageAccumulator.fold(
67
+ file_paths.lazy.map { |file_path| valid_results(file_path, ignore_timeout: ignore_timeout, &on_parse) }
68
+ )
41
69
  end
42
70
 
43
- def valid_results(file_path, ignore_timeout: false)
44
- merge_valid_results(ResultsetFile.parse(file_path), ignore_timeout: ignore_timeout)
71
+ # Yields the surviving entries before they are reduced.
72
+ def valid_results(file_path, ignore_timeout: false, &on_parse)
73
+ merge_valid_results(ResultsetFile.parse(file_path), ignore_timeout: ignore_timeout, &on_parse)
45
74
  end
46
75
 
76
+ # Yields the entries that survived the merge timeout, so a caller that
77
+ # wants to observe what a resultset carried sees only what is being
78
+ # merged. An expired entry contributes nothing, tracked paths included.
47
79
  def merge_valid_results(results, ignore_timeout: false)
48
80
  results = drop_expired_results(results) unless ignore_timeout
81
+ yield results if block_given?
49
82
 
50
83
  command_plus_coverage = results.map do |command_name, data|
51
84
  [[command_name], LegacyFormatAdapter.call(data.fetch("coverage"))]
@@ -68,12 +101,9 @@ module SimpleCov
68
101
  end
69
102
 
70
103
  def warn_about_expired_results(expired_command_names)
71
- # Subprocesses merge the resultset too (each forked worker calls
72
- # `SimpleCov.result` to store its slice), and the default `at_fork`
73
- # sets `print_errors false` for them. Without this guard the warning
74
- # is emitted once per worker — N copies of the same message for an
75
- # N-worker run. Gate on `print_errors` like every other SimpleCov
76
- # warning so only the reporting process speaks up.
104
+ # Respect quiet configurations and keep this consistent with every
105
+ # other SimpleCov diagnostic. Ordinary parallel workers only store
106
+ # their own slice; the selected final process performs this merge.
77
107
  return unless SimpleCov.print_errors
78
108
 
79
109
  warn "[SimpleCov]: Excluded #{expired_command_names.size} result(s) older than " \
@@ -82,26 +112,29 @@ module SimpleCov
82
112
  "Increase SimpleCov.merge_timeout to include them."
83
113
  end
84
114
 
85
- def create_result(command_names, coverage)
115
+ def create_result(command_names, coverage, tracked_files: Set.new)
86
116
  return nil unless coverage
87
117
 
88
118
  command_name = command_names.reject(&:empty?).sort.join(", ")
119
+ coverage, injected = UnloadedFiles.inject(coverage, tracked_files)
89
120
  # The merged result is the authoritative one users actually see, so
90
121
  # it's the one that warns about source files dropped because they no
91
122
  # longer exist on disk (issue #980). The per-process slices built in
92
123
  # `process_coverage_result` stay quiet to avoid one warning per worker.
93
- SimpleCov::Result.new(coverage, command_name: command_name, report: true)
124
+ SimpleCov::Result.new(
125
+ coverage,
126
+ command_name: command_name,
127
+ not_loaded_files: UnloadedFiles.never_executed(coverage) | injected,
128
+ tracked_files: tracked_files.to_a,
129
+ report: true
130
+ )
94
131
  end
95
132
 
96
133
  def merge_coverage(*results)
97
134
  return [[""], nil] if results.empty?
98
135
  return results.first if results.size == 1
99
136
 
100
- results.reduce do |(memo_command, memo_coverage), (command, coverage)|
101
- # timestamp is dropped here, which is intentional (we merge it, it gets a new time stamp as of now)
102
- merged_coverage = Combine.combine(Combine::ResultsCombiner, memo_coverage, coverage)
103
- [memo_command + command, merged_coverage]
104
- end
137
+ Combine::CoverageAccumulator.fold(results)
105
138
  end
106
139
 
107
140
  #
@@ -109,8 +142,9 @@ module SimpleCov
109
142
  # SimpleCov::Result with merged coverage data and the command_name
110
143
  # for the result consisting of a join on all source result's names
111
144
  def merged_result
112
- command_names, coverage = merge_valid_results(read_resultset)
113
- create_result(command_names, coverage)
145
+ tracked_files = Set.new
146
+ command_names, coverage = merge_valid_results(read_resultset, &UnloadedFiles.collector(tracked_files))
147
+ create_result(command_names, coverage, tracked_files: tracked_files)
114
148
  end
115
149
 
116
150
  def read_resultset
@@ -139,19 +173,12 @@ module SimpleCov
139
173
  # overwriting, so an empty parent-process result doesn't clobber the
140
174
  # subprocess's real data. See https://github.com/simplecov-ruby/simplecov/issues/581.
141
175
  def merged_entry(existing, incoming)
142
- return incoming unless concurrent_runner_entry?(existing)
176
+ return incoming unless concurrent_runner_entry?(existing, incoming)
143
177
 
144
- incoming.merge(
145
- "coverage" => Combine.combine(Combine::ResultsCombiner, existing["coverage"], incoming["coverage"])
178
+ merged = incoming.merge(
179
+ "coverage" => Combine::ResultsCombiner.combine(existing["coverage"], incoming["coverage"])
146
180
  )
147
- end
148
-
149
- def concurrent_runner_entry?(entry)
150
- return false unless entry.is_a?(Hash)
151
-
152
- timestamp = entry["timestamp"]
153
- process_start = SimpleCov.process_start_time
154
- timestamp && process_start && timestamp.to_i >= process_start.to_i
181
+ UnloadedFiles.carry_tracked(merged, existing, incoming)
155
182
  end
156
183
 
157
184
  def synchronize_resultset(&)