simplecov 0.22.0 → 1.0.2

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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +1058 -508
  4. data/doc/alternate-formatters.md +0 -5
  5. data/doc/commercial-services.md +5 -5
  6. data/exe/simplecov +11 -0
  7. data/lib/minitest/simplecov_plugin.rb +13 -5
  8. data/lib/simplecov/autostart.rb +11 -0
  9. data/lib/simplecov/cli/clean.rb +47 -0
  10. data/lib/simplecov/cli/coverage.rb +93 -0
  11. data/lib/simplecov/cli/diff.rb +152 -0
  12. data/lib/simplecov/cli/dotfile.rb +105 -0
  13. data/lib/simplecov/cli/merge.rb +118 -0
  14. data/lib/simplecov/cli/open.rb +50 -0
  15. data/lib/simplecov/cli/report.rb +89 -0
  16. data/lib/simplecov/cli/run.rb +36 -0
  17. data/lib/simplecov/cli/serve.rb +149 -0
  18. data/lib/simplecov/cli/uncovered.rb +108 -0
  19. data/lib/simplecov/cli.rb +150 -0
  20. data/lib/simplecov/color.rb +79 -0
  21. data/lib/simplecov/combine/branches_combiner.rb +28 -5
  22. data/lib/simplecov/combine/files_combiner.rb +47 -1
  23. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  24. data/lib/simplecov/combine/methods_combiner.rb +49 -0
  25. data/lib/simplecov/combine/results_combiner.rb +7 -5
  26. data/lib/simplecov/command_guesser.rb +46 -32
  27. data/lib/simplecov/configuration/coverage.rb +175 -0
  28. data/lib/simplecov/configuration/coverage_criteria.rb +158 -0
  29. data/lib/simplecov/configuration/filters.rb +197 -0
  30. data/lib/simplecov/configuration/formatting.rb +129 -0
  31. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  32. data/lib/simplecov/configuration/merging.rb +164 -0
  33. data/lib/simplecov/configuration/thresholds.rb +178 -0
  34. data/lib/simplecov/configuration.rb +105 -412
  35. data/lib/simplecov/coverage_statistics.rb +15 -9
  36. data/lib/simplecov/coverage_violations.rb +149 -0
  37. data/lib/simplecov/defaults.rb +29 -22
  38. data/lib/simplecov/deprecation.rb +45 -0
  39. data/lib/simplecov/directive.rb +167 -0
  40. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  41. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  42. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  43. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  45. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  46. data/lib/simplecov/exit_codes.rb +15 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +70 -19
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +102 -0
  51. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  53. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +85 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +90 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +128 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +89 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +11 -7
  63. data/lib/simplecov/formatter/simple_formatter.rb +11 -11
  64. data/lib/simplecov/formatter.rb +4 -0
  65. data/lib/simplecov/last_run.rb +10 -3
  66. data/lib/simplecov/lines_classifier.rb +25 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +61 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +44 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +129 -0
  73. data/lib/simplecov/process.rb +52 -12
  74. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  75. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  76. data/lib/simplecov/profiles/rails.rb +24 -10
  77. data/lib/simplecov/profiles/root_filter.rb +6 -5
  78. data/lib/simplecov/profiles/strict.rb +32 -0
  79. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  80. data/lib/simplecov/profiles.rb +32 -3
  81. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  82. data/lib/simplecov/result/source_file_builder.rb +51 -0
  83. data/lib/simplecov/result.rb +108 -19
  84. data/lib/simplecov/result_adapter.rb +105 -8
  85. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  86. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  87. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  88. data/lib/simplecov/result_merger.rb +59 -91
  89. data/lib/simplecov/result_processing.rb +179 -0
  90. data/lib/simplecov/simulate_coverage.rb +54 -8
  91. data/lib/simplecov/source_file/branch.rb +1 -3
  92. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  93. data/lib/simplecov/source_file/builder_context.rb +28 -0
  94. data/lib/simplecov/source_file/line.rb +9 -4
  95. data/lib/simplecov/source_file/line_builder.rb +43 -0
  96. data/lib/simplecov/source_file/method.rb +52 -0
  97. data/lib/simplecov/source_file/method_builder.rb +60 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +75 -0
  100. data/lib/simplecov/source_file/source_loader.rb +63 -0
  101. data/lib/simplecov/source_file/statistics.rb +57 -0
  102. data/lib/simplecov/source_file.rb +66 -232
  103. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +49 -0
  104. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +247 -0
  105. data/lib/simplecov/static_coverage_extractor/method_collector.rb +55 -0
  106. data/lib/simplecov/static_coverage_extractor/value_position.rb +82 -0
  107. data/lib/simplecov/static_coverage_extractor/visitor.rb +196 -0
  108. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  109. data/lib/simplecov/useless_results_remover.rb +16 -7
  110. data/lib/simplecov/version.rb +1 -1
  111. data/lib/simplecov-html.rb +4 -0
  112. data/lib/simplecov.rb +178 -380
  113. data/lib/simplecov_json_formatter.rb +4 -0
  114. data/schemas/coverage-v1.0.schema.json +306 -0
  115. data/schemas/coverage.schema.json +306 -0
  116. data/sig/simplecov.rbs +1638 -0
  117. metadata +94 -57
  118. data/CHANGELOG.md +0 -191
  119. 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,11 @@ 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
