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.
- checksums.yaml +4 -4
- data/README.md +65 -1425
- data/lib/simplecov/atomic_file.rb +70 -0
- data/lib/simplecov/cli/clean.rb +43 -3
- data/lib/simplecov/cli/command_helpers.rb +55 -0
- data/lib/simplecov/cli/coverage.rb +21 -28
- data/lib/simplecov/cli/coverage_file.rb +65 -0
- data/lib/simplecov/cli/diff.rb +43 -48
- data/lib/simplecov/cli/dotfile.rb +12 -6
- data/lib/simplecov/cli/merge.rb +12 -10
- data/lib/simplecov/cli/open.rb +3 -5
- data/lib/simplecov/cli/report.rb +31 -23
- data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
- data/lib/simplecov/cli/serve.rb +30 -94
- data/lib/simplecov/cli/uncovered.rb +20 -25
- data/lib/simplecov/cli.rb +15 -2
- data/lib/simplecov/combine/branches_combiner.rb +43 -19
- data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
- data/lib/simplecov/combine/identity_interner.rb +30 -0
- data/lib/simplecov/combine/interned_counts.rb +30 -0
- data/lib/simplecov/combine/lines_combiner.rb +48 -20
- data/lib/simplecov/combine/methods_combiner.rb +46 -21
- data/lib/simplecov/combine/results_combiner.rb +12 -37
- data/lib/simplecov/combine.rb +5 -23
- data/lib/simplecov/command_guesser.rb +66 -9
- data/lib/simplecov/configuration/coverage.rb +12 -15
- data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
- data/lib/simplecov/configuration/eval_coverage.rb +41 -0
- data/lib/simplecov/configuration/filters.rb +11 -44
- data/lib/simplecov/configuration/formatting.rb +18 -9
- data/lib/simplecov/configuration/groups.rb +42 -0
- data/lib/simplecov/configuration/merging.rb +8 -7
- data/lib/simplecov/configuration/thresholds.rb +14 -13
- data/lib/simplecov/configuration.rb +13 -43
- data/lib/simplecov/coverage_json.rb +24 -0
- data/lib/simplecov/coverage_statistics.rb +1 -1
- data/lib/simplecov/coverage_violations.rb +15 -5
- data/lib/simplecov/defaults.rb +7 -3
- data/lib/simplecov/directive.rb +1 -1
- data/lib/simplecov/exit_codes/check.rb +33 -0
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
- data/lib/simplecov/exit_codes.rb +1 -0
- data/lib/simplecov/exit_handling.rb +11 -41
- data/lib/simplecov/file_list.rb +5 -10
- data/lib/simplecov/filter.rb +43 -9
- data/lib/simplecov/formatter/base.rb +11 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
- data/lib/simplecov/formatter/html_formatter.rb +67 -47
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
- data/lib/simplecov/formatter/json_formatter.rb +5 -48
- data/lib/simplecov/formatter/multi_formatter.rb +1 -1
- data/lib/simplecov/formatter/simple_formatter.rb +8 -5
- data/lib/simplecov/formatter.rb +12 -0
- data/lib/simplecov/group_names.rb +32 -0
- data/lib/simplecov/last_run.rb +16 -9
- data/lib/simplecov/lines_classifier.rb +29 -8
- data/lib/simplecov/load_global_config.rb +5 -2
- data/lib/simplecov/parallel_adapters/base.rb +17 -0
- data/lib/simplecov/parallel_adapters/generic.rb +2 -2
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
- data/lib/simplecov/parallel_coordination.rb +6 -1
- data/lib/simplecov/parallel_result_merger.rb +230 -0
- data/lib/simplecov/report_deferral.rb +49 -0
- data/lib/simplecov/report_stamp.rb +28 -0
- data/lib/simplecov/result.rb +40 -10
- data/lib/simplecov/result_adapter.rb +48 -16
- data/lib/simplecov/result_merger/resultset_file.rb +43 -7
- data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
- data/lib/simplecov/result_merger/resultset_store.rb +15 -12
- data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
- data/lib/simplecov/result_merger.rb +69 -42
- data/lib/simplecov/result_processing.rb +82 -43
- data/lib/simplecov/run_identity.rb +77 -0
- data/lib/simplecov/simulate_coverage.rb +35 -11
- data/lib/simplecov/source_file/method.rb +7 -1
- data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
- data/lib/simplecov/source_file/skip_chunks.rb +7 -10
- data/lib/simplecov/source_file/source_loader.rb +23 -7
- data/lib/simplecov/source_file/statistics.rb +24 -16
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
- data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
- data/lib/simplecov/unloaded_file_injector.rb +75 -0
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov.rb +11 -5
- data/schemas/coverage-v1.0.schema.json +2 -2
- data/schemas/coverage.schema.json +2 -2
- data/sig/simplecov.rbs +206 -67
- metadata +28 -14
- data/doc/alternate-formatters.md +0 -66
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -70
- data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
- data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
- data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
- 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
|
data/lib/simplecov/result.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
64
|
-
# location: a block handed to `define_method` /
|
|
65
|
-
# from a shared code path
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
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
|
-
|
|
80
|
-
retained_key, existing = memo[
|
|
81
|
-
memo[
|
|
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]
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
9
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
|
39
|
+
return yield if LOCK_MONITOR.mon_owned?
|
|
34
40
|
|
|
35
|
-
|
|
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
|