simplecov 0.22.0 → 1.3.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/LICENSE +1 -1
- data/README.md +173 -875
- data/exe/simplecov +8 -0
- data/lib/minitest/simplecov_plugin.rb +18 -7
- data/lib/simplecov/atomic_file.rb +67 -0
- data/lib/simplecov/autostart.rb +9 -0
- data/lib/simplecov/baseline/parser.rb +58 -0
- data/lib/simplecov/baseline.rb +170 -0
- data/lib/simplecov/cli/affected/changed_files.rb +57 -0
- data/lib/simplecov/cli/affected/selection.rb +117 -0
- data/lib/simplecov/cli/affected.rb +148 -0
- data/lib/simplecov/cli/annotations.rb +166 -0
- data/lib/simplecov/cli/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +86 -0
- data/lib/simplecov/cli/command_helpers.rb +91 -0
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +83 -0
- data/lib/simplecov/cli/coverage_file.rb +109 -0
- data/lib/simplecov/cli/dead_code/output.rb +100 -0
- data/lib/simplecov/cli/dead_code.rb +139 -0
- data/lib/simplecov/cli/diff/output.rb +45 -0
- data/lib/simplecov/cli/diff.rb +115 -0
- data/lib/simplecov/cli/dotfile.rb +156 -0
- data/lib/simplecov/cli/git.rb +41 -0
- data/lib/simplecov/cli/history/output.rb +135 -0
- data/lib/simplecov/cli/history.rb +67 -0
- data/lib/simplecov/cli/merge.rb +115 -0
- data/lib/simplecov/cli/open.rb +45 -0
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +151 -0
- data/lib/simplecov/cli/patch.rb +175 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +91 -0
- data/lib/simplecov/cli/run.rb +35 -0
- data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
- data/lib/simplecov/cli/serve.rb +88 -0
- data/lib/simplecov/cli/show/annotator.rb +87 -0
- data/lib/simplecov/cli/show/sweep.rb +36 -0
- data/lib/simplecov/cli/show.rb +133 -0
- data/lib/simplecov/cli/status/facts.rb +87 -0
- data/lib/simplecov/cli/status.rb +93 -0
- data/lib/simplecov/cli/tests/redundancy.rb +78 -0
- data/lib/simplecov/cli/tests.rb +148 -0
- data/lib/simplecov/cli/uncovered/misses.rb +33 -0
- data/lib/simplecov/cli/uncovered.rb +127 -0
- data/lib/simplecov/cli/usage.rb +153 -0
- data/lib/simplecov/cli/watch/live_report.rb +73 -0
- data/lib/simplecov/cli/watch/narrator.rb +55 -0
- data/lib/simplecov/cli/watch/poller.rb +45 -0
- data/lib/simplecov/cli/watch/session.rb +134 -0
- data/lib/simplecov/cli/watch/test_plan.rb +43 -0
- data/lib/simplecov/cli/watch.rb +85 -0
- data/lib/simplecov/cli.rb +114 -0
- data/lib/simplecov/color.rb +65 -0
- data/lib/simplecov/combine/branches_combiner.rb +44 -21
- data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
- data/lib/simplecov/combine/identity_interner.rb +27 -0
- data/lib/simplecov/combine/interned_counts.rb +28 -0
- data/lib/simplecov/combine/lines_combiner.rb +35 -29
- data/lib/simplecov/combine/methods_combiner.rb +48 -0
- data/lib/simplecov/combine/results_combiner.rb +9 -49
- data/lib/simplecov/combine.rb +0 -24
- data/lib/simplecov/command_guesser.rb +99 -42
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +184 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +37 -0
- data/lib/simplecov/configuration/filters.rb +131 -0
- data/lib/simplecov/configuration/formatting.rb +129 -0
- data/lib/simplecov/configuration/groups.rb +48 -0
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +72 -0
- data/lib/simplecov/configuration/merging.rb +197 -0
- data/lib/simplecov/configuration/missed_caps.rb +51 -0
- data/lib/simplecov/configuration/production.rb +23 -0
- data/lib/simplecov/configuration/test_tracking.rb +48 -0
- data/lib/simplecov/configuration/thresholds.rb +150 -0
- data/lib/simplecov/configuration/view_coverage.rb +37 -0
- data/lib/simplecov/configuration.rb +100 -443
- data/lib/simplecov/configuration_error.rb +5 -0
- data/lib/simplecov/context_map/union.rb +81 -0
- data/lib/simplecov/context_map.rb +187 -0
- data/lib/simplecov/coverage_json.rb +21 -0
- data/lib/simplecov/coverage_statistics.rb +24 -28
- data/lib/simplecov/coverage_violations.rb +254 -0
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +20 -28
- data/lib/simplecov/deprecation.rb +63 -0
- data/lib/simplecov/directive/erb.rb +44 -0
- data/lib/simplecov/directive/haml.rb +33 -0
- data/lib/simplecov/directive/indented_template.rb +37 -0
- data/lib/simplecov/directive/slim.rb +35 -0
- data/lib/simplecov/directive/template.rb +29 -0
- data/lib/simplecov/directive.rb +152 -0
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +39 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
- data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
- data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
- data/lib/simplecov/exit_codes.rb +26 -0
- data/lib/simplecov/exit_handling.rb +163 -0
- data/lib/simplecov/file_list.rb +63 -48
- data/lib/simplecov/filter.rb +98 -42
- data/lib/simplecov/formatter/base.rb +100 -0
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
- data/lib/simplecov/formatter/html_formatter.rb +88 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
- data/lib/simplecov/formatter/json_formatter.rb +40 -0
- data/lib/simplecov/formatter/multi_formatter.rb +8 -10
- data/lib/simplecov/formatter/simple_formatter.rb +12 -15
- data/lib/simplecov/formatter.rb +21 -1
- data/lib/simplecov/group_names.rb +33 -0
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +16 -5
- data/lib/simplecov/lines_classifier.rb +67 -19
- data/lib/simplecov/load_global_config.rb +12 -4
- data/lib/simplecov/parallel_adapters/base.rb +50 -0
- data/lib/simplecov/parallel_adapters/generic.rb +33 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
- data/lib/simplecov/parallel_adapters.rb +54 -0
- data/lib/simplecov/parallel_coordination.rb +124 -0
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +171 -0
- data/lib/simplecov/process.rb +34 -12
- data/lib/simplecov/production/error.rb +11 -0
- data/lib/simplecov/production/file_sink.rb +130 -0
- data/lib/simplecov/production.rb +249 -0
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +13 -10
- data/lib/simplecov/profiles/root_filter.rb +2 -5
- data/lib/simplecov/profiles/strict.rb +22 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +26 -19
- data/lib/simplecov/report_deferral.rb +45 -0
- data/lib/simplecov/report_stamp.rb +25 -0
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +46 -0
- data/lib/simplecov/result.rb +123 -47
- data/lib/simplecov/result_adapter.rb +130 -11
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
- data/lib/simplecov/result_merger/resultset_file.rb +67 -0
- data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
- data/lib/simplecov/result_merger/resultset_store.rb +62 -0
- data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
- data/lib/simplecov/result_merger.rb +120 -124
- data/lib/simplecov/result_processing.rb +188 -0
- data/lib/simplecov/run_identity.rb +75 -0
- data/lib/simplecov/simulate_coverage.rb +72 -17
- data/lib/simplecov/source_file/branch.rb +8 -35
- data/lib/simplecov/source_file/branch_builder.rb +95 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +17 -32
- data/lib/simplecov/source_file/line_builder.rb +37 -0
- data/lib/simplecov/source_file/method.rb +54 -0
- data/lib/simplecov/source_file/method_builder.rb +51 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
- data/lib/simplecov/source_file/skip_chunks.rb +72 -0
- data/lib/simplecov/source_file/source_loader.rb +89 -0
- data/lib/simplecov/source_file/statistics.rb +76 -0
- data/lib/simplecov/source_file.rb +64 -260
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
- data/lib/simplecov/static_coverage_extractor.rb +78 -0
- data/lib/simplecov/test_tracker/accessors.rb +34 -0
- data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
- data/lib/simplecov/test_tracker/delta.rb +69 -0
- data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
- data/lib/simplecov/test_tracker.rb +162 -0
- data/lib/simplecov/unloaded_file_injector.rb +62 -0
- data/lib/simplecov/useless_results_remover.rb +17 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
- data/lib/simplecov/view_coverage.rb +55 -0
- data/lib/simplecov-html.rb +5 -0
- data/lib/simplecov.rb +143 -394
- data/lib/simplecov_json_formatter.rb +5 -0
- data/man/simplecov.1 +354 -0
- data/sig/simplecov.rbs +1710 -0
- metadata +183 -61
- data/CHANGELOG.md +0 -191
- data/doc/alternate-formatters.md +0 -71
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -24
- data/lib/simplecov/default_formatter.rb +0 -20
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
module Coverage
|
|
10
|
+
extend CommandHelpers
|
|
11
|
+
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
def run(args, stdout:, stderr:)
|
|
15
|
+
opts = parse(args, stderr: stderr)
|
|
16
|
+
return 1 unless opts
|
|
17
|
+
|
|
18
|
+
match = locate_match(opts, stderr)
|
|
19
|
+
return 1 unless match
|
|
20
|
+
|
|
21
|
+
emit(match, opts, stdout)
|
|
22
|
+
0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse(args, stderr:)
|
|
26
|
+
opts, rest = parse_common(args)
|
|
27
|
+
if rest.empty?
|
|
28
|
+
stderr.puts("simplecov coverage: missing file argument")
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
opts[:path] = rest.first
|
|
33
|
+
opts
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def locate_match(opts, stderr)
|
|
37
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "coverage", stderr: stderr)
|
|
38
|
+
return unless coverage
|
|
39
|
+
|
|
40
|
+
match = lookup(coverage, opts.fetch(:path))
|
|
41
|
+
return report_not_found(stderr, coverage, opts) if match.nil?
|
|
42
|
+
return match if match.last.instance_of?(Hash)
|
|
43
|
+
|
|
44
|
+
CoverageFile.report_invalid(stderr, "coverage", opts.fetch(:input),
|
|
45
|
+
"entry for #{opts.fetch(:path)} must be an object")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def report_not_found(stderr, coverage, opts)
|
|
49
|
+
message = CoverageFile.not_found_message(coverage, opts.fetch(:path), opts.fetch(:input))
|
|
50
|
+
stderr.puts("simplecov coverage: #{message}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def lookup(coverage_hash, path)
|
|
54
|
+
CoverageFile.lookup(coverage_hash, path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def emit(match, opts, stdout)
|
|
58
|
+
filename, payload = match
|
|
59
|
+
if opts.fetch(:json)
|
|
60
|
+
entry = {filename => payload} #: Hash[untyped, untyped]
|
|
61
|
+
stdout.puts(JSON.pretty_generate(entry))
|
|
62
|
+
else
|
|
63
|
+
print_human(filename, payload, stdout, CLI.color_enabled?(opts, stdout))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def print_human(filename, payload, stdout, color)
|
|
68
|
+
stdout.puts(filename)
|
|
69
|
+
CoverageFile::CRITERIA.each_value { |c| emit_criterion(stdout, payload, c, color) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def emit_criterion(stdout, payload, criterion, color)
|
|
73
|
+
return unless payload.key?(criterion.fetch(:percent))
|
|
74
|
+
|
|
75
|
+
pct = payload.fetch(criterion.fetch(:percent)).to_f
|
|
76
|
+
stdout.puts(stats_row(criterion.fetch(:label),
|
|
77
|
+
Color.colorize_percent(pct, enabled: color),
|
|
78
|
+
payload[criterion.fetch(:covered)],
|
|
79
|
+
payload[criterion.fetch(:total)]))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
CRITERIA = {
|
|
12
|
+
line: {label: "Line", percent: "lines_covered_percent",
|
|
13
|
+
covered: "covered_lines", missed: "missed_lines", total: "total_lines"},
|
|
14
|
+
branch: {label: "Branch", percent: "branches_covered_percent",
|
|
15
|
+
covered: "covered_branches", missed: "missed_branches", total: "total_branches"},
|
|
16
|
+
method: {label: "Method", percent: "methods_covered_percent",
|
|
17
|
+
covered: "covered_methods", missed: "missed_methods", total: "total_methods"}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
# An exact match, the absolute path or the literal string passed, wins over
|
|
23
|
+
# a suffix match, so a nested "vendor/lib/foo.rb" can't shadow the real
|
|
24
|
+
# "lib/foo.rb" just by sitting earlier in the report. A subpath can end more
|
|
25
|
+
# than one key, and a collision is left as "not found" for the caller rather
|
|
26
|
+
# than scoring whichever key happens to sit first.
|
|
27
|
+
def lookup(coverage_hash, path)
|
|
28
|
+
absolute = File.expand_path(path)
|
|
29
|
+
return [absolute, coverage_hash.fetch(absolute)] if coverage_hash.key?(absolute)
|
|
30
|
+
return [path, coverage_hash.fetch(path)] if coverage_hash.key?(path)
|
|
31
|
+
|
|
32
|
+
matches = suffix_matches(coverage_hash, path)
|
|
33
|
+
matches.first if matches.one?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The suffix's leading slash keeps "foo.rb" from matching "barfoo.rb", and
|
|
37
|
+
# hoisting it keeps the scan from allocating a string per entry.
|
|
38
|
+
def suffix_matches(coverage_hash, path)
|
|
39
|
+
suffix = "/#{path}"
|
|
40
|
+
coverage_hash.select { |fname, _| fname.end_with?(suffix) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The report's entries under exact, realpath-normalized keys, for
|
|
44
|
+
# programmatic resolution where a suffix fallback could bind a path to the
|
|
45
|
+
# wrong entry. The realpath step keeps a symlinked project root from
|
|
46
|
+
# splitting a resolved path apart from the report's keys.
|
|
47
|
+
def exact_index(coverage_hash)
|
|
48
|
+
index = {} #: Hash[String, untyped]
|
|
49
|
+
coverage_hash.each do |key, payload|
|
|
50
|
+
index[key] ||= payload
|
|
51
|
+
index[normalize(key)] ||= payload
|
|
52
|
+
end
|
|
53
|
+
index
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A key whose file no longer exists keeps its literal spelling.
|
|
57
|
+
def normalize(key)
|
|
58
|
+
File.realdirpath(key)
|
|
59
|
+
rescue SystemCallError
|
|
60
|
+
key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# An ambiguous subpath names its candidates: "no entry" would send the user
|
|
64
|
+
# hunting for a typo in a path that exists twice.
|
|
65
|
+
def not_found_message(coverage_hash, path, input)
|
|
66
|
+
matches = suffix_matches(coverage_hash, path).keys
|
|
67
|
+
return "no entry for #{path} in #{input}" if matches.size < 2
|
|
68
|
+
|
|
69
|
+
"#{path} matches #{matches.size} files in #{input}: #{matches.sort.join(", ")} " \
|
|
70
|
+
"(use a longer path to pick one)"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_document(path, command:, stderr:)
|
|
74
|
+
CoverageJSON.load(path)
|
|
75
|
+
rescue Errno::ENOENT
|
|
76
|
+
report_missing(stderr, command, path)
|
|
77
|
+
rescue CoverageJSON::Error => e
|
|
78
|
+
report_invalid(stderr, command, path, e.message)
|
|
79
|
+
rescue SystemCallError => e
|
|
80
|
+
report_unreadable(stderr, command, path, e.message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def load_coverage(path, command:, stderr:)
|
|
84
|
+
document = load_document(path, command: command, stderr: stderr)
|
|
85
|
+
return unless document
|
|
86
|
+
|
|
87
|
+
none = {} #: Hash[String, untyped]
|
|
88
|
+
coverage = document.fetch("coverage", none)
|
|
89
|
+
return coverage if coverage.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
report_invalid(stderr, command, path, '"coverage" must be an object')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def report_invalid(stderr, command, path, reason)
|
|
95
|
+
detail = reason.lines.first.to_s.strip
|
|
96
|
+
stderr.puts("simplecov #{command}: input file #{path.inspect} isn't valid JSON (#{detail})")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def report_missing(stderr, command, path)
|
|
100
|
+
stderr.puts("simplecov #{command}: #{path} not found")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def report_unreadable(stderr, command, path, reason)
|
|
104
|
+
detail = reason.lines.first.to_s.strip
|
|
105
|
+
stderr.puts("simplecov #{command}: cannot read #{path.inspect} (#{detail})")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../patch/output"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
module DeadCode
|
|
8
|
+
module Output
|
|
9
|
+
extend CommandHelpers
|
|
10
|
+
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
def emit(stdout, opts, matrix, production)
|
|
14
|
+
return stdout.puts(JSON.generate(json_payload(matrix, production))) if opts.fetch(:json)
|
|
15
|
+
|
|
16
|
+
stdout.puts("Production coverage: #{opts.fetch(:production)}#{window_suffix(production)}")
|
|
17
|
+
stdout.puts
|
|
18
|
+
last_seen = production.fetch("last_seen")
|
|
19
|
+
opts.fetch(:untested) ? emit_untested(stdout, matrix, last_seen) : emit_dead(stdout, matrix, last_seen)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def emit_dead(stdout, matrix, last_seen)
|
|
23
|
+
rows = section(stdout, "Dead code (not run in production, not covered by tests):", matrix, :dead,
|
|
24
|
+
last_seen)
|
|
25
|
+
rows += section(stdout, "Possibly dead (not run in production, covered only by tests):", matrix,
|
|
26
|
+
:possibly_dead, last_seen)
|
|
27
|
+
return stdout.puts("No dead code found.") if rows.zero?
|
|
28
|
+
|
|
29
|
+
dead = count(matrix, :dead)
|
|
30
|
+
possibly = count(matrix, :possibly_dead)
|
|
31
|
+
stdout.puts("#{pluralize(dead, "dead line")}, #{pluralize(possibly, "possibly dead line")}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def emit_untested(stdout, matrix, last_seen)
|
|
35
|
+
rows = section(stdout, "Untested code running in production:", matrix, :untested_in_production, last_seen)
|
|
36
|
+
return stdout.puts("No untested production code found.") if rows.zero?
|
|
37
|
+
|
|
38
|
+
stdout.puts("#{pluralize(count(matrix, :untested_in_production), "untested line")} running in production")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def section(stdout, heading, matrix, bucket, last_seen)
|
|
42
|
+
rows = matrix.fetch(bucket).sort
|
|
43
|
+
return 0 if rows.empty?
|
|
44
|
+
|
|
45
|
+
stdout.puts(heading)
|
|
46
|
+
rows.each do |file, lines|
|
|
47
|
+
stdout.puts(" #{file}:#{Patch::Output.ranges(lines, ",")}#{markers(matrix, file, last_seen)}")
|
|
48
|
+
end
|
|
49
|
+
stdout.puts
|
|
50
|
+
rows.size
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The row's parenthesized evidence. "entire file" says every relevant line
|
|
54
|
+
# skipped production; "last run" dates the store's last sighting of the file,
|
|
55
|
+
# which for a dead or possibly dead row means other lines of the file ran
|
|
56
|
+
# then, and its absence means the window never saw the file at all. Both can
|
|
57
|
+
# hold at once.
|
|
58
|
+
def markers(matrix, file, last_seen)
|
|
59
|
+
markers = [] #: Array[String]
|
|
60
|
+
markers << "entire file" if matrix.fetch(:entire).include?(file)
|
|
61
|
+
stamp = last_seen[file]
|
|
62
|
+
markers << "last run #{stamp[0, 10]}" if stamp.instance_of?(String)
|
|
63
|
+
" (#{markers.join(", ")})" unless markers.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def count(matrix, bucket)
|
|
67
|
+
matrix.fetch(bucket).sum { |_file, lines| lines.size }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def pluralize(number, noun)
|
|
71
|
+
"#{number} #{noun}#{"s" unless one?(number)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def window_suffix(production)
|
|
75
|
+
started = production["started_at"]
|
|
76
|
+
updated = production["updated_at"]
|
|
77
|
+
" (window #{started} to #{updated})" if started && updated
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def json_payload(matrix, production)
|
|
81
|
+
last_seen = production.fetch("last_seen")
|
|
82
|
+
payload = {window: {started_at: production["started_at"], updated_at: production["updated_at"]}}
|
|
83
|
+
%i[dead possibly_dead untested_in_production].each do |bucket|
|
|
84
|
+
payload[bucket] = matrix.fetch(bucket).sort.map { |file, lines| json_entry(file, lines, last_seen) }
|
|
85
|
+
end
|
|
86
|
+
payload
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# JSON carries the full stamp where the text views print its date, and omits
|
|
90
|
+
# the key for a file the window never saw.
|
|
91
|
+
def json_entry(file, lines, last_seen)
|
|
92
|
+
entry = {file: file, lines: lines} #: Hash[Symbol, untyped]
|
|
93
|
+
stamp = last_seen[file]
|
|
94
|
+
entry[:last_seen] = stamp if stamp.instance_of?(String)
|
|
95
|
+
entry
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_helpers"
|
|
4
|
+
require_relative "coverage_file"
|
|
5
|
+
require_relative "../production/file_sink"
|
|
6
|
+
require_relative "dead_code/output"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov dead-code --production PATH` — cross the coverage a
|
|
11
|
+
# `SimpleCov::Production` sink accumulated with the test report, and
|
|
12
|
+
# answer the question neither answers alone. Per line:
|
|
13
|
+
#
|
|
14
|
+
# production | tests | meaning
|
|
15
|
+
# yes | yes | normal
|
|
16
|
+
# yes | no | untested code real users are running
|
|
17
|
+
# no | yes | possibly dead, a deletion candidate
|
|
18
|
+
# no | no | dead
|
|
19
|
+
#
|
|
20
|
+
# The default view prints the bottom two rows (the deletion candidates);
|
|
21
|
+
# `--untested-in-production` prints the second (the highest-value place to
|
|
22
|
+
# add a test). Coverage over a long enough window is far better evidence
|
|
23
|
+
# for deleting Ruby than any static analysis of it, which is why the header
|
|
24
|
+
# names the window the production data spans.
|
|
25
|
+
module DeadCode
|
|
26
|
+
extend CommandHelpers
|
|
27
|
+
|
|
28
|
+
extend self
|
|
29
|
+
|
|
30
|
+
# The module-name derivation would say "deadcode"; the command is
|
|
31
|
+
# hyphenated.
|
|
32
|
+
def command_name
|
|
33
|
+
"dead-code"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run(args, stdout:, stderr:, **)
|
|
37
|
+
opts = parse(args, stderr) or return 1
|
|
38
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: command_name, stderr: stderr) or return 1
|
|
39
|
+
production = load_production(opts.fetch(:production), stderr) or return 1
|
|
40
|
+
|
|
41
|
+
matrix = cross(coverage, production.fetch("coverage"))
|
|
42
|
+
Output.emit(stdout, opts, matrix, production)
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse(args, stderr)
|
|
47
|
+
# `production` needs no default: it is filled in below from the project's
|
|
48
|
+
# configured store whenever the flag went unused.
|
|
49
|
+
opts, rest = parse_common(args, untested: false) do |parser, options|
|
|
50
|
+
parser.on("--production PATH") { |v| options[:production] = v }
|
|
51
|
+
parser.on("--untested-in-production") { options[:untested] = true }
|
|
52
|
+
end
|
|
53
|
+
return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
|
|
54
|
+
|
|
55
|
+
# The project's configured store fills in for the flag, the way ratchet
|
|
56
|
+
# reads `baseline_file`, so the DSL stays the single source of truth for
|
|
57
|
+
# where production coverage lives.
|
|
58
|
+
opts[:production] ||= Dotfile.production_coverage
|
|
59
|
+
opts.fetch(:production) ? opts : missing_production(stderr)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def missing_production(stderr)
|
|
63
|
+
error_nil(stderr,
|
|
64
|
+
"missing --production PATH (the file a SimpleCov::Production sink wrote, " \
|
|
65
|
+
"or configure SimpleCov.production_coverage in .simplecov)")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def load_production(path, stderr)
|
|
69
|
+
Production::FileSink.read(path)
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
error_nil(stderr, "#{path} not found")
|
|
72
|
+
rescue SystemCallError, Production::Error => e
|
|
73
|
+
# The exception stands for its own message in `error`'s interpolation.
|
|
74
|
+
error_nil(stderr, e)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Classifies every relevant line of the report against the production set,
|
|
78
|
+
# and sweeps in production files the report never tracked (nothing tested
|
|
79
|
+
# them, so every recorded line is untested-in-production). Lines the
|
|
80
|
+
# report deems irrelevant or deliberately ignored stay out of every
|
|
81
|
+
# bucket.
|
|
82
|
+
def cross(coverage, production_coverage)
|
|
83
|
+
matrix = {dead: {}, possibly_dead: {}, untested_in_production: {}, entire: Set.new} #: Hash[Symbol, untyped]
|
|
84
|
+
coverage.each do |file, entry|
|
|
85
|
+
lines = entry["lines"]
|
|
86
|
+
classify_file(matrix, file, lines, production_coverage[file] || []) if lines.instance_of?(Array)
|
|
87
|
+
end
|
|
88
|
+
# Files go in unsorted: both the text sections and the JSON payload sort
|
|
89
|
+
# what they print.
|
|
90
|
+
(production_coverage.keys - coverage.keys).each do |file|
|
|
91
|
+
matrix.fetch(:untested_in_production)[file] = production_coverage.fetch(file).sort
|
|
92
|
+
end
|
|
93
|
+
matrix
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# mutant:disable -- the production lines are held as a Set for the cost of
|
|
97
|
+
# asking, and a list answers `include?` the same way, so the conversion
|
|
98
|
+
# has no witness at any call site it could live at.
|
|
99
|
+
def classify_file(matrix, file, lines, production_lines)
|
|
100
|
+
production = production_lines.to_set
|
|
101
|
+
buckets = {dead: [], possibly_dead: [], untested_in_production: []} #: Hash[Symbol, Array[Integer]]
|
|
102
|
+
relevant = 0
|
|
103
|
+
lines.each_with_index do |value, index|
|
|
104
|
+
next unless value.is_a?(Numeric)
|
|
105
|
+
|
|
106
|
+
relevant += 1
|
|
107
|
+
bucket = classify_line(value.positive?, production.include?(index + 1))
|
|
108
|
+
buckets.fetch(bucket) << (index + 1) if bucket
|
|
109
|
+
end
|
|
110
|
+
record(matrix, file, buckets, relevant)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The matrix's four cells; the yes/yes cell is the one nothing needs
|
|
114
|
+
# reporting about.
|
|
115
|
+
def classify_line(tested, in_production)
|
|
116
|
+
return :untested_in_production if in_production && !tested
|
|
117
|
+
return nil if in_production
|
|
118
|
+
|
|
119
|
+
tested ? :possibly_dead : :dead
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# mutant:disable -- the count of relevant lines is checked before a file is
|
|
123
|
+
# called entirely dead, but a file with no relevant lines lands in no
|
|
124
|
+
# bucket, so the mark is never read back. The guard says what is meant
|
|
125
|
+
# rather than what can be seen.
|
|
126
|
+
def record(matrix, file, buckets, relevant)
|
|
127
|
+
buckets.each { |bucket, found| matrix.fetch(bucket)[file] = found unless found.empty? }
|
|
128
|
+
# Counted down to nothing rather than compared for equality: two counts
|
|
129
|
+
# that are equal are equal through every spelling of the comparison.
|
|
130
|
+
unhit = buckets.fetch(:dead).size + buckets.fetch(:possibly_dead).size
|
|
131
|
+
return unless relevant.positive? && (relevant - unhit).zero?
|
|
132
|
+
|
|
133
|
+
# Every relevant line unhit in production: the deletion candidate is the
|
|
134
|
+
# whole file.
|
|
135
|
+
matrix.fetch(:entire) << file
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Diff
|
|
6
|
+
module Output
|
|
7
|
+
STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
|
|
8
|
+
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
def emit_text(stdout, rows, color)
|
|
12
|
+
if rows.empty?
|
|
13
|
+
stdout.puts("simplecov diff: no per-file coverage changes")
|
|
14
|
+
else
|
|
15
|
+
rows.each { |row| stdout.puts(format_row(row, color)) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def format_row(row, color)
|
|
20
|
+
line = " #{delta_parts(row, color).join(" ")} #{row.fetch(:file)}"
|
|
21
|
+
suffix = STATUS_SUFFIX[row.fetch(:status)]
|
|
22
|
+
suffix ? "#{line} #{suffix}" : line
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delta_parts(row, color)
|
|
26
|
+
[
|
|
27
|
+
format_delta(row.fetch(:line_delta), "lines", color),
|
|
28
|
+
(format_delta(row.fetch(:branch_delta), "branches", color) if row.fetch(:branch_delta).abs > EPSILON),
|
|
29
|
+
(format_delta(row.fetch(:method_delta), "methods", color) if row.fetch(:method_delta).abs > EPSILON)
|
|
30
|
+
].compact
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deltas are sign-based, not threshold-based: a +5% bump is green and a -5%
|
|
34
|
+
# drop is red, regardless of where the absolute coverage level lands. A drop
|
|
35
|
+
# carries its own minus, so the sign is a prefix a rise has and a drop has
|
|
36
|
+
# not.
|
|
37
|
+
def format_delta(delta, label, color)
|
|
38
|
+
sign = "+" if delta.positive?
|
|
39
|
+
text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
|
|
40
|
+
Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
require_relative "diff/output"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov diff <baseline>`: per-file coverage deltas between coverage.json
|
|
11
|
+
# and a baseline coverage.json checked in alongside the suite. Only files
|
|
12
|
+
# whose coverage moved are listed, and `--fail-on-drop` exits non-zero when
|
|
13
|
+
# any file regressed, so this composes as a "coverage of this PR didn't drop"
|
|
14
|
+
# gate.
|
|
15
|
+
module Diff
|
|
16
|
+
extend CommandHelpers
|
|
17
|
+
|
|
18
|
+
# Tolerance below which a delta is considered noise.
|
|
19
|
+
EPSILON = 0.005
|
|
20
|
+
|
|
21
|
+
extend self
|
|
22
|
+
|
|
23
|
+
def run(args, stdout:, stderr:, **)
|
|
24
|
+
opts = parse(args, stderr)
|
|
25
|
+
return 1 unless opts
|
|
26
|
+
|
|
27
|
+
rows = compute_rows(opts.fetch(:current), opts.fetch(:baseline), opts.fetch(:threshold))
|
|
28
|
+
rows.sort_by! { |row| row.fetch(:line_delta) }
|
|
29
|
+
if opts.fetch(:json)
|
|
30
|
+
stdout.puts(JSON.pretty_generate(rows))
|
|
31
|
+
else
|
|
32
|
+
Output.emit_text(stdout, rows, CLI.color_enabled?(opts, stdout))
|
|
33
|
+
end
|
|
34
|
+
(opts.fetch(:fail_on_drop) && coverage_drop?(rows)) ? 1 : 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse(args, stderr)
|
|
38
|
+
opts = parse_flags(args)
|
|
39
|
+
if opts.fetch(:rest).empty?
|
|
40
|
+
stderr.puts("simplecov diff: missing baseline argument")
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
opts[:baseline] = load_coverage(opts.fetch(:rest).first, stderr) or return nil
|
|
45
|
+
opts[:current] = load_coverage(opts.fetch(:input), stderr) or return nil
|
|
46
|
+
opts
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_flags(args)
|
|
50
|
+
opts, rest = parse_common(args, fail_on_drop: false, threshold: 0.0) do |o, options|
|
|
51
|
+
o.on("--fail-on-drop") { options[:fail_on_drop] = true }
|
|
52
|
+
o.on("--threshold N", Float) { |v| options[:threshold] = v }
|
|
53
|
+
end
|
|
54
|
+
opts.merge(rest: rest)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_coverage(path, stderr)
|
|
58
|
+
coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
|
|
59
|
+
return unless coverage
|
|
60
|
+
|
|
61
|
+
normalize_keys(coverage)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Strips a leading slash so coverage.json files written before the
|
|
65
|
+
# `project_filename` change still diff cleanly against newer reports.
|
|
66
|
+
def normalize_keys(coverage)
|
|
67
|
+
coverage.transform_keys { |key| key.delete_prefix("/") }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def compute_rows(current, baseline, threshold)
|
|
71
|
+
files = current.keys | baseline.keys
|
|
72
|
+
files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The threshold is inclusive, so a file that moved exactly N% is listed under
|
|
76
|
+
# `--threshold N`, matching the "at least N%" the usage text promises.
|
|
77
|
+
def compute_row(fname, current_payload, baseline_payload, threshold)
|
|
78
|
+
deltas = compute_deltas(current_payload, baseline_payload)
|
|
79
|
+
floor = threshold.abs
|
|
80
|
+
return nil unless deltas.values.any? { |delta| delta.abs > EPSILON && delta.abs >= floor }
|
|
81
|
+
|
|
82
|
+
{file: fname, status: status_for(current_payload, baseline_payload),
|
|
83
|
+
line_delta: deltas.fetch(:line), branch_delta: deltas.fetch(:branch), method_delta: deltas.fetch(:method)}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def compute_deltas(current_payload, baseline_payload)
|
|
87
|
+
CoverageFile::CRITERIA.transform_values do |fields|
|
|
88
|
+
pct_for(fields, current_payload) - pct_for(fields, baseline_payload)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def status_for(current_payload, baseline_payload)
|
|
93
|
+
return "added" if baseline_payload.nil?
|
|
94
|
+
return "removed" if current_payload.nil?
|
|
95
|
+
|
|
96
|
+
"changed"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def pct_for(fields, payload)
|
|
100
|
+
return 0.0 unless payload.instance_of?(Hash) && payload[fields.fetch(:total)].to_i.positive?
|
|
101
|
+
|
|
102
|
+
payload[fields.fetch(:percent)].to_f
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A removed file's deltas are all -baseline%, but deleting a covered file is
|
|
106
|
+
# not a coverage regression, so removed rows never trip the gate. Drops are
|
|
107
|
+
# gated on EPSILON like row inclusion and display, so a row listed for a gain
|
|
108
|
+
# in one criterion cannot fail the run over float noise in another.
|
|
109
|
+
def coverage_drop?(rows)
|
|
110
|
+
rows.reject { |row| row.fetch(:status).eql?("removed") }
|
|
111
|
+
.any? { |row| row.fetch_values(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|