+ # Returns all files that are applicable to this result (sans filters!) as instances of
19
+ # SimpleCov::SourceFile. Aliased as :source_files
16
20
  attr_reader :files
17
21
  alias source_files files
18
22
  # Explicitly set the Time this result has been created
@@ -20,20 +24,47 @@ module SimpleCov
20
24
  # Explicitly set the command name that was used for this coverage result. Defaults to SimpleCov.command_name
21
25
  attr_writer :command_name
22
26
 
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
27
+ def_delegators :files, :covered_percent, :covered_percentages, :least_covered_file, :covered_strength,
28
+ :covered_lines, :missed_lines,
29
+ :total_branches, :covered_branches, :missed_branches,
30
+ :total_methods, :covered_methods, :missed_methods,
31
+ :coverage_statistics, :coverage_statistics_by_file
24
32
  def_delegator :files, :lines_of_code, :total_lines
25
33
 
34
+ # Bundles the filter and grouping configuration a Result applies to its
35
+ # source files after building them. Each field defaults to the SimpleCov
36
+ # singleton's configuration, so ordinary callers never construct one;
37
+ # tests pass a custom instance to opt out of (or extend) the project's
38
+ # filters or groups (e.g. `filters: []` to keep every file). Grouping the
39
+ # three together keeps Result#initialize's parameter list small.
40
+ class FilterConfig
41
+ attr_reader :filters, :cover_filters, :groups
42
+
43
+ def initialize(filters: SimpleCov.filters, cover_filters: SimpleCov.cover_filters, groups: SimpleCov.groups)
44
+ @filters = filters
45
+ @cover_filters = cover_filters
46
+ @groups = groups
47
+ end
48
+ end
49
+
26
50
  # 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
51
+ # coverage data).
52
+ #
53
+ # `filter_config` defaults to the SimpleCov singleton's filter / group
54
+ # configuration so existing call sites are unchanged. Pass a custom
55
+ # FilterConfig to opt out — useful for tests that build synthetic Results
56
+ # and don't want the project's filters or groups applied.
57
+ def initialize(original_result, command_name: nil, created_at: nil, not_loaded_files: Set.new,
58
+ report: false, filter_config: FilterConfig.new)
59
+ @original_result = original_result.freeze
31
60
  @command_name = command_name
32
61
  @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!
62
+ @groups_config = filter_config.groups
63
+ builder = SourceFileBuilder.new(original_result, not_loaded_files: not_loaded_files)
64
+ @files = builder.call
65
+ warn_about_missing_source_files(builder.missing_source_files, original_result.size) if report
66
+ apply_cover_filters!(filter_config.cover_filters)
67
+ apply_filters!(filter_config.filters)
37
68
  end
38
69
 
39
70
  # Returns all filenames for source files contained in this result
@@ -41,14 +72,37 @@ module SimpleCov
41
72
  files.map(&:filename)
42
73
  end
43
74
 
