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
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,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
|
|
@@ -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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "command_helpers"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
module CLI
|
|
8
|
+
# `simplecov clean [--dry-run]` — remove the coverage report
|
|
9
|
+
# directory (or whatever `SimpleCov.coverage_dir` resolves to). The
|
|
10
|
+
# `--dry-run` flag prints what would be deleted without touching
|
|
11
|
+
# disk, for when you're not sure what's in there.
|
|
12
|
+
module Clean
|
|
13
|
+
extend CommandHelpers
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(args, stdout:, stderr:, **)
|
|
18
|
+
opts = parse(args)
|
|
19
|
+
dir = SimpleCov::CLI.coverage_dir
|
|
20
|
+
return error(stderr, "refusing to remove unsafe coverage directory #{dir.inspect}") unless safe_to_remove?(dir)
|
|
21
|
+
return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
|
|
22
|
+
|
|
23
|
+
sweep(dir, opts, stdout)
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sweep(dir, opts, stdout)
|
|
28
|
+
if opts[:dry_run]
|
|
29
|
+
announce(stdout, opts, "would remove #{dir} (#{entry_count(dir)} entries)")
|
|
30
|
+
else
|
|
31
|
+
require "fileutils"
|
|
32
|
+
FileUtils.rm_rf(dir)
|
|
33
|
+
announce(stdout, opts, "removed #{dir}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
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
|
+
|
|
45
|
+
def announce(stdout, opts, message)
|
|
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)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse(args)
|
|
78
|
+
opts = {dry_run: false, quiet: false}
|
|
79
|
+
OptionParser.new do |o|
|
|
80
|
+
o.on("--dry-run") { opts[:dry_run] = true }
|
|
81
|
+
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
82
|
+
end.parse(args)
|
|
83
|
+
opts
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
|
|
7
|
+
module SimpleCov
|
|
8
|
+
module CLI
|
|
9
|
+
# `simplecov coverage <path>` — print per-criterion stats for one
|
|
10
|
+
# file from a JSONFormatter coverage.json.
|
|
11
|
+
module Coverage
|
|
12
|
+
extend CommandHelpers
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def run(args, stdout:, stderr:)
|
|
17
|
+
opts = parse(args, stderr: stderr)
|
|
18
|
+
return 1 unless opts
|
|
19
|
+
|
|
20
|
+
match = locate_match(opts, stderr)
|
|
21
|
+
return 1 unless match
|
|
22
|
+
|
|
23
|
+
emit(match, opts, stdout)
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse(args, stderr:)
|
|
28
|
+
opts, rest = parse_common(args)
|
|
29
|
+
return stderr.puts("simplecov coverage: missing file argument") && nil if rest.empty?
|
|
30
|
+
|
|
31
|
+
opts[:path] = rest.first
|
|
32
|
+
opts
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def locate_match(opts, stderr)
|
|
36
|
+
coverage = CoverageFile.load_coverage(opts[:input], command: "coverage", stderr: stderr)
|
|
37
|
+
return unless coverage
|
|
38
|
+
|
|
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)
|
|
46
|
+
|
|
47
|
+
CoverageFile.report_invalid(stderr, "coverage", opts[:input], "entry for #{opts[:path]} must be an object")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Match either the absolute path, the literal string passed, or
|
|
51
|
+
# any coverage entry whose absolute filename ends with "/<path>".
|
|
52
|
+
# That covers the three natural ways a user types a path: relative
|
|
53
|
+
# to project root ("app/foo.rb"), absolute, or basename-only.
|
|
54
|
+
def lookup(coverage_hash, path)
|
|
55
|
+
absolute = File.expand_path(path)
|
|
56
|
+
suffix = "/#{path}"
|
|
57
|
+
coverage_hash.find { |fname, _| fname == absolute || fname == path || fname.end_with?(suffix) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def emit(match, opts, stdout)
|
|
61
|
+
filename, payload = match
|
|
62
|
+
if opts[:json]
|
|
63
|
+
entry = {filename => payload} #: Hash[untyped, untyped]
|
|
64
|
+
stdout.puts(JSON.pretty_generate(entry))
|
|
65
|
+
else
|
|
66
|
+
print_human(filename, payload, stdout, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def print_human(filename, payload, stdout, color)
|
|
71
|
+
stdout.puts(filename)
|
|
72
|
+
CoverageFile::CRITERIA.each_value { |c| emit_criterion(stdout, payload, c, color) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def emit_criterion(stdout, payload, criterion, color)
|
|
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]]))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
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
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
|
|
7
|
+
module SimpleCov
|
|
8
|
+
module CLI
|
|
9
|
+
# `simplecov diff <baseline>` — print per-file coverage deltas between
|
|
10
|
+
# coverage.json (--input) and a baseline coverage.json
|
|
11
|
+
# checked in alongside the suite. Only files whose coverage moved
|
|
12
|
+
# are listed; --fail-on-drop exits non-zero when any file regressed,
|
|
13
|
+
# so this composes with CI as a "coverage of this PR didn't drop"
|
|
14
|
+
# gate. Resolves the long-standing "diff coverage" feature request.
|
|
15
|
+
module Diff
|
|
16
|
+
extend CommandHelpers
|
|
17
|
+
|
|
18
|
+
EPSILON = 0.005 # tolerance below which a delta is considered noise
|
|
19
|
+
|
|
20
|
+
STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def run(args, stdout:, stderr:, **)
|
|
25
|
+
opts = parse(args, stderr)
|
|
26
|
+
return 1 unless opts
|
|
27
|
+
|
|
28
|
+
rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
|
|
29
|
+
rows.sort_by! { |row| row[:line_delta] }
|
|
30
|
+
if opts[:json]
|
|
31
|
+
stdout.puts(JSON.pretty_generate(rows))
|
|
32
|
+
else
|
|
33
|
+
emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
34
|
+
end
|
|
35
|
+
opts[:fail_on_drop] && coverage_drop?(rows) ? 1 : 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse(args, stderr)
|
|
39
|
+
opts = parse_flags(args)
|
|
40
|
+
return stderr.puts("simplecov diff: missing baseline argument") && nil if opts[:rest].empty?
|
|
41
|
+
|
|
42
|
+
opts[:baseline] = load_coverage(opts[:rest].first, stderr) or return nil
|
|
43
|
+
opts[:current] = load_coverage(opts[:input], stderr) or return nil
|
|
44
|
+
opts
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_flags(args)
|
|
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 }
|
|
51
|
+
end
|
|
52
|
+
opts.merge(rest: rest)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def load_coverage(path, stderr)
|
|
56
|
+
coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
|
|
57
|
+
return unless coverage
|
|
58
|
+
|
|
59
|
+
normalize_keys(coverage)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Strip a leading slash so coverage.json files written before the
|
|
63
|
+
# `project_filename` change (keys like "/lib/foo.rb") still diff
|
|
64
|
+
# cleanly against newer reports (keys like "lib/foo.rb").
|
|
65
|
+
def normalize_keys(coverage)
|
|
66
|
+
coverage.transform_keys { |key| key.delete_prefix("/") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compute_rows(current, baseline, threshold)
|
|
70
|
+
files = current.keys | baseline.keys
|
|
71
|
+
files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
|
|
72
|
+
end
|
|
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.
|
|
78
|
+
def compute_row(fname, current_payload, baseline_payload, threshold)
|
|
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
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def status_for(current_payload, baseline_payload)
|
|
94
|
+
return "added" if baseline_payload.nil?
|
|
95
|
+
return "removed" if current_payload.nil?
|
|
96
|
+
|
|
97
|
+
"changed"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pct_for(fields, payload)
|
|
101
|
+
return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
|
|
102
|
+
|
|
103
|
+
payload[fields[:percent]].to_f
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def emit_text(stdout, rows, color)
|
|
107
|
+
return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?
|
|
108
|
+
|
|
109
|
+
rows.each { |row| stdout.puts(format_row(row, color)) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def format_row(row, color)
|
|
113
|
+
line = " #{delta_parts(row, color).join(' ')} #{row[:file]}"
|
|
114
|
+
suffix = STATUS_SUFFIX[row[:status]]
|
|
115
|
+
suffix ? "#{line} #{suffix}" : line
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def delta_parts(row, color)
|
|
119
|
+
[
|
|
120
|
+
format_delta(row[:line_delta], "lines", color),
|
|
121
|
+
(format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
|
|
122
|
+
(format_delta(row[:method_delta], "methods", color) if row[:method_delta].abs > EPSILON)
|
|
123
|
+
].compact
|
|
124
|
+
end
|
|
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
|
+
|
|
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
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
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 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.).
|
|
27
|
+
warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
|
|
28
|
+
"coverage"
|
|
29
|
+
# simplecov:enable
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Load the dotfile, snapshot+restore `SimpleCov.coverage_dir` so we
|
|
33
|
+
# don't quietly clobber it in a host process that's already
|
|
34
|
+
# configured (e.g. when the CLI is exercised inline by simplecov's
|
|
35
|
+
# own spec suite). The snapshot is intentionally narrow: a dotfile
|
|
36
|
+
# can still mutate other SimpleCov configuration (filters, groups,
|
|
37
|
+
# formatters, command_name, ...) via `SimpleCov.configure` or
|
|
38
|
+
# `SimpleCov.start { ... }` blocks. The CLI normally runs as a
|
|
39
|
+
# top-level process where that's harmless; callers driving it from
|
|
40
|
+
# inside a Ruby host that cares about isolation should arrange that
|
|
41
|
+
# themselves.
|
|
42
|
+
def read_from(dotfile)
|
|
43
|
+
snapshot = SimpleCov.instance_variable_get(:@coverage_dir) #: String?
|
|
44
|
+
load_with_start_neutered(dotfile)
|
|
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?
|
|
50
|
+
SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def find
|
|
54
|
+
dir = Pathname.new(Dir.pwd)
|
|
55
|
+
loop do
|
|
56
|
+
candidate = dir.join(".simplecov")
|
|
57
|
+
return candidate.to_s if candidate.exist?
|
|
58
|
+
break if dir.root?
|
|
59
|
+
|
|
60
|
+
dir = dir.parent
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def with_simplecov_loaded
|
|
66
|
+
previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil) #: String?
|
|
67
|
+
previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
|
|
68
|
+
ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
|
|
69
|
+
# SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
|
|
70
|
+
# CLI-only behavior — e.g. simplecov itself sets `coverage_dir`
|
|
71
|
+
# to the dogfood path here but skips that for descendants.
|
|
72
|
+
ENV["SIMPLECOV_CLI"] = "1"
|
|
73
|
+
require "simplecov"
|
|
74
|
+
yield
|
|
75
|
+
ensure
|
|
76
|
+
# @type var previous_no_defaults: String?
|
|
77
|
+
# @type var previous_cli: String?
|
|
78
|
+
ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
|
|
79
|
+
ENV["SIMPLECOV_CLI"] = previous_cli
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Load `path` with `SimpleCov.start` and the at_exit installer
|
|
83
|
+
# turned into no-ops, so a project whose dotfile calls
|
|
84
|
+
# `SimpleCov.start` doesn't trigger Coverage just because we asked
|
|
85
|
+
# for `coverage_dir`. Config inside any `SimpleCov.start { ... }`
|
|
86
|
+
# block still runs.
|
|
87
|
+
def load_with_start_neutered(path)
|
|
88
|
+
klass = SimpleCov.singleton_class
|
|
89
|
+
names = %i[start_tracking install_at_exit_hook]
|
|
90
|
+
stash = names.to_h { |name| [name, klass.instance_method(name)] } #: Hash[Symbol, UnboundMethod]
|
|
91
|
+
# define_method over an existing method emits a "method redefined"
|
|
92
|
+
# warning under $VERBOSE; the override and restore are intentional.
|
|
93
|
+
silence_verbose { names.each { |name| klass.define_method(name) { nil } } }
|
|
94
|
+
load path
|
|
95
|
+
ensure
|
|
96
|
+
# @type var stash: Hash[Symbol, UnboundMethod]
|
|
97
|
+
# @type var klass: Class
|
|
98
|
+
silence_verbose { stash.each { |name, method| klass.define_method(name, method) } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def silence_verbose
|
|
102
|
+
previous = $VERBOSE #: bool?
|
|
103
|
+
$VERBOSE = nil
|
|
104
|
+
yield
|
|
105
|
+
ensure
|
|
106
|
+
# @type var previous: bool?
|
|
107
|
+
$VERBOSE = previous
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|