simplecov 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1425
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +46 -21
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +18 -9
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +12 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_adapter.rb +48 -16
  76. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  77. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  78. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  79. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  80. data/lib/simplecov/result_merger.rb +69 -42
  81. data/lib/simplecov/result_processing.rb +82 -43
  82. data/lib/simplecov/run_identity.rb +77 -0
  83. data/lib/simplecov/simulate_coverage.rb +35 -11
  84. data/lib/simplecov/source_file/method.rb +7 -1
  85. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  86. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  87. data/lib/simplecov/source_file/source_loader.rb +23 -7
  88. data/lib/simplecov/source_file/statistics.rb +24 -16
  89. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  90. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  91. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  92. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  93. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  94. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  95. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  96. data/lib/simplecov/version.rb +1 -1
  97. data/lib/simplecov.rb +11 -5
  98. data/schemas/coverage-v1.0.schema.json +2 -2
  99. data/schemas/coverage.schema.json +2 -2
  100. data/sig/simplecov.rbs +206 -67
  101. metadata +28 -14
  102. data/doc/alternate-formatters.md +0 -66
  103. data/doc/commercial-services.md +0 -25
  104. data/doc/editor-integration.md +0 -18
  105. data/lib/simplecov/combine/files_combiner.rb +0 -70
  106. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  107. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  110. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module SimpleCov
6
+ # Touches coverage/.report_stamp whenever a report is formatted, no
7
+ # matter how the run ends. The clobber-prevention backstop
8
+ # (`SimpleCov.defer_to_existing_report?`) needs an on-disk signal that
9
+ # a fresher report exists, and `.last_run.json` can't be it alone:
10
+ # that file is only written after fully successful runs, which left
11
+ # the backstop inert exactly when the child run failed and its report
12
+ # mattered most.
13
+ module ReportStamp
14
+ class << self
15
+ def path
16
+ File.join(SimpleCov.coverage_path, ".report_stamp")
17
+ end
18
+
19
+ def touch
20
+ FileUtils.touch(path)
21
+ rescue SystemCallError
22
+ # A read-only or vanished coverage dir must not crash reporting;
23
+ # the stamp only powers the deferral heuristic.
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -15,6 +15,13 @@ module SimpleCov
15
15
 
16
16
  # Returns the original Coverage.result used for this instance of SimpleCov::Result
17
17
  attr_reader :original_result
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
18
25
  # Returns all files that are applicable to this result (sans filters!) as instances of
19
26
  # SimpleCov::SourceFile. Aliased as :source_files
20
27
  attr_reader :files
@@ -55,10 +62,11 @@ module SimpleCov
55
62
  # FilterConfig to opt out — useful for tests that build synthetic Results
56
63
  # and don't want the project's filters or groups applied.
57
64
  def initialize(original_result, command_name: nil, created_at: nil, not_loaded_files: Set.new,
58
- report: false, filter_config: FilterConfig.new)
65
+ tracked_files: [], run_id: nil, worker_id: nil, report: false, filter_config: FilterConfig.new)
59
66
  @original_result = original_result.freeze
60
67
  @command_name = command_name
61
68
  @created_at = created_at
69
+ initialize_coordination_metadata(tracked_files, run_id, worker_id)
62
70
  @groups_config = filter_config.groups
63
71
  builder = SourceFileBuilder.new(original_result, not_loaded_files: not_loaded_files)
64
72
  @files = builder.call
@@ -102,7 +110,13 @@ module SimpleCov
102
110
  formatter = SimpleCov.formatter
103
111
  return nil if formatter.nil?
104
112
 
105
- formatter.new.format(self)
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
106
120
  end
107
121
 
108
122
  # Defines when this result has been created. Defaults to Time.now
@@ -118,23 +132,32 @@ module SimpleCov
118
132
 
119
133
  # Returns a hash representation of this Result that can be used for marshalling it into JSON
120
134
  def to_hash
121
- {
122
- command_name => {
123
- "coverage" => coverage,
124
- "timestamp" => created_at.to_i
125
- }
126
- }
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}
127
142
  end
128
143
 
129
144
  # Loads a SimpleCov::Result#to_hash dump
130
145
  def self.from_hash(hash)
131
146
  hash.map do |command_name, data|
132
- 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"])
133
150
  end
134
151
  end
135
152
 
136
153
  private
137
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
+
138
161
  def warn_about_missing_source_files(missing, input_size)
139
162
  return if missing.empty?
140
163
 