44
- # Returns a Hash of groups for this result. Define groups using SimpleCov.add_group 'Models', 'app/models'
75
+ # Returns the SimpleCov::SourceFile for the given path, or nil if no
76
+ # matching file is in this result. The path is resolved against
77
+ # SimpleCov.root, so callers can pass either an absolute path or a
78
+ # project-relative one.
79
+ def source_file_for(path)
80
+ target = File.expand_path(path, SimpleCov.root)
81
+ files.find { |file| file.filename == target }
82
+ end
83
+
84
+ # Returns the {line:/branch:/method:} coverage_statistics hash for the
85
+ # given file path, or nil if no matching source file is in this
86
+ # result. See SimpleCov::Result#source_file_for for path resolution.
87
+ def coverage_for(path)
88
+ source_file_for(path)&.coverage_statistics
89
+ end
90
+
91
+ # Returns a Hash of groups for this result. Define groups using SimpleCov.group 'Models', 'app/models'
45
92
  def groups
46
- @groups ||= SimpleCov.grouped(files)
93
+ @groups ||= SimpleCov.grouped(files, groups: @groups_config)
47
94
  end
48
95
 
49
- # Applies the configured SimpleCov.formatter on this result
96
+ # Applies the configured SimpleCov.formatter on this result. Returns
97
+ # nil if formatting has been opted out of (`SimpleCov.formatter false`
98
+ # / `SimpleCov.formatters []`) — the cheap path for non-final
99
+ # processes in a parallel CI run, which only need their
100
+ # `.resultset.json` on disk. See #964.
50
101
  def format!
51
- SimpleCov.formatter.new.format(self)
102
+ formatter = SimpleCov.formatter
103
+ return nil if formatter.nil?
104
+
105
+ formatter.new.format(self)
52
106
  end
53
107
 
54
108
  # Defines when this result has been created. Defaults to Time.now
@@ -81,14 +135,49 @@ module SimpleCov
81
135
 
82
136
  private
83
137
 
138
+ def warn_about_missing_source_files(missing, input_size)
139
+ return if missing.empty?
140
+
141
+ # Emit only from the process that writes the final report. The merged
142
+ # result is rebuilt in every parallel worker (each one stores its own
143
+ # slice), so without this gate the warning prints once per worker — this
144
+ # is the same signal SimpleCov uses to pick the process that runs the
145
+ # report and threshold checks. It's intentionally not gated on
146
+ # print_errors: the default at_fork sets print_errors false on workers,
147
+ # and in many parallel runners the final-report process is itself a
148
+ # worker, so a print_errors gate would suppress the one warning we want.
149
+ # See issues #980 and #1171.
150
+ return unless SimpleCov.final_result_process?
151
+
152
+ MissingSourceFilesReporter.new(
153
+ missing,
154
+ input_size: input_size,
155
+ every_entry_dropped: @files.empty? && missing.size == input_size
156
+ ).warn!
157
+ end
158
+
84
159
  def coverage
85
- keys = original_result.keys & filenames
86
- Hash[keys.zip(original_result.values_at(*keys))]
160
+ original_result.slice(*filenames)
87
161
  end
88
162
 
89
- # Applies all configured SimpleCov filters on this result's source files
90
- def filter!
91
- @files = SimpleCov.filtered(files)
163
+ # Applies the given filter chain to `@files`, dropping each source
164
+ # file that any filter matches.
165
+ def apply_filters!(filters)
166
+ filters.each do |filter|
167
+ @files = SimpleCov::FileList.new(@files.reject { |source_file| filter.matches?(source_file) })
168
+ end
169
+ end
170
+
171
+ # When any `cover` matcher is configured, restrict `@files` to source
172
+ # files matching at least one of them. With no cover matchers configured
173
+ # this is a no-op, preserving the historical "everything required, then
174
+ # filtered" universe.
175
+ def apply_cover_filters!(cover_filters)
176
+ return if cover_filters.empty?
177
+
178
+ @files = SimpleCov::FileList.new(
179
+ @files.select { |source_file| cover_filters.any? { |filter| filter.matches?(source_file) } }
180
+ )
92
181
  end
93
182
  end
94
183
  end
@@ -11,20 +11,117 @@ module SimpleCov
11
11
  @result = result
12
12
  end
13
13
 
14
- def self.call(*args)
15
- new(*args).adapt
14
+ def self.call(*)
15
+ new(*).adapt
16
16
  end
17
17
 
18
18
  def adapt
