simplecov 1.0.2 → 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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1425
  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 +46 -21
  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 +18 -9
  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 +12 -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_adapter.rb +48 -16
  76. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  77. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  78. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  79. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  80. data/lib/simplecov/result_merger.rb +69 -42
  81. data/lib/simplecov/result_processing.rb +82 -43
  82. data/lib/simplecov/run_identity.rb +77 -0
  83. data/lib/simplecov/simulate_coverage.rb +35 -11
  84. data/lib/simplecov/source_file/method.rb +7 -1
  85. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  86. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  87. data/lib/simplecov/source_file/source_loader.rb +23 -7
  88. data/lib/simplecov/source_file/statistics.rb +24 -16
  89. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  90. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  91. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  92. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  93. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  94. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  95. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  96. data/lib/simplecov/version.rb +1 -1
  97. data/lib/simplecov.rb +11 -5
  98. data/schemas/coverage-v1.0.schema.json +2 -2
  99. data/schemas/coverage.schema.json +2 -2
  100. data/sig/simplecov.rbs +206 -67
  101. metadata +28 -14
  102. data/doc/alternate-formatters.md +0 -66
  103. data/doc/commercial-services.md +0 -25
  104. data/doc/editor-integration.md +0 -18
  105. data/lib/simplecov/combine/files_combiner.rb +0 -70
  106. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  107. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  110. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -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(&)
@@ -13,13 +13,27 @@ module SimpleCov
13
13
  # so all results in all files specified will be merged. Pass
14
14
  # `ignore_timeout: false` to honor it.
15
15
  #
16
- def collate(result_filenames, profile = nil, ignore_timeout: true, &)
16
+ # `processes:` above 1 fans the merge out across that many forked worker
17
+ # processes, for a collate big enough that reading and parsing the
18
+ # resultsets dominates it. The report is identical either way, not merely
19
+ # equivalent — the workers visit the resultsets in the order the
20
+ # single-process merge visits them — and one process never forks at all.
21
+ # The count is deliberately not clamped to the machine's core count nor
22
+ # gated on some minimum number of resultsets: how many processes a collate
23
+ # job can afford is the caller's call, not SimpleCov's. Anything below 1 is
24
+ # taken as 1, so a count computed from arithmetic that can reach zero needs
25
+ # no guarding. Defaults to `SIMPLECOV_CONCURRENCY`, or 1 when that is
26
+ # unset. See `SimpleCov::ParallelResultMerger`.
27
+ #
28
+ def collate(result_filenames, profile = nil, processes: ENV.fetch("SIMPLECOV_CONCURRENCY", 1).to_i,
29
+ ignore_timeout: true, &)
17
30
  raise ArgumentError, "There are no reports to be merged" if result_filenames.empty?
18
31
 
19
32
  initial_setup(profile, &)
20
33
 
21
34
  # Use the ResultMerger to produce a single, merged result, ready to use.
22
- @result = ResultMerger.merge_and_store(*result_filenames, ignore_timeout: ignore_timeout)
35
+ @result = ParallelResultMerger.merge_and_store(*result_filenames, processes: [1, processes].max,
36
+ ignore_timeout: ignore_timeout)
23
37
 
24
38
  @collating_result = true
25
39
  run_exit_tasks!
@@ -28,24 +42,22 @@ module SimpleCov
28
42
  end
29
43
 
30
44
  #
31
- # Returns the result for the current coverage run, merging it across test suites
32
- # from cache using SimpleCov::ResultMerger if use_merging is activated (default)
45
+ # Returns the result for the current coverage run. With merging enabled,
46
+ # every process stores its own slice, but only the finalization owner reads
47
+ # and caches the merged result.
33
48
  #
34
49
  def result
35
50
  return @result if result?
36
51
 
37
52
  use_merging = merging
38
53
 
39
- # Collect our coverage result. When merging is off there is no merge
40
- # step, so this per-process result is the final one and reports any
41
- # dropped source files; otherwise the merged result does the reporting.
42
- process_coverage_result(report: !use_merging) if defined?(Coverage) && Coverage.running?
54
+ collect_own_coverage(standalone: !use_merging)
43
55
 
44
56
  # If we're using merging of results, store the current result
45
57
  # first (if there is one), then merge the results and return those
46
58
  if use_merging
47
59
  SimpleCov::ResultMerger.store_result(@result) if result?
48
- return @result unless finalize_merge?
60
+ return @result unless merge_finalization_owner?
49
61
 
