simplecov 0.22.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +71 -916
- data/exe/simplecov +11 -0
- data/lib/minitest/simplecov_plugin.rb +13 -5
- data/lib/simplecov/atomic_file.rb +70 -0
- data/lib/simplecov/autostart.rb +11 -0
- data/lib/simplecov/cli/clean.rb +87 -0
- data/lib/simplecov/cli/command_helpers.rb +55 -0
- data/lib/simplecov/cli/coverage.rb +86 -0
- data/lib/simplecov/cli/coverage_file.rb +65 -0
- data/lib/simplecov/cli/diff.rb +147 -0
- data/lib/simplecov/cli/dotfile.rb +111 -0
- data/lib/simplecov/cli/merge.rb +120 -0
- data/lib/simplecov/cli/open.rb +48 -0
- data/lib/simplecov/cli/report.rb +97 -0
- data/lib/simplecov/cli/run.rb +36 -0
- 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 +85 -0
- data/lib/simplecov/cli/uncovered.rb +103 -0
- data/lib/simplecov/cli.rb +163 -0
- data/lib/simplecov/color.rb +83 -0
- data/lib/simplecov/combine/branches_combiner.rb +54 -7
- 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 +53 -23
- data/lib/simplecov/combine/methods_combiner.rb +74 -0
- data/lib/simplecov/combine/results_combiner.rb +13 -36
- data/lib/simplecov/combine.rb +5 -23
- data/lib/simplecov/command_guesser.rb +104 -33
- data/lib/simplecov/configuration/coverage.rb +172 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
- data/lib/simplecov/configuration/eval_coverage.rb +41 -0
- data/lib/simplecov/configuration/filters.rb +164 -0
- data/lib/simplecov/configuration/formatting.rb +138 -0
- data/lib/simplecov/configuration/groups.rb +42 -0
- data/lib/simplecov/configuration/ignored_entries.rb +63 -0
- data/lib/simplecov/configuration/merging.rb +165 -0
- data/lib/simplecov/configuration/thresholds.rb +179 -0
- data/lib/simplecov/configuration.rb +83 -420
- data/lib/simplecov/coverage_json.rb +24 -0
- data/lib/simplecov/coverage_statistics.rb +16 -10
- data/lib/simplecov/coverage_violations.rb +159 -0
- data/lib/simplecov/defaults.rb +34 -23
- data/lib/simplecov/deprecation.rb +45 -0
- data/lib/simplecov/directive.rb +167 -0
- data/lib/simplecov/exit_codes/check.rb +33 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
- data/lib/simplecov/exit_codes.rb +20 -0
- data/lib/simplecov/exit_handling.rb +128 -0
- data/lib/simplecov/file_list.rb +70 -24
- data/lib/simplecov/filter.rb +105 -26
- data/lib/simplecov/formatter/base.rb +118 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
- data/lib/simplecov/formatter/html_formatter.rb +105 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
- data/lib/simplecov/formatter/json_formatter.rb +46 -0
- data/lib/simplecov/formatter/multi_formatter.rb +12 -8
- data/lib/simplecov/formatter/simple_formatter.rb +14 -11
- data/lib/simplecov/formatter.rb +16 -0
- data/lib/simplecov/group_names.rb +32 -0
- data/lib/simplecov/last_run.rb +18 -4
- data/lib/simplecov/lines_classifier.rb +48 -15
- data/lib/simplecov/load_global_config.rb +12 -4
- data/lib/simplecov/parallel_adapters/base.rb +78 -0
- data/lib/simplecov/parallel_adapters/generic.rb +45 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
- data/lib/simplecov/parallel_adapters.rb +85 -0
- data/lib/simplecov/parallel_coordination.rb +134 -0
- data/lib/simplecov/parallel_result_merger.rb +230 -0
- data/lib/simplecov/process.rb +52 -12
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +24 -10
- data/lib/simplecov/profiles/root_filter.rb +6 -5
- data/lib/simplecov/profiles/strict.rb +32 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +32 -3
- data/lib/simplecov/report_deferral.rb +49 -0
- data/lib/simplecov/report_stamp.rb +28 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
- data/lib/simplecov/result/source_file_builder.rb +51 -0
- data/lib/simplecov/result.rb +145 -26
- data/lib/simplecov/result_adapter.rb +139 -8
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
- data/lib/simplecov/result_merger/resultset_file.rb +74 -0
- data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
- data/lib/simplecov/result_merger/resultset_store.rb +53 -0
- data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
- data/lib/simplecov/result_merger.rb +109 -114
- data/lib/simplecov/result_processing.rb +218 -0
- data/lib/simplecov/run_identity.rb +77 -0
- data/lib/simplecov/simulate_coverage.rb +83 -12
- data/lib/simplecov/source_file/branch.rb +1 -3
- data/lib/simplecov/source_file/branch_builder.rb +114 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +9 -4
- data/lib/simplecov/source_file/line_builder.rb +43 -0
- data/lib/simplecov/source_file/method.rb +58 -0
- data/lib/simplecov/source_file/method_builder.rb +60 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
- data/lib/simplecov/source_file/skip_chunks.rb +72 -0
- data/lib/simplecov/source_file/source_loader.rb +79 -0
- data/lib/simplecov/source_file/statistics.rb +65 -0
- data/lib/simplecov/source_file.rb +66 -232
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
- data/lib/simplecov/static_coverage_extractor.rb +110 -0
- data/lib/simplecov/unloaded_file_injector.rb +75 -0
- data/lib/simplecov/useless_results_remover.rb +16 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov-html.rb +4 -0
- data/lib/simplecov.rb +185 -380
- data/lib/simplecov_json_formatter.rb +4 -0
- data/sig/simplecov.rbs +1777 -0
- metadata +111 -62
- data/CHANGELOG.md +0 -191
- data/doc/alternate-formatters.md +0 -71
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -24
- data/lib/simplecov/default_formatter.rb +0 -20
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "color"
|
|
5
|
+
require_relative "cli/dotfile"
|
|
6
|
+
require_relative "cli/clean"
|
|
7
|
+
require_relative "cli/coverage_file"
|
|
8
|
+
require_relative "cli/coverage"
|
|
9
|
+
require_relative "cli/diff"
|
|
10
|
+
require_relative "cli/merge"
|
|
11
|
+
require_relative "cli/open"
|
|
12
|
+
require_relative "cli/report"
|
|
13
|
+
require_relative "cli/run"
|
|
14
|
+
require_relative "cli/serve"
|
|
15
|
+
require_relative "cli/uncovered"
|
|
16
|
+
|
|
17
|
+
module SimpleCov
|
|
18
|
+
# Lightweight command-line front-end. `run` dispatches a subcommand
|
|
19
|
+
# (`coverage`, `report`, `uncovered`, `merge`, `diff`, `open`, etc.) —
|
|
20
|
+
# see the `usage` text below for the full list, or run `simplecov help`.
|
|
21
|
+
#
|
|
22
|
+
# Read-only subcommands consume JSONFormatter output (`coverage.json`),
|
|
23
|
+
# which the bundled HTMLFormatter already drops alongside the HTML, so
|
|
24
|
+
# no runtime hooking is needed for those. Default paths follow the
|
|
25
|
+
# project's `.simplecov` `SimpleCov.coverage_dir` setting when one is
|
|
26
|
+
# present, so a project that writes its report somewhere other than
|
|
27
|
+
# `coverage/` doesn't have to pass `--input` / `--report` every
|
|
28
|
+
# invocation.
|
|
29
|
+
module CLI
|
|
30
|
+
COMMANDS = {
|
|
31
|
+
"coverage" => Coverage,
|
|
32
|
+
"run" => Run,
|
|
33
|
+
"open" => Open,
|
|
34
|
+
"report" => Report,
|
|
35
|
+
"uncovered" => Uncovered,
|
|
36
|
+
"merge" => Merge,
|
|
37
|
+
"diff" => Diff,
|
|
38
|
+
"serve" => Serve,
|
|
39
|
+
"clean" => Clean
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Resolved once per process. Walks up from cwd looking for a
|
|
45
|
+
# `.simplecov`; if present, the file is loaded with
|
|
46
|
+
# `SimpleCov.start` neutered so it can't trigger coverage tracking
|
|
47
|
+
# or an at_exit hook just because we asked it for a config value.
|
|
48
|
+
def coverage_dir
|
|
49
|
+
@coverage_dir ||= Dotfile.coverage_dir
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def default_input
|
|
53
|
+
File.join(coverage_dir, "coverage.json")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resolve "should this subcommand colorize?" once per invocation.
|
|
57
|
+
# `--no-color` (opts[:no_color]) is the per-invocation kill-switch;
|
|
58
|
+
# otherwise we defer to `SimpleCov::Color.enabled?`, which honors
|
|
59
|
+
# `NO_COLOR` / `FORCE_COLOR` and falls back to `stream.tty?`.
|
|
60
|
+
def color_enabled?(opts, stream)
|
|
61
|
+
return false if opts[:no_color]
|
|
62
|
+
|
|
63
|
+
SimpleCov::Color.enabled?(stream)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def default_report
|
|
67
|
+
File.join(coverage_dir, "index.html")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def default_resultset
|
|
71
|
+
File.join(coverage_dir, ".resultset.json")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns a process exit status (0 on success, non-zero on error).
|
|
75
|
+
def run(argv, stdout: $stdout, stderr: $stderr)
|
|
76
|
+
command, *rest = argv
|
|
77
|
+
handler = COMMANDS[command]
|
|
78
|
+
return dispatch(handler, command, rest, stdout: stdout, stderr: stderr) if handler
|
|
79
|
+
return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
|
|
80
|
+
|
|
81
|
+
stderr.puts("simplecov: unknown command #{command.inspect}", usage)
|
|
82
|
+
1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# One rescue covers every subcommand's OptionParser, so a typo'd
|
|
86
|
+
# flag or a malformed typed argument becomes a one-line error and
|
|
87
|
+
# exit status 1 instead of an unhandled-exception backtrace.
|
|
88
|
+
def dispatch(handler, command, rest, stdout:, stderr:)
|
|
89
|
+
handler.run(rest, stdout: stdout, stderr: stderr)
|
|
90
|
+
rescue OptionParser::ParseError => e
|
|
91
|
+
stderr.puts("simplecov #{command}: #{e.message} (run `simplecov help` for usage)")
|
|
92
|
+
1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def usage
|
|
96
|
+
<<~USAGE
|
|
97
|
+
Usage: simplecov <command> [options]
|
|
98
|
+
|
|
99
|
+
Commands:
|
|
100
|
+
run <command...> Execute <command> with simplecov pre-loaded
|
|
101
|
+
(so a coverage report is generated even
|
|
102
|
+
when the project has no test_helper hook)
|
|
103
|
+
coverage <path> Print coverage stats for the given file
|
|
104
|
+
report Print the overall summary and group totals
|
|
105
|
+
uncovered List the lowest-coverage files
|
|
106
|
+
merge <files...> Merge multiple .resultset.json files
|
|
107
|
+
diff <baseline> Show per-file coverage delta vs baseline
|
|
108
|
+
open Open the HTML report in the default browser
|
|
109
|
+
serve Serve the coverage report over HTTP
|
|
110
|
+
clean Remove the coverage report directory
|
|
111
|
+
help Show this message
|
|
112
|
+
|
|
113
|
+
Default paths follow SimpleCov.coverage_dir from a project's
|
|
114
|
+
`.simplecov` when one is present (#{coverage_dir} for this run).
|
|
115
|
+
|
|
116
|
+
coverage / report / uncovered / diff options:
|
|
117
|
+
--input PATH Read from PATH instead of #{default_input}
|
|
118
|
+
--no-color Disable colorized percentages
|
|
119
|
+
(also honors NO_COLOR / FORCE_COLOR env)
|
|
120
|
+
|
|
121
|
+
coverage options:
|
|
122
|
+
--json Print the file's JSON entry verbatim
|
|
123
|
+
|
|
124
|
+
report options:
|
|
125
|
+
--json Emit totals and group sections as JSON
|
|
126
|
+
|
|
127
|
+
uncovered options:
|
|
128
|
+
--threshold N Only show files below N% coverage
|
|
129
|
+
--top N Show at most N files (default: 10)
|
|
130
|
+
--criterion C line, branch, or method (default: line)
|
|
131
|
+
--json Emit results as a JSON array (for CI)
|
|
132
|
+
|
|
133
|
+
merge options:
|
|
134
|
+
--output PATH Write merged resultset to PATH
|
|
135
|
+
(default: #{default_resultset})
|
|
136
|
+
--honor-timeout Drop entries older than merge_timeout
|
|
137
|
+
--dry-run Print what would be written without
|
|
138
|
+
actually writing
|
|
139
|
+
-q, --quiet Suppress the success status line
|
|
140
|
+
|
|
141
|
+
diff options:
|
|
142
|
+
--fail-on-drop Exit non-zero when any file's coverage
|
|
143
|
+
dropped vs the baseline (deleted
|
|
144
|
+
files don't count as drops)
|
|
145
|
+
--json Emit results as a JSON array (for CI)
|
|
146
|
+
--threshold N Only show files whose absolute delta
|
|
147
|
+
in any criterion is at least N%
|
|
148
|
+
|
|
149
|
+
open options:
|
|
150
|
+
--report PATH Open PATH instead of #{default_report}
|
|
151
|
+
|
|
152
|
+
serve options:
|
|
153
|
+
--port N Bind to port N (default: random open port)
|
|
154
|
+
--host HOST Bind to HOST (default: 127.0.0.1)
|
|
155
|
+
|
|
156
|
+
clean options:
|
|
157
|
+
--dry-run Print what would be removed without
|
|
158
|
+
deleting anything
|
|
159
|
+
-q, --quiet Suppress status lines
|
|
160
|
+
USAGE
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
# ANSI colorization for stderr diagnostics. Thresholds mirror the
|
|
5
|
+
# HTML formatter (>= 90 green, >= 75 yellow, otherwise red) so a
|
|
6
|
+
# team's mental model of "what's the cutoff" is the same whether
|
|
7
|
+
# they're reading the terminal output or the HTML report.
|
|
8
|
+
#
|
|
9
|
+
# Color precedence, highest first:
|
|
10
|
+
#
|
|
11
|
+
# - `SimpleCov.color = true` / `false` (programmatic override, wins
|
|
12
|
+
# over everything; default is `:auto` which falls through)
|
|
13
|
+
# - `NO_COLOR` env var (any non-empty value) → off (see no-color.org)
|
|
14
|
+
# - `FORCE_COLOR` env var (any non-empty value) → on
|
|
15
|
+
# - `stream.tty?` fallback
|
|
16
|
+
#
|
|
17
|
+
# `NO_COLOR` wins over `FORCE_COLOR` if both env vars are set.
|
|
18
|
+
module Color
|
|
19
|
+
GREEN_THRESHOLD = 90
|
|
20
|
+
YELLOW_THRESHOLD = 75
|
|
21
|
+
|
|
22
|
+
ANSI = {
|
|
23
|
+
red: "\e[31m",
|
|
24
|
+
yellow: "\e[33m",
|
|
25
|
+
green: "\e[32m",
|
|
26
|
+
reset: "\e[0m"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# `stream` is the IO that the colorized text is destined for. The
|
|
32
|
+
# formatter writes to stderr, so that's the default. CLI subcommands
|
|
33
|
+
# that print to stdout should pass `$stdout` so a redirected pipe
|
|
34
|
+
# doesn't get ANSI sequences. See the module-level comment for
|
|
35
|
+
# precedence.
|
|
36
|
+
def enabled?(stream = $stderr)
|
|
37
|
+
# `SimpleCov.color` only exists once the full library is loaded.
|
|
38
|
+
# The standalone CLI (`exe/simplecov`) loads `simplecov/color`
|
|
39
|
+
# without `simplecov` itself to stay lightweight, so treat a
|
|
40
|
+
# missing config the same as its `:auto` default: fall through to
|
|
41
|
+
# the env vars and tty check below.
|
|
42
|
+
config = SimpleCov.color if SimpleCov.respond_to?(:color)
|
|
43
|
+
return config if [true, false].include?(config)
|
|
44
|
+
return false if env_set?("NO_COLOR")
|
|
45
|
+
return true if env_set?("FORCE_COLOR")
|
|
46
|
+
|
|
47
|
+
stream.tty?
|
|
48
|
+
rescue IOError
|
|
49
|
+
# A parallel runner can close a worker's stdio before its at_exit
|
|
50
|
+
# hooks run (rspec-conductor does). A closed stream is not a tty.
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def for_percent(percent)
|
|
55
|
+
return :green if percent >= GREEN_THRESHOLD
|
|
56
|
+
return :yellow if percent >= YELLOW_THRESHOLD
|
|
57
|
+
|
|
58
|
+
:red
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Wrap `text` in the ANSI sequence for `color` (a key of ANSI).
|
|
62
|
+
# Returns the bare text if color is disabled. The `enabled:`
|
|
63
|
+
# keyword lets callers (e.g., CLI subcommands honoring `--no-color`)
|
|
64
|
+
# override the auto-detection without touching env vars.
|
|
65
|
+
def colorize(text, color, enabled: enabled?)
|
|
66
|
+
return text unless enabled
|
|
67
|
+
|
|
68
|
+
"#{ANSI.fetch(color)}#{text}#{ANSI.fetch(:reset)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Render `percent` as a fixed "NN.NN%" string colored by which
|
|
72
|
+
# threshold band it falls into. Callers that want a different
|
|
73
|
+
# rendering of the number can pass the pre-rendered `text`.
|
|
74
|
+
def colorize_percent(percent, text = nil, enabled: enabled?)
|
|
75
|
+
colorize(text || format("%.2f%%", percent), for_percent(percent), enabled: enabled)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def env_set?(name)
|
|
79
|
+
value = ENV.fetch(name, nil)
|
|
80
|
+
value && !value.empty?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -1,31 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../source_file/ruby_data_parser"
|
|
4
|
+
require_relative "identity_interner"
|
|
5
|
+
require_relative "interned_counts"
|
|
6
|
+
|
|
3
7
|
module SimpleCov
|
|
4
8
|
module Combine
|
|
5
9
|
#
|
|
6
10
|
# Combine different branch coverage results on single file.
|
|
7
11
|
#
|
|
8
|
-
# Should be called through `
|
|
12
|
+
# Should be called through `CoverageAccumulator`.
|
|
9
13
|
module BranchesCombiner
|
|
10
14
|
module_function
|
|
11
15
|
|
|
12
16
|
#
|
|
13
|
-
# Return merged branches or the existed
|
|
17
|
+
# Return merged branches or the existed branch if other is missing.
|
|
14
18
|
#
|
|
15
19
|
# Branches inside files are always same if they exist, the difference only in coverage count.
|
|
16
20
|
# Branch coverage report for any conditional case is built from hash, it's key is a condition and
|
|
17
21
|
# it's body is a hash << keys from condition and value is coverage rate >>.
|
|
18
|
-
# ex: branches =>{ [:if, 3, 8, 6, 8, 36] =>
|
|
22
|
+
# ex: branches => { [:if, 3, 8, 6, 8, 36] =>
|
|
23
|
+
# {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... }
|
|
19
24
|
# We create copy of result and update it values depending on the combined branches coverage values.
|
|
20
25
|
#
|
|
21
26
|
# @return [Hash]
|
|
22
27
|
#
|
|
23
28
|
def combine(coverage_a, coverage_b)
|
|
24
|
-
coverage_a
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
|
|
30
|
+
materialize(absorb(merged, coverage_b))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Folds `coverage` into `target`, an interned table keyed by condition
|
|
34
|
+
# identity. Kept interned (rather than turned back into tuple keys per
|
|
35
|
+
# merge) so a fold over N resultsets interns each condition once
|
|
36
|
+
# instead of re-interning the whole accumulated table N times.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash] `target`
|
|
39
|
+
def absorb(target, coverage)
|
|
40
|
+
return target unless coverage
|
|
41
|
+
|
|
42
|
+
coverage.each do |condition, branches_inside|
|
|
43
|
+
entry = target[identities[condition]] ||= new_condition(condition)
|
|
44
|
+
InternedCounts.absorb_counts(entry[1], branches_inside, identities)
|
|
28
45
|
end
|
|
46
|
+
|
|
47
|
+
target
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Split out so the empty arm table has somewhere to carry its type
|
|
51
|
+
# annotation. Only allocated the first time a condition is seen.
|
|
52
|
+
def new_condition(condition)
|
|
53
|
+
arms = {} #: Hash[untyped, untyped]
|
|
54
|
+
[condition, arms]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Turns an interned table back into the tuple-keyed hash the rest of
|
|
58
|
+
# SimpleCov reads. Done once, at the end of a fold.
|
|
59
|
+
#
|
|
60
|
+
# @return [Hash]
|
|
61
|
+
def materialize(target)
|
|
62
|
+
target.values.to_h { |condition, branches| [condition, branches.values.to_h] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Bounded by the project's branch count, like `RubyDataParser`'s parse
|
|
66
|
+
# cache.
|
|
67
|
+
def identities
|
|
68
|
+
@identities ||= IdentityInterner.build { |tuple| tuple_identity(tuple) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Branches match on source span, whatever ids the recording processes
|
|
72
|
+
# handed them (issue #1233).
|
|
73
|
+
def tuple_identity(tuple)
|
|
74
|
+
type, _id, start_line, start_column, end_line, end_column = SourceFile::RubyDataParser.call(tuple)
|
|
75
|
+
[type, start_line, start_column, end_line, end_column].freeze
|
|
29
76
|
end
|
|
30
77
|
end
|
|
31
78
|
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "branches_combiner"
|
|
4
|
+
require_relative "lines_combiner"
|
|
5
|
+
require_relative "methods_combiner"
|
|
6
|
+
|
|
7
|
+
module SimpleCov
|
|
8
|
+
module Combine
|
|
9
|
+
#
|
|
10
|
+
# Folds any number of resultsets' coverage into one, absorbing them one
|
|
11
|
+
# at a time.
|
|
12
|
+
#
|
|
13
|
+
# This replaces a pairwise `reduce` over the resultsets, which rebuilt
|
|
14
|
+
# the entire accumulated structure on every one of its N-1 steps: a
|
|
15
|
+
# fresh outer file hash, a fresh lines array for every file, and a fresh
|
|
16
|
+
# branch / method table for every file whose keys were re-interned from
|
|
17
|
+
# their tuples each time. On a 160-worker run over ~1,800 files that is
|
|
18
|
+
# ~290,000 whole-file rebuilds to produce ~1,800 files of output.
|
|
19
|
+
#
|
|
20
|
+
# The accumulated side is private to the fold, so it can be updated in
|
|
21
|
+
# place instead, and the interned tables only have to be turned back into
|
|
22
|
+
# tuple-keyed hashes once, at the end.
|
|
23
|
+
#
|
|
24
|
+
# Resultsets are absorbed one at a time rather than taken as a list, so
|
|
25
|
+
# `ResultMerger.merge_results` can keep reading and discarding them one
|
|
26
|
+
# file at a time — reading 100s of CI jobs' worth of coverage into memory
|
|
27
|
+
# at once is what that method is careful not to do.
|
|
28
|
+
#
|
|
29
|
+
class CoverageAccumulator
|
|
30
|
+
# A file some process actually loaded has at least one executed line;
|
|
31
|
+
# a simulated (never-loaded) file's lines are all `nil` or `0`. This is
|
|
32
|
+
# the signal the merge reconciles synthesized tuples on, and the one
|
|
33
|
+
# `ResultMerger` re-derives a merged result's not-loaded set from, so it
|
|
34
|
+
# lives here rather than being spelled out at each site.
|
|
35
|
+
#
|
|
36
|
+
# `Array()` plus the `Numeric` test rather than a bare
|
|
37
|
+
# `any?(&:positive?)` because this reads straight off a parsed
|
|
38
|
+
# resultset, which is external input: a file written by another
|
|
39
|
+
# SimpleCov version, or hand-edited, can carry anything under
|
|
40
|
+
# "lines" — a Hash coerces to pairs, a String to itself. Keeping a
|
|
41
|
+
# malformed entry to a wrong answer instead of a NoMethodError out
|
|
42
|
+
# of the middle of a merge is the point.
|
|
43
|
+
def self.executed?(lines)
|
|
44
|
+
counts = Array(lines) #: Array[untyped]
|
|
45
|
+
counts.any? { |count| count.is_a?(Numeric) && count.positive? }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#
|
|
49
|
+
# Folds `[command_names, coverage]` pairs into one merged coverage.
|
|
50
|
+
#
|
|
51
|
+
# `pairs` is only ever iterated, so a caller that reads resultsets off
|
|
52
|
+
# disk can hand in a lazy enumerable and never hold more than one in
|
|
53
|
+
# memory — which is what `ResultMerger.merge_results` is careful about.
|
|
54
|
+
#
|
|
55
|
+
# @return [Array] the concatenated command names and the merged coverage
|
|
56
|
+
#
|
|
57
|
+
def self.fold(pairs)
|
|
58
|
+
accumulator = new
|
|
59
|
+
command_names = [] #: Array[String]
|
|
60
|
+
|
|
61
|
+
pairs.each do |names, coverage|
|
|
62
|
+
command_names.concat(names)
|
|
63
|
+
accumulator.absorb(coverage)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[command_names, accumulator.result]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def initialize
|
|
70
|
+
@files = {} #: Hash[String, untyped]
|
|
71
|
+
@absorbed = false
|
|
72
|
+
# Whether a criterion is enabled can't change mid-fold, so read it once
|
|
73
|
+
# here rather than once per file. `branch_coverage?` reaches
|
|
74
|
+
# `Coverage.supported?`, and a large parallel run merges thousands of
|
|
75
|
+
# files.
|
|
76
|
+
@branch_coverage = SimpleCov.branch_coverage?
|
|
77
|
+
@method_coverage = SimpleCov.method_coverage?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#
|
|
81
|
+
# Folds one resultset's coverage (filename => per-file coverage) into
|
|
82
|
+
# the accumulator. A `nil` coverage is ignored — a resultset that
|
|
83
|
+
# carried nothing contributes nothing.
|
|
84
|
+
#
|
|
85
|
+
# @return [CoverageAccumulator] self, so absorbs can be chained
|
|
86
|
+
#
|
|
87
|
+
def absorb(coverage)
|
|
88
|
+
return self unless coverage
|
|
89
|
+
|
|
90
|
+
@absorbed = true
|
|
91
|
+
coverage.each do |filename, file_coverage|
|
|
92
|
+
@files[filename] = merge_file(@files[filename], file_coverage)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#
|
|
99
|
+
# The merged coverage, or `nil` when nothing was absorbed at all — the
|
|
100
|
+
# caller needs to tell "no results" apart from "results that cover
|
|
101
|
+
# nothing", and only the former means there is no report to build.
|
|
102
|
+
#
|
|
103
|
+
# A file only one resultset carried is returned exactly as it came in,
|
|
104
|
+
# untouched: with nothing to merge it into, copying it would only cost
|
|
105
|
+
# memory.
|
|
106
|
+
#
|
|
107
|
+
# @return [Hash, nil]
|
|
108
|
+
#
|
|
109
|
+
def result
|
|
110
|
+
return nil unless @absorbed
|
|
111
|
+
|
|
112
|
+
@files.transform_values do |entry|
|
|
113
|
+
entry.is_a?(MergedFile) ? entry.to_h : entry
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# The first sighting of a file is kept as-is; the second promotes it to
|
|
120
|
+
# a `MergedFile` that owns its state, and every later one folds into
|
|
121
|
+
# that same object.
|
|
122
|
+
def merge_file(existing, file_coverage)
|
|
123
|
+
case existing
|
|
124
|
+
when nil then file_coverage
|
|
125
|
+
when MergedFile then existing.absorb(file_coverage)
|
|
126
|
+
else MergedFile.new(existing, branches: @branch_coverage, methods: @method_coverage).absorb(file_coverage)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
#
|
|
131
|
+
# One file's accumulated coverage, against state this object owns
|
|
132
|
+
# outright rather than rebuilt for each merged pair.
|
|
133
|
+
#
|
|
134
|
+
class MergedFile
|
|
135
|
+
# A criterion is carried for this file when the data carries it, not
|
|
136
|
+
# when this process happens to measure it. A merge runs on behalf of
|
|
137
|
+
# the processes that produced the resultsets and does not necessarily
|
|
138
|
+
# share their configuration: `simplecov merge` never ran SimpleCov.start
|
|
139
|
+
# at all, and dropping a table it did not ask for would lose branch data
|
|
140
|
+
# the producers did measure. A nil table means nobody measured that
|
|
141
|
+
# criterion; an empty one means it was measured and the file has none.
|
|
142
|
+
def initialize(coverage, branches:, methods:)
|
|
143
|
+
@branch_coverage = branches
|
|
144
|
+
@method_coverage = methods
|
|
145
|
+
@lines = coverage["lines"]&.dup
|
|
146
|
+
# Branch coverage always reports a table, even an empty one, so
|
|
147
|
+
# `SourceFile` can tell "no branches in this file" from "branch
|
|
148
|
+
# coverage was off". Method coverage stays `nil` until some resultset
|
|
149
|
+
# actually carries methods.
|
|
150
|
+
@branches = BranchesCombiner.absorb({}, coverage["branches"]) if branches || coverage["branches"]
|
|
151
|
+
@methods = MethodsCombiner.absorb(nil, coverage["methods"]) if methods || coverage["methods"]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#
|
|
155
|
+
# Folds one more resultset's coverage for this file in.
|
|
156
|
+
#
|
|
157
|
+
# @return [MergedFile] self
|
|
158
|
+
#
|
|
159
|
+
def absorb(coverage)
|
|
160
|
+
reconcile_synthesized(coverage)
|
|
161
|
+
@lines = LinesCombiner.merge_into(@lines, coverage["lines"])
|
|
162
|
+
self
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def to_h
|
|
166
|
+
merged = {"lines" => @lines} #: Hash[String, untyped]
|
|
167
|
+
merged["branches"] = BranchesCombiner.materialize(@branches) if @branches
|
|
168
|
+
merged["methods"] = MethodsCombiner.materialize(@methods) if @methods
|
|
169
|
+
merged
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def new_table
|
|
175
|
+
{} #: Hash[untyped, untyped]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# When exactly one side of the merge was actually executed, its branch
|
|
179
|
+
# and method tuples are authoritative and the other side's are
|
|
180
|
+
# dropped. A simulated entry (SimulateCoverage backfills
|
|
181
|
+
# tracked-but-unloaded files) synthesizes those tuples statically, so
|
|
182
|
+
# a location that drifts from what Coverage emits would otherwise be
|
|
183
|
+
# unioned in by position and survive as a phantom, permanently-missed
|
|
184
|
+
# branch (see #1233). This contains any such drift to denominator
|
|
185
|
+
# inflation for files no process loaded, rather than a false miss on a
|
|
186
|
+
# covered file. Lines are never dropped: a simulated file's line shape
|
|
187
|
+
# is correct and carries the unloaded-file denominator (#1059).
|
|
188
|
+
#
|
|
189
|
+
# The accumulated side's executed-ness is re-read on every absorb
|
|
190
|
+
# because folding in an executed resultset can flip it, exactly as it
|
|
191
|
+
# would have flipped between two steps of the pairwise fold.
|
|
192
|
+
def reconcile_synthesized(coverage)
|
|
193
|
+
return absorb_tuples(coverage) unless judgeable?(@lines) && judgeable?(coverage["lines"])
|
|
194
|
+
|
|
195
|
+
accumulated_executed = executed?(@lines)
|
|
196
|
+
incoming_executed = executed?(coverage["lines"])
|
|
197
|
+
|
|
198
|
+
if accumulated_executed == incoming_executed
|
|
199
|
+
absorb_tuples(coverage)
|
|
200
|
+
elsif incoming_executed
|
|
201
|
+
replace_tuples(coverage)
|
|
202
|
+
else
|
|
203
|
+
drop_incoming_tuples(coverage)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Both sides agree on whether they ran: union their tuples. A criterion
|
|
208
|
+
# only the incoming side carries comes into play here, because its
|
|
209
|
+
# tuples survive. It deliberately does not come into play on the path
|
|
210
|
+
# that drops them: promoting there would advertise an empty table
|
|
211
|
+
# sourced from data this merge just discarded, which claims the file
|
|
212
|
+
# has no branches when what is true is that nobody measured them.
|
|
213
|
+
def absorb_tuples(coverage)
|
|
214
|
+
@branches = BranchesCombiner.absorb(@branches || new_table, coverage["branches"]) if
|
|
215
|
+
@branches || coverage["branches"]
|
|
216
|
+
@methods = MethodsCombiner.absorb(@methods, coverage["methods"]) if @methods || coverage["methods"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Only the accumulated side ran, so the incoming tuples are synthesized
|
|
220
|
+
# and contribute nothing. When the discarded side carried a methods
|
|
221
|
+
# table, a measuring process still gets one — the empty table stands in
|
|
222
|
+
# for the blanked side, which is what a pair against a blank one used
|
|
223
|
+
# to produce for a file that has no methods yet. When no side has
|
|
224
|
+
# carried methods at all, the table stays nil (the rule `initialize`
|
|
225
|
+
# documents), the same answer the union path gives.
|
|
226
|
+
def drop_incoming_tuples(coverage)
|
|
227
|
+
@methods ||= {} if @method_coverage && coverage["methods"] #: Hash[untyped, untyped]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Only the incoming side ran, so what's accumulated is synthesized:
|
|
231
|
+
# start its tuples over from the executed side's. The authoritative
|
|
232
|
+
# side decides which criteria the file carries, not just their tuples.
|
|
233
|
+
# Keeping a table in play that only the dropped side carried would make
|
|
234
|
+
# the answer depend on which side was seen first.
|
|
235
|
+
def replace_tuples(coverage)
|
|
236
|
+
@branches = authoritative_table(BranchesCombiner, coverage["branches"], @branch_coverage)
|
|
237
|
+
# Methods stay nil until some resultset carries them (see
|
|
238
|
+
# `initialize`), so a measuring process keeps an empty table only
|
|
239
|
+
# when one did.
|
|
240
|
+
@methods = authoritative_table(MethodsCombiner, coverage["methods"], @method_coverage && !@methods.nil?)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The executed side's tuples when it has them. Otherwise `keep_empty`
|
|
244
|
+
# decides whether the criterion survives as an empty table, which is
|
|
245
|
+
# what keeps `SourceFile` able to tell "no branches here" from
|
|
246
|
+
# "branches were not measured".
|
|
247
|
+
def authoritative_table(combiner, table, keep_empty)
|
|
248
|
+
return combiner.absorb(new_table, table) if table
|
|
249
|
+
|
|
250
|
+
keep_empty ? new_table : nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Whether a side can be judged at all. Absent lines do not mean the side
|
|
254
|
+
# never ran: a branch-only or method-only run omits them even for the
|
|
255
|
+
# files it loaded, so treating that as synthesized would discard real
|
|
256
|
+
# tuples. Unknown means union, which is already what happens when
|
|
257
|
+
# neither side carries lines.
|
|
258
|
+
def judgeable?(lines)
|
|
259
|
+
!Array(lines).empty?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def executed?(lines)
|
|
263
|
+
CoverageAccumulator.executed?(lines)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module Combine
|
|
5
|
+
#
|
|
6
|
+
# Builds the raw-key => interned-identity caches the tuple combiners keep.
|
|
7
|
+
#
|
|
8
|
+
# The cache maps a raw key to its interned identity on first sight and
|
|
9
|
+
# keeps it: the pairwise fold would otherwise re-derive the accumulator's
|
|
10
|
+
# identities on every one of its merges, always to the same answer.
|
|
11
|
+
#
|
|
12
|
+
# Identities exist only to be `combine`'s hash keys, and `Array#hash` is
|
|
13
|
+
# not memoized, so keying on the identity tuple itself would rehash its
|
|
14
|
+
# elements for every key of both sides of every merge. An Integer hashes
|
|
15
|
+
# as an immediate. Two keys share an id exactly when their identities are
|
|
16
|
+
# equal, so the string and array forms of one key still merge together.
|
|
17
|
+
#
|
|
18
|
+
module IdentityInterner
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def build
|
|
22
|
+
ids = {} #: Hash[::Array[untyped], Integer]
|
|
23
|
+
Hash.new do |cache, key|
|
|
24
|
+
identity = yield(key)
|
|
25
|
+
cache[key] = ids[identity] ||= ids.size
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module Combine
|
|
5
|
+
# The inner merge loop the branches and methods combiners share:
|
|
6
|
+
# fold `source`'s tuple => count pairs into `target`, an interned
|
|
7
|
+
# table keyed by each tuple's merge identity and holding
|
|
8
|
+
# `[raw_key, count]` pairs. The first-seen raw key is kept for
|
|
9
|
+
# display; counts sum. `target` and its pairs are always built by
|
|
10
|
+
# this loop, so updating them in place can't reach data a caller
|
|
11
|
+
# still holds.
|
|
12
|
+
module InternedCounts
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @return [Hash] `target`
|
|
16
|
+
def absorb_counts(target, source, identities)
|
|
17
|
+
source.each do |key, count|
|
|
18
|
+
interned = identities[key]
|
|
19
|
+
entry = target[interned]
|
|
20
|
+
if entry
|
|
21
|
+
entry[1] += count
|
|
22
|
+
else
|
|
23
|
+
target[interned] = [key, count]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
target
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|