19
19
  return unless result
20
20
 
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
21
+ result.to_h do |file_name, cover_statistic|
22
+ [file_name, adapt_one(file_name, cover_statistic)]
27
23
  end
28
24
  end
25
+
26
+ private
27
+
28
+ # Pre-0.18 resultsets pointed each filename straight at a line-coverage
29
+ # array; everything since uses the `{lines:, branches:, methods:}`
30
+ # shape. Newer entries also need their methods and branches tables
31
+ # massaged before downstream code reports or merges them.
32
+ def adapt_one(file_name, cover_statistic)
33
+ return {"lines" => cover_statistic} if cover_statistic.is_a?(Array)
34
+
35
+ adapt_oneshot_lines_if_needed(file_name, cover_statistic)
36
+ normalize_method_keys(cover_statistic)
37
+ aggregate_duplicated_branches(cover_statistic)
38
+ cover_statistic
39
+ end
40
+
41
+ # Normalize memory addresses in method coverage keys so that results
42
+ # from different processes can be merged. Anonymous class names like
43
+ # "#<Class:0x00007ff19ab24790>" get inconsistent addresses across runs.
44
+ # Address widths vary by runtime (32-bit hosts: 8 hex chars; 64-bit
45
+ # CRuby: 16; some JVM/TruffleRuby formats may differ), so match any
46
+ # length of hex digits and collapse to a single placeholder.
47
+ ADDRESS_PATTERN = /0x\h+/
48
+ private_constant :ADDRESS_PATTERN
49
+
50
+ ADDRESS_PLACEHOLDER = "0x0"
51
+ private_constant :ADDRESS_PLACEHOLDER
52
+
53
+ # Strip the `#<Class:Foo>` wrapper Ruby's Coverage adds to singleton-class
54
+ # method keys. `module_function` and class methods get recorded both as
55
+ # singleton (`[#<Class:Foo>, :m, …]`) and instance/module (`[Foo, :m, …]`)
56
+ # entries pointing at the same source location; only one of the two is
57
+ # ever reachable at runtime, so we merge them. Only applies to named
58
+ # constants — anonymous-class addresses like `#<Class:0x0>` are left
59
+ # alone (handled by ADDRESS_PATTERN above).
60
+ SINGLETON_WRAPPER_PATTERN = /\A#<Class:([A-Z_][\w:]*)>\z/
61
+ private_constant :SINGLETON_WRAPPER_PATTERN
62
+
63
+ # Ruby's method coverage records one entry per RECEIVER, not per source
64
+ # location: a block handed to `define_method` / `define_singleton_method`
65
+ # from a shared code path (a module's `included` hook, a builder) yields
66
+ # a separate `[receiver, name, location]` entry for every class it's
67
+ # defined on, all pointing at the same source. A file-based report can
68
+ # only express "was the method at this location ever executed", so
69
+ # entries are aggregated by (name, location), summing hits — otherwise
70
+ # each receiver whose copy never ran shows as a phantom uncovered method
71
+ # on a line whose line coverage is 100% (issue #1234). The first entry's
72
+ # (normalized) receiver is kept for display.
73
+ def normalize_method_keys(cover_statistic)
74
+ methods = cover_statistic[:methods]
75
+ return unless methods
76
+
77
+ aggregated = {} #: Hash[untyped, [untyped, Integer]]
78
+ methods.each_with_object(aggregated) do |(key, count), memo|
79
+ identity = key[1..] #: Array[untyped]
80
+ retained_key, existing = memo[identity] || [normalize_method_key(key), 0]
81
+ memo[identity] = [retained_key, existing + count]
82
+ end
83
+ cover_statistic[:methods] = aggregated.values.to_h
84
+ end
85
+
86
+ def normalize_method_key(key)
87
+ normalized_key = key.dup
88
+ normalized_key[0] = key[0].to_s
89
+ .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
90
+ .sub(SINGLETON_WRAPPER_PATTERN, '\1')
91
+ normalized_key
92
+ end
93
+
94
+ # Ruby's eval coverage records a fresh set of branch entries for every
95
+ # COMPILE of an eval'd string: a template rendered through multiple view
96
+ # classes (e.g. hanami-view compiles each template once per view) yields
97
+ # several `[:if, id, location]` conditions at identical coordinates in
98
+ # the same file, each counting only the renders that flowed through that
99
+ # compile. Reported as-is they inflate the branch denominator and turn a
100
+ # side covered under a different compile into a phantom miss (issue
101
+ # #1235). Aggregate them by (type, location) — combining a branches hash
102
+ # with an empty one dedups within it, since BranchesCombiner keys arms
103
+ # on location identity. Regular (non-eval) source can never produce two
104
+ # conditions at the same location, so this is a no-op outside eval.
105
+ def aggregate_duplicated_branches(cover_statistic)
106
+ branches = cover_statistic[:branches]
107
+ return unless branches
108
+
109
+ cover_statistic[:branches] = Combine::BranchesCombiner.combine(branches, {})
110
+ end
111
+
112
+ def adapt_oneshot_lines_if_needed(file_name, cover_statistic)
113
+ return unless cover_statistic.key?(:oneshot_lines)
114
+
115
+ oneshot_lines = cover_statistic.delete(:oneshot_lines)
116
+ line_stub = build_line_stub(file_name, oneshot_lines)
117
+ oneshot_lines.each { |covered_line| line_stub[covered_line - 1] = 1 }
118
+ cover_statistic[:lines] = line_stub
119
+ end
120
+
121
+ def build_line_stub(file_name, oneshot_lines)
122
+ Coverage.line_stub(file_name)
123
+ rescue Errno::ENOENT, SyntaxError
124
+ Array.new(oneshot_lines.max || 0, nil)
125
+ end
29
126
  end