50
62
  wait_for_other_processes
51
63
  @result = SimpleCov::ResultMerger.merged_result
@@ -54,6 +66,16 @@ module SimpleCov
54
66
  @result
55
67
  end
56
68
 
69
+ # Build this process's slice of the coverage result. `standalone` is true
70
+ # when no merge step follows, which makes this the final result: it is the
71
+ # one that reports dropped source files, and the one that injects unloaded
72
+ # files. When a merge does follow, both jobs belong to the merged result.
73
+ def collect_own_coverage(standalone:)
74
+ return unless defined?(Coverage) && Coverage.running?
75
+
76
+ process_coverage_result(report: standalone, inject_unloaded: standalone)
77
+ end
78
+
57
79
  # Returns nil if the result has not been computed, otherwise the result.
58
80
  def result?
59
81
  defined?(@result) && @result
@@ -79,7 +101,7 @@ module SimpleCov
79
101
  # construction). Files matched by no group fall into the implicit
80
102
  # "Ungrouped" bucket.
81
103
  def grouped(files, groups: SimpleCov.groups)
82
- return {} if groups.empty?
104
+ return {} if GroupNames.validate!(groups.keys).empty?
83
105
 
84
106
  grouped = groups.transform_values do |filter|
85
107
  SimpleCov::FileList.new(files.select { |source_file| filter.matches?(source_file) })
@@ -87,7 +109,7 @@ module SimpleCov
87
109
 
88
110
  in_group = grouped_file_set(grouped)
89
111
  ungrouped = files.reject { |source_file| in_group.include?(source_file) }
90
- grouped["Ungrouped"] = SimpleCov::FileList.new(ungrouped) if ungrouped.any?
112
+ grouped[GroupNames::UNGROUPED] = SimpleCov::FileList.new(ungrouped) if ungrouped.any?
91
113
 
92
114
  grouped
93
115
  end
@@ -115,6 +137,29 @@ module SimpleCov
115
137
  coverage.floor(2)
116
138
  end
117
139
 
140
+ # Add simulated coverage for each of `candidate_paths` the result doesn't
141
+ # already carry, and return it with the set of paths added.
142
+ #
143
+ # @api private — the seam `SimpleCov::ResultMerger` injects through. Public
144
+ # only because the merge runs in another object, on behalf of processes
145
+ # whose configuration it may not share, so it supplies the paths itself.
146
+ def inject_unloaded_files(result, candidate_paths, synthesize: nil, lines: nil)
147
+ return [result, Set.new] if candidate_paths.empty?
148
+
149
+ # Synthesizing branch and method tuples means parsing every tracked file
150
+ # that wasn't loaded, which is about half the cost of simulating one.
151
+ # Nothing reads those tuples when neither criterion is enabled —
152
+ # `Combine::CoverageAccumulator` only combines them per criterion, and the
153
+ # statistics drop them the same way — so skip the parse. The same goes
154
+ # for line data, which a branch-only or method-only run neither reports
155
+ # nor receives from `Coverage` for the files it loaded. See #1250.
156
+ UnloadedFileInjector.call(
157
+ result, candidate_paths,
158
+ synthesize: synthesize.nil? ? branch_coverage? || method_coverage? : synthesize,
159
+ lines: lines.nil? ? line_coverage? : lines
160
+ )
161
+ end
162
+
118
163
  private
119
164
 
120
165
  def initial_setup(profile, &block)
@@ -126,14 +171,15 @@ module SimpleCov
126
171
  grouped.values.each_with_object(Set.new) { |file_list, set| set.merge(file_list) }
127
172
  end
128
173
 
129
- # Finds files that were to be tracked but were not loaded, and
130
- # initializes their line-by-line coverage to zero (or nil for
131
- # comments / whitespace).
132
- def add_not_loaded_files(result)
133
- globs = unloaded_file_discovery_globs
134
- return [result, Set.new] if globs.empty?
135
-
136
- inject_unloaded_files(result.dup, discover_unloaded_paths(globs))
174
+ # Every path this process was told to track, whether or not it loaded them.
175
+ # Recorded on the result (and from there into the resultset) so that a merge
176
+ # in another process can inject the ones nobody loaded without needing this
177
+ # process's `cover` / `track_files` configuration. A standalone `collate`
178
+ # never ran `SimpleCov.start` and so has none of its own. See #1250.
179
+ def tracked_file_paths
180
+ UnloadedFileInjector.discover(
181
+ unloaded_file_discovery_globs, root: root, reject: filters.select(&:path_only?)
182
+ )
137
183
  end
