simplecov 0.22.0 → 1.3.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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +173 -875
  4. data/exe/simplecov +8 -0
  5. data/lib/minitest/simplecov_plugin.rb +18 -7
  6. data/lib/simplecov/atomic_file.rb +67 -0
  7. data/lib/simplecov/autostart.rb +9 -0
  8. data/lib/simplecov/baseline/parser.rb +58 -0
  9. data/lib/simplecov/baseline.rb +170 -0
  10. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +148 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +86 -0
  17. data/lib/simplecov/cli/command_helpers.rb +91 -0
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +83 -0
  21. data/lib/simplecov/cli/coverage_file.rb +109 -0
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +115 -0
  26. data/lib/simplecov/cli/dotfile.rb +156 -0
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +115 -0
  31. data/lib/simplecov/cli/open.rb +45 -0
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/report.rb +91 -0
  38. data/lib/simplecov/cli/run.rb +35 -0
  39. data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
  40. data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
  41. data/lib/simplecov/cli/serve.rb +88 -0
  42. data/lib/simplecov/cli/show/annotator.rb +87 -0
  43. data/lib/simplecov/cli/show/sweep.rb +36 -0
  44. data/lib/simplecov/cli/show.rb +133 -0
  45. data/lib/simplecov/cli/status/facts.rb +87 -0
  46. data/lib/simplecov/cli/status.rb +93 -0
  47. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  48. data/lib/simplecov/cli/tests.rb +148 -0
  49. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  50. data/lib/simplecov/cli/uncovered.rb +127 -0
  51. data/lib/simplecov/cli/usage.rb +153 -0
  52. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  53. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  54. data/lib/simplecov/cli/watch/poller.rb +45 -0
  55. data/lib/simplecov/cli/watch/session.rb +134 -0
  56. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  57. data/lib/simplecov/cli/watch.rb +85 -0
  58. data/lib/simplecov/cli.rb +114 -0
  59. data/lib/simplecov/color.rb +65 -0
  60. data/lib/simplecov/combine/branches_combiner.rb +44 -21
  61. data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
  62. data/lib/simplecov/combine/identity_interner.rb +27 -0
  63. data/lib/simplecov/combine/interned_counts.rb +28 -0
  64. data/lib/simplecov/combine/lines_combiner.rb +35 -29
  65. data/lib/simplecov/combine/methods_combiner.rb +48 -0
  66. data/lib/simplecov/combine/results_combiner.rb +9 -49
  67. data/lib/simplecov/combine.rb +0 -24
  68. data/lib/simplecov/command_guesser.rb +99 -42
  69. data/lib/simplecov/configuration/baseline.rb +31 -0
  70. data/lib/simplecov/configuration/coverage.rb +184 -0
  71. data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
  72. data/lib/simplecov/configuration/deprecations.rb +21 -0
  73. data/lib/simplecov/configuration/eval_coverage.rb +37 -0
  74. data/lib/simplecov/configuration/filters.rb +131 -0
  75. data/lib/simplecov/configuration/formatting.rb +129 -0
  76. data/lib/simplecov/configuration/groups.rb +48 -0
  77. data/lib/simplecov/configuration/history.rb +44 -0
  78. data/lib/simplecov/configuration/ignored_entries.rb +72 -0
  79. data/lib/simplecov/configuration/merging.rb +197 -0
  80. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  81. data/lib/simplecov/configuration/production.rb +23 -0
  82. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  83. data/lib/simplecov/configuration/thresholds.rb +150 -0
  84. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  85. data/lib/simplecov/configuration.rb +100 -443
  86. data/lib/simplecov/configuration_error.rb +5 -0
  87. data/lib/simplecov/context_map/union.rb +81 -0
  88. data/lib/simplecov/context_map.rb +187 -0
  89. data/lib/simplecov/coverage_json.rb +21 -0
  90. data/lib/simplecov/coverage_statistics.rb +24 -28
  91. data/lib/simplecov/coverage_violations.rb +254 -0
  92. data/lib/simplecov/current_run.rb +61 -0
  93. data/lib/simplecov/defaults.rb +20 -28
  94. data/lib/simplecov/deprecation.rb +63 -0
  95. data/lib/simplecov/directive/erb.rb +44 -0
  96. data/lib/simplecov/directive/haml.rb +33 -0
  97. data/lib/simplecov/directive/indented_template.rb +37 -0
  98. data/lib/simplecov/directive/slim.rb +35 -0
  99. data/lib/simplecov/directive/template.rb +29 -0
  100. data/lib/simplecov/directive.rb +152 -0
  101. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  102. data/lib/simplecov/exit_codes/check.rb +39 -0
  103. data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
  104. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
  105. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  106. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  107. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
  110. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
  111. data/lib/simplecov/exit_codes.rb +26 -0
  112. data/lib/simplecov/exit_handling.rb +163 -0
  113. data/lib/simplecov/file_list.rb +63 -48
  114. data/lib/simplecov/filter.rb +98 -42
  115. data/lib/simplecov/formatter/base.rb +100 -0
  116. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  117. data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
  118. data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
  119. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
  120. data/lib/simplecov/formatter/html_formatter.rb +88 -0
  121. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
  122. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  123. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
  124. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
  125. data/lib/simplecov/formatter/json_formatter.rb +40 -0
  126. data/lib/simplecov/formatter/multi_formatter.rb +8 -10
  127. data/lib/simplecov/formatter/simple_formatter.rb +12 -15
  128. data/lib/simplecov/formatter.rb +21 -1
  129. data/lib/simplecov/group_names.rb +33 -0
  130. data/lib/simplecov/history.rb +115 -0
  131. data/lib/simplecov/last_run.rb +16 -5
  132. data/lib/simplecov/lines_classifier.rb +67 -19
  133. data/lib/simplecov/load_global_config.rb +12 -4
  134. data/lib/simplecov/parallel_adapters/base.rb +50 -0
  135. data/lib/simplecov/parallel_adapters/generic.rb +33 -0
  136. data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
  137. data/lib/simplecov/parallel_adapters.rb +54 -0
  138. data/lib/simplecov/parallel_coordination.rb +124 -0
  139. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  140. data/lib/simplecov/parallel_result_merger.rb +171 -0
  141. data/lib/simplecov/process.rb +34 -12
  142. data/lib/simplecov/production/error.rb +11 -0
  143. data/lib/simplecov/production/file_sink.rb +130 -0
  144. data/lib/simplecov/production.rb +249 -0
  145. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  146. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  147. data/lib/simplecov/profiles/rails.rb +13 -10
  148. data/lib/simplecov/profiles/root_filter.rb +2 -5
  149. data/lib/simplecov/profiles/strict.rb +22 -0
  150. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  151. data/lib/simplecov/profiles.rb +26 -19
  152. data/lib/simplecov/report_deferral.rb +45 -0
  153. data/lib/simplecov/report_stamp.rb +25 -0
  154. data/lib/simplecov/result/filter_config.rb +19 -0
  155. data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
  156. data/lib/simplecov/result/serialization.rb +43 -0
  157. data/lib/simplecov/result/source_file_builder.rb +46 -0
  158. data/lib/simplecov/result.rb +123 -47
  159. data/lib/simplecov/result_adapter.rb +130 -11
  160. data/lib/simplecov/result_merger/contexts.rb +22 -0
  161. data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
  162. data/lib/simplecov/result_merger/resultset_file.rb +67 -0
  163. data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
  164. data/lib/simplecov/result_merger/resultset_store.rb +62 -0
  165. data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
  166. data/lib/simplecov/result_merger.rb +120 -124
  167. data/lib/simplecov/result_processing.rb +188 -0
  168. data/lib/simplecov/run_identity.rb +75 -0
  169. data/lib/simplecov/simulate_coverage.rb +72 -17
  170. data/lib/simplecov/source_file/branch.rb +8 -35
  171. data/lib/simplecov/source_file/branch_builder.rb +95 -0
  172. data/lib/simplecov/source_file/builder_context.rb +28 -0
  173. data/lib/simplecov/source_file/line.rb +17 -32
  174. data/lib/simplecov/source_file/line_builder.rb +37 -0
  175. data/lib/simplecov/source_file/method.rb +54 -0
  176. data/lib/simplecov/source_file/method_builder.rb +51 -0
  177. data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
  178. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  179. data/lib/simplecov/source_file/source_loader.rb +89 -0
  180. data/lib/simplecov/source_file/statistics.rb +76 -0
  181. data/lib/simplecov/source_file.rb +64 -260
  182. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
  183. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
  184. data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
  185. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
  186. data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
  187. data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
  188. data/lib/simplecov/static_coverage_extractor.rb +78 -0
  189. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  190. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  191. data/lib/simplecov/test_tracker/delta.rb +69 -0
  192. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  193. data/lib/simplecov/test_tracker.rb +162 -0
  194. data/lib/simplecov/unloaded_file_injector.rb +62 -0
  195. data/lib/simplecov/useless_results_remover.rb +17 -7
  196. data/lib/simplecov/version.rb +1 -1
  197. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  198. data/lib/simplecov/view_coverage.rb +55 -0
  199. data/lib/simplecov-html.rb +5 -0
  200. data/lib/simplecov.rb +143 -394
  201. data/lib/simplecov_json_formatter.rb +5 -0
  202. data/man/simplecov.1 +354 -0
  203. data/sig/simplecov.rbs +1710 -0
  204. metadata +183 -61
  205. data/CHANGELOG.md +0 -191
  206. data/doc/alternate-formatters.md +0 -71
  207. data/doc/commercial-services.md +0 -25
  208. data/doc/editor-integration.md +0 -18
  209. data/lib/simplecov/combine/files_combiner.rb +0 -24
  210. data/lib/simplecov/default_formatter.rb +0 -20
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "coverage"
4
+
3
5
  module SimpleCov