30
127
  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,38 @@
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: missing file returns `{}`, an empty
9
+ # or unparseable file warns and returns `{}`, parse success returns
10
+ # the decoded Hash.
11
+ module ResultsetFile
12
+ module_function
13
+
14
+ def parse(path)
15
+ data = read(path)
16
+ decode(data)
17
+ end
18
+
19
+ def read(path)
20
+ return unless File.exist?(path)
21
+
22
+ data = File.read(path)
23
+ return if data.nil? || data.length < 2
24
+
25
+ data
26
+ end
27
+
28
+ def decode(content)
29
+ return {} unless content
30
+
31
+ JSON.parse(content) || {}
32
+ rescue StandardError
33
+ warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
34
+ {}
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module SimpleCov
7
+ module ResultMerger
8
+ # Reads and writes the persistent `.resultset.json` cache, including
9
+ # file-lock synchronization between processes and atomic temp-file
10
+ # renames so concurrent readers don't observe a truncated file.
11
+ module ResultsetStore
12
+ module_function
13
+
14
+ def resultset_path
15
+ File.join(SimpleCov.coverage_path, ".resultset.json")
16
+ end
17
+
18
+ def writelock_path
19
+ File.join(SimpleCov.coverage_path, ".resultset.json.lock")
20
+ end
21
+
22
+ def write(resultset)
23
+ FileUtils.mkdir_p(SimpleCov.coverage_path)
24
+ temp_path = "#{resultset_path}.#{Process.pid}.tmp"
25
+ File.open(temp_path, "w") { |f| f.puts JSON.pretty_generate(resultset) }
26
+ File.rename(temp_path, resultset_path)
27
+ end
28
+
29
+ # Ensure only one process is reading or writing the resultset at
30
+ # any given time. Reentrant: the lock is acquired once per outer
31
+ # call no matter how deeply nested.
32
+ def synchronize(&)
33
+ return yield if @locked
34
+
35
+ @locked = true
36
+ with_flock(&)
37
+ ensure
38
+ @locked = false
39
+ end
40
+
41
+ def with_flock
42
+ FileUtils.mkdir_p(SimpleCov.coverage_path)
43
+ File.open(writelock_path, "w+") do |f|
44
+ f.flock(File::LOCK_EX)
45
+ yield
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require_relative "result_merger/legacy_format_adapter"
4
+ require_relative "result_merger/resultset_file"
5
+ require_relative "result_merger/resultset_store"
4
6
 
5
7
  module SimpleCov
6
8
  #
@@ -10,13 +12,8 @@ module SimpleCov
10
12
  #
11
13
  module ResultMerger
12
14
  class << self
13
- # The path to the .resultset.json cache file
14
15
  def resultset_path