@@ -156,8 +179,15 @@ module SimpleCov
156
179
  ).warn!
157
180
  end
158
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.
159
187
  def coverage
160
- original_result.slice(*filenames)
188
+ original_result.slice(*filenames).transform_values do |file_coverage|
189
+ file_coverage.transform_keys(&:to_s)
190
+ end
161
191
  end
162
192
 
163
193
  # Applies the given filter chain to `@files`, dropping each source
@@ -60,37 +60,69 @@ module SimpleCov
60
60
  SINGLETON_WRAPPER_PATTERN = /\A#<Class:([A-Z_][\w:]*)>\z/
61
61
  private_constant :SINGLETON_WRAPPER_PATTERN
62
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.
63
+ # Ruby's method coverage records one entry per DEFINED METHOD, not per
64
+ # source location: a block handed to `define_method` /
65
+ # `define_singleton_method` from a shared code path yields a separate
66
+ # `[receiver, name, location]` entry for every class it's defined on
67
+ # (a module's `included` hook defining onto each descendant) AND for
68
+ # every name it's defined under (a builder looping `define_method key`
69
+ # over a container), all pointing at the same source. A file-based
70
+ # report can only express "was the method at this location ever
71
+ # executed", so entries are aggregated by location alone, summing
72
+ # hits — otherwise each receiver or name whose generated copy never
73
+ # ran shows as a phantom uncovered method on a line whose line
74
+ # coverage is 100%. Regular `def`s map one location to one name, so
75
+ # they are unaffected. The first entry's (normalized) key is kept for
76
+ # display. See issue #1234.
73
77
  def normalize_method_keys(cover_statistic)
74
78
  methods = cover_statistic[:methods]
75
79
  return unless methods
76
80
 
77
81
  aggregated = {} #: Hash[untyped, [untyped, Integer]]
78
82
  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]
83
+ location = key[2..] #: Array[untyped]
84
+ retained_key, existing = memo[location] || [normalize_method_key(key), 0]
85
+ memo[location] = [retained_key, existing + count]
82
86
  end
83
87
  cover_statistic[:methods] = aggregated.values.to_h
84
88
  end
85
89
 
86
90
  def normalize_method_key(key)
87
91
  normalized_key = key.dup
88
- normalized_key[0] = key[0].to_s
89
- .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
90
- .sub(SINGLETON_WRAPPER_PATTERN, '\1')
92
+ normalized_key[0] = class_display_name(key[0])
93
+ .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
94
+ .sub(SINGLETON_WRAPPER_PATTERN, '\1')
91
95
  normalized_key
92
96
  end
93
97
 
98
+ # Rendering a class name can execute user code: a singleton class's
99
+ # `to_s` renders its attached object via `#inspect`, which a module can
100
+ # shadow with an incompatible signature (Liquid::Utils defines
101
+ # `inspect(value, max_depth = 2)` as a module_function, so rendering
102
+ # `#<Class:Liquid::Utils>` raises ArgumentError). A coverage report
103
+ # must never crash the host suite over that, so on failure rebuild the
104
+ # singleton wrapper from `Module#name` via bound methods (which cannot
105
+ # be shadowed), falling back to the address form, which
106
+ # ADDRESS_PATTERN then normalizes. See issue #1236.
107
+ def class_display_name(klass)
108
+ klass.to_s
109
+ rescue StandardError
110
+ singleton_wrapper_name(klass) || Object.instance_method(:to_s).bind_call(klass)
111
+ end
112
+
113
+ def singleton_wrapper_name(klass)
114
+ return nil unless klass.is_a?(Class) && klass.singleton_class?
115
+
116
+ attached = klass.attached_object
117
+ # simplecov:disable branch — CRuby only reaches the rescue via a
118
+ # Module/Class attached object (instance singletons render from the
119
+ # class name chain without calling user inspect), so the non-Module
120
+ # arm is defensive.
121
+ name = Module.instance_method(:name).bind_call(attached) if attached.is_a?(Module)
122
+ # simplecov:enable branch
123
+ name && "#<Class:#{name}>"
124
+ end
125
+
94
126
  # Ruby's eval coverage records a fresh set of branch entries for every
95
127
  # COMPILE of an eval'd string: a template rendered through multiple view
96
128
  # classes (e.g. hanami-view compiles each template once per view) yields
@@ -5,9 +5,10 @@ require "json"
5
5
  module SimpleCov
6
6
  module ResultMerger
