simplecov 0.22.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest/sha1"
4
4
  require "forwardable"
5
+ require_relative "result/missing_source_files_reporter"
6
+ require_relative "result/source_file_builder"
5
7
 
6
8
  module SimpleCov
7
9
  #
@@ -10,9 +12,18 @@ module SimpleCov
10
12
  #
11
13
  class Result
12
14
  extend Forwardable
15
+
13
16
  # Returns the original Coverage.result used for this instance of SimpleCov::Result
14
17
  attr_reader :original_result
15
- # Returns all files that are applicable to this result (sans filters!) as instances of SimpleCov::SourceFile. Aliased as :source_files
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
25
+ # Returns all files that are applicable to this result (sans filters!) as instances of
26
+ # SimpleCov::SourceFile. Aliased as :source_files
16
27
  attr_reader :files
17
28
  alias source_files files
18
29
  # Explicitly set the Time this result has been created
@@ -20,20 +31,48 @@ module SimpleCov
20
31
  # Explicitly set the command name that was used for this coverage result. Defaults to SimpleCov.command_name
21
32
  attr_writer :command_name
22
33
 
23
- def_delegators :files, :covered_percent, :covered_percentages, :least_covered_file, :covered_strength, :covered_lines, :missed_lines, :total_branches, :covered_branches, :missed_branches, :coverage_statistics, :coverage_statistics_by_file
34
+ def_delegators :files, :covered_percent, :covered_percentages, :least_covered_file, :covered_strength,
35
+ :covered_lines, :missed_lines,
36
+ :total_branches, :covered_branches, :missed_branches,
37
+ :total_methods, :covered_methods, :missed_methods,
38
+ :coverage_statistics, :coverage_statistics_by_file
24
39
  def_delegator :files, :lines_of_code, :total_lines
25
40
 
41
+ # Bundles the filter and grouping configuration a Result applies to its
42
+ # source files after building them. Each field defaults to the SimpleCov
43
+ # singleton's configuration, so ordinary callers never construct one;
44
+ # tests pass a custom instance to opt out of (or extend) the project's
45
+ # filters or groups (e.g. `filters: []` to keep every file). Grouping the
46
+ # three together keeps Result#initialize's parameter list small.
47
+ class FilterConfig
48
+ attr_reader :filters, :cover_filters, :groups
49
+
50
+ def initialize(filters: SimpleCov.filters, cover_filters: SimpleCov.cover_filters, groups: SimpleCov.groups)
51
+ @filters = filters
52
+ @cover_filters = cover_filters
53
+ @groups = groups
54
+ end
55
+ end
56
+
26
57
  # Initialize a new SimpleCov::Result from given Coverage.result (a Hash of filenames each containing an array of
27
- # coverage data)
28
- def initialize(original_result, command_name: nil, created_at: nil)
29
- result = original_result
30
- @original_result = result.freeze
58
+ # coverage data).
59
+ #
60
+ # `filter_config` defaults to the SimpleCov singleton's filter / group
61
+ # configuration so existing call sites are unchanged. Pass a custom
62
+ # FilterConfig to opt out — useful for tests that build synthetic Results
63
+ # and don't want the project's filters or groups applied.
64
+ def initialize(original_result, command_name: nil, created_at: nil, not_loaded_files: Set.new,
65
+ tracked_files: [], run_id: nil, worker_id: nil, report: false, filter_config: FilterConfig.new)
66
+ @original_result = original_result.freeze
31
67
  @command_name = command_name
32
68
  @created_at = created_at
33
- @files = SimpleCov::FileList.new(result.map do |filename, coverage|
34
- SimpleCov::SourceFile.new(filename, JSON.parse(JSON.dump(coverage))) if File.file?(filename)
35
- end.compact.sort_by(&:filename))
36
- filter!
69
+ initialize_coordination_metadata(tracked_files, run_id, worker_id)
70
+ @groups_config = filter_config.groups
71
+ builder = SourceFileBuilder.new(original_result, not_loaded_files: not_loaded_files)
72
+ @files = builder.call
73
+ warn_about_missing_source_files(builder.missing_source_files, original_result.size) if report
74
+ apply_cover_filters!(filter_config.cover_filters)
75
+ apply_filters!(filter_config.filters)
37
76
  end