15
- File.join(SimpleCov.coverage_path, ".resultset.json")
16
- end
17
-
18
- def resultset_writelock
19
- File.join(SimpleCov.coverage_path, ".resultset.json.lock")
16
+ ResultsetStore.resultset_path
20
17
  end
21
18
 
22
19
  def merge_and_store(*file_paths, ignore_timeout: false)
@@ -44,57 +41,56 @@ module SimpleCov
44
41
  end
45
42
 
46
43
  def valid_results(file_path, ignore_timeout: false)
47
- results = parse_file(file_path)
48
- merge_valid_results(results, ignore_timeout: ignore_timeout)
49
- end
50
-
51
- def parse_file(path)
52
- data = read_file(path)
53
- parse_json(data)
54
- end
55
-
56
- def read_file(path)
57
- return unless File.exist?(path)
58
-
59
- data = File.read(path)
60
- return if data.nil? || data.length < 2
61
-
62
- data
63
- end
64
-
65
- def parse_json(content)
66
- return {} unless content
67
-
68
- JSON.parse(content) || {}
69
- rescue StandardError
70
- warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
71
- {}
44
+ merge_valid_results(ResultsetFile.parse(file_path), ignore_timeout: ignore_timeout)
72
45
  end
73
46
 
74
47
  def merge_valid_results(results, ignore_timeout: false)
75
- results = results.select { |_command_name, data| within_merge_timeout?(data) } unless ignore_timeout
48
+ results = drop_expired_results(results) unless ignore_timeout
76
49
 
77
50
  command_plus_coverage = results.map do |command_name, data|
78
- [[command_name], adapt_result(data.fetch("coverage"))]
51
+ [[command_name], LegacyFormatAdapter.call(data.fetch("coverage"))]
79
52
  end
80
53
 
81
54
  # one file itself _might_ include multiple test runs
82
55
  merge_coverage(*command_plus_coverage)
83
56
  end
84
57
 
58
+ def drop_expired_results(results)
59
+ fresh, expired = results.partition { |_command_name, data| within_merge_timeout?(data) }
60
+ return results if expired.empty?
61
+
62
+ warn_about_expired_results(expired.map(&:first))
63
+ fresh.to_h
64
+ end
65
+
85
66
  def within_merge_timeout?(data)
86
- time_since_result_creation(data) < SimpleCov.merge_timeout
67
+ (Time.now - Time.at(data.fetch("timestamp"))) < SimpleCov.merge_timeout
87
68
  end
88
69
 
89
- def time_since_result_creation(data)
90
- Time.now - Time.at(data.fetch("timestamp"))
70
+ 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.
77
+ return unless SimpleCov.print_errors
78
+
79
+ warn "[SimpleCov]: Excluded #{expired_command_names.size} result(s) older than " \
80
+ "merge_timeout (#{SimpleCov.merge_timeout}s) from the merged report: " \
81
+ "#{expired_command_names.sort.join(', ')}. " \
82
+ "Increase SimpleCov.merge_timeout to include them."
91
83
  end
92
84
 
93
85
  def create_result(command_names, coverage)
94
86
  return nil unless coverage
95
87
 
96
88
  command_name = command_names.reject(&:empty?).sort.join(", ")
97
- SimpleCov::Result.new(coverage, command_name: command_name)
89
+ # The merged result is the authoritative one users actually see, so
90
+ # it's the one that warns about source files dropped because they no
91
+ # longer exist on disk (issue #980). The per-process slices built in
92
+ # `process_coverage_result` stay quiet to avoid one warning per worker.
93
+ SimpleCov::Result.new(coverage, command_name: command_name, report: true)
98
94
  end
99
95
 
100
96
  def merge_coverage(*results)
@@ -104,9 +100,7 @@ module SimpleCov
104
100
  results.reduce do |(memo_command, memo_coverage), (command, coverage)|
105
101
  # timestamp is dropped here, which is intentional (we merge it, it gets a new time stamp as of now)
106
102
  merged_coverage = Combine.combine(Combine::ResultsCombiner, memo_coverage, coverage)
107
- merged_command = memo_command + command
108
-
109
- [merged_command, merged_coverage]
103
+ [memo_command + command, merged_coverage]
110
104
  end
111
105
  end
112
106
 