138
184
 
139
185
  # Globs to expand on disk when injecting unloaded files into the
@@ -144,36 +190,29 @@ module SimpleCov
144
190
  [tracked_files, *cover_globs].compact
145
191
  end
146
192
 
147
- # Expand the given globs relative to SimpleCov.root, not Dir.pwd —
148
- # test runners that chdir (or CI scripts that invoke the suite
149
- # from a subdir) would otherwise silently miss the unloaded-file
150
- # injection and produce a different file set per environment. See
151
- # issue #1106.
152
- def discover_unloaded_paths(globs)
153
- globs.flat_map { |glob| Dir.glob(glob, base: root) }.uniq
154
- end
155
-
156
- def inject_unloaded_files(result, candidate_paths)
157
- not_loaded_files = candidate_paths.each_with_object(Set.new) do |file, set|
158
- absolute_path = File.expand_path(file, root)
159
- next if result.key?(absolute_path)
160
-
161
- result[absolute_path] = SimulateCoverage.call(absolute_path)
162
- set << absolute_path
163
- end
164
-
165
- [result, not_loaded_files]
166
- end
167
-
168
193
  # Run all the steps that handle processing the raw coverage result.
169
194
  # `report:` is true only when this slice is the final result (merging
170
195
  # off); with merging on the merged result reports dropped source files,
171
196
  # so the per-process slice stays quiet to avoid one warning per worker.
172
- def process_coverage_result(report:)
197
+ #
198
+ # `inject_unloaded:` is likewise false when a merge step follows. Only the
199
+ # union of every process's loaded files says what was really never loaded,
200
+ # so injecting here means each worker simulates nearly the whole project
201
+ # and all but one of those passes is merged away. See #1250.
202
+ def process_coverage_result(report:, inject_unloaded: true)
173
203
  raw = SimpleCov::UselessResultsRemover.call(Coverage.result)
174
204
  adapted = SimpleCov::ResultAdapter.call(raw)
175
- result, not_loaded_files = add_not_loaded_files(adapted)
176
- @result = SimpleCov::Result.new(result, not_loaded_files: not_loaded_files, report: report)
205
+ # What this process was told to track and did not load. Subtracting what
206
+ # it loaded matters because `Result#to_hash` serializes coverage after
207
+ # filtering: a file this process loaded and then filtered out would
208
+ # otherwise be recorded as tracked while absent from the stored coverage,
209
+ # and the merge would simulate it back in as never-loaded. See #1250.
210
+ tracked = tracked_file_paths - adapted.keys
211
+ result, not_loaded = inject_unloaded ? inject_unloaded_files(adapted, tracked) : [adapted, Set.new]
212
+ @result = SimpleCov::Result.new(
213
+ result, not_loaded_files: not_loaded, tracked_files: tracked,
214
+ run_id: SimpleCov.run_id, worker_id: SimpleCov.worker_id, report: report
215
+ )
177
216
  end
178
217
  end
179
218
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SimpleCov
6
+ # Identifies one test invocation and each top-level parallel worker within
7
+ # it. Forked subprocesses inherit both values from their parent worker.
8
+ module RunIdentity
9
+ module_function
10
+
11
+ # Returns the run id together with its provenance: `true` when the id
12
+ # alone proves same-run membership (explicitly configured, derived from
13
+ # parallel_tests' per-invocation pid file, or freshly random), `false`
14
+ # when it was inferred from the parent pid, which consecutive runs
15
+ # launched by the same long-lived parent process share.
16
+ def generate
17
+ explicit = ENV.fetch("SIMPLECOV_RUN_ID", nil)
18
+ return [explicit, true] if explicit && !explicit.empty?
19
+
20
+ pid_file = ENV.fetch("PARALLEL_PID_FILE", nil)
21
+ return ["parallel-tests:#{File.basename(pid_file)}", true] if pid_file && !pid_file.empty?
22
+
23
+ return ["parallel-parent:#{Process.ppid}", false] if SimpleCov::ParallelAdapters.current
24
+
25
+ [SecureRandom.uuid, true]
26
+ end
27
+
28
+ def worker_id
29
+ explicit = ENV.fetch("SIMPLECOV_WORKER_ID", nil)
30
+ return explicit if explicit && !explicit.empty?
31
+
32
+ number = ENV.fetch("TEST_ENV_NUMBER", nil)
33
+ return number.empty? ? "1" : number if number
34
+
35
+ Process.pid.to_s
36
+ end
37
+
38
+ # Whether the current run id alone proves same-run membership. Decided
39
+ # where the id is generated, never re-inferred from the id's shape, so
40
+ # an explicit SIMPLECOV_RUN_ID that happens to look like an inferred
41
+ # one is still trusted.
42
+ def authoritative?
43
+ materialize_current
44
+ @authoritative
45
+ end
46
+
47
+ def current
48
+ materialize_current
49
+ @current
50
+ end
51
+
52
+ def materialize_current
53
+ @current, @authoritative = generate unless defined?(@current)
54
+ end
55
+
56
+ def current_worker_id
57
+ @current_worker_id ||= worker_id
58
+ end
59
+
60
+ def prepare
61
+ SimpleCov::ParallelAdapters.current
62
+ SimpleCov.run_id
63
+ SimpleCov.worker_id
64
+ end
65
+
66
+ # Mixed into SimpleCov after ParallelAdapters has loaded.
67
+ module Accessors
68
+ def run_id
69
+ SimpleCov::RunIdentity.current
70
+ end
71
+
72
+ def worker_id
73
+ SimpleCov::RunIdentity.current_worker_id
74
+ end
75
+ end
76
+ end
77
+ end
@@ -33,25 +33,46 @@ module SimpleCov
33
33
  # can't be parsed, fall back to the old empty hashes — old behavior,