38
77
 
39
78
  # Returns all filenames for source files contained in this result
@@ -41,14 +80,43 @@ module SimpleCov
41
80
  files.map(&:filename)
42
81
  end
43
82
 
44
- # Returns a Hash of groups for this result. Define groups using SimpleCov.add_group 'Models', 'app/models'
83
+ # Returns the SimpleCov::SourceFile for the given path, or nil if no
84
+ # matching file is in this result. The path is resolved against
85
+ # SimpleCov.root, so callers can pass either an absolute path or a
86
+ # project-relative one.
87
+ def source_file_for(path)
88
+ target = File.expand_path(path, SimpleCov.root)
89
+ files.find { |file| file.filename == target }
90
+ end
91
+
92
+ # Returns the {line:/branch:/method:} coverage_statistics hash for the
93
+ # given file path, or nil if no matching source file is in this
94
+ # result. See SimpleCov::Result#source_file_for for path resolution.
95
+ def coverage_for(path)
96
+ source_file_for(path)&.coverage_statistics
97
+ end
98
+
99
+ # Returns a Hash of groups for this result. Define groups using SimpleCov.group 'Models', 'app/models'
45
100
  def groups
46
- @groups ||= SimpleCov.grouped(files)
101
+ @groups ||= SimpleCov.grouped(files, groups: @groups_config)
47
102
  end
48
103
 
49
- # Applies the configured SimpleCov.formatter on this result
104
+ # Applies the configured SimpleCov.formatter on this result. Returns
105
+ # nil if formatting has been opted out of (`SimpleCov.formatter false`
106
+ # / `SimpleCov.formatters []`) — the cheap path for non-final
107
+ # processes in a parallel CI run, which only need their
108
+ # `.resultset.json` on disk. See #964.
50
109
  def format!
51
- SimpleCov.formatter.new.format(self)
110
+ formatter = SimpleCov.formatter
111
+ return nil if formatter.nil?
112
+
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
52
120
  end
53
121
 
54
122
  # Defines when this result has been created. Defaults to Time.now
@@ -64,31 +132,82 @@ module SimpleCov
64
132
 
65
133
  # Returns a hash representation of this Result that can be used for marshalling it into JSON
66
134
  def to_hash
67
- {
68
- command_name => {
69
- "coverage" => coverage,
70
- "timestamp" => created_at.to_i
71
- }
72
- }
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}
73
142
  end
74
143
 
75
144
  # Loads a SimpleCov::Result#to_hash dump
76
145
  def self.from_hash(hash)
77
146
  hash.map do |command_name, data|
78
- 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"])
79
150
  end
80
151
  end
81
152
 
82
153
  private
83
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
+
161
+ def warn_about_missing_source_files(missing, input_size)
162
+ return if missing.empty?
163
+
164
+ # Emit only from the process that writes the final report. The merged
165
+ # result is rebuilt in every parallel worker (each one stores its own
166
+ # slice), so without this gate the warning prints once per worker — this
167
+ # is the same signal SimpleCov uses to pick the process that runs the
168
+ # report and threshold checks. It's intentionally not gated on
169
+ # print_errors: the default at_fork sets print_errors false on workers,
170
+ # and in many parallel runners the final-report process is itself a
171
+ # worker, so a print_errors gate would suppress the one warning we want.
172
+ # See issues #980 and #1171.
173
+ return unless SimpleCov.final_result_process?
174
+
175
+ MissingSourceFilesReporter.new(
176
+ missing,
177
+ input_size: input_size,
178
+ every_entry_dropped: @files.empty? && missing.size == input_size
179
+ ).warn!
180
+ end
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.
84
187
  def coverage
85
- keys = original_result.keys & filenames
86
- Hash[keys.zip(original_result.values_at(*keys))]
188
+ original_result.slice(*filenames).transform_values do |file_coverage|
189
+ file_coverage.transform_keys(&:to_s)
190
+ end
87
191
  end