@@ -115,79 +109,53 @@ module SimpleCov
115
109
  # SimpleCov::Result with merged coverage data and the command_name
116
110
  # for the result consisting of a join on all source result's names
117
111
  def merged_result
118
- # conceptually this is just doing `merge_results(resultset_path)`
119
- # it's more involved to make syre `synchronize_resultset` is only used around reading
120
- resultset_hash = read_resultset
121
- command_names, coverage = merge_valid_results(resultset_hash)
122
-
112
+ command_names, coverage = merge_valid_results(read_resultset)
123
113
  create_result(command_names, coverage)
124
114
  end
125
115
 
126
116
  def read_resultset
127
- resultset_content =
128
- synchronize_resultset do
129
- read_file(resultset_path)
130
- end
131
-
132
- parse_json(resultset_content)
117
+ content = synchronize_resultset { ResultsetFile.read(resultset_path) }
118
+ ResultsetFile.decode(content)
133
119
  end
134
120
 
135
121
  # Saves the given SimpleCov::Result in the resultset cache
136
- def store_result(result)
122
+ def store_result(result) # rubocop:disable Naming/PredicateMethod
137
123
  synchronize_resultset do
138
124
  # Ensure we have the latest, in case it was already cached
139
125
  new_resultset = read_resultset
140
126
 
141
127
  # A single result only ever has one command_name, see `SimpleCov::Result#to_hash`
142
128
  command_name, data = result.to_hash.first
143
- new_resultset[command_name] = data
144
- File.open(resultset_path, "w+") do |f_|
145
- f_.puts JSON.pretty_generate(new_resultset)
146
- end
129
+ new_resultset[command_name] = merged_entry(new_resultset[command_name], data)
130
+
131
+ ResultsetStore.write(new_resultset)
147
132
  end
148
133
  true
149
134
  end
150
135
 
151
- # Ensure only one process is reading or writing the resultset at any
152
- # given time
153
- def synchronize_resultset
154
- # make it reentrant
155
- return yield if defined?(@resultset_locked) && @resultset_locked
156
-
157
- begin
158
- @resultset_locked = true
159
- File.open(resultset_writelock, "w+") do |f|
160
- f.flock(File::LOCK_EX)
161
- yield
162
- end
163
- ensure
164
- @resultset_locked = false
165
- end
166
- end
136
+ # If an entry with the same command_name was written AFTER our process
137
+ # started, a sibling test runner (typically a subprocess our parent
138
+ # process shelled out to) wrote it. Combine coverage data rather than
139
+ # overwriting, so an empty parent-process result doesn't clobber the
140
+ # subprocess's real data. See https://github.com/simplecov-ruby/simplecov/issues/581.
141
+ def merged_entry(existing, incoming)
142
+ return incoming unless concurrent_runner_entry?(existing)
167
143
 
168
- # We changed the format of the raw result data in simplecov, as people are likely
169
- # to have "old" resultsets lying around (but not too old so that they're still
170
- # considered we can adapt them).
171
- # See https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747
172
- def adapt_result(result)
173
- if pre_simplecov_0_18_result?(result)
174
- adapt_pre_simplecov_0_18_result(result)
175
- else
176
- result
177
- end
144
+ incoming.merge(
145
+ "coverage" => Combine.combine(Combine::ResultsCombiner, existing["coverage"], incoming["coverage"])
146
+ )
178
147
  end
179
148
 
180
- # pre 0.18 coverage data pointed from file directly to an array of line coverage
181
- def pre_simplecov_0_18_result?(result)
182
- _key, data = result.first
149
+ def concurrent_runner_entry?(entry)
150
+ return false unless entry.is_a?(Hash)
183
151
 
184
- data.is_a?(Array)
152
+ timestamp = entry["timestamp"]
153
+ process_start = SimpleCov.process_start_time
154
+ timestamp && process_start && timestamp.to_i >= process_start.to_i
185
155
  end
186
156
 
187
- def adapt_pre_simplecov_0_18_result(result)
188
- result.transform_values do |line_coverage_data|
189
- {"lines" => line_coverage_data}
190
- end
157
+ def synchronize_resultset(&)
158
+ ResultsetStore.synchronize(&)
191
159
  end
192
160
  end
193
161
  end