7
7
  # Read + parse a `.resultset.json` file with the same tolerance the
8
- # historical `ResultMerger` had: missing file returns `{}`, an empty
9
- # or unparseable file warns and returns `{}`, parse success returns
10
- # the decoded Hash.
8
+ # historical `ResultMerger` had: a missing or empty file quietly
9
+ # returns `{}`, an unparseable one warns and returns `{}`, and parse
10
+ # success returns the decoded Hash with any malformed entries
11
+ # warned about and dropped.
11
12
  module ResultsetFile
12
13
  module_function
13
14
 
@@ -16,20 +17,55 @@ module SimpleCov
16
17
  decode(data)
17
18
  end
18
19
 
20
+ # A missing or blank file quietly means "no results yet"; anything
21
+ # else — a 1-byte truncation included — flows through decode so
22
+ # corruption warns instead of silently vanishing (the old
23
+ # `length < 2` check swallowed exactly those). Read first and
24
+ # rescue rather than check exist? (racy against a concurrent
25
+ # clean).
19
26
  def read(path)
20
- return unless File.exist?(path)
21
-
22
27
  data = File.read(path)
23
- return if data.nil? || data.length < 2
28
+ return if data.match?(/\A\s*\z/)
24
29
 
25
30
  data
31
+ rescue Errno::ENOENT
32
+ nil
26
33
  end
27
34
 
28
35
  def decode(content)
29
36
  return {} unless content
30
37
 
31
- JSON.parse(content) || {}
38
+ parsed = JSON.parse(content)
39
+ return {} if parsed.nil? # a "null" file has long meant an empty resultset
40
+
41
+ # Valid JSON need not be an object ("[1,2]" and "\"x\"" parse
42
+ # fine), but everything downstream iterates command => data
43
+ # pairs, so anything else would crash out of the middle of a
44
+ # merge. Same tolerance as a parse failure: warn and move on.
45
+ parsed.is_a?(Hash) ? drop_malformed_entries(parsed) : invalid_resultset
32
46
  rescue StandardError
47
+ invalid_resultset
48
+ end
49
+
50
+ # Each surviving entry must have the shape every consumer relies
51
+ # on: a Hash carrying a Numeric "timestamp" (the merge-timeout
52
+ # check) and a Hash "coverage" (the merge fold). A truncated or
53
+ # hand-edited entry would otherwise crash out of the middle of an
54
+ # at_exit merge, so it gets the same warn-and-move-on treatment
55
+ # as an unparseable file.
56
+ def drop_malformed_entries(resultset)
57
+ malformed, valid = resultset.partition { |_command_name, data| !well_formed_entry?(data) }
58
+ return resultset if malformed.empty?
59
+
60
+ warn "[SimpleCov]: Warning! Ignoring malformed resultset entries: #{malformed.map(&:first).sort.join(', ')}"
61
+ valid.to_h
62
+ end
63
+
64
+ def well_formed_entry?(data)
65
+ data.is_a?(Hash) && data["timestamp"].is_a?(Numeric) && data["coverage"].is_a?(Hash)
66
+ end
67
+
68
+ def invalid_resultset
33
69
  warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
34
70
  {}
