simplecov 1.1.1 → 1.2.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 +148 -5
- data/exe/simplecov +0 -3
- data/lib/minitest/simplecov_plugin.rb +12 -9
- data/lib/simplecov/atomic_file.rb +16 -19
- data/lib/simplecov/autostart.rb +5 -7
- 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/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +23 -24
- data/lib/simplecov/cli/command_helpers.rb +55 -19
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +25 -28
- data/lib/simplecov/cli/coverage_file.rb +58 -14
- 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 +35 -67
- data/lib/simplecov/cli/dotfile.rb +83 -38
- 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 +34 -39
- data/lib/simplecov/cli/open.rb +12 -15
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +135 -0
- data/lib/simplecov/cli/patch.rb +169 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +18 -24
- data/lib/simplecov/cli/run.rb +8 -9
- data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
- data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
- data/lib/simplecov/cli/serve.rb +27 -24
- 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 +37 -0
- data/lib/simplecov/cli/uncovered.rb +70 -32
- data/lib/simplecov/cli/usage.rb +152 -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 +43 -92
- data/lib/simplecov/color.rb +19 -37
- data/lib/simplecov/combine/branches_combiner.rb +7 -31
- data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
- data/lib/simplecov/combine/identity_interner.rb +10 -13
- data/lib/simplecov/combine/interned_counts.rb +7 -9
- data/lib/simplecov/combine/lines_combiner.rb +20 -44
- data/lib/simplecov/combine/methods_combiner.rb +9 -35
- data/lib/simplecov/combine/results_combiner.rb +4 -21
- data/lib/simplecov/combine.rb +0 -6
- data/lib/simplecov/command_guesser.rb +20 -34
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +91 -79
- data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +6 -10
- data/lib/simplecov/configuration/filters.rb +40 -73
- data/lib/simplecov/configuration/formatting.rb +74 -83
- data/lib/simplecov/configuration/groups.rb +19 -13
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +31 -22
- data/lib/simplecov/configuration/merging.rb +94 -62
- 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 +33 -62
- data/lib/simplecov/configuration/view_coverage.rb +38 -0
- data/lib/simplecov/configuration.rb +70 -76
- 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 +2 -5
- data/lib/simplecov/coverage_statistics.rb +14 -24
- data/lib/simplecov/coverage_violations.rb +157 -64
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +4 -23
- data/lib/simplecov/deprecation.rb +45 -27
- data/lib/simplecov/directive.rb +46 -62
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +17 -11
- data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
- 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 +7 -12
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
- data/lib/simplecov/exit_codes.rb +16 -10
- data/lib/simplecov/exit_handling.rb +94 -59
- data/lib/simplecov/file_list.rb +20 -51
- data/lib/simplecov/filter.rb +43 -66
- data/lib/simplecov/formatter/base.rb +37 -55
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
- data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
- data/lib/simplecov/formatter/html_formatter.rb +24 -41
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
- data/lib/simplecov/formatter/json_formatter.rb +7 -13
- data/lib/simplecov/formatter/multi_formatter.rb +5 -11
- data/lib/simplecov/formatter/simple_formatter.rb +2 -8
- data/lib/simplecov/formatter.rb +14 -10
- data/lib/simplecov/group_names.rb +13 -12
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +6 -9
- data/lib/simplecov/lines_classifier.rb +31 -16
- data/lib/simplecov/load_global_config.rb +7 -7
- data/lib/simplecov/parallel_adapters/base.rb +13 -41
- data/lib/simplecov/parallel_adapters/generic.rb +7 -19
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
- data/lib/simplecov/parallel_adapters.rb +24 -55
- data/lib/simplecov/parallel_coordination.rb +39 -49
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +67 -126
- data/lib/simplecov/process.rb +14 -32
- 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/rails.rb +1 -12
- data/lib/simplecov/profiles/root_filter.rb +0 -4
- data/lib/simplecov/profiles/strict.rb +9 -19
- data/lib/simplecov/profiles.rb +11 -33
- data/lib/simplecov/report_deferral.rb +11 -15
- data/lib/simplecov/report_stamp.rb +6 -9
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +7 -12
- data/lib/simplecov/result.rb +68 -117
- data/lib/simplecov/result_adapter.rb +56 -68
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
- data/lib/simplecov/result_merger/resultset_file.rb +20 -27
- data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
- data/lib/simplecov/result_merger/resultset_store.rb +23 -14
- data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
- data/lib/simplecov/result_merger.rb +59 -58
- data/lib/simplecov/result_processing.rb +74 -119
- data/lib/simplecov/run_identity.rb +16 -18
- data/lib/simplecov/simulate_coverage.rb +34 -50
- data/lib/simplecov/source_file/branch.rb +8 -33
- data/lib/simplecov/source_file/branch_builder.rb +28 -47
- data/lib/simplecov/source_file/builder_context.rb +6 -13
- data/lib/simplecov/source_file/line.rb +13 -33
- data/lib/simplecov/source_file/line_builder.rb +9 -15
- data/lib/simplecov/source_file/method.rb +3 -7
- data/lib/simplecov/source_file/method_builder.rb +6 -15
- data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
- data/lib/simplecov/source_file/skip_chunks.rb +19 -28
- data/lib/simplecov/source_file/source_loader.rb +45 -35
- data/lib/simplecov/source_file/statistics.rb +10 -9
- data/lib/simplecov/source_file.rb +21 -51
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
- data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
- data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
- data/lib/simplecov/static_coverage_extractor.rb +46 -62
- 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 +18 -31
- data/lib/simplecov/useless_results_remover.rb +7 -6
- 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 +1 -0
- data/lib/simplecov.rb +57 -113
- data/lib/simplecov_json_formatter.rb +1 -0
- data/man/simplecov.1 +349 -0
- data/sig/simplecov.rbs +523 -595
- metadata +72 -5
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Show
|
|
6
|
+
module Sweep
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
def misses(coverage)
|
|
10
|
+
rows = coverage.filter_map do |filename, entry|
|
|
11
|
+
next unless entry.instance_of?(Hash) && entry["lines"].instance_of?(Array)
|
|
12
|
+
|
|
13
|
+
missed = Annotator.missed_lines(entry)
|
|
14
|
+
[display_path(filename), missed] unless missed.empty?
|
|
15
|
+
end
|
|
16
|
+
rows.sort_by(&:first)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def emit(rows, opts, stdout, stderr)
|
|
20
|
+
if opts.fetch(:json)
|
|
21
|
+
stdout.puts(JSON.pretty_generate(rows.map { |path, missed| {path: path, missed: missed} }))
|
|
22
|
+
elsif rows.empty?
|
|
23
|
+
stderr.puts("simplecov show: nothing uncovered")
|
|
24
|
+
else
|
|
25
|
+
rows.each { |path, missed| stdout.puts("#{path}:#{Patch::Output.ranges(missed, ",")}") }
|
|
26
|
+
end
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_path(filename)
|
|
31
|
+
filename.delete_prefix("#{File.expand_path(SimpleCov.root)}/")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
require_relative "patch/output"
|
|
7
|
+
require_relative "show/annotator"
|
|
8
|
+
require_relative "show/sweep"
|
|
9
|
+
|
|
10
|
+
module SimpleCov
|
|
11
|
+
module CLI
|
|
12
|
+
# `simplecov show <path>`: annotated source in the terminal, the way
|
|
13
|
+
# `go tool cover` and `llvm-cov show` print it. `--uncovered-only` collapses
|
|
14
|
+
# the answer to `path:ranges`, a form that greps, fits in a commit message,
|
|
15
|
+
# and hands a coding agent exactly the lines whose tests are missing.
|
|
16
|
+
module Show
|
|
17
|
+
extend CommandHelpers
|
|
18
|
+
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
def run(args, stdout:, stderr:)
|
|
22
|
+
opts = parse(args)
|
|
23
|
+
return run_project(opts, stdout, stderr) unless opts.fetch(:path)
|
|
24
|
+
|
|
25
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
|
|
26
|
+
return 1 unless coverage
|
|
27
|
+
|
|
28
|
+
located = locate_or_report(coverage, opts, stderr)
|
|
29
|
+
return 1 unless located
|
|
30
|
+
|
|
31
|
+
render(located, opts, stdout, stderr)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# No path sweeps the whole project, in the compact forms only. The annotated
|
|
35
|
+
# text form still wants one file.
|
|
36
|
+
def run_project(opts, stdout, stderr)
|
|
37
|
+
unless opts.fetch(:uncovered_only) || opts.fetch(:json)
|
|
38
|
+
return error(stderr, "missing path (annotating needs one file; --uncovered-only sweeps the whole project)")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
|
|
42
|
+
return 1 unless coverage
|
|
43
|
+
|
|
44
|
+
Sweep.emit(Sweep.misses(coverage), opts, stdout, stderr)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse(args)
|
|
48
|
+
opts = {input: CLI.default_input, no_color: false,
|
|
49
|
+
uncovered_only: false, json: false} #: Hash[Symbol, untyped]
|
|
50
|
+
rest = build_parser { |parser| options(parser, opts) }.parse(args)
|
|
51
|
+
opts[:path] = rest.first
|
|
52
|
+
opts
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def options(parser, opts)
|
|
56
|
+
parser.on("--input PATH") { |v| opts[:input] = v }
|
|
57
|
+
parser.on("--no-color") { opts[:no_color] = true }
|
|
58
|
+
parser.on("--uncovered-only") { opts[:uncovered_only] = true }
|
|
59
|
+
parser.on("--json") { opts[:json] = true }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A line-less report has no per-line hits to put in a gutter, so it is
|
|
63
|
+
# reported like an unresolvable path is.
|
|
64
|
+
def locate_or_report(coverage, opts, stderr)
|
|
65
|
+
path, input = opts.fetch_values(:path, :input)
|
|
66
|
+
match = CoverageFile.lookup(coverage, path)
|
|
67
|
+
return error_nil(stderr, CoverageFile.not_found_message(coverage, path, input)) unless match
|
|
68
|
+
|
|
69
|
+
entry = match.last
|
|
70
|
+
unless entry.instance_of?(Hash)
|
|
71
|
+
return CoverageFile.report_invalid(stderr, "show", input, "entry for #{path} must be an object")
|
|
72
|
+
end
|
|
73
|
+
return match if entry["lines"].instance_of?(Array)
|
|
74
|
+
|
|
75
|
+
error_nil(stderr, "no line coverage for #{path} in #{input}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render(located, opts, stdout, stderr)
|
|
79
|
+
filename, entry = located
|
|
80
|
+
return emit_json(entry, opts, stdout) if opts.fetch(:json)
|
|
81
|
+
return show_uncovered(entry, opts, stdout, stderr) if opts.fetch(:uncovered_only)
|
|
82
|
+
|
|
83
|
+
source = source_for(filename, entry, opts, stderr)
|
|
84
|
+
return 1 unless source
|
|
85
|
+
|
|
86
|
+
Annotator.call(source, entry, stdout, color: CLI.color_enabled?(opts, stdout))
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def emit_json(entry, opts, stdout)
|
|
91
|
+
stdout.puts(JSON.pretty_generate(
|
|
92
|
+
path: opts.fetch(:path), missed: Annotator.missed_lines(entry),
|
|
93
|
+
lines: relevant_lines(entry), markers: Annotator.markers_for(entry)
|
|
94
|
+
))
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def relevant_lines(entry)
|
|
99
|
+
entry.fetch("lines").each_with_index.filter_map do |hit, index|
|
|
100
|
+
{number: index + 1, hits: hit} if hit.instance_of?(Integer)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def show_uncovered(entry, opts, stdout, stderr)
|
|
105
|
+
missed = Annotator.missed_lines(entry)
|
|
106
|
+
if missed.empty?
|
|
107
|
+
stderr.puts("simplecov show: nothing uncovered in #{opts.fetch(:path)}")
|
|
108
|
+
else
|
|
109
|
+
stdout.puts("#{opts.fetch(:path)}:#{Patch::Output.ranges(missed, ",")}")
|
|
110
|
+
end
|
|
111
|
+
0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The report's own source when it carries one; otherwise the file on disk,
|
|
115
|
+
# accepted only while its line count still matches the report's, since
|
|
116
|
+
# annotating drifted source would put hit counts on the wrong lines.
|
|
117
|
+
def source_for(filename, entry, opts, stderr)
|
|
118
|
+
embedded = entry["source"]
|
|
119
|
+
return embedded if embedded.instance_of?(Array) && embedded.all?(String)
|
|
120
|
+
unless File.file?(filename)
|
|
121
|
+
return error_nil(stderr, "no source for #{opts.fetch(:path)} in #{opts.fetch(:input)} and no file " \
|
|
122
|
+
"at #{filename}, regenerate the report with `source_in_json true`")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
lines = File.readlines(filename, chomp: true)
|
|
126
|
+
return lines if lines.size.eql?(entry.fetch("lines").size)
|
|
127
|
+
|
|
128
|
+
error_nil(stderr, "#{filename} has changed since the report (#{lines.size} lines now, " \
|
|
129
|
+
"#{entry.fetch("lines").size} recorded), regenerate the report")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Status
|
|
6
|
+
module Facts
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
def gather(document, resultset_path)
|
|
10
|
+
contexts = document["contexts"]
|
|
11
|
+
meta_facts(document).merge(
|
|
12
|
+
totals: totals(document["total"]),
|
|
13
|
+
contexts: (contexts.size if contexts.instance_of?(Array)),
|
|
14
|
+
resultset_path: resultset_path, resultset: readable_resultset(resultset_path)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def meta_facts(document)
|
|
19
|
+
none = {} #: Hash[String, untyped]
|
|
20
|
+
recorded = document["meta"]
|
|
21
|
+
meta = recorded.instance_of?(Hash) ? recorded : none
|
|
22
|
+
generated = parse_time(meta["timestamp"])
|
|
23
|
+
{
|
|
24
|
+
generated_at: meta["timestamp"], age: generated && whole_seconds(Time.now - generated),
|
|
25
|
+
version: meta["simplecov_version"], command_name: meta["command_name"],
|
|
26
|
+
commit: meta["commit"], behind: behind(meta["commit"])
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def age_of(timestamp)
|
|
31
|
+
whole_seconds(Process.clock_gettime(Process::CLOCK_REALTIME) - timestamp)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def whole_seconds(elapsed)
|
|
35
|
+
elapsed.truncate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_time(value)
|
|
39
|
+
Time.iso8601(value) if value.instance_of?(String)
|
|
40
|
+
rescue ArgumentError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Commits between the report's commit and HEAD: 0 at HEAD, nil when
|
|
45
|
+
# unanswerable, meaning no commit recorded, no git, or a commit this
|
|
46
|
+
# repository has never seen.
|
|
47
|
+
def behind(commit)
|
|
48
|
+
return nil unless commit.instance_of?(String)
|
|
49
|
+
|
|
50
|
+
stdout, _detail, success = Git.capture("rev-list", "--count", "#{commit}..HEAD")
|
|
51
|
+
commit_count(_ = stdout) if success
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `Integer` ignores surrounding whitespace on its own, so git's trailing
|
|
55
|
+
# newline needs no trimming. The base is spelled out because bare `Integer`
|
|
56
|
+
# reads a leading zero as octal.
|
|
57
|
+
def commit_count(output)
|
|
58
|
+
Integer(output, 10)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def totals(total)
|
|
62
|
+
return {} unless total.instance_of?(Hash)
|
|
63
|
+
|
|
64
|
+
rows = {"line" => "lines", "branch" => "branches", "method" => "methods"}.filter_map do |name, key|
|
|
65
|
+
percent = total.dig(key, "percent")
|
|
66
|
+
[name, percent] if percent.is_a?(Numeric)
|
|
67
|
+
end
|
|
68
|
+
rows.to_h
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# An unreadable resultset answers nothing rather than raising: its
|
|
72
|
+
# absence is a fact, not an error.
|
|
73
|
+
def readable_resultset(path)
|
|
74
|
+
parsed = JSON.parse(File.read(path))
|
|
75
|
+
return nil unless parsed.instance_of?(Hash)
|
|
76
|
+
|
|
77
|
+
parsed.map do |command, data|
|
|
78
|
+
timestamp = data["timestamp"] if data.instance_of?(Hash)
|
|
79
|
+
{command: command, age: (age_of(timestamp) if timestamp.is_a?(Numeric))}
|
|
80
|
+
end
|
|
81
|
+
rescue SystemCallError, JSON::ParserError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "command_helpers"
|
|
7
|
+
require_relative "git"
|
|
8
|
+
require_relative "status/facts"
|
|
9
|
+
|
|
10
|
+
module SimpleCov
|
|
11
|
+
module CLI
|
|
12
|
+
module Status
|
|
13
|
+
extend CommandHelpers
|
|
14
|
+
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
def run(args, stdout:, stderr:)
|
|
18
|
+
opts, = parse_common(args)
|
|
19
|
+
document = CoverageFile.load_document(opts.fetch(:input), command: "status", stderr: stderr)
|
|
20
|
+
return 1 unless document
|
|
21
|
+
|
|
22
|
+
facts = Facts.gather(document, CLI.default_resultset)
|
|
23
|
+
opts.fetch(:json) ? stdout.puts(JSON.pretty_generate(facts)) : print_facts(facts, opts, stdout)
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def print_facts(facts, opts, stdout)
|
|
28
|
+
stdout.puts("report #{opts.fetch(:input)}")
|
|
29
|
+
report_lines(facts).each { |line| stdout.puts(" #{line}") }
|
|
30
|
+
print_resultset(facts, stdout)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def report_lines(facts)
|
|
34
|
+
[
|
|
35
|
+
facts.fetch(:generated_at) && "generated #{facts.fetch(:generated_at)}#{age_suffix(facts.fetch(:age))}",
|
|
36
|
+
provenance_words(facts),
|
|
37
|
+
"commit #{commit_words(facts)}",
|
|
38
|
+
(totals_words(facts.fetch(:totals)) unless facts.fetch(:totals).empty?),
|
|
39
|
+
"tests recorded: #{contexts_words(facts.fetch(:contexts))}"
|
|
40
|
+
].compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def provenance_words(facts)
|
|
44
|
+
"by simplecov #{facts.fetch(:version)} running #{facts.fetch(:command_name)}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def commit_words(facts)
|
|
48
|
+
commit = facts.fetch(:commit)
|
|
49
|
+
return "not recorded" unless commit.instance_of?(String)
|
|
50
|
+
|
|
51
|
+
"#{commit[0, 7]} (#{distance_words(facts.fetch(:behind))})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def distance_words(behind)
|
|
55
|
+
return "not in this repository's history" unless behind
|
|
56
|
+
return "current HEAD" if behind.zero?
|
|
57
|
+
|
|
58
|
+
"#{behind} commit#{"s" unless one?(behind)} behind HEAD"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def totals_words(totals)
|
|
62
|
+
totals.map { |name, percent| "#{name} #{format("%.2f%%", percent)}" }.join(", ")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def contexts_words(count)
|
|
66
|
+
count ? "#{count} (track_tests)" : "none (enable track_tests to select and re-run by test)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def print_resultset(facts, stdout)
|
|
70
|
+
entries = facts.fetch(:resultset)
|
|
71
|
+
return stdout.puts("resultset none") unless entries
|
|
72
|
+
|
|
73
|
+
stdout.puts("resultset #{facts.fetch(:resultset_path)}")
|
|
74
|
+
entries.each do |entry|
|
|
75
|
+
age = entry.fetch(:age) ? "#{age_in_words(entry.fetch(:age))} ago" : "age unknown"
|
|
76
|
+
stdout.puts(" #{entry.fetch(:command)}: #{age}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def age_suffix(age)
|
|
81
|
+
" (#{age_in_words(age)} ago)" if age
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def age_in_words(seconds)
|
|
85
|
+
return "#{seconds.round} seconds" if seconds < 90
|
|
86
|
+
return "#{(seconds / 60.0).round} minutes" if seconds < 5400
|
|
87
|
+
return "#{(seconds / 3600.0).round} hours" if seconds < 129_600
|
|
88
|
+
|
|
89
|
+
"#{(seconds / 86_400.0).round} days"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Tests
|
|
6
|
+
# The `--redundant` sweep: the contexts whose covered lines other contexts
|
|
7
|
+
# also cover, the tests contributing no coverage of their own. A context is
|
|
8
|
+
# non-redundant exactly when some line is covered by it alone, so per file
|
|
9
|
+
# two bitmaps (lines seen once, lines seen more than once) find the uniquely
|
|
10
|
+
# covered lines and a second pass credits each to its owner.
|
|
11
|
+
module Redundancy
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
def redundant_ids(document, contexts, opts, stderr)
|
|
15
|
+
tables = sweep_tables(document, contexts, opts, stderr)
|
|
16
|
+
return unless tables
|
|
17
|
+
|
|
18
|
+
unique = unique_owners(tables, contexts.size)
|
|
19
|
+
contexts.each_index.reject { |index| unique.fetch(index) }.map { |index| contexts.fetch(index) }.sort
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unique_owners(tables, context_count)
|
|
23
|
+
unique = Array.new(context_count, false)
|
|
24
|
+
tables.each do |table|
|
|
25
|
+
lone = lone_bits(table)
|
|
26
|
+
table.each { |index, bitmap| unique[index] = true if bitmap.anybits?(lone) }
|
|
27
|
+
end
|
|
28
|
+
unique
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A bit enters `once` when brand new and leaves for good when any later
|
|
32
|
+
# bitmap carries it again. The two halves of the update are provably
|
|
33
|
+
# disjoint, so they are summed: for disjoint bits that builds the number OR
|
|
34
|
+
# would, and it leaves no spelling of the combination without a witness.
|
|
35
|
+
def lone_bits(table)
|
|
36
|
+
once = 0
|
|
37
|
+
ever = 0
|
|
38
|
+
table.each_value do |bitmap|
|
|
39
|
+
once = (once & ~bitmap) + (bitmap & ~ever)
|
|
40
|
+
ever |= bitmap
|
|
41
|
+
end
|
|
42
|
+
once
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The sweep reads tables no query named, so a malformed one anywhere poisons
|
|
46
|
+
# the whole answer, matching the targeted queries' all-or-nothing tolerance.
|
|
47
|
+
def sweep_tables(document, contexts, opts, stderr)
|
|
48
|
+
coverage = document["coverage"]
|
|
49
|
+
return invalid(opts, stderr, '"coverage" must be an object') unless coverage.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
tables = [] #: Array[Hash[Integer, Integer]]
|
|
52
|
+
coverage.each do |path, entry|
|
|
53
|
+
table = entry.is_a?(Hash) && swept_table(entry, contexts)
|
|
54
|
+
return invalid(opts, stderr, complaint(path, entry)) unless table
|
|
55
|
+
|
|
56
|
+
tables << table
|
|
57
|
+
end
|
|
58
|
+
tables
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def swept_table(entry, contexts)
|
|
62
|
+
raw = entry["contexts"] || {}
|
|
63
|
+
Tests.decode_table(raw, contexts.size) if raw.is_a?(Hash)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def complaint(path, entry)
|
|
67
|
+
return "entry for #{path} must be an object" unless entry.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
"entry for #{path} carries a malformed \"contexts\" table"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def invalid(opts, stderr, reason)
|
|
73
|
+
CoverageFile.report_invalid(stderr, "tests", opts.fetch(:input), reason)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
require_relative "tests/redundancy"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov tests [<path>[:<line>]]`: list the recorded tests from a
|
|
11
|
+
# coverage.json written under `track_tests`. Text output is one id per line
|
|
12
|
+
# so the list can feed a test runner directly; empty answers keep stdout
|
|
13
|
+
# empty and note the emptiness on stderr instead.
|
|
14
|
+
module Tests
|
|
15
|
+
extend CommandHelpers
|
|
16
|
+
|
|
17
|
+
extend self
|
|
18
|
+
|
|
19
|
+
def run(args, stdout:, stderr:)
|
|
20
|
+
opts = parse(args)
|
|
21
|
+
return error(stderr, "line number must be positive") if opts.fetch(:line)&.zero?
|
|
22
|
+
|
|
23
|
+
document = CoverageFile.load_document(opts.fetch(:input), command: "tests", stderr: stderr)
|
|
24
|
+
return 1 unless document
|
|
25
|
+
|
|
26
|
+
ids = resolve(document, opts, stderr)
|
|
27
|
+
return 1 unless ids
|
|
28
|
+
|
|
29
|
+
emit(ids, opts, stdout, stderr)
|
|
30
|
+
0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse(args)
|
|
34
|
+
opts, rest = parse_common(args) do |parser, options|
|
|
35
|
+
parser.on("--redundant") { options[:redundant] = true }
|
|
36
|
+
end
|
|
37
|
+
opts[:target] = rest.first
|
|
38
|
+
split = split_target(rest.first)
|
|
39
|
+
opts[:path] = split.fetch(:path)
|
|
40
|
+
opts[:line] = split.fetch(:line)
|
|
41
|
+
opts
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Only an all-digit tail after the last colon reads as a line number, so
|
|
45
|
+
# Windows drive colons and exotic filenames stay plain paths.
|
|
46
|
+
def split_target(target)
|
|
47
|
+
return {path: nil, line: nil} unless target
|
|
48
|
+
|
|
49
|
+
path, sep, tail = target.rpartition(":")
|
|
50
|
+
return {path: target, line: nil} if sep.empty? || !tail.match?(/\A\d+\z/)
|
|
51
|
+
|
|
52
|
+
{path: path, line: Integer(tail, 10)}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolve(document, opts, stderr)
|
|
56
|
+
contexts = recorded_contexts(document, opts, stderr)
|
|
57
|
+
return unless contexts
|
|
58
|
+
|
|
59
|
+
opts[:no_recording] = contexts.empty?
|
|
60
|
+
ids = selected_ids(document, contexts, opts, stderr)
|
|
61
|
+
return ids unless ids && opts[:redundant]
|
|
62
|
+
|
|
63
|
+
redundant = Redundancy.redundant_ids(document, contexts, opts, stderr)
|
|
64
|
+
redundant && (ids & redundant)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def selected_ids(document, contexts, opts, stderr)
|
|
68
|
+
return contexts.sort unless opts.fetch(:path)
|
|
69
|
+
|
|
70
|
+
entry = locate_entry(document, opts, stderr)
|
|
71
|
+
return unless entry
|
|
72
|
+
|
|
73
|
+
table = context_table(entry, contexts, opts, stderr)
|
|
74
|
+
table && ids_from(table, contexts, opts.fetch(:line))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def locate_entry(document, opts, stderr)
|
|
78
|
+
coverage = document["coverage"]
|
|
79
|
+
input = opts.fetch(:input)
|
|
80
|
+
unless coverage.is_a?(Hash)
|
|
81
|
+
return CoverageFile.report_invalid(stderr, "tests", input, '"coverage" must be an object')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
path = opts.fetch(:path)
|
|
85
|
+
match = CoverageFile.lookup(coverage, path)
|
|
86
|
+
return error_nil(stderr, CoverageFile.not_found_message(coverage, path, input)) unless match
|
|
87
|
+
return match.last if match.last.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
CoverageFile.report_invalid(stderr, "tests", input, "entry for #{path} must be an object")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def context_table(entry, contexts, opts, stderr)
|
|
93
|
+
raw = entry["contexts"] || {}
|
|
94
|
+
table = decode_table(raw, contexts.size) if raw.is_a?(Hash)
|
|
95
|
+
return table if table
|
|
96
|
+
|
|
97
|
+
CoverageFile.report_invalid(stderr, "tests", opts.fetch(:input),
|
|
98
|
+
"entry for #{opts.fetch(:path)} carries a malformed \"contexts\" table")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# An absent key is an untouched file; anything malformed, a foreign index or
|
|
102
|
+
# a non-hex bitmap, poisons the whole answer, matching the all-or-nothing
|
|
103
|
+
# tolerance the resultset reader applies.
|
|
104
|
+
def decode_table(raw, context_count)
|
|
105
|
+
table = {} #: Hash[Integer, Integer]
|
|
106
|
+
raw.each do |index, hex|
|
|
107
|
+
return nil unless index.is_a?(String) && index.match?(/\A\d+\z/)
|
|
108
|
+
return nil unless hex.is_a?(String) && hex.match?(/\A\h+\z/)
|
|
109
|
+
|
|
110
|
+
position = Integer(index, 10)
|
|
111
|
+
return nil unless position < context_count
|
|
112
|
+
|
|
113
|
+
table[position] = Integer(hex, 16)
|
|
114
|
+
end
|
|
115
|
+
table
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ids_from(table, contexts, line)
|
|
119
|
+
bit = line && (1 << (line - 1))
|
|
120
|
+
table.filter_map { |index, bitmap| contexts.fetch(index) if bit.nil? || bitmap.anybits?(bit) }.sort
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def emit(ids, opts, stdout, stderr)
|
|
124
|
+
return stdout.puts(JSON.pretty_generate(ids)) if opts.fetch(:json)
|
|
125
|
+
return note_empty(opts, stderr) if ids.empty?
|
|
126
|
+
|
|
127
|
+
ids.each { |id| stdout.puts(id) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def note_empty(opts, stderr)
|
|
131
|
+
stderr.puts("simplecov tests: #{empty_message(opts)}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def empty_message(opts)
|
|
135
|
+
target = opts.fetch(:target)
|
|
136
|
+
if opts[:redundant] && !opts.fetch(:no_recording)
|
|
137
|
+
return "no redundant test covers #{target}" if target
|
|
138
|
+
|
|
139
|
+
"no redundant tests, every recorded test covers at least one line uniquely"
|
|
140
|
+
elsif target
|
|
141
|
+
"no recorded test covers #{target}"
|
|
142
|
+
else
|
|
143
|
+
"no tests recorded"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Uncovered
|
|
6
|
+
module Misses
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
def missed_for(payload, criterion)
|
|
10
|
+
case criterion
|
|
11
|
+
when :line then payload["lines"].instance_of?(Array) ? Show::Annotator.missed_lines(payload) : []
|
|
12
|
+
when :branch then collect(payload["branches"])
|
|
13
|
+
else collect(payload["methods"])
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def collect(items)
|
|
18
|
+
missed = [] #: Array[Integer]
|
|
19
|
+
Show::Annotator.each_missed(items) { |line| missed << line }
|
|
20
|
+
missed.uniq.sort
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# GitHub workflow commands, one ::warning per contiguous missed range, so a
|
|
24
|
+
# plain workflow gets inline diff annotations with no upload step and no
|
|
25
|
+
# code-scanning permissions.
|
|
26
|
+
def annotate(stdout, files)
|
|
27
|
+
files.each do |fname, _pct, _covered, _total, missed|
|
|
28
|
+
path = fname.delete_prefix("#{File.expand_path(SimpleCov.root)}/")
|
|
29
|
+
missed.slice_when { |previous, current| current > previous + 1 }.each do |run|
|
|
30
|
+
stdout.puts("::warning file=#{path},line=#{run.first},endLine=#{run.last}::Not covered by tests")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|