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.
Files changed (194) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -5
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/badge/svg.rb +70 -0
  13. data/lib/simplecov/cli/badge.rb +62 -0
  14. data/lib/simplecov/cli/clean.rb +23 -24
  15. data/lib/simplecov/cli/command_helpers.rb +55 -19
  16. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  17. data/lib/simplecov/cli/completions.rb +83 -0
  18. data/lib/simplecov/cli/coverage.rb +25 -28
  19. data/lib/simplecov/cli/coverage_file.rb +58 -14
  20. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  21. data/lib/simplecov/cli/dead_code.rb +139 -0
  22. data/lib/simplecov/cli/diff/output.rb +45 -0
  23. data/lib/simplecov/cli/diff.rb +35 -67
  24. data/lib/simplecov/cli/dotfile.rb +83 -38
  25. data/lib/simplecov/cli/git.rb +41 -0
  26. data/lib/simplecov/cli/history/output.rb +135 -0
  27. data/lib/simplecov/cli/history.rb +67 -0
  28. data/lib/simplecov/cli/merge.rb +34 -39
  29. data/lib/simplecov/cli/open.rb +12 -15
  30. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  31. data/lib/simplecov/cli/patch/output.rb +135 -0
  32. data/lib/simplecov/cli/patch.rb +169 -0
  33. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  34. data/lib/simplecov/cli/ratchet.rb +83 -0
  35. data/lib/simplecov/cli/report.rb +18 -24
  36. data/lib/simplecov/cli/run.rb +8 -9
  37. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  38. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  39. data/lib/simplecov/cli/serve.rb +27 -24
  40. data/lib/simplecov/cli/show/annotator.rb +87 -0
  41. data/lib/simplecov/cli/show/sweep.rb +36 -0
  42. data/lib/simplecov/cli/show.rb +133 -0
  43. data/lib/simplecov/cli/status/facts.rb +87 -0
  44. data/lib/simplecov/cli/status.rb +93 -0
  45. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  46. data/lib/simplecov/cli/tests.rb +148 -0
  47. data/lib/simplecov/cli/uncovered/misses.rb +37 -0
  48. data/lib/simplecov/cli/uncovered.rb +70 -32
  49. data/lib/simplecov/cli/usage.rb +152 -0
  50. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  51. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  52. data/lib/simplecov/cli/watch/poller.rb +45 -0
  53. data/lib/simplecov/cli/watch/session.rb +134 -0
  54. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  55. data/lib/simplecov/cli/watch.rb +85 -0
  56. data/lib/simplecov/cli.rb +43 -92
  57. data/lib/simplecov/color.rb +19 -37
  58. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  59. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  60. data/lib/simplecov/combine/identity_interner.rb +10 -13
  61. data/lib/simplecov/combine/interned_counts.rb +7 -9
  62. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  63. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  64. data/lib/simplecov/combine/results_combiner.rb +4 -21
  65. data/lib/simplecov/combine.rb +0 -6
  66. data/lib/simplecov/command_guesser.rb +20 -34
  67. data/lib/simplecov/configuration/baseline.rb +31 -0
  68. data/lib/simplecov/configuration/coverage.rb +91 -79
  69. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  70. data/lib/simplecov/configuration/deprecations.rb +21 -0
  71. data/lib/simplecov/configuration/eval_coverage.rb +6 -10
  72. data/lib/simplecov/configuration/filters.rb +40 -73
  73. data/lib/simplecov/configuration/formatting.rb +74 -83
  74. data/lib/simplecov/configuration/groups.rb +19 -13
  75. data/lib/simplecov/configuration/history.rb +44 -0
  76. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  77. data/lib/simplecov/configuration/merging.rb +94 -62
  78. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  79. data/lib/simplecov/configuration/production.rb +23 -0
  80. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  81. data/lib/simplecov/configuration/thresholds.rb +33 -62
  82. data/lib/simplecov/configuration/view_coverage.rb +38 -0
  83. data/lib/simplecov/configuration.rb +70 -76
  84. data/lib/simplecov/configuration_error.rb +5 -0
  85. data/lib/simplecov/context_map/union.rb +81 -0
  86. data/lib/simplecov/context_map.rb +187 -0
  87. data/lib/simplecov/coverage_json.rb +2 -5
  88. data/lib/simplecov/coverage_statistics.rb +14 -24
  89. data/lib/simplecov/coverage_violations.rb +157 -64
  90. data/lib/simplecov/current_run.rb +61 -0
  91. data/lib/simplecov/defaults.rb +4 -23
  92. data/lib/simplecov/deprecation.rb +45 -27
  93. data/lib/simplecov/directive.rb +46 -62
  94. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  95. data/lib/simplecov/exit_codes/check.rb +17 -11
  96. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  97. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  98. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  99. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  100. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  101. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  102. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  103. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  104. data/lib/simplecov/exit_codes.rb +16 -10
  105. data/lib/simplecov/exit_handling.rb +94 -59
  106. data/lib/simplecov/file_list.rb +20 -51
  107. data/lib/simplecov/filter.rb +43 -66
  108. data/lib/simplecov/formatter/base.rb +37 -55
  109. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  110. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  111. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  112. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  113. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  114. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  115. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  116. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  117. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  118. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  119. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  120. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  121. data/lib/simplecov/formatter.rb +14 -10
  122. data/lib/simplecov/group_names.rb +13 -12
  123. data/lib/simplecov/history.rb +115 -0
  124. data/lib/simplecov/last_run.rb +6 -9
  125. data/lib/simplecov/lines_classifier.rb +31 -16
  126. data/lib/simplecov/load_global_config.rb +7 -7
  127. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  128. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -55
  131. data/lib/simplecov/parallel_coordination.rb +39 -49
  132. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  133. data/lib/simplecov/parallel_result_merger.rb +67 -126
  134. data/lib/simplecov/process.rb +14 -32
  135. data/lib/simplecov/production/error.rb +11 -0
  136. data/lib/simplecov/production/file_sink.rb +130 -0
  137. data/lib/simplecov/production.rb +249 -0
  138. data/lib/simplecov/profiles/rails.rb +1 -12
  139. data/lib/simplecov/profiles/root_filter.rb +0 -4
  140. data/lib/simplecov/profiles/strict.rb +9 -19
  141. data/lib/simplecov/profiles.rb +11 -33
  142. data/lib/simplecov/report_deferral.rb +11 -15
  143. data/lib/simplecov/report_stamp.rb +6 -9
  144. data/lib/simplecov/result/filter_config.rb +19 -0
  145. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  146. data/lib/simplecov/result/serialization.rb +43 -0
  147. data/lib/simplecov/result/source_file_builder.rb +7 -12
  148. data/lib/simplecov/result.rb +68 -117
  149. data/lib/simplecov/result_adapter.rb +56 -68
  150. data/lib/simplecov/result_merger/contexts.rb +22 -0
  151. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  152. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  153. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  154. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  155. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  156. data/lib/simplecov/result_merger.rb +59 -58
  157. data/lib/simplecov/result_processing.rb +74 -119
  158. data/lib/simplecov/run_identity.rb +16 -18
  159. data/lib/simplecov/simulate_coverage.rb +34 -50
  160. data/lib/simplecov/source_file/branch.rb +8 -33
  161. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  162. data/lib/simplecov/source_file/builder_context.rb +6 -13
  163. data/lib/simplecov/source_file/line.rb +13 -33
  164. data/lib/simplecov/source_file/line_builder.rb +9 -15
  165. data/lib/simplecov/source_file/method.rb +3 -7
  166. data/lib/simplecov/source_file/method_builder.rb +6 -15
  167. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  168. data/lib/simplecov/source_file/skip_chunks.rb +19 -28
  169. data/lib/simplecov/source_file/source_loader.rb +45 -35
  170. data/lib/simplecov/source_file/statistics.rb +10 -9
  171. data/lib/simplecov/source_file.rb +21 -51
  172. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
  173. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  174. data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
  175. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  176. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  177. data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
  178. data/lib/simplecov/static_coverage_extractor.rb +46 -62
  179. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  180. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  181. data/lib/simplecov/test_tracker/delta.rb +69 -0
  182. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  183. data/lib/simplecov/test_tracker.rb +162 -0
  184. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  185. data/lib/simplecov/useless_results_remover.rb +7 -6
  186. data/lib/simplecov/version.rb +1 -1
  187. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  188. data/lib/simplecov/view_coverage.rb +55 -0
  189. data/lib/simplecov-html.rb +1 -0
  190. data/lib/simplecov.rb +57 -113
  191. data/lib/simplecov_json_formatter.rb +1 -0
  192. data/man/simplecov.1 +349 -0
  193. data/sig/simplecov.rbs +523 -595
  194. 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