35
71
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ # Run/worker metadata queries used by resultset storage and parallel
6
+ # coordination. Kept separate from the coverage-folding implementation.
7
+ module ResultsetRunIdentity
8
+ # One namespaced identity per distinct worker that wrote to the
9
+ # current run: `[:worker, id]` for entries that carry a worker id,
10
+ # `[:legacy, command_name]` for fresh entries written without one
11
+ # (an old SimpleCov writing to the same resultset). Pairs rather
12
+ # than mangled strings, so a resultset mixing both kinds can never
13
+ # alias a real worker id to a synthesized legacy identity.
14
+ def worker_identities_for_run(results, run_id, started_at)
15
+ results.filter_map do |command_name, data|
16
+ next unless current_run_entry?(data, run_id, started_at)
17
+
18
+ worker_id = data["worker_id"]
19
+ worker_id.to_s.empty? ? [:legacy, command_name] : [:worker, worker_id.to_s]
20
+ end.uniq
21
+ end
22
+
23
+ def current_run_entry?(entry, run_id, started_at)
24
+ return false unless entry.is_a?(Hash)
25
+
26
+ entry_run_id = entry["run_id"]
27
+ return fresh_entry?(entry, started_at) unless entry_run_id
28
+ return false unless run_id && entry_run_id == run_id
29
+ return true if SimpleCov::RunIdentity.authoritative?
30
+
31
+ fresh_entry?(entry, started_at)
32
+ end
33
+
34
+ def fresh_entry?(entry, started_at)
35
+ timestamp = entry["timestamp"]
36
+ timestamp.is_a?(Numeric) && started_at && timestamp >= started_at.to_f
37
+ end
38
+
39
+ # Whether `entry` was written by a runner concurrent with this
40
+ # process: either it shares our run id (`current_run_entry?`), or it
41
+ # was written strictly after our process started. A mismatched run
42
+ # id must not defeat the timestamp check — a subprocess we shelled
43
+ # out to (fork+exec) generates its own random run id, and its
44
+ # freshly written entry is exactly the data issue #581 protects
45
+ # from being clobbered. Strict id matching is reserved for worker
46
+ # counting (`worker_identities_for_run`), where admitting a stale
47
+ # entry would end a sibling wait early.
48
+ def concurrent_runner_entry?(entry, incoming = nil)
49
+ return false unless entry.is_a?(Hash)
50
+
51
+ incoming_run_id = incoming["run_id"] if incoming.is_a?(Hash)
52
+ started_at = SimpleCov.process_start_time
53
+ return true if current_run_entry?(entry, incoming_run_id, started_at)
54
+
55
+ written_after_start?(entry, started_at)
56
+ end
57
+
58
+ # Strictly after, unlike `fresh_entry?`: an entry stamped at the
59
+ # exact instant we started is treated as leftover from a previous
60
+ # run, so a same-second stale entry is still overwritten.
61
+ def written_after_start?(entry, started_at)
62
+ timestamp = entry["timestamp"]
63
+ timestamp.is_a?(Numeric) && started_at && timestamp > started_at.to_f
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "monitor"
6
+ require_relative "../atomic_file"
5
7
 
6
8
  module SimpleCov
7
9
  module ResultMerger
@@ -9,6 +11,9 @@ module SimpleCov
9
11
  # file-lock synchronization between processes and atomic temp-file
10
12
  # renames so concurrent readers don't observe a truncated file.
11
13
  module ResultsetStore
14
+ LOCK_MONITOR = Monitor.new
15
+ private_constant :LOCK_MONITOR
16
+
12
17
  module_function
13
18
 
14
19
  def resultset_path
@@ -19,23 +24,21 @@ module SimpleCov
19
24
  File.join(SimpleCov.coverage_path, ".resultset.json.lock")
20
25
  end
21
26
 
27
+ # Compact JSON, not pretty-printed: this is a machine-read cache
28
+ # that every parallel worker rewrites wholesale, and on a large
29
+ # project pretty printing nearly doubles the bytes written, read
30
+ # back, and parsed on each of those store-merge round trips.
22
31
  def write(resultset)
23
- FileUtils.mkdir_p(SimpleCov.coverage_path)
24
- temp_path = "#{resultset_path}.#{Process.pid}.tmp"
25
- File.open(temp_path, "w") { |f| f.puts JSON.pretty_generate(resultset) }
26
- File.rename(temp_path, resultset_path)
32
+ AtomicFile.write(resultset_path, "#{JSON.generate(resultset)}\n")
27
33
  end
28
34
 
29
- # Ensure only one process is reading or writing the resultset at
30
- # any given time. Reentrant: the lock is acquired once per outer
31
- # call no matter how deeply nested.
35
+ # Serialize threads before taking the process-wide file lock. Nested
36
+ # calls by the owning thread bypass flock so they cannot deadlock on a
37
+ # second descriptor for the same lock file.
32
38
  def synchronize(&)
33
- return yield if @locked
39
+ return yield if LOCK_MONITOR.mon_owned?
34
40
 
35
- @locked = true
36
- with_flock(&)
37
- ensure
38
- @locked = false
41
+ LOCK_MONITOR.synchronize { with_flock(&) }
39
42
  end
40
43
 