34
34
  # old tradeoff.
35
35
  #
36
+ # Pass `synthesize: false` to skip the static analysis and return the
37
+ # empty hashes directly. Callers use it when neither branch nor method
38
+ # coverage is enabled, since nothing will read the tuples and the Prism
39
+ # parse is about half the cost of simulating a file. See #1250.
40
+ #
41
+ # Pass `lines: false` to omit the `"lines"` key entirely, mirroring what
42
+ # `Coverage.result` reports for a file loaded under a branch-only or
43
+ # method-only run. Emitting zeroed lines there would make a simulated
44
+ # file indistinguishable from one a sibling process actually loaded once
45
+ # the two are merged.
46
+ #
36
47
  # @return [Hash]
37
48
  #
38
- def call(absolute_path)
49
+ def call(absolute_path, synthesize: true, lines: true)
39
50
  source_lines = read_lines(absolute_path)
40
- lines = coverage_stub(absolute_path, source_lines) ||
41
- LinesClassifier.new.classify(source_lines)
51
+ simulated = synthesized_tuples(source_lines, synthesize)
52
+ return simulated unless lines
53
+
54
+ classified = coverage_stub(absolute_path, source_lines) ||
55
+ LinesClassifier.new.classify(source_lines)
56
+ {"lines" => classified}.merge(simulated)
57
+ end
58
+
59
+ # The branch and method tuples for a file, or empty hashes when the static
60
+ # analysis is skipped (nothing enabled reads them) or unavailable (no Prism,
61
+ # or the file doesn't parse).
62
+ def synthesized_tuples(source_lines, synthesize)
42
63
  empty = {"branches" => {}, "methods" => {}} #: Hash[String, Hash[untyped, untyped]]
43
- synthesized = StaticCoverageExtractor.call(source_lines.join) || empty
64
+ synthesized = (StaticCoverageExtractor.call(source_lines.join) if synthesize) || empty
44
65
 
45
- {
46
- "lines" => lines,
47
- "branches" => synthesized["branches"],
48
- "methods" => synthesized["methods"]
49
- }
66
+ {"branches" => synthesized["branches"], "methods" => synthesized["methods"]}
50
67
  end
51
68
 
69
+ # SystemCallError, not just ENOENT: a `track_files` glob can sweep
70
+ # up an unreadable file or a directory named like a Ruby file
71
+ # (EACCES, EISDIR), and simulation must degrade to "empty file"
72
+ # rather than crash the merge or report step.
52
73
  def read_lines(path)
53
74
  File.readlines(path)
54
- rescue Errno::ENOENT
75
+ rescue SystemCallError
55
76
  []
56
77
  end
57
78
 
@@ -68,7 +89,10 @@ module SimpleCov
68
89
  classifier_output = LinesClassifier.new.classify(source_lines)
69
90
  stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
70
91
  stub
71
- rescue Errno::ENOENT, SyntaxError
92
+ rescue SystemCallError, SyntaxError
93
+ # SystemCallError for the same reason as read_lines above:
94
+ # line_stub reads the file itself, so EACCES/EISDIR surface here
95
+ # too.
72
96
  nil
