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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
# Replaces an artifact through a collision-safe temporary file in the
|
|
8
|
+
# destination directory, so readers see either the old or complete new file.
|
|
9
|
+
module AtomicFile
|
|
10
|
+
DEFAULT_MODE = 0o666
|
|
11
|
+
private_constant :DEFAULT_MODE
|
|
12
|
+
|
|
13
|
+
def self.write(path, content, binary: false)
|
|
14
|
+
directory = File.dirname(path)
|
|
15
|
+
FileUtils.mkdir_p(directory)
|
|
16
|
+
mode = destination_mode(path)
|
|
17
|
+
|
|
18
|
+
Tempfile.create([".simplecov-", ".tmp"], directory) do |temp|
|
|
19
|
+
replace(temp, path, content, mode, binary: binary)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.replace(temp, path, content, mode, binary:)
|
|
26
|
+
temp.binmode if binary
|
|
27
|
+
temp.write(content)
|
|
28
|
+
temp.close
|
|
29
|
+
File.chmod(mode, temp.path)
|
|
30
|
+
rename_over(temp.path, path)
|
|
31
|
+
ensure
|
|
32
|
+
temp.close unless temp.closed?
|
|
33
|
+
FileUtils.rm_f(temp.path)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :replace
|
|
36
|
+
|
|
37
|
+
# On POSIX systems rename replaces the destination atomically. On
|
|
38
|
+
# Windows it fails with EACCES when the destination is open in a
|
|
39
|
+
# reader or mid-replacement by a concurrent writer, so retry briefly
|
|
40
|
+
# there before giving up.
|
|
41
|
+
def self.rename_over(temp_path, path)
|
|
42
|
+
attempts = 0
|
|
43
|
+
begin
|
|
44
|
+
File.rename(temp_path, path)
|
|
45
|
+
rescue Errno::EACCES
|
|
46
|
+
# simplecov:disable branch — which arm runs is fixed by the platform
|
|
47
|
+
raise unless Gem.win_platform?
|
|
48
|
+
|
|
49
|
+
# simplecov:enable branch
|
|
50
|
+
# simplecov:disable — Windows-only; unreachable where dogfood runs
|
|
51
|
+
attempts += 1
|
|
52
|
+
raise if attempts >= 10
|
|
53
|
+
|
|
54
|
+
sleep(0.01)
|
|
55
|
+
retry
|
|
56
|
+
# simplecov:enable
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_class_method :rename_over
|
|
60
|
+
|
|
61
|
+
def self.destination_mode(path)
|
|
62
|
+
return DEFAULT_MODE & ~File.umask if File.symlink?(path)
|
|
63
|
+
|
|
64
|
+
File.stat(path).mode & 0o777
|
|
65
|
+
rescue Errno::ENOENT
|
|
66
|
+
DEFAULT_MODE & ~File.umask
|
|
67
|
+
end
|
|
68
|
+
private_class_method :destination_mode
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/simplecov/cli/clean.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require_relative "command_helpers"
|
|
4
5
|
|
|
5
6
|
module SimpleCov
|
|
6
7
|
module CLI
|
|
@@ -9,11 +10,14 @@ module SimpleCov
|
|
|
9
10
|
# `--dry-run` flag prints what would be deleted without touching
|
|
10
11
|
# disk, for when you're not sure what's in there.
|
|
11
12
|
module Clean
|
|
13
|
+
extend CommandHelpers
|
|
14
|
+
|
|
12
15
|
module_function
|
|
13
16
|
|
|
14
|
-
def run(args, stdout:, **)
|
|
17
|
+
def run(args, stdout:, stderr:, **)
|
|
15
18
|
opts = parse(args)
|
|
16
19
|
dir = SimpleCov::CLI.coverage_dir
|
|
20
|
+
return error(stderr, "refusing to remove unsafe coverage directory #{dir.inspect}") unless safe_to_remove?(dir)
|
|
17
21
|
return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
|
|
18
22
|
|
|
19
23
|
sweep(dir, opts, stdout)
|
|
@@ -22,7 +26,7 @@ module SimpleCov
|
|
|
22
26
|
|
|
23
27
|
def sweep(dir, opts, stdout)
|
|
24
28
|
if opts[:dry_run]
|
|
25
|
-
announce(stdout, opts, "would remove #{dir} (#{
|
|
29
|
+
announce(stdout, opts, "would remove #{dir} (#{entry_count(dir)} entries)")
|
|
26
30
|
else
|
|
27
31
|
require "fileutils"
|
|
28
32
|
FileUtils.rm_rf(dir)
|
|
@@ -30,8 +34,44 @@ module SimpleCov
|
|
|
30
34
|
end
|
|
31
35
|
end
|
|
32
36
|
|
|
37
|
+
# FNM_DOTMATCH so the dotfiles rm_rf will actually delete
|
|
38
|
+
# (.resultset.json, .last_run.json) count too; the glob still
|
|
39
|
+
# yields the directory's own "." entry, which rm_rf does not
|
|
40
|
+
# remove separately.
|
|
41
|
+
def entry_count(dir)
|
|
42
|
+
Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).count { |path| !path.end_with?("/.") }
|
|
43
|
+
end
|
|
44
|
+
|
|
33
45
|
def announce(stdout, opts, message)
|
|
34
|
-
stdout.puts("simplecov
|
|
46
|
+
stdout.puts("simplecov #{command_name}: #{message}") unless opts[:quiet]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def safe_to_remove?(dir)
|
|
50
|
+
target = canonical_path(dir)
|
|
51
|
+
# A filesystem root is its own dirname ("/" everywhere, drive
|
|
52
|
+
# roots like "D:/" on Windows). The descendant check below only
|
|
53
|
+
# protects roots that happen to contain the cwd or project root,
|
|
54
|
+
# which misses other drives, so refuse roots outright.
|
|
55
|
+
return false if File.dirname(target) == target
|
|
56
|
+
|
|
57
|
+
protected_paths.none? { |path| path == target || descendant_of?(path, target) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def protected_paths
|
|
61
|
+
project_root = SimpleCov::CLI::Dotfile.find
|
|
62
|
+
[Dir.pwd, (File.dirname(project_root) if project_root)].compact.map { |path| canonical_path(path) }.uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Canonical paths only carry a trailing separator on a filesystem
|
|
66
|
+
# root, and roots are refused before this check runs.
|
|
67
|
+
def descendant_of?(path, possible_ancestor)
|
|
68
|
+
path.start_with?("#{possible_ancestor}#{File::SEPARATOR}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def canonical_path(path)
|
|
72
|
+
File.realpath(path)
|
|
73
|
+
rescue SystemCallError
|
|
74
|
+
File.expand_path(path)
|
|
35
75
|
end
|
|
36
76
|
|
|
37
77
|
def parse(args)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
# Plumbing the subcommands repeat, which their modules `extend`: the
|
|
8
|
+
# standard option trio the read-only commands share, the parse
|
|
9
|
+
# scaffold around it, the one-line error helper (prefixed with the
|
|
10
|
+
# command's own name), and the human-readable stats row `coverage`
|
|
11
|
+
# and `report` both print.
|
|
12
|
+
module CommandHelpers
|
|
13
|
+
STATS_ROW_FORMAT = " %<label>-7s %<pct>s (%<covered>d / %<total>d)"
|
|
14
|
+
|
|
15
|
+
# "simplecov merge: ..." — derived from the extending module's
|
|
16
|
+
# name. The self cast collapses the mixin's view of `self` to the
|
|
17
|
+
# Module that extends it, which is what carries `name`.
|
|
18
|
+
def command_name
|
|
19
|
+
(_ = self).name.split("::").last.downcase
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def error(stderr, message)
|
|
23
|
+
stderr.puts("simplecov #{command_name}: #{message}")
|
|
24
|
+
1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The --input / --json / --no-color trio every read-only
|
|
28
|
+
# subcommand accepts.
|
|
29
|
+
def common_options(parser, opts)
|
|
30
|
+
parser.on("--input PATH") { |v| opts[:input] = v }
|
|
31
|
+
parser.on("--json") { opts[:json] = true }
|
|
32
|
+
parser.on("--no-color") { opts[:no_color] = true }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The parse scaffold every read-only subcommand repeats: seed the
|
|
36
|
+
# shared defaults (plus the command's own), wire up the common
|
|
37
|
+
# option trio, yield the parser for command-specific flags, and
|
|
38
|
+
# return the parsed opts alongside the positional arguments.
|
|
39
|
+
def parse_common(args, defaults = {})
|
|
40
|
+
opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false} #: Hash[Symbol, untyped]
|
|
41
|
+
opts.merge!(defaults)
|
|
42
|
+
rest =
|
|
43
|
+
OptionParser.new do |parser|
|
|
44
|
+
common_options(parser, opts)
|
|
45
|
+
yield parser, opts if block_given?
|
|
46
|
+
end.parse(args)
|
|
47
|
+
[opts, rest]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stats_row(label, percent_text, covered, total)
|
|
51
|
+
format(STATS_ROW_FORMAT, label: "#{label}:", pct: percent_text, covered: covered.to_i, total: total.to_i)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -2,17 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
5
6
|
|
|
6
7
|
module SimpleCov
|
|
7
8
|
module CLI
|
|
8
9
|
# `simplecov coverage <path>` — print per-criterion stats for one
|
|
9
10
|
# file from a JSONFormatter coverage.json.
|
|
10
11
|
module Coverage
|
|
11
|
-
|
|
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
|
|
12
|
+
extend CommandHelpers
|
|
16
13
|
|
|
17
14
|
module_function
|
|
18
15
|
|
|
@@ -28,13 +25,7 @@ module SimpleCov
|
|
|
28
25
|
end
|
|
29
26
|
|
|
30
27
|
def parse(args, stderr:)
|
|
31
|
-
opts
|
|
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)
|
|
28
|
+
opts, rest = parse_common(args)
|
|
38
29
|
return stderr.puts("simplecov coverage: missing file argument") && nil if rest.empty?
|
|
39
30
|
|
|
40
31
|
opts[:path] = rest.first
|
|
@@ -42,15 +33,18 @@ module SimpleCov
|
|
|
42
33
|
end
|
|
43
34
|
|
|
44
35
|
def locate_match(opts, stderr)
|
|
45
|
-
|
|
36
|
+
coverage = CoverageFile.load_coverage(opts[:input], command: "coverage", stderr: stderr)
|
|
37
|
+
return unless coverage
|
|
46
38
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
match = lookup(coverage, opts[:path])
|
|
40
|
+
if match.nil?
|
|
41
|
+
stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
|
|
42
|
+
return nil
|
|
43
|
+
end
|
|
44
|
+
# A wrong-typed entry used to escape here and crash print_human.
|
|
45
|
+
return match if match.last.is_a?(Hash)
|
|
51
46
|
|
|
52
|
-
|
|
53
|
-
nil
|
|
47
|
+
CoverageFile.report_invalid(stderr, "coverage", opts[:input], "entry for #{opts[:path]} must be an object")
|
|
54
48
|
end
|
|
55
49
|
|
|
56
50
|
# Match either the absolute path, the literal string passed, or
|
|
@@ -75,18 +69,17 @@ module SimpleCov
|
|
|
75
69
|
|
|
76
70
|
def print_human(filename, payload, stdout, color)
|
|
77
71
|
stdout.puts(filename)
|
|
78
|
-
CRITERIA.
|
|
72
|
+
CoverageFile::CRITERIA.each_value { |c| emit_criterion(stdout, payload, c, color) }
|
|
79
73
|
end
|
|
80
74
|
|
|
81
75
|
def emit_criterion(stdout, payload, criterion, color)
|
|
82
|
-
return unless payload.key?(criterion[:
|
|
83
|
-
|
|
84
|
-
pct = payload[criterion[:
|
|
85
|
-
stdout.puts(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
total: payload[criterion[:tot]] || 0))
|
|
76
|
+
return unless payload.key?(criterion[:percent])
|
|
77
|
+
|
|
78
|
+
pct = payload[criterion[:percent]].to_f
|
|
79
|
+
stdout.puts(stats_row(criterion[:label],
|
|
80
|
+
SimpleCov::Color.colorize_percent(pct, enabled: color),
|
|
81
|
+
payload[criterion[:covered]],
|
|
82
|
+
payload[criterion[:total]]))
|
|
90
83
|
end
|
|
91
84
|
end
|
|
92
85
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../coverage_json"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
# Shared input boundary for commands that consume JSONFormatter's
|
|
8
|
+
# coverage.json. It validates only the stable outer shape so older schema
|
|
9
|
+
# versions remain readable.
|
|
10
|
+
module CoverageFile
|
|
11
|
+
# The coverage.json fields backing each criterion, keyed by the
|
|
12
|
+
# criterion's singular name. The one table the coverage, uncovered,
|
|
13
|
+
# and diff subcommands all read from, so a schema change lands in
|
|
14
|
+
# exactly one place.
|
|
15
|
+
CRITERIA = {
|
|
16
|
+
line: {label: "Line", percent: "lines_covered_percent",
|
|
17
|
+
covered: "covered_lines", total: "total_lines"},
|
|
18
|
+
branch: {label: "Branch", percent: "branches_covered_percent",
|
|
19
|
+
covered: "covered_branches", total: "total_branches"},
|
|
20
|
+
method: {label: "Method", percent: "methods_covered_percent",
|
|
21
|
+
covered: "covered_methods", total: "total_methods"}
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def load_document(path, command:, stderr:)
|
|
27
|
+
SimpleCov::CoverageJSON.load(path)
|
|
28
|
+
rescue Errno::ENOENT
|
|
29
|
+
report_missing(stderr, command, path)
|
|
30
|
+
rescue SimpleCov::CoverageJSON::Error => e
|
|
31
|
+
report_invalid(stderr, command, path, e.message)
|
|
32
|
+
rescue SystemCallError => e
|
|
33
|
+
report_unreadable(stderr, command, path, e.message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load_coverage(path, command:, stderr:)
|
|
37
|
+
document = load_document(path, command: command, stderr: stderr)
|
|
38
|
+
return unless document
|
|
39
|
+
|
|
40
|
+
none = {} #: Hash[String, untyped]
|
|
41
|
+
coverage = document.fetch("coverage", none)
|
|
42
|
+
return coverage if coverage.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
report_invalid(stderr, command, path, '"coverage" must be an object')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def report_invalid(stderr, command, path, reason)
|
|
48
|
+
detail = reason.lines.first.to_s.strip
|
|
49
|
+
stderr.puts("simplecov #{command}: input file #{path.inspect} isn't valid JSON (#{detail})")
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report_missing(stderr, command, path)
|
|
54
|
+
stderr.puts("simplecov #{command}: #{path} not found")
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def report_unreadable(stderr, command, path, reason)
|
|
59
|
+
detail = reason.lines.first.to_s.strip
|
|
60
|
+
stderr.puts("simplecov #{command}: cannot read #{path.inspect} (#{detail})")
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/simplecov/cli/diff.rb
CHANGED
|
@@ -2,28 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
5
6
|
|
|
6
7
|
module SimpleCov
|
|
7
8
|
module CLI
|
|
8
|
-
# `simplecov diff <baseline>` — print
|
|
9
|
-
#
|
|
9
|
+
# `simplecov diff <baseline>` — print per-file coverage deltas between
|
|
10
|
+
# coverage.json (--input) and a baseline coverage.json
|
|
10
11
|
# checked in alongside the suite. Only files whose coverage moved
|
|
11
12
|
# are listed; --fail-on-drop exits non-zero when any file regressed,
|
|
12
13
|
# so this composes with CI as a "coverage of this PR didn't drop"
|
|
13
14
|
# gate. Resolves the long-standing "diff coverage" feature request.
|
|
14
15
|
module Diff
|
|
15
|
-
|
|
16
|
+
extend CommandHelpers
|
|
16
17
|
|
|
17
|
-
#
|
|
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
|
|
18
|
+
EPSILON = 0.005 # tolerance below which a delta is considered noise
|
|
27
19
|
|
|
28
20
|
STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
|
|
29
21
|
|
|
@@ -36,11 +28,11 @@ module SimpleCov
|
|
|
36
28
|
rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
|
|
37
29
|
rows.sort_by! { |row| row[:line_delta] }
|
|
38
30
|
if opts[:json]
|
|
39
|
-
|
|
31
|
+
stdout.puts(JSON.pretty_generate(rows))
|
|
40
32
|
else
|
|
41
33
|
emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
42
34
|
end
|
|
43
|
-
opts[:fail_on_drop] && rows
|
|
35
|
+
opts[:fail_on_drop] && coverage_drop?(rows) ? 1 : 0
|
|
44
36
|
end
|
|
45
37
|
|
|
46
38
|
def parse(args, stderr)
|
|
@@ -53,26 +45,18 @@ module SimpleCov
|
|
|
53
45
|
end
|
|
54
46
|
|
|
55
47
|
def parse_flags(args)
|
|
56
|
-
opts =
|
|
57
|
-
|
|
58
|
-
|
|
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 }
|
|
48
|
+
opts, rest = parse_common(args, fail_on_drop: false, threshold: 0.0) do |o, options|
|
|
49
|
+
o.on("--fail-on-drop") { options[:fail_on_drop] = true }
|
|
50
|
+
o.on("--threshold N", Float) { |v| options[:threshold] = v }
|
|
67
51
|
end
|
|
52
|
+
opts.merge(rest: rest)
|
|
68
53
|
end
|
|
69
54
|
|
|
70
55
|
def load_coverage(path, stderr)
|
|
71
|
-
|
|
72
|
-
return
|
|
56
|
+
coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
|
|
57
|
+
return unless coverage
|
|
73
58
|
|
|
74
|
-
|
|
75
|
-
nil
|
|
59
|
+
normalize_keys(coverage)
|
|
76
60
|
end
|
|
77
61
|
|
|
78
62
|
# Strip a leading slash so coverage.json files written before the
|
|
@@ -87,18 +71,23 @@ module SimpleCov
|
|
|
87
71
|
files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
|
|
88
72
|
end
|
|
89
73
|
|
|
74
|
+
# EPSILON keeps float noise out regardless of threshold; the
|
|
75
|
+
# threshold itself is inclusive, so a file that moved exactly N%
|
|
76
|
+
# is listed under `--threshold N`, matching the "at least N%" the
|
|
77
|
+
# usage text promises.
|
|
90
78
|
def compute_row(fname, current_payload, baseline_payload, threshold)
|
|
91
|
-
deltas =
|
|
92
|
-
floor =
|
|
93
|
-
return nil unless deltas.values.any? { |delta| delta.abs > floor }
|
|
94
|
-
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
79
|
+
deltas = compute_deltas(current_payload, baseline_payload)
|
|
80
|
+
floor = threshold.abs
|
|
81
|
+
return nil unless deltas.values.any? { |delta| delta.abs > EPSILON && delta.abs >= floor }
|
|
82
|
+
|
|
83
|
+
{file: fname, status: status_for(current_payload, baseline_payload),
|
|
84
|
+
line_delta: deltas[:line], branch_delta: deltas[:branch], method_delta: deltas[:method]}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def compute_deltas(current_payload, baseline_payload)
|
|
88
|
+
CoverageFile::CRITERIA.transform_values do |fields|
|
|
89
|
+
pct_for(fields, current_payload) - pct_for(fields, baseline_payload)
|
|
90
|
+
end
|
|
102
91
|
end
|
|
103
92
|
|
|
104
93
|
def status_for(current_payload, baseline_payload)
|
|
@@ -108,11 +97,10 @@ module SimpleCov
|
|
|
108
97
|
"changed"
|
|
109
98
|
end
|
|
110
99
|
|
|
111
|
-
def pct_for(
|
|
112
|
-
fields = CRITERION_FIELDS.fetch(criterion)
|
|
100
|
+
def pct_for(fields, payload)
|
|
113
101
|
return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
|
|
114
102
|
|
|
115
|
-
payload[fields[:
|
|
103
|
+
payload[fields[:percent]].to_f
|
|
116
104
|
end
|
|
117
105
|
|
|
118
106
|
def emit_text(stdout, rows, color)
|
|
@@ -135,6 +123,17 @@ module SimpleCov
|
|
|
135
123
|
].compact
|
|
136
124
|
end
|
|
137
125
|
|
|
126
|
+
# A removed file's deltas are all -baseline%, but deleting a
|
|
127
|
+
# covered file (dead code cleanup) is not a coverage regression,
|
|
128
|
+
# so removed rows never trip the --fail-on-drop gate. Drops are
|
|
129
|
+
# gated on EPSILON like row inclusion and display: a row listed
|
|
130
|
+
# for a gain in one criterion must not fail the run over float
|
|
131
|
+
# noise in another, invisible in the printed output.
|
|
132
|
+
def coverage_drop?(rows)
|
|
133
|
+
rows.reject { |row| row[:status] == "removed" }
|
|
134
|
+
.any? { |row| row.values_at(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
|
|
135
|
+
end
|
|
136
|
+
|
|
138
137
|
# Deltas are sign-based, not threshold-based: a +5% bump is good
|
|
139
138
|
# (green) and a -5% drop is bad (red), regardless of where the
|
|
140
139
|
# absolute coverage level lands.
|
|
@@ -143,10 +142,6 @@ module SimpleCov
|
|
|
143
142
|
text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
|
|
144
143
|
SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
|
|
145
144
|
end
|
|
146
|
-
|
|
147
|
-
def emit_json(stdout, rows)
|
|
148
|
-
stdout.puts(JSON.pretty_generate(rows))
|
|
149
|
-
end
|
|
150
145
|
end
|
|
151
146
|
end
|
|
152
147
|
end
|
|
@@ -18,9 +18,12 @@ module SimpleCov
|
|
|
18
18
|
return "coverage" unless dotfile
|
|
19
19
|
|
|
20
20
|
with_simplecov_loaded { read_from(dotfile) }
|
|
21
|
-
rescue
|
|
22
|
-
# simplecov:disable — defensive fallback for a bad dotfile
|
|
23
|
-
#
|
|
21
|
+
rescue ScriptError, StandardError => e
|
|
22
|
+
# simplecov:disable — defensive fallback for a bad dotfile; never
|
|
23
|
+
# fires in the project's own dogfood run. ScriptError covers the
|
|
24
|
+
# SyntaxError a malformed dotfile raises from `load` (it is not a
|
|
25
|
+
# StandardError) along with LoadError; StandardError covers the
|
|
26
|
+
# rest (EACCES, errors raised by the dotfile's own code, etc.).
|
|
24
27
|
warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
|
|
25
28
|
"coverage"
|
|
26
29
|
# simplecov:enable
|
|
@@ -37,11 +40,14 @@ module SimpleCov
|
|
|
37
40
|
# inside a Ruby host that cares about isolation should arrange that
|
|
38
41
|
# themselves.
|
|
39
42
|
def read_from(dotfile)
|
|
40
|
-
snapshot = SimpleCov.instance_variable_get(:@coverage_dir)
|
|
43
|
+
snapshot = SimpleCov.instance_variable_get(:@coverage_dir) #: String?
|
|
41
44
|
load_with_start_neutered(dotfile)
|
|
42
|
-
|
|
45
|
+
SimpleCov.coverage_dir
|
|
46
|
+
ensure
|
|
47
|
+
# Restore even when the load raises, so a bad dotfile doesn't
|
|
48
|
+
# leave a host process's configured dir clobbered.
|
|
49
|
+
# @type var snapshot: String?
|
|
43
50
|
SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
|
|
44
|
-
dir
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def find
|
data/lib/simplecov/cli/merge.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require_relative "../atomic_file"
|
|
6
|
+
require_relative "command_helpers"
|
|
5
7
|
|
|
6
8
|
module SimpleCov
|
|
7
9
|
module CLI
|
|
@@ -12,6 +14,8 @@ module SimpleCov
|
|
|
12
14
|
# load path; lazy-required so the read-only subcommands above don't
|
|
13
15
|
# pay for ResultMerger (and its Coverage runtime guard).
|
|
14
16
|
module Merge
|
|
17
|
+
extend CommandHelpers
|
|
18
|
+
|
|
15
19
|
module_function
|
|
16
20
|
|
|
17
21
|
def run(args, stdout:, stderr:, **)
|
|
@@ -65,15 +69,20 @@ module SimpleCov
|
|
|
65
69
|
end
|
|
66
70
|
end
|
|
67
71
|
|
|
72
|
+
# Read first, classify by the exception: an exist?-then-read pair
|
|
73
|
+
# is racy, and rescuing only ENOENT crashed with a backtrace on a
|
|
74
|
+
# directory (EISDIR) or an unreadable file (EACCES).
|
|
68
75
|
def parse_input(path, stderr)
|
|
69
|
-
return parse_input_error(stderr, path, "not found") unless File.exist?(path)
|
|
70
|
-
|
|
71
76
|
data = JSON.parse(File.read(path))
|
|
72
77
|
return data if data.is_a?(Hash) && !data.empty?
|
|
73
78
|
|
|
74
79
|
parse_input_error(stderr, path, "has no resultset entries")
|
|
80
|
+
rescue Errno::ENOENT
|
|
81
|
+
parse_input_error(stderr, path, "not found")
|
|
75
82
|
rescue JSON::ParserError => e
|
|
76
83
|
parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
|
|
84
|
+
rescue SystemCallError => e
|
|
85
|
+
parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.strip})")
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
def parse_input_error(stderr, path, reason)
|
|
@@ -104,14 +113,7 @@ module SimpleCov
|
|
|
104
113
|
end
|
|
105
114
|
|
|
106
115
|
def write(path, result)
|
|
107
|
-
|
|
108
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
109
|
-
File.write(path, JSON.pretty_generate(result.to_hash))
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def error(stderr, message)
|
|
113
|
-
stderr.puts("simplecov merge: #{message}")
|
|
114
|
-
1
|
|
116
|
+
AtomicFile.write(path, JSON.pretty_generate(result.to_hash))
|
|
115
117
|
end
|
|
116
118
|
end
|
|
117
119
|
end
|
data/lib/simplecov/cli/open.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require_relative "command_helpers"
|
|
4
5
|
|
|
5
6
|
module SimpleCov
|
|
6
7
|
module CLI
|
|
@@ -8,6 +9,8 @@ module SimpleCov
|
|
|
8
9
|
# platform's default browser. Tiny QoL wrapper around `xdg-open` /
|
|
9
10
|
# `open` / `start` so users don't have to type a file:// URL.
|
|
10
11
|
module Open
|
|
12
|
+
extend CommandHelpers
|
|
13
|
+
|
|
11
14
|
module_function
|
|
12
15
|
|
|
13
16
|
def run(args, stderr:, **)
|
|
@@ -20,11 +23,6 @@ module SimpleCov
|
|
|
20
23
|
system(*opener, path) ? 0 : 1
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
def error(stderr, message)
|
|
24
|
-
stderr.puts("simplecov open: #{message}")
|
|
25
|
-
1
|
|
26
|
-
end
|
|
27
|
-
|
|
28
26
|
def parse(args)
|
|
29
27
|
path = SimpleCov::CLI.default_report
|
|
30
28
|
OptionParser.new do |o|
|