41
44
  def with_flock
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ResultMerger
5
+ # The merge step's half of unloaded-file handling: the policy of when
6
+ # to inject and which criteria simulated files carry. The mechanism —
7
+ # expanding globs and simulating each file — is
8
+ # `SimpleCov::UnloadedFileInjector`, an easy name to confuse with this
9
+ # one.
10
+ #
11
+ # Injection moved here from the individual processes because only the union
12
+ # of what they all loaded says what was really never loaded. Doing it per
13
+ # process meant N workers simulated the same file up to N times and the
14
+ # merge threw all but one away. See #1250.
15
+ module UnloadedFiles
16
+ module_function
17
+
18
+ # The union of what every contributing process was told to track. Absent
19
+ # from resultsets written before this was recorded, in which case those
20
+ # processes injected their own unloaded files and the data is already in
21
+ # the coverage hash.
22
+ # A collector for the merge to hand `merge_valid_results`, gathering the
23
+ # tracked paths of every resultset that survives the merge timeout.
24
+ def collector(into)
25
+ ->(surviving) { into.merge(tracked_in(surviving)) }
26
+ end
27
+
28
+ def tracked_in(resultset)
29
+ resultset.each_with_object(Set.new) do |(_command_name, data), set|
30
+ set.merge(Array(data["tracked_files"]))
31
+ end
32
+ end
33
+
34
+ # Concurrent workers sharing a command name may have been told to track
35
+ # different sets, so keep both rather than letting the later write win.
36
+ def carry_tracked(entry, existing, incoming)
37
+ tracked = Array(existing["tracked_files"]) | Array(incoming["tracked_files"])
38
+ tracked.empty? ? entry : entry.merge("tracked_files" => tracked)
39
+ end
40
+
41
+ # Simulate each tracked file the merged coverage doesn't already carry.
42
+ # The paths come from the resultsets rather than this process's own
43
+ # configuration, which a standalone `collate` would not have: it never ran
44
+ # `SimpleCov.start`, so it has no `cover` glob to expand. Idempotent, so
45
+ # resultsets from an older SimpleCov (or from a `merging false` process)
46
+ # that already carry their unloaded files pass through untouched.
47
+ def inject(coverage, tracked_files)
48
+ SimpleCov.inject_unloaded_files(
49
+ coverage, tracked_files.to_a,
50
+ synthesize: carries?(coverage, "branches") || carries?(coverage, "methods"),
51
+ lines: carries?(coverage, "lines")
52
+ )
53
+ end
54
+
55
+ # A simulated file should have the same shape as the files it is being
56
+ # merged alongside, so the criteria come from the merged data rather than
57
+ # from this process's configuration, which is not necessarily the
58
+ # configuration any contributing process measured under: `simplecov merge`
59
+ # never ran `SimpleCov.start` at all. Giving injected files fewer tables
60
+ # than their neighbours is what inflates the percentage #1059 fixed.
61
+ #
62
+ # Falls back to the configuration when there is nothing to be consistent
63
+ # with, which is a merge of resultsets that carried no files.
64
+ def carries?(coverage, criterion)
65
+ return SimpleCov.public_send(CRITERION_PREDICATES.fetch(criterion)) if coverage.empty?
66
+
67
+ coverage.any? { |_filename, file_coverage| file_coverage[criterion] }
68
+ end
69
+
70
+ CRITERION_PREDICATES = {
71
+ "lines" => :line_coverage?,
72
+ "branches" => :branch_coverage?,
73
+ "methods" => :method_coverage?
74
+ }.freeze
75
+ private_constant :CRITERION_PREDICATES
76
+
77
+ # Which files no contributing process ever loaded. Injection reports the
78
+ # ones it added, but a file can also arrive already simulated from a
79
+ # resultset this merge didn't inject into, so the merged line counts are
80
+ # still consulted, using the same signal
81
+ # `Combine::CoverageAccumulator` reconciles synthesized tuples on.
82
+ #
83
+ # A file is judged only when it has a relevant line. A branch-only or
84
+ # method-only run reports no line data at all for the files it loaded,
85
+ # and a loaded file with no executable lines (a comment-only constants
86
+ # stub, say) reports every line as `nil` — either would otherwise read
87
+ # as "never executed" and mark a genuinely loaded file not loaded,
88
+ # turning its branch and method coverage into #902's 0%.
89
+ # `SimulateCoverage` omits lines under those criteria too and gives a
90
+ # simulated file a `0` on every relevant line, so genuinely unloaded
91
+ # files still carry judgeable data, and a simulated file with none is
92
+ # already flagged by injection reporting it.
93
+ def never_executed(coverage)
94
+ coverage.each_with_object(Set.new) do |(filename, file_coverage), set|
95
+ counts = Array(file_coverage["lines"]) #: Array[Integer?]
96
+ next unless counts.any? { |count| !count.nil? }
97
+
98
+ set << filename unless Combine::CoverageAccumulator.executed?(file_coverage["lines"])
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end