4
- #
5
- # Responsible for adapting the format of the coverage result whether it's default or with statistics
6
- #
7
6
  class ResultAdapter
8
7
  attr_reader :result
9
8
 
@@ -11,20 +10,140 @@ module SimpleCov
11
10
  @result = result
12
11
  end
13
12
 
14
- def self.call(*args)
15
- new(*args).adapt
13
+ def self.call(*)
14
+ new(*).adapt
16
15
  end
17
16
 
18
17
  def adapt
19
18
  return unless result
20
19
 
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
20
+ result.to_h do |file_name, cover_statistic|
21
+ [file_name, adapt_one(file_name, cover_statistic)]
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # Pre-0.18 resultsets pointed each filename straight at a line-coverage
28
+ # array; everything since uses the `{lines:, branches:, methods:}` shape.
29
+ def adapt_one(file_name, cover_statistic)
30
+ return {"lines" => cover_statistic} if cover_statistic.instance_of?(Array)
31
+
32
+ adapt_oneshot_lines_if_needed(file_name, cover_statistic)
33
+ normalize_method_keys(cover_statistic)
34
+ aggregate_duplicated_branches(cover_statistic)
35
+ cover_statistic
36
+ end
37
+
38
+ # Normalizes memory addresses in method coverage keys so results from
39
+ # different processes can be merged: anonymous class names like
40
+ # "#<Class:0x00007ff19ab24790>" get inconsistent addresses across runs, and
41
+ # address widths vary by runtime.
42
+ ADDRESS_PATTERN = /0x\h+/
43
+ private_constant :ADDRESS_PATTERN
44
+
45
+ ADDRESS_PLACEHOLDER = "0x0"
46
+ private_constant :ADDRESS_PLACEHOLDER
47
+
48
+ # Strips the `#<Class:Foo>` wrapper Ruby's Coverage adds to singleton-class
49
+ # method keys. `module_function` and class methods get recorded both as
50
+ # singleton and instance/module entries pointing at the same source
51
+ # location, and only one of the two is ever reachable at runtime. Only
52
+ # named constants: anonymous-class addresses are left to ADDRESS_PATTERN.
53
+ SINGLETON_WRAPPER_PATTERN = /\A#<Class:([A-Z_][\w:]*)>\z/
54
+ private_constant :SINGLETON_WRAPPER_PATTERN
55
+
56
+ # Ruby's method coverage records one entry per defined method, not per
57
+ # source location: a block handed to `define_method` from a shared code
58
+ # path yields a separate `[receiver, name, location]` entry for every class
59
+ # it's defined on and every name it's defined under, all pointing at the
60
+ # same source. A file-based report can only express "was the method at this
61
+ # location ever executed", so entries are aggregated by location alone,
62
+ # summing hits. Otherwise each generated copy that never ran shows as a
63
+ # phantom uncovered method on a line whose line coverage is 100% (#1234).
64
+ # The first entry's normalized key is kept for display.
65
+ def normalize_method_keys(cover_statistic)
66
+ methods = cover_statistic[:methods]
67
+ return unless methods
68
+
69
+ aggregated = {} #: Hash[untyped, [untyped, Integer]]
70
+ methods.each_with_object(aggregated) do |(key, count), memo|
71
+ location = key[2..] #: Array[untyped]
72
+ retained_key, existing = memo[location] || [normalize_method_key(key), 0]
73
+ memo[location] = [retained_key, existing + count]
27
74
  end