88
192
 
89
- # Applies all configured SimpleCov filters on this result's source files
90
- def filter!
91
- @files = SimpleCov.filtered(files)
193
+ # Applies the given filter chain to `@files`, dropping each source
194
+ # file that any filter matches.
195
+ def apply_filters!(filters)
196
+ filters.each do |filter|
197
+ @files = SimpleCov::FileList.new(@files.reject { |source_file| filter.matches?(source_file) })
198
+ end
199
+ end
200
+
201
+ # When any `cover` matcher is configured, restrict `@files` to source
202
+ # files matching at least one of them. With no cover matchers configured
203
+ # this is a no-op, preserving the historical "everything required, then
204
+ # filtered" universe.
205
+ def apply_cover_filters!(cover_filters)
206
+ return if cover_filters.empty?
207
+
208
+ @files = SimpleCov::FileList.new(
209
+ @files.select { |source_file| cover_filters.any? { |filter| filter.matches?(source_file) } }
210
+ )
92
211
  end
93
212
  end
94
213
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "coverage"
4
+
3
5
  module SimpleCov
4
6
  #
5
7
  # Responsible for adapting the format of the coverage result whether it's default or with statistics
@@ -11,20 +13,149 @@ module SimpleCov
11
13
  @result = result
12
14
  end
13
15
 
14
- def self.call(*args)
15
- new(*args).adapt
16
+ def self.call(*)
17
+ new(*).adapt
16
18
  end
17
19
 
18
20
  def adapt
19
21
  return unless result
20
22
 
21
- result.each_with_object({}) do |(file_name, cover_statistic), adapted_result|
22
- if cover_statistic.is_a?(Array)
23
- adapted_result.merge!(file_name => {"lines" => cover_statistic})
24
- else
25
- adapted_result.merge!(file_name => cover_statistic)
26
- end
23
+ result.to_h do |file_name, cover_statistic|
24
+ [file_name, adapt_one(file_name, cover_statistic)]
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # Pre-0.18 resultsets pointed each filename straight at a line-coverage
31
+ # array; everything since uses the `{lines:, branches:, methods:}`
32
+ # shape. Newer entries also need their methods and branches tables
33
+ # massaged before downstream code reports or merges them.
34
+ def adapt_one(file_name, cover_statistic)
35
+ return {"lines" => cover_statistic} if cover_statistic.is_a?(Array)
36
+
37
+ adapt_oneshot_lines_if_needed(file_name, cover_statistic)
38
+ normalize_method_keys(cover_statistic)
39
+ aggregate_duplicated_branches(cover_statistic)
40
+ cover_statistic
41
+ end
42
+
43
+ # Normalize memory addresses in method coverage keys so that results
44
+ # from different processes can be merged. Anonymous class names like
45
+ # "#<Class:0x00007ff19ab24790>" get inconsistent addresses across runs.
46
+ # Address widths vary by runtime (32-bit hosts: 8 hex chars; 64-bit
47
+ # CRuby: 16; some JVM/TruffleRuby formats may differ), so match any
48
+ # length of hex digits and collapse to a single placeholder.
49
+ ADDRESS_PATTERN = /0x\h+/
50
+ private_constant :ADDRESS_PATTERN
51
+
52
+ ADDRESS_PLACEHOLDER = "0x0"
53
+ private_constant :ADDRESS_PLACEHOLDER
54
+
55
+ # Strip the `#<Class:Foo>` wrapper Ruby's Coverage adds to singleton-class
56
+ # method keys. `module_function` and class methods get recorded both as
57
+ # singleton (`[#<Class:Foo>, :m, …]`) and instance/module (`[Foo, :m, …]`)
58
+ # entries pointing at the same source location; only one of the two is
59
+ # ever reachable at runtime, so we merge them. Only applies to named
60
+ # constants — anonymous-class addresses like `#<Class:0x0>` are left
61
+ # alone (handled by ADDRESS_PATTERN above).
62
+ SINGLETON_WRAPPER_PATTERN = /\A#<Class:([A-Z_][\w:]*)>\z/
63
+ private_constant :SINGLETON_WRAPPER_PATTERN
64
+
65
+ # Ruby's method coverage records one entry per DEFINED METHOD, not per
66
+ # source location: a block handed to `define_method` /
67
+ # `define_singleton_method` from a shared code path yields a separate
68
+ # `[receiver, name, location]` entry for every class it's defined on
69
+ # (a module's `included` hook defining onto each descendant) AND for
70
+ # every name it's defined under (a builder looping `define_method key`
71
+ # over a container), all pointing at the same source. A file-based
72
+ # report can only express "was the method at this location ever
73
+ # executed", so entries are aggregated by location alone, summing
74
+ # hits — otherwise each receiver or name whose generated copy never
75
+ # ran shows as a phantom uncovered method on a line whose line
76
+ # coverage is 100%. Regular `def`s map one location to one name, so
77
+ # they are unaffected. The first entry's (normalized) key is kept for
78
+ # display. See issue #1234.
79
+ def normalize_method_keys(cover_statistic)
80
+ methods = cover_statistic[:methods]
81
+ return unless methods
82
+
83
+ aggregated = {} #: Hash[untyped, [untyped, Integer]]
84
+ methods.each_with_object(aggregated) do |(key, count), memo|
85
+ location = key[2..] #: Array[untyped]
86
+ retained_key, existing = memo[location] || [normalize_method_key(key), 0]
87
+ memo[location] = [retained_key, existing + count]
27
88
  end
