simplecov 0.22.0 → 1.0.0.rc1
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/CHANGELOG.md +81 -1
- data/LICENSE +1 -1
- data/README.md +1009 -511
- data/doc/alternate-formatters.md +0 -5
- data/doc/commercial-services.md +5 -5
- data/exe/simplecov +11 -0
- data/lib/minitest/simplecov_plugin.rb +13 -5
- data/lib/simplecov/autostart.rb +11 -0
- data/lib/simplecov/cli/clean.rb +47 -0
- data/lib/simplecov/cli/coverage.rb +91 -0
- data/lib/simplecov/cli/diff.rb +151 -0
- data/lib/simplecov/cli/dotfile.rb +100 -0
- data/lib/simplecov/cli/merge.rb +116 -0
- data/lib/simplecov/cli/open.rb +50 -0
- data/lib/simplecov/cli/report.rb +84 -0
- data/lib/simplecov/cli/run.rb +36 -0
- data/lib/simplecov/cli/serve.rb +139 -0
- data/lib/simplecov/cli/uncovered.rb +107 -0
- data/lib/simplecov/cli.rb +150 -0
- data/lib/simplecov/color.rb +74 -0
- data/lib/simplecov/combine/branches_combiner.rb +3 -2
- data/lib/simplecov/combine/files_combiner.rb +7 -1
- data/lib/simplecov/combine/lines_combiner.rb +19 -17
- data/lib/simplecov/combine/methods_combiner.rb +26 -0
- data/lib/simplecov/combine/results_combiner.rb +5 -4
- data/lib/simplecov/command_guesser.rb +46 -32
- data/lib/simplecov/configuration/coverage.rb +171 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
- data/lib/simplecov/configuration/filters.rb +195 -0
- data/lib/simplecov/configuration/formatting.rb +119 -0
- data/lib/simplecov/configuration/ignored_entries.rb +63 -0
- data/lib/simplecov/configuration/merging.rb +74 -0
- data/lib/simplecov/configuration/thresholds.rb +174 -0
- data/lib/simplecov/configuration.rb +79 -405
- data/lib/simplecov/coverage_statistics.rb +12 -9
- data/lib/simplecov/coverage_violations.rb +148 -0
- data/lib/simplecov/defaults.rb +27 -20
- data/lib/simplecov/directive.rb +162 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
- data/lib/simplecov/exit_codes.rb +3 -0
- data/lib/simplecov/exit_handling.rb +158 -0
- data/lib/simplecov/file_list.rb +61 -17
- data/lib/simplecov/filter.rb +69 -24
- data/lib/simplecov/formatter/base.rb +101 -0
- data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
- data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
- 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
- data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
- data/lib/simplecov/formatter/html_formatter.rb +79 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +127 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
- data/lib/simplecov/formatter/json_formatter.rb +77 -0
- data/lib/simplecov/formatter/multi_formatter.rb +4 -5
- data/lib/simplecov/formatter/simple_formatter.rb +9 -11
- data/lib/simplecov/formatter.rb +4 -0
- data/lib/simplecov/last_run.rb +10 -3
- data/lib/simplecov/lines_classifier.rb +26 -13
- data/lib/simplecov/load_global_config.rb +9 -4
- data/lib/simplecov/parallel_adapters/base.rb +51 -0
- data/lib/simplecov/parallel_adapters/generic.rb +42 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +77 -0
- data/lib/simplecov/parallel_adapters.rb +83 -0
- data/lib/simplecov/parallel_coordination.rb +95 -0
- data/lib/simplecov/process.rb +20 -14
- 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/result/missing_source_files_reporter.rb +49 -0
- data/lib/simplecov/result/source_file_builder.rb +51 -0
- data/lib/simplecov/result.rb +97 -19
- data/lib/simplecov/result_adapter.rb +68 -6
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
- data/lib/simplecov/result_merger/resultset_file.rb +38 -0
- data/lib/simplecov/result_merger/resultset_store.rb +50 -0
- data/lib/simplecov/result_merger.rb +46 -90
- data/lib/simplecov/result_processing.rb +162 -0
- data/lib/simplecov/simulate_coverage.rb +54 -8
- 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 +7 -2
- data/lib/simplecov/source_file/line_builder.rb +43 -0
- data/lib/simplecov/source_file/method.rb +52 -0
- data/lib/simplecov/source_file/method_builder.rb +58 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
- data/lib/simplecov/source_file/skip_chunks.rb +77 -0
- data/lib/simplecov/source_file/source_loader.rb +63 -0
- data/lib/simplecov/source_file/statistics.rb +57 -0
- data/lib/simplecov/source_file.rb +66 -232
- data/lib/simplecov/static_coverage_extractor/visitor.rb +193 -0
- data/lib/simplecov/static_coverage_extractor.rb +111 -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 +131 -377
- data/lib/simplecov_json_formatter.rb +4 -0
- data/schemas/coverage-v1.0.schema.json +300 -0
- data/schemas/coverage.schema.json +300 -0
- metadata +88 -56
- data/lib/simplecov/default_formatter.rb +0 -20
data/doc/alternate-formatters.md
CHANGED
|
@@ -64,8 +64,3 @@ t_wada AA formatter for SimpleCov
|
|
|
64
64
|
*by [Chiefpansancolt](https://github.com/chiefpansancolt)*
|
|
65
65
|
|
|
66
66
|
A TailwindCSS & TailwindUI Designed HTML formatter with clean and easy search of files with a tabular left Navigation.
|
|
67
|
-
|
|
68
|
-
#### [simplecov-material](https://github.com/chiefpansancolt/simplecov-material)
|
|
69
|
-
*by [Chiefpansancolt](https://github.com/chiefpansancolt)*
|
|
70
|
-
|
|
71
|
-
A Material Designed HTML formatter with clean and easy search of files with a tabular left Navigation.
|
data/doc/commercial-services.md
CHANGED
|
@@ -9,11 +9,6 @@ these integrations with their respective owners.
|
|
|
9
9
|
|
|
10
10
|
Upload coverage reports to [codacy.com](https://www.codacy.com/), a hosted (or self-hosted) software quality analysis platform that also includes coverage reporting.
|
|
11
11
|
|
|
12
|
-
#### [codeclimate](https://github.com/codeclimate/ruby-test-reporter)
|
|
13
|
-
*by [Code Climate](https://codeclimate.com/)*
|
|
14
|
-
|
|
15
|
-
Upload coverage reports to [codeclimate.com](https://codeclimate.com/), a hosted software quality analysis and that also includes coverage reporting.
|
|
16
|
-
|
|
17
12
|
#### [codecov](https://github.com/codecov/codecov-ruby)
|
|
18
13
|
*by [Codecov](https://codecov.io/)*
|
|
19
14
|
|
|
@@ -23,3 +18,8 @@ Upload coverage reports to [codecov.io](https://codecov.io/), a hosted coverage
|
|
|
23
18
|
*by [Coveralls](https://coveralls.io/)*
|
|
24
19
|
|
|
25
20
|
Upload coverage reports to [coveralls.io](https://coveralls.io/), a hosted coverage reporting solution.
|
|
21
|
+
|
|
22
|
+
#### [Qlty Cloud](https://github.com/qltysh/qlty)
|
|
23
|
+
*by [Qlty Software](https://qlty.sh/)*
|
|
24
|
+
|
|
25
|
+
Upload coverage reports to [Qlty Cloud](https://qlty.sh/), a hosted software quality analysis and that also includes coverage reporting.
|
data/exe/simplecov
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Make `./exe/simplecov` work from a fresh checkout, not only via
|
|
5
|
+
# `bundle exec`. RubyGems and Bundler already inject lib/ for an
|
|
6
|
+
# installed gem; this is a no-op in that case.
|
|
7
|
+
lib = File.expand_path("../lib", __dir__)
|
|
8
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
9
|
+
|
|
10
|
+
require "simplecov/cli"
|
|
11
|
+
exit SimpleCov::CLI.run(ARGV)
|
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
# How minitest plugins. See https://github.com/simplecov-ruby/simplecov/pull/756 for why we need this.
|
|
4
4
|
# https://github.com/seattlerb/minitest#writing-extensions
|
|
5
|
+
#
|
|
6
|
+
# Handles the SimpleCov-first / Minitest-second ordering: SimpleCov.start
|
|
7
|
+
# runs before `require "minitest/autorun"`, so the SimpleCov.start-time
|
|
8
|
+
# detection in `install_at_exit_hook` can't see Minitest yet. By the time
|
|
9
|
+
# this plugin fires (inside `Minitest.run`), Minitest is loaded and we
|
|
10
|
+
# can flip the same switch. The opposite ordering (Minitest first) is
|
|
11
|
+
# handled in `SimpleCov.install_at_exit_hook` — see `#minitest_autorun_pending?`.
|
|
5
12
|
module Minitest
|
|
6
13
|
def self.plugin_simplecov_init(_options)
|
|
7
|
-
|
|
8
|
-
|
|
14
|
+
return unless defined?(SimpleCov)
|
|
15
|
+
return if SimpleCov.external_at_exit?
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
SimpleCov.external_at_exit = true
|
|
18
|
+
|
|
19
|
+
Minitest.after_run do
|
|
20
|
+
SimpleCov.at_exit_behavior
|
|
13
21
|
end
|
|
14
22
|
end
|
|
15
23
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded via `RUBYOPT="-rsimplecov/autostart"` from `simplecov run`. The
|
|
4
|
+
# `require "simplecov"` here also auto-loads `~/.simplecov` and the
|
|
5
|
+
# project's `.simplecov` (per simplecov/defaults.rb), which may already
|
|
6
|
+
# call `SimpleCov.start`. `SimpleCov.start` is idempotent — it won't
|
|
7
|
+
# restart Coverage if it's already running, and `install_at_exit_hook`
|
|
8
|
+
# guards against double-installing — so calling it unconditionally is
|
|
9
|
+
# the safe way to ensure the report is formatted at exit.
|
|
10
|
+
require "simplecov"
|
|
11
|
+
SimpleCov.start
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
# `simplecov clean [--dry-run]` — remove the coverage report
|
|
8
|
+
# directory (or whatever `SimpleCov.coverage_dir` resolves to). The
|
|
9
|
+
# `--dry-run` flag prints what would be deleted without touching
|
|
10
|
+
# disk, for when you're not sure what's in there.
|
|
11
|
+
module Clean
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def run(args, stdout:, **)
|
|
15
|
+
opts = parse(args)
|
|
16
|
+
dir = SimpleCov::CLI.coverage_dir
|
|
17
|
+
return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
|
|
18
|
+
|
|
19
|
+
sweep(dir, opts, stdout)
|
|
20
|
+
0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sweep(dir, opts, stdout)
|
|
24
|
+
if opts[:dry_run]
|
|
25
|
+
announce(stdout, opts, "would remove #{dir} (#{Dir["#{dir}/**/*"].size} entries)")
|
|
26
|
+
else
|
|
27
|
+
require "fileutils"
|
|
28
|
+
FileUtils.rm_rf(dir)
|
|
29
|
+
announce(stdout, opts, "removed #{dir}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def announce(stdout, opts, message)
|
|
34
|
+
stdout.puts("simplecov clean: #{message}") unless opts[:quiet]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse(args)
|
|
38
|
+
opts = {dry_run: false, quiet: false}
|
|
39
|
+
OptionParser.new do |o|
|
|
40
|
+
o.on("--dry-run") { opts[:dry_run] = true }
|
|
41
|
+
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
42
|
+
end.parse(args)
|
|
43
|
+
opts
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
module CLI
|
|
8
|
+
# `simplecov coverage <path>` — print per-criterion stats for one
|
|
9
|
+
# file from a JSONFormatter coverage.json.
|
|
10
|
+
module Coverage
|
|
11
|
+
CRITERIA = [
|
|
12
|
+
{label: "Line", pct: "lines_covered_percent", cov: "covered_lines", tot: "total_lines"},
|
|
13
|
+
{label: "Branch", pct: "branches_covered_percent", cov: "covered_branches", tot: "total_branches"},
|
|
14
|
+
{label: "Method", pct: "methods_covered_percent", cov: "covered_methods", tot: "total_methods"}
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def run(args, stdout:, stderr:)
|
|
20
|
+
opts = parse(args, stderr: stderr)
|
|
21
|
+
return 1 unless opts
|
|
22
|
+
|
|
23
|
+
match = locate_match(opts, stderr)
|
|
24
|
+
return 1 unless match
|
|
25
|
+
|
|
26
|
+
emit(match, opts, stdout)
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse(args, stderr:)
|
|
31
|
+
opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false}
|
|
32
|
+
rest =
|
|
33
|
+
OptionParser.new do |o|
|
|
34
|
+
o.on("--input PATH") { |v| opts[:input] = v }
|
|
35
|
+
o.on("--json") { opts[:json] = true }
|
|
36
|
+
o.on("--no-color") { opts[:no_color] = true }
|
|
37
|
+
end.parse(args)
|
|
38
|
+
return stderr.puts("simplecov coverage: missing file argument") && nil if rest.empty?
|
|
39
|
+
|
|
40
|
+
opts[:path] = rest.first
|
|
41
|
+
opts
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def locate_match(opts, stderr)
|
|
45
|
+
return stderr.puts("simplecov coverage: #{opts[:input]} not found") && nil unless File.exist?(opts[:input])
|
|
46
|
+
|
|
47
|
+
data = JSON.parse(File.read(opts[:input]))
|
|
48
|
+
match = lookup(data.fetch("coverage", {}), opts[:path])
|
|
49
|
+
return match if match
|
|
50
|
+
|
|
51
|
+
stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Match either the absolute path, the literal string passed, or
|
|
56
|
+
# any coverage entry whose absolute filename ends with "/<path>".
|
|
57
|
+
# That covers the three natural ways a user types a path: relative
|
|
58
|
+
# to project root ("app/foo.rb"), absolute, or basename-only.
|
|
59
|
+
def lookup(coverage_hash, path)
|
|
60
|
+
absolute = File.expand_path(path)
|
|
61
|
+
suffix = "/#{path}"
|
|
62
|
+
coverage_hash.find { |fname, _| fname == absolute || fname == path || fname.end_with?(suffix) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def emit(match, opts, stdout)
|
|
66
|
+
filename, payload = match
|
|
67
|
+
if opts[:json]
|
|
68
|
+
stdout.puts(JSON.pretty_generate(filename => payload))
|
|
69
|
+
else
|
|
70
|
+
print_human(filename, payload, stdout, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def print_human(filename, payload, stdout, color)
|
|
75
|
+
stdout.puts(filename)
|
|
76
|
+
CRITERIA.each { |c| emit_criterion(stdout, payload, c, color) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def emit_criterion(stdout, payload, criterion, color)
|
|
80
|
+
return unless payload.key?(criterion[:pct])
|
|
81
|
+
|
|
82
|
+
pct = payload[criterion[:pct]].to_f
|
|
83
|
+
stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
|
|
84
|
+
label: "#{criterion[:label]}:",
|
|
85
|
+
pct: SimpleCov::Color.colorize_percent(pct, enabled: color),
|
|
86
|
+
covered: payload[criterion[:cov]] || 0,
|
|
87
|
+
total: payload[criterion[:tot]] || 0))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
module CLI
|
|
8
|
+
# `simplecov diff <baseline>` — print the per-file line-coverage
|
|
9
|
+
# delta between coverage.json (--input) and a baseline coverage.json
|
|
10
|
+
# checked in alongside the suite. Only files whose coverage moved
|
|
11
|
+
# are listed; --fail-on-drop exits non-zero when any file regressed,
|
|
12
|
+
# so this composes with CI as a "coverage of this PR didn't drop"
|
|
13
|
+
# gate. Resolves the long-standing "diff coverage" feature request.
|
|
14
|
+
module Diff
|
|
15
|
+
EPSILON = 0.005 # tolerance below which a delta is considered noise
|
|
16
|
+
|
|
17
|
+
# Per-criterion key map. coverage.json carries `lines_covered_percent`
|
|
18
|
+
# plus `branches_covered_percent` / `methods_covered_percent` when
|
|
19
|
+
# the corresponding criterion is enabled, so the diff can describe
|
|
20
|
+
# whichever criteria the baseline + current both report on.
|
|
21
|
+
CRITERIA = %i[lines branches methods].freeze
|
|
22
|
+
CRITERION_FIELDS = {
|
|
23
|
+
lines: {pct: "lines_covered_percent", total: "total_lines"},
|
|
24
|
+
branches: {pct: "branches_covered_percent", total: "total_branches"},
|
|
25
|
+
methods: {pct: "methods_covered_percent", total: "total_methods"}
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def run(args, stdout:, stderr:, **)
|
|
33
|
+
opts = parse(args, stderr)
|
|
34
|
+
return 1 unless opts
|
|
35
|
+
|
|
36
|
+
rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
|
|
37
|
+
rows.sort_by! { |row| row[:line_delta] }
|
|
38
|
+
if opts[:json]
|
|
39
|
+
emit_json(stdout, rows)
|
|
40
|
+
else
|
|
41
|
+
emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
42
|
+
end
|
|
43
|
+
opts[:fail_on_drop] && rows.any? { |row| row[:line_delta].negative? } ? 1 : 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse(args, stderr)
|
|
47
|
+
opts = parse_flags(args)
|
|
48
|
+
return stderr.puts("simplecov diff: missing baseline argument") && nil if opts[:rest].empty?
|
|
49
|
+
|
|
50
|
+
opts[:baseline] = load_coverage(opts[:rest].first, stderr) or return nil
|
|
51
|
+
opts[:current] = load_coverage(opts[:input], stderr) or return nil
|
|
52
|
+
opts
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_flags(args)
|
|
56
|
+
opts = {input: SimpleCov::CLI.default_input, fail_on_drop: false, json: false, threshold: 0.0, no_color: false}
|
|
57
|
+
opts.merge(rest: option_parser(opts).parse(args))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def option_parser(opts)
|
|
61
|
+
OptionParser.new do |o|
|
|
62
|
+
o.on("--input PATH") { |v| opts[:input] = v }
|
|
63
|
+
o.on("--fail-on-drop") { opts[:fail_on_drop] = true }
|
|
64
|
+
o.on("--json") { opts[:json] = true }
|
|
65
|
+
o.on("--threshold N", Float) { |v| opts[:threshold] = v }
|
|
66
|
+
o.on("--no-color") { opts[:no_color] = true }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def load_coverage(path, stderr)
|
|
71
|
+
return normalize_keys(JSON.parse(File.read(path)).fetch("coverage", {})) if File.exist?(path)
|
|
72
|
+
|
|
73
|
+
stderr.puts("simplecov diff: #{path} not found")
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Strip a leading slash so coverage.json files written before the
|
|
78
|
+
# `project_filename` change (keys like "/lib/foo.rb") still diff
|
|
79
|
+
# cleanly against newer reports (keys like "lib/foo.rb").
|
|
80
|
+
def normalize_keys(coverage)
|
|
81
|
+
coverage.transform_keys { |key| key.delete_prefix("/") }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def compute_rows(current, baseline, threshold)
|
|
85
|
+
files = current.keys | baseline.keys
|
|
86
|
+
files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def compute_row(fname, current_payload, baseline_payload, threshold)
|
|
90
|
+
deltas = CRITERIA.to_h { |c| [c, pct_for(c, current_payload) - pct_for(c, baseline_payload)] }
|
|
91
|
+
floor = [threshold.abs, EPSILON].max
|
|
92
|
+
return nil unless deltas.values.any? { |delta| delta.abs > floor }
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
file: fname,
|
|
96
|
+
status: status_for(current_payload, baseline_payload),
|
|
97
|
+
line_delta: deltas[:lines],
|
|
98
|
+
branch_delta: deltas[:branches],
|
|
99
|
+
method_delta: deltas[:methods]
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def status_for(current_payload, baseline_payload)
|
|
104
|
+
return "added" if baseline_payload.nil?
|
|
105
|
+
return "removed" if current_payload.nil?
|
|
106
|
+
|
|
107
|
+
"changed"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def pct_for(criterion, payload)
|
|
111
|
+
fields = CRITERION_FIELDS.fetch(criterion)
|
|
112
|
+
return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
|
|
113
|
+
|
|
114
|
+
payload[fields[:pct]].to_f
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def emit_text(stdout, rows, color)
|
|
118
|
+
return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?
|
|
119
|
+
|
|
120
|
+
rows.each { |row| stdout.puts(format_row(row, color)) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def format_row(row, color)
|
|
124
|
+
line = " #{delta_parts(row, color).join(' ')} #{row[:file]}"
|
|
125
|
+
suffix = STATUS_SUFFIX[row[:status]]
|
|
126
|
+
suffix ? "#{line} #{suffix}" : line
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def delta_parts(row, color)
|
|
130
|
+
[
|
|
131
|
+
format_delta(row[:line_delta], "lines", color),
|
|
132
|
+
(format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
|
|
133
|
+
(format_delta(row[:method_delta], "methods", color) if row[:method_delta].abs > EPSILON)
|
|
134
|
+
].compact
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Deltas are sign-based, not threshold-based: a +5% bump is good
|
|
138
|
+
# (green) and a -5% drop is bad (red), regardless of where the
|
|
139
|
+
# absolute coverage level lands.
|
|
140
|
+
def format_delta(delta, label, color)
|
|
141
|
+
sign = delta.positive? ? "+" : ""
|
|
142
|
+
text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
|
|
143
|
+
SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def emit_json(stdout, rows)
|
|
147
|
+
stdout.puts(JSON.pretty_generate(rows))
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
# Loads a project's `.simplecov` config file purely to read
|
|
8
|
+
# `coverage_dir` from it, with `SimpleCov.start` and the at_exit
|
|
9
|
+
# hook installer neutered so the load doesn't trigger coverage
|
|
10
|
+
# tracking. Used by the CLI to default `--input` / `--report`
|
|
11
|
+
# paths to whatever the project's dotfile declares, without making
|
|
12
|
+
# every read-only subcommand pay for actually starting Coverage.
|
|
13
|
+
module Dotfile
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def coverage_dir
|
|
17
|
+
dotfile = find
|
|
18
|
+
return "coverage" unless dotfile
|
|
19
|
+
|
|
20
|
+
with_simplecov_loaded { read_from(dotfile) }
|
|
21
|
+
rescue LoadError, StandardError => e
|
|
22
|
+
# simplecov:disable — defensive fallback for a bad dotfile (parse
|
|
23
|
+
# error, EACCES, etc.); never fires in the project's own dogfood run
|
|
24
|
+
warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
|
|
25
|
+
"coverage"
|
|
26
|
+
# simplecov:enable
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Load the dotfile, snapshot+restore `SimpleCov.coverage_dir` so we
|
|
30
|
+
# don't quietly clobber it in a host process that's already
|
|
31
|
+
# configured (e.g. when the CLI is exercised inline by simplecov's
|
|
32
|
+
# own spec suite). The snapshot is intentionally narrow: a dotfile
|
|
33
|
+
# can still mutate other SimpleCov configuration (filters, groups,
|
|
34
|
+
# formatters, command_name, ...) via `SimpleCov.configure` or
|
|
35
|
+
# `SimpleCov.start { ... }` blocks. The CLI normally runs as a
|
|
36
|
+
# top-level process where that's harmless; callers driving it from
|
|
37
|
+
# inside a Ruby host that cares about isolation should arrange that
|
|
38
|
+
# themselves.
|
|
39
|
+
def read_from(dotfile)
|
|
40
|
+
snapshot = SimpleCov.instance_variable_get(:@coverage_dir)
|
|
41
|
+
load_with_start_neutered(dotfile)
|
|
42
|
+
dir = SimpleCov.coverage_dir
|
|
43
|
+
SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
|
|
44
|
+
dir
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find
|
|
48
|
+
dir = Pathname.new(Dir.pwd)
|
|
49
|
+
loop do
|
|
50
|
+
candidate = dir.join(".simplecov")
|
|
51
|
+
return candidate.to_s if candidate.exist?
|
|
52
|
+
break if dir.root?
|
|
53
|
+
|
|
54
|
+
dir = dir.parent
|
|
55
|
+
end
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def with_simplecov_loaded
|
|
60
|
+
previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil)
|
|
61
|
+
previous_cli = ENV.fetch("SIMPLECOV_CLI", nil)
|
|
62
|
+
ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
|
|
63
|
+
# SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
|
|
64
|
+
# CLI-only behavior — e.g. simplecov itself sets `coverage_dir`
|
|
65
|
+
# to the dogfood path here but skips that for descendants.
|
|
66
|
+
ENV["SIMPLECOV_CLI"] = "1"
|
|
67
|
+
require "simplecov"
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
|
|
71
|
+
ENV["SIMPLECOV_CLI"] = previous_cli
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Load `path` with `SimpleCov.start` and the at_exit installer
|
|
75
|
+
# turned into no-ops, so a project whose dotfile calls
|
|
76
|
+
# `SimpleCov.start` doesn't trigger Coverage just because we asked
|
|
77
|
+
# for `coverage_dir`. Config inside any `SimpleCov.start { ... }`
|
|
78
|
+
# block still runs.
|
|
79
|
+
def load_with_start_neutered(path)
|
|
80
|
+
klass = SimpleCov.singleton_class
|
|
81
|
+
names = %i[start_tracking install_at_exit_hook]
|
|
82
|
+
stash = names.to_h { |name| [name, klass.instance_method(name)] }
|
|
83
|
+
# define_method over an existing method emits a "method redefined"
|
|
84
|
+
# warning under $VERBOSE; the override and restore are intentional.
|
|
85
|
+
silence_verbose { names.each { |name| klass.define_method(name) { nil } } }
|
|
86
|
+
load path
|
|
87
|
+
ensure
|
|
88
|
+
silence_verbose { stash.each { |name, method| klass.define_method(name, method) } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def silence_verbose
|
|
92
|
+
previous = $VERBOSE
|
|
93
|
+
$VERBOSE = nil
|
|
94
|
+
yield
|
|
95
|
+
ensure
|
|
96
|
+
$VERBOSE = previous
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
module CLI
|
|
8
|
+
# `simplecov merge <files...>` — wrap SimpleCov::ResultMerger so a
|
|
9
|
+
# CI matrix that produces one .resultset.json per worker can stitch
|
|
10
|
+
# them together from the shell instead of dropping a Rake task into
|
|
11
|
+
# every project. Requires the full simplecov library to be on the
|
|
12
|
+
# load path; lazy-required so the read-only subcommands above don't
|
|
13
|
+
# pay for ResultMerger (and its Coverage runtime guard).
|
|
14
|
+
module Merge
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(args, stdout:, stderr:, **)
|
|
18
|
+
opts = parse(args)
|
|
19
|
+
return error(stderr, "missing input files") if opts[:files].empty?
|
|
20
|
+
return 1 unless valid_inputs?(opts[:files], stderr)
|
|
21
|
+
|
|
22
|
+
require "simplecov"
|
|
23
|
+
result = SimpleCov::ResultMerger.merge_results(*opts[:files], ignore_timeout: !opts[:honor_timeout])
|
|
24
|
+
return error(stderr, "no mergeable results in input files") unless result
|
|
25
|
+
|
|
26
|
+
commit(opts, result, stdout)
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def commit(opts, result, stdout)
|
|
31
|
+
verb = opts[:dry_run] ? "would write" : "wrote"
|
|
32
|
+
write(opts[:output], result) unless opts[:dry_run]
|
|
33
|
+
stdout.puts("simplecov merge: #{verb} #{opts[:output]}") unless opts[:quiet]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def valid_inputs?(files, stderr)
|
|
37
|
+
parsed = parse_inputs(files, stderr) or return false
|
|
38
|
+
|
|
39
|
+
warn_about_duplicate_command_names(parsed, stderr)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse(args)
|
|
44
|
+
opts = {output: SimpleCov::CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
|
|
45
|
+
files =
|
|
46
|
+
OptionParser.new do |o|
|
|
47
|
+
o.on("--output PATH") { |v| opts[:output] = v }
|
|
48
|
+
o.on("--honor-timeout") { opts[:honor_timeout] = true }
|
|
49
|
+
o.on("--dry-run") { opts[:dry_run] = true }
|
|
50
|
+
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
51
|
+
end.parse(args)
|
|
52
|
+
opts.merge(files: files)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validate every input file up-front and return a {path => parsed}
|
|
56
|
+
# hash. Surfacing per-file errors here turns ResultMerger's
|
|
57
|
+
# generic "no mergeable results" into a message that points at
|
|
58
|
+
# the specific input causing the failure.
|
|
59
|
+
def parse_inputs(files, stderr)
|
|
60
|
+
files.each_with_object({}) do |path, memo|
|
|
61
|
+
data = parse_input(path, stderr) or return nil
|
|
62
|
+
|
|
63
|
+
memo[path] = data
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_input(path, stderr)
|
|
68
|
+
return parse_input_error(stderr, path, "not found") unless File.exist?(path)
|
|
69
|
+
|
|
70
|
+
data = JSON.parse(File.read(path))
|
|
71
|
+
return data if data.is_a?(Hash) && !data.empty?
|
|
72
|
+
|
|
73
|
+
parse_input_error(stderr, path, "has no resultset entries")
|
|
74
|
+
rescue JSON::ParserError => e
|
|
75
|
+
parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_input_error(stderr, path, reason)
|
|
79
|
+
stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# When two input files share a command_name, ResultMerger folds
|
|
84
|
+
# them together with last-write-wins on the timestamp — easy to
|
|
85
|
+
# mistake for "no merge happened." Surface the overlap so the
|
|
86
|
+
# operator can rename the workers or accept the merge knowingly.
|
|
87
|
+
def warn_about_duplicate_command_names(parsed, stderr)
|
|
88
|
+
files_per_command = parsed.each_with_object({}) do |(path, data), memo|
|
|
89
|
+
data.each_key { |command_name| (memo[command_name] ||= []) << path }
|
|
90
|
+
end
|
|
91
|
+
files_per_command.each do |command_name, paths|
|
|
92
|
+
next if paths.size < 2
|
|
93
|
+
|
|
94
|
+
stderr.puts(duplicate_warning(command_name, paths))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def duplicate_warning(command_name, paths)
|
|
99
|
+
"simplecov merge: warning — command_name #{command_name.inspect} " \
|
|
100
|
+
"appears in #{paths.size} input files (#{paths.join(', ')}); " \
|
|
101
|
+
"entries will be merged"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def write(path, result)
|
|
105
|
+
require "fileutils"
|
|
106
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
107
|
+
File.write(path, JSON.pretty_generate(result.to_hash))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def error(stderr, message)
|
|
111
|
+
stderr.puts("simplecov merge: #{message}")
|
|
112
|
+
1
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
# `simplecov open [--report PATH]` — open the HTML report in the
|
|
8
|
+
# platform's default browser. Tiny QoL wrapper around `xdg-open` /
|
|
9
|
+
# `open` / `start` so users don't have to type a file:// URL.
|
|
10
|
+
module Open
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def run(args, stderr:, **)
|
|
14
|
+
path = parse(args)
|
|
15
|
+
return error(stderr, "#{path} not found") unless File.exist?(path)
|
|
16
|
+
|
|
17
|
+
opener = browser_opener
|
|
18
|
+
return error(stderr, "no known opener for #{RbConfig::CONFIG['host_os']}") unless opener
|
|
19
|
+
|
|
20
|
+
system(*opener, path) ? 0 : 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def error(stderr, message)
|
|
24
|
+
stderr.puts("simplecov open: #{message}")
|
|
25
|
+
1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse(args)
|
|
29
|
+
path = SimpleCov::CLI.default_report
|
|
30
|
+
OptionParser.new do |o|
|
|
31
|
+
o.on("--report PATH") { |v| path = v }
|
|
32
|
+
end.parse(args)
|
|
33
|
+
path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the argv for the platform's "open this file" command, or
|
|
37
|
+
# nil if the host OS isn't recognized. On Windows, `start` is a
|
|
38
|
+
# cmd.exe builtin (not an executable), so route through `cmd /c`;
|
|
39
|
+
# the empty string is the window-title positional `start` takes
|
|
40
|
+
# before the path so a quoted path isn't mis-parsed as the title.
|
|
41
|
+
def browser_opener
|
|
42
|
+
case RbConfig::CONFIG["host_os"]
|
|
43
|
+
when /darwin/ then ["open"]
|
|
44
|
+
when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
|
|
45
|
+
when /linux|bsd|solaris/ then ["xdg-open"]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|