75
+ cover_statistic[:methods] = aggregated.values.to_h
76
+ end
77
+
78
+ def normalize_method_key(key)
79
+ receiver, *rest = key
80
+ normalized_receiver = class_display_name(receiver)
81
+ .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
82
+ .sub(SINGLETON_WRAPPER_PATTERN, '\1')
83
+ [normalized_receiver, *rest]
84
+ end
85
+
86
+ # Rendering a class name can execute user code: a singleton class's `to_s`
87
+ # renders its attached object via `#inspect`, which a module can shadow
88
+ # with an incompatible signature (Liquid::Utils defines
89
+ # `inspect(value, max_depth = 2)` as a module_function, so rendering
90
+ # `#<Class:Liquid::Utils>` raises ArgumentError). A coverage report must
91
+ # never crash the host suite over that (#1236).
92
+ def class_display_name(klass)
93
+ klass.to_s
94
+ rescue
95
+ singleton_wrapper_name(klass) || Object.instance_method(:to_s).bind_call(klass)
96
+ end
97
+
98
+ # `singleton_class?` is a Module method, so a receiver that is neither a
99
+ # class nor a module has to be turned away first. Anything that then
100
+ # answers `singleton_class?` truthily is a Class, so `attached_object`
101
+ # applies, reached through a bound method so shadowing cannot divert it.
102
+ def singleton_wrapper_name(klass)
103
+ return nil unless klass.is_a?(Module) && klass.singleton_class?
104
+
105
+ attached = Class.instance_method(:attached_object).bind_call(klass)
106
+ name = Module.instance_method(:name).bind_call(attached) if attached.is_a?(Module)
107
+ name && "#<Class:#{name}>"
108
+ end
109
+
110
+ # Ruby's eval coverage records a fresh set of branch entries for every
111
+ # compile of an eval'd string: a template rendered through multiple view
112
+ # classes yields several `[:if, id, location]` conditions at identical
113
+ # coordinates, each counting only the renders that flowed through that
114
+ # compile. Reported as-is they inflate the branch denominator and turn a
115
+ # side covered under a different compile into a phantom miss (#1235).
116
+ # Absorbing into an empty table dedups, since BranchesCombiner keys arms on
117
+ # location identity. Regular source can never produce two conditions at the
118
+ # same location, so this is a no-op outside eval.
119
+ def aggregate_duplicated_branches(cover_statistic)
120
+ branches = cover_statistic[:branches]
121
+ return unless branches
122
+
123
+ combiner = Combine::BranchesCombiner
124
+ cover_statistic[:branches] = combiner.materialize(combiner.absorb({}, branches))
125
+ end
126
+
127
+ def adapt_oneshot_lines_if_needed(file_name, cover_statistic)
128
+ return unless cover_statistic.key?(:oneshot_lines)
129
+
130
+ oneshot_lines = cover_statistic.delete(:oneshot_lines)
131
+ line_stub = build_line_stub(file_name)
132
+ oneshot_lines.each { |covered_line| line_stub[covered_line - 1] = 1 }
133
+ cover_statistic[:lines] = line_stub
134
+ end
135
+
136
+ # A file that has vanished or no longer parses has no stub to build from,
137
+ # and neither does a runtime without `Coverage.line_stub` (TruffleRuby), so
138
+ # start from nothing: the assignments in `adapt_oneshot_lines_if_needed`
139
+ # grow the array to the highest covered line, which is as far as the
140
+ # oneshot data reaches anyway.
141
+ def build_line_stub(file_name)
142
+ return [] unless Coverage.respond_to?(:line_stub)
143
+
144
+ Coverage.line_stub(file_name)
145
+ rescue Errno::ENOENT, SyntaxError
146
+ []
28
147
  end