89
+ cover_statistic[:methods] = aggregated.values.to_h
90
+ end
91
+
92
+ def normalize_method_key(key)
93
+ normalized_key = key.dup
94
+ normalized_key[0] = class_display_name(key[0])
95
+ .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
96
+ .sub(SINGLETON_WRAPPER_PATTERN, '\1')
97
+ normalized_key
98
+ end
99
+
100
+ # Rendering a class name can execute user code: a singleton class's
101
+ # `to_s` renders its attached object via `#inspect`, which a module can
102
+ # shadow with an incompatible signature (Liquid::Utils defines
103
+ # `inspect(value, max_depth = 2)` as a module_function, so rendering
104
+ # `#<Class:Liquid::Utils>` raises ArgumentError). A coverage report
105
+ # must never crash the host suite over that, so on failure rebuild the
106
+ # singleton wrapper from `Module#name` via bound methods (which cannot
107
+ # be shadowed), falling back to the address form, which
108
+ # ADDRESS_PATTERN then normalizes. See issue #1236.
109
+ def class_display_name(klass)
110
+ klass.to_s
111
+ rescue StandardError
112
+ singleton_wrapper_name(klass) || Object.instance_method(:to_s).bind_call(klass)
113
+ end
114
+
115
+ def singleton_wrapper_name(klass)
116
+ return nil unless klass.is_a?(Class) && klass.singleton_class?
117
+
118
+ attached = klass.attached_object
119
+ # simplecov:disable branch — CRuby only reaches the rescue via a
120
+ # Module/Class attached object (instance singletons render from the
121
+ # class name chain without calling user inspect), so the non-Module
122
+ # arm is defensive.
123
+ name = Module.instance_method(:name).bind_call(attached) if attached.is_a?(Module)
124
+ # simplecov:enable branch
125
+ name && "#<Class:#{name}>"
126
+ end
127
+
128
+ # Ruby's eval coverage records a fresh set of branch entries for every
129
+ # COMPILE of an eval'd string: a template rendered through multiple view
130
+ # classes (e.g. hanami-view compiles each template once per view) yields
131
+ # several `[:if, id, location]` conditions at identical coordinates in
132
+ # the same file, each counting only the renders that flowed through that
133
+ # compile. Reported as-is they inflate the branch denominator and turn a
134
+ # side covered under a different compile into a phantom miss (issue
135
+ # #1235). Aggregate them by (type, location) — combining a branches hash
136
+ # with an empty one dedups within it, since BranchesCombiner keys arms
137
+ # on location identity. Regular (non-eval) source can never produce two
138
+ # conditions at the same location, so this is a no-op outside eval.
139
+ def aggregate_duplicated_branches(cover_statistic)
140
+ branches = cover_statistic[:branches]
141
+ return unless branches
142
+
143
+ cover_statistic[:branches] = Combine::BranchesCombiner.combine(branches, {})
144
+ end
145
+
146
+ def adapt_oneshot_lines_if_needed(file_name, cover_statistic)
147
+ return unless cover_statistic.key?(:oneshot_lines)
148
+
149
+ oneshot_lines = cover_statistic.delete(:oneshot_lines)
150
+ line_stub = build_line_stub(file_name, oneshot_lines)
151
+ oneshot_lines.each { |covered_line| line_stub[covered_line - 1] = 1 }
152
+ cover_statistic[:lines] = line_stub
153
+ end
154
+
155
+ def build_line_stub(file_name, oneshot_lines)
156
+ Coverage.line_stub(file_name)
157
+ rescue Errno::ENOENT, SyntaxError
158
+ Array.new(oneshot_lines.max || 0, nil)
28
159
  end