73
97
  end
74
98
  end
@@ -18,10 +18,16 @@ module SimpleCov
18
18
  !skipped? && coverage.positive?
19
19
  end
20
20
 
21
+ # Criterion-level skips (nocov chunks, `# simplecov:disable` /
22
+ # `# simplecov:disable method` regions) arrive via `skipped!` from
23
+ # MethodBuilder. Deliberately NOT derived from the lines' skip
24
+ # state: a line-only directive around a def must not remove the
25
+ # method from method totals. Without line info there is nothing to
26
+ # report against, so such a method stays skipped.
21
27
  def skipped?
22
28
  return @skipped if defined?(@skipped)
23
29
 
24
- @skipped = lines.all?(&:skipped?)
30
+ @skipped = lines.empty?
25
31
  end
26
32
 
27
33
  # Flag the method as skipped directly, without going through its lines.
@@ -15,10 +15,28 @@ module SimpleCov
15
15
 
16
16
  # Tests use the real data structures (except for integration tests)
17
17
  # so no need to put them through here.
18
+ #
19
+ # String parses are memoized: `Combine::BranchesCombiner` and
20
+ # `Combine::MethodsCombiner` derive a merge identity from every key of
21
+ # both sides on every pairwise merge, so collating N resultsets parses
22
+ # each key string N-1 times — and Ripper dominates the wall time of a
23
+ # large collate.
24
+ # Key strings repeat across folds and within report building, while the
25
+ # set of unique keys is bounded by the project's branch and method
26
+ # count, so a permanent cache stays small. Cached arrays are frozen
27
+ # element-wise (String class names included, not just the tuple):
28
+ # every caller destructures without mutating, and sharing one array
29
+ # across callers must stay that way. The cache key needs no such
30
+ # care — `Hash#[]=` dups and freezes String keys on its own.
18
31
  def call(structure)
19
32
  return structure if structure.is_a?(Array)
20
33
 
21
- parse_array_string(structure.to_s)
34
+ string = structure.to_s
35
+ parse_cache[string] ||= parse_array_string(string).each(&:freeze).freeze
36
+ end
37
+
38
+ def parse_cache
39
+ @parse_cache ||= {} #: Hash[String, untyped]
22
40
  end
23
41
 
24
42
  # Parse a string like '[:if, 0, 3, 4, 3, 21]' or
@@ -79,9 +97,13 @@ module SimpleCov
79
97
  # Method coverage keys can contain inspect-format class references
80
98
  # like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby
81
99
  # syntax. Wrap them in quotes so Ripper can parse the surrounding
82
- # array literal; downstream we treat them as opaque strings.
100
+ # array literal; downstream we treat them as opaque strings. The
101
+ # pattern recurses because singleton methods on instances nest one
102
+ # inspect segment inside another (`#<Class:#<Object:0x...>>`), and
103
+ # stopping at the first `>` leaves a dangling `>` that fails both
104
+ # Ripper passes and crashes the merge.
83
105
  def quote_inspected_class_segments(str)
84
- str.gsub(/#<[^>]*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
106
+ str.gsub(/#<(?:[^<>]|\g<0>)*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
85
107
  end
86
108
  end
87
109
  end
@@ -22,19 +22,16 @@ module SimpleCov
22
22
  @src = src
23
23
  end
24
24
 
25
- # `:method` ignores nocov chunks (Ruby's Coverage doesn't tie
26
- # method entries to line ranges); `:line` / `:branch` honor both
27
- # the nocov chunks and the per-criterion directive ranges.
25
+ # Every criterion honors the deprecated all-criteria `# :nocov:`
26
+ # chunks plus its own per-criterion directive ranges. Methods
27
+ # included: they carry a source range, and nocov has always meant
28
+ # "exclude everything here".
28
29
  def for(criterion)
29
- if criterion == :method
30
- directive_chunks.fetch(:method)
31
- else
32
- nocov_chunks + directive_chunks.fetch(criterion)
33
- end
30
+ nocov_chunks + directive_chunks.fetch(criterion)
34
31
  end
35
32
 
36
- # no_cov_chunks is zero indexed to work directly with the array
37
- # holding the lines.
33
+ # Ranges of 1-based line numbers (see `with_index(1)` below);
34
+ # consumers subtract 1 to index into the zero-based lines array.
38
35
  def nocov_chunks
39
36
  @nocov_chunks ||= build_nocov_chunks
40
37
  end