29
148
  end
30
149
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ module Contexts
6
+ extend self
7
+
8
+ # The same union-or-drop rule the cross-suite merge applies: the union when
9
+ # both sides recorded a map, no map at all when either did not, since a
10
+ # partial map would present one runner's tests as the pair's. `entry` starts
11
+ # as a copy of `incoming`, so the drop has to remove the key rather than
12
+ # merely decline to add it.
13
+ def carry(entry, existing, incoming)
14
+ ours = ContextMap.from_hash(existing["contexts"])
15
+ theirs = ContextMap.from_hash(incoming["contexts"])
16
+ return entry.except("contexts") unless ours && theirs
17
+
18
+ entry.merge("contexts" => ours.absorb(theirs).to_h)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ module LegacyFormatAdapter
6
+ extend self
7
+
8
+ def call(result)
9
+ pre_0_18?(result) ? upgrade(result) : result
10
+ end
11
+
12
+ # Pre-0.18 coverage data pointed from file directly to an array of line
13
+ # coverage rather than a `{"lines" => [...]}` hash.
14
+ def pre_0_18?(result)
15
+ _key, data = result.first
16
+ data.instance_of?(Array)
17
+ end
18
+
19
+ def upgrade(result)
20
+ result.transform_values { |line_coverage_data| {"lines" => line_coverage_data} }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SimpleCov
6
+ module ResultMerger
7
+ # Read and parse a `.resultset.json` with the same tolerance the historical
8
+ # `ResultMerger` had: a missing or empty file quietly returns `{}`, an
9
+ # unparseable one warns and returns `{}`, and malformed entries inside a
10
+ # valid file are warned about and dropped.
11
+ module ResultsetFile
12
+ extend self
13
+
14
+ def parse(path)
15
+ data = read(path)
16
+ decode(data)
17
+ end
18
+
19
+ # A missing or blank file quietly means "no results yet"; anything else, a
20
+ # 1-byte truncation included, flows through decode so corruption warns
21
+ # instead of silently vanishing. Read first and rescue rather than check
22
+ # exist?, which is racy against a concurrent clean.
23
+ def read(path)
24
+ data = File.read(path)
25
+ return if data.match?(/\A\s*\z/)
26
+
27
+ data
28
+ rescue Errno::ENOENT
29
+ nil
30
+ end
31
+
32
+ # Valid JSON need not be an object, but everything downstream iterates
33
+ # command => data pairs, so anything else gets the same tolerance as a parse
34
+ # failure. A "null" file has long meant an empty resultset.
35
+ def decode(content)
36
+ return {} unless content
37
+
38
+ parsed = JSON.parse(content)
39
+ return {} if parsed.nil?
40
+
41
+ parsed.instance_of?(Hash) ? drop_malformed_entries(parsed) : invalid_resultset
42
+ rescue
43
+ invalid_resultset
44
+ end
45
+
46
+ def drop_malformed_entries(resultset)
47
+ malformed, valid = resultset.partition { |_command_name, data| !well_formed_entry?(data) }
48
+ return resultset if malformed.empty?
49
+
50
+ warn "[SimpleCov]: Warning! Ignoring malformed resultset entries: #{malformed.map(&:first).sort.join(", ")}"
51
+ valid.to_h
52
+ end
53
+
54
+ # Every consumer relies on a Hash carrying a Numeric "timestamp" and a Hash
55
+ # "coverage". A truncated or hand-edited entry would otherwise crash out of
56
+ # the middle of an at_exit merge.
57
+ def well_formed_entry?(data)
58
+ data.instance_of?(Hash) && data["timestamp"].is_a?(Numeric) && data["coverage"].instance_of?(Hash)
59
+ end
60
+
61
+ def invalid_resultset
62
+ warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
63
+ {}
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ # Run and worker metadata queries used by resultset storage and parallel
6
+ # coordination.
7
+ module ResultsetRunIdentity
8
+ # One namespaced identity per distinct worker that wrote to the current run:
9
+ # `[:worker, id]` for entries carrying a worker id, `[:legacy, command_name]`
10
+ # for fresh entries written without one. Pairs rather than mangled strings,
11
+ # so a resultset mixing both kinds can never alias a real worker id to a
12
+ # synthesized legacy identity.
13
+ def worker_identities_for_run(results, run_id, started_at)
14
+ results.filter_map do |command_name, data|
15
+ next unless current_run_entry?(data, run_id, started_at)
16
+
17
+ worker_id = data["worker_id"]
18
+ worker_id.to_s.empty? ? [:legacy, command_name] : [:worker, worker_id.to_s]
19
+ end.uniq
20
+ end
21
+
22
+ # `eql?` rather than `==`: run ids are strings, and value equality already
23
+ # answers false for the nil run id a caller with no identity of its own
24
+ # passes.
25
+ def current_run_entry?(entry, run_id, started_at)
26
+ return false unless entry.instance_of?(Hash)
27
+
28
+ entry_run_id = entry["run_id"]
29
+ return fresh_entry?(entry, started_at) unless entry_run_id
30
+ return false unless entry_run_id.eql?(run_id)
31
+ return true if RunIdentity.authoritative?
32
+
33
+ fresh_entry?(entry, started_at)
34
+ end
35
+
36
+ def fresh_entry?(entry, started_at)
37
+ timestamp = entry["timestamp"]
38
+ timestamp.is_a?(Numeric) && started_at && timestamp >= started_at.to_f
39
+ end
40
+
41
+ # Either the entry shares our run id, or it was written strictly after our
42
+ # process started. A mismatched run id must not defeat the timestamp check: a
43
+ # subprocess we shelled out to generates its own random run id, and its
44
+ # freshly written entry is exactly the data #581 protects from being
45
+ # clobbered. Strict id matching is reserved for worker counting, where
46
+ # admitting a stale entry would end a sibling wait early.
47
+ def concurrent_runner_entry?(entry, incoming = nil)
48
+ return false unless entry.instance_of?(Hash)
49
+
50
+ incoming_run_id = incoming["run_id"] if incoming.instance_of?(Hash)
51
+ started_at = SimpleCov.process_start_time
52
+ return true if current_run_entry?(entry, incoming_run_id, started_at)
53
+
54
+ written_after_start?(entry, started_at)
55
+ end
56
+
57
+ # Strictly after, unlike `fresh_entry?`: an entry stamped at the exact
58
+ # instant we started is treated as leftover from a previous run.
59
+ def written_after_start?(entry, started_at)
60
+ timestamp = entry["timestamp"]
61
+ timestamp.is_a?(Numeric) && started_at && timestamp > started_at.to_f
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,62 @@
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
+ module ResultsetStore
11
+ LOCK_MONITOR = Monitor.new
12
+ private_constant :LOCK_MONITOR
13
+
14
+ extend self
15
+
16
+ def resultset_path
17
+ File.join(SimpleCov.coverage_path, ".resultset.json")
18
+ end
19
+
20
+ def writelock_path
21
+ File.join(SimpleCov.coverage_path, ".resultset.json.lock")
22
+ end
23
+
24
+ # Compact JSON, not pretty-printed: this is a machine-read cache that every
25
+ # parallel worker rewrites wholesale, and on a large project pretty printing
26
+ # nearly doubles the bytes written, read back, and parsed on each of those
27
+ # store-merge round trips.
28
+ def write(resultset)
29
+ AtomicFile.write(resultset_path, "#{JSON.generate(resultset)}\n")
30
+ end
31
+
32
+ # Threads are serialized before the process-wide file lock is taken. Nested
33
+ # calls by the owning thread bypass flock so they cannot deadlock on a second
34
+ # descriptor for the same lock file.
35
+ def synchronize(&)
36
+ return yield if LOCK_MONITOR.mon_owned?
37
+
38
+ LOCK_MONITOR.synchronize { with_flock(&) }
39
+ end
40
+
41
+ def with_flock(&)
42
+ FileUtils.mkdir_p(SimpleCov.coverage_path)
43
+ holding_writelock(&)
44
+ end
45
+
46
+ def holding_writelock
47
+ open_file(writelock_path, "w+") do |file|
48
+ file.flock(File::LOCK_EX)
49
+ yield
50
+ end
51
+ end
52
+
53
+ # mutant:disable — Ruby 4.0 removed Kernel#open's leading-pipe
54
+ # command mode, so no test can tell `File.open` from `open` here
55
+ # any more. The explicit receiver is kept: on older rubies it is
56
+ # what refuses to run a lock path as a command.
57
+ def open_file(name, mode, &)
58
+ File.open(name, mode, &)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,95 @@
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 to
6
+ # inject and which criteria simulated files carry. The mechanism, expanding
7
+ # globs and simulating each file, is `SimpleCov::UnloadedFileInjector`.
8
+ #
9
+ # Injection moved here from the individual processes because only the union
10
+ # of what they all loaded says what was really never loaded. Doing it per
11
+ # process meant N workers simulated the same file up to N times and the
12
+ # merge threw all but one away (#1250).
13
+ module UnloadedFiles
14
+ extend self
15
+
16
+ # A collector for the merge to hand `merge_valid_results`, gathering the
17
+ # tracked paths of every resultset that survives the merge timeout.
18
+ # Absent from resultsets written before this was recorded, in which case
19
+ # those processes injected their own unloaded files already.
20
+ def collector(into)
21
+ ->(surviving) { into.merge(tracked_in(surviving)) }
22
+ end
23
+
24
+ def tracked_in(resultset)
25
+ resultset.each_with_object(Set.new) do |(_command_name, data), set|
26
+ set.merge(Array(data["tracked_files"]))
27
+ end
28
+ end
29
+
30
+ # Concurrent workers sharing a command name may have been told to track
31
+ # different sets, so keep both rather than letting the later write win.
32
+ def carry_tracked(entry, existing, incoming)
33
+ tracked = Array(existing["tracked_files"]) | Array(incoming["tracked_files"])
34
+ tracked.empty? ? entry : entry.merge("tracked_files" => tracked)
35
+ end
36
+
37
+ # Simulates each tracked file the merged coverage doesn't already carry.
38
+ # The paths come from the resultsets rather than this process's own
39
+ # configuration, which a standalone `collate` would not have. Idempotent,
40
+ # so resultsets that already carry their unloaded files pass through
41
+ # untouched.
42
+ def inject(coverage, tracked_files)
43
+ SimpleCov.inject_unloaded_files(
44
+ coverage, tracked_files.to_a,
45
+ synthesize: carries?(coverage, "branches") || carries?(coverage, "methods"),
46
+ lines: carries?(coverage, "lines")
47
+ )
48
+ end
49
+
50
+ # A simulated file should have the same shape as the files it is being
51
+ # merged alongside, so the criteria come from the merged data rather than
52
+ # from this process's configuration, which is not necessarily what any
53
+ # contributing process measured under: `simplecov merge` never ran
54
+ # `SimpleCov.start` at all. Giving injected files fewer tables than their
55
+ # neighbours is what inflates the percentage #1059 fixed.
56
+ #
57
+ # Falls back to the configuration when there is nothing to be consistent
58
+ # with, which is a merge of resultsets that carried no files.
59
+ def carries?(coverage, criterion)
60
+ return SimpleCov.public_send(CRITERION_PREDICATES.fetch(criterion)) if coverage.empty?
61
+
62
+ coverage.any? { |_filename, file_coverage| file_coverage[criterion] }
63
+ end
64
+
65
+ CRITERION_PREDICATES = {
66
+ "lines" => :line_coverage?,
67
+ "branches" => :branch_coverage?,
68
+ "methods" => :method_coverage?
69
+ }.freeze
70
+ private_constant :CRITERION_PREDICATES
71
+
72
+ # Which files no contributing process ever loaded. Injection reports the
73
+ # ones it added, but a file can also arrive already simulated from a
74
+ # resultset this merge didn't inject into, so the merged line counts are
75
+ # still consulted.
76
+ #
77
+ # A file is judged only when it has a relevant line. A branch-only or
78
+ # method-only run reports no line data at all for the files it loaded,
79
+ # and a loaded file with no executable lines reports every line as `nil`.
80
+ # Either would otherwise read as "never executed" and mark a genuinely
81
+ # loaded file not loaded, turning its branch and method coverage into
82
+ # #902's 0%.
83
+ def never_executed(coverage)
84
+ coverage.each_with_object(Set.new) do |(filename, file_coverage), set|
85
+ counts = Array(file_coverage["lines"]) #: Array[Integer?]
86
+ # A line count is an Integer or nil, and every Integer is truthy, so a
87
+ # truthy element is exactly a relevant line.
88
+ next unless counts.any?
89
+
90
+ set << filename unless Combine::CoverageAccumulator.executed?(counts)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end