29
160
  end
30
161
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ # We changed the format of the raw result data in simplecov, as people
6
+ # are likely to have "old" resultsets lying around (but not too old so
7
+ # that they're still considered we can adapt them). See
8
+ # https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747
9
+ module LegacyFormatAdapter
10
+ module_function
11
+
12
+ def call(result)
13
+ pre_0_18?(result) ? upgrade(result) : result
14
+ end
15
+
16
+ # Pre-0.18 coverage data pointed from file directly to an array of
17
+ # line coverage rather than a `{"lines" => [...]}` hash.
18
+ def pre_0_18?(result)
19
+ _key, data = result.first
20
+ data.is_a?(Array)
21
+ end
22
+
23
+ def upgrade(result)
24
+ result.transform_values { |line_coverage_data| {"lines" => line_coverage_data} }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SimpleCov
6
+ module ResultMerger
7
+ # Read + parse a `.resultset.json` file with the same tolerance the
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.
12
+ module ResultsetFile
13
+ module_function
14
+
15
+ def parse(path)
16
+ data = read(path)
17
+ decode(data)
18
+ end
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).
26
+ def read(path)
27
+ data = File.read(path)
28
+ return if data.match?(/\A\s*\z/)
29
+
30
+ data
31
+ rescue Errno::ENOENT
32
+ nil
33
+ end
34
+
35
+ def decode(content)
36
+ return {} unless content
37
+
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
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
69
+ warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
70
+ {}
71
+ end
72
+ end
73
+ end
74
+ 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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "monitor"
6
+ require_relative "../atomic_file"
7
+
8
+ module SimpleCov
9
+ module ResultMerger
10
+ # Reads and writes the persistent `.resultset.json` cache, including
11
+ # file-lock synchronization between processes and atomic temp-file
12
+ # renames so concurrent readers don't observe a truncated file.
13
+ module ResultsetStore
14
+ LOCK_MONITOR = Monitor.new
15
+ private_constant :LOCK_MONITOR
16
+
17
+ module_function
18
+
19
+ def resultset_path
20
+ File.join(SimpleCov.coverage_path, ".resultset.json")
21
+ end
22
+
23
+ def writelock_path
24
+ File.join(SimpleCov.coverage_path, ".resultset.json.lock")
25
+ end
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.
31
+ def write(resultset)
32
+ AtomicFile.write(resultset_path, "#{JSON.generate(resultset)}\n")
33
+ end
34
+
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.
38
+ def synchronize(&)
39
+ return yield if LOCK_MONITOR.mon_owned?
40
+
41
+ LOCK_MONITOR.synchronize { with_flock(&) }
42
+ end
43
+
44
+ def with_flock
45
+ FileUtils.mkdir_p(SimpleCov.coverage_path)
46
+ File.open(writelock_path, "w+") do |f|
47
+ f.flock(File::LOCK_EX)
48
+ yield
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end