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.
Files changed (210) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +173 -875
  4. data/exe/simplecov +8 -0
  5. data/lib/minitest/simplecov_plugin.rb +18 -7
  6. data/lib/simplecov/atomic_file.rb +67 -0
  7. data/lib/simplecov/autostart.rb +9 -0
  8. data/lib/simplecov/baseline/parser.rb +58 -0
  9. data/lib/simplecov/baseline.rb +170 -0
  10. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +148 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +86 -0
  17. data/lib/simplecov/cli/command_helpers.rb +91 -0
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +83 -0
  21. data/lib/simplecov/cli/coverage_file.rb +109 -0
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +115 -0
  26. data/lib/simplecov/cli/dotfile.rb +156 -0
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +115 -0
  31. data/lib/simplecov/cli/open.rb +45 -0
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/report.rb +91 -0
  38. data/lib/simplecov/cli/run.rb +35 -0
  39. data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
  40. data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
  41. data/lib/simplecov/cli/serve.rb +88 -0
  42. data/lib/simplecov/cli/show/annotator.rb +87 -0
  43. data/lib/simplecov/cli/show/sweep.rb +36 -0
  44. data/lib/simplecov/cli/show.rb +133 -0
  45. data/lib/simplecov/cli/status/facts.rb +87 -0
  46. data/lib/simplecov/cli/status.rb +93 -0
  47. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  48. data/lib/simplecov/cli/tests.rb +148 -0
  49. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  50. data/lib/simplecov/cli/uncovered.rb +127 -0
  51. data/lib/simplecov/cli/usage.rb +153 -0
  52. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  53. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  54. data/lib/simplecov/cli/watch/poller.rb +45 -0
  55. data/lib/simplecov/cli/watch/session.rb +134 -0
  56. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  57. data/lib/simplecov/cli/watch.rb +85 -0
  58. data/lib/simplecov/cli.rb +114 -0
  59. data/lib/simplecov/color.rb +65 -0
  60. data/lib/simplecov/combine/branches_combiner.rb +44 -21
  61. data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
  62. data/lib/simplecov/combine/identity_interner.rb +27 -0
  63. data/lib/simplecov/combine/interned_counts.rb +28 -0
  64. data/lib/simplecov/combine/lines_combiner.rb +35 -29
  65. data/lib/simplecov/combine/methods_combiner.rb +48 -0
  66. data/lib/simplecov/combine/results_combiner.rb +9 -49
  67. data/lib/simplecov/combine.rb +0 -24
  68. data/lib/simplecov/command_guesser.rb +99 -42
  69. data/lib/simplecov/configuration/baseline.rb +31 -0
  70. data/lib/simplecov/configuration/coverage.rb +184 -0
  71. data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
  72. data/lib/simplecov/configuration/deprecations.rb +21 -0
  73. data/lib/simplecov/configuration/eval_coverage.rb +37 -0
  74. data/lib/simplecov/configuration/filters.rb +131 -0
  75. data/lib/simplecov/configuration/formatting.rb +129 -0
  76. data/lib/simplecov/configuration/groups.rb +48 -0
  77. data/lib/simplecov/configuration/history.rb +44 -0
  78. data/lib/simplecov/configuration/ignored_entries.rb +72 -0
  79. data/lib/simplecov/configuration/merging.rb +197 -0
  80. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  81. data/lib/simplecov/configuration/production.rb +23 -0
  82. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  83. data/lib/simplecov/configuration/thresholds.rb +150 -0
  84. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  85. data/lib/simplecov/configuration.rb +100 -443
  86. data/lib/simplecov/configuration_error.rb +5 -0
  87. data/lib/simplecov/context_map/union.rb +81 -0
  88. data/lib/simplecov/context_map.rb +187 -0
  89. data/lib/simplecov/coverage_json.rb +21 -0
  90. data/lib/simplecov/coverage_statistics.rb +24 -28
  91. data/lib/simplecov/coverage_violations.rb +254 -0
  92. data/lib/simplecov/current_run.rb +61 -0
  93. data/lib/simplecov/defaults.rb +20 -28
  94. data/lib/simplecov/deprecation.rb +63 -0
  95. data/lib/simplecov/directive/erb.rb +44 -0
  96. data/lib/simplecov/directive/haml.rb +33 -0
  97. data/lib/simplecov/directive/indented_template.rb +37 -0
  98. data/lib/simplecov/directive/slim.rb +35 -0
  99. data/lib/simplecov/directive/template.rb +29 -0
  100. data/lib/simplecov/directive.rb +152 -0
  101. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  102. data/lib/simplecov/exit_codes/check.rb +39 -0
  103. data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
  104. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
  105. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  106. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  107. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
  110. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
  111. data/lib/simplecov/exit_codes.rb +26 -0
  112. data/lib/simplecov/exit_handling.rb +163 -0
  113. data/lib/simplecov/file_list.rb +63 -48
  114. data/lib/simplecov/filter.rb +98 -42
  115. data/lib/simplecov/formatter/base.rb +100 -0
  116. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  117. data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
  118. data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
  119. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
  120. data/lib/simplecov/formatter/html_formatter.rb +88 -0
  121. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
  122. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  123. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
  124. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
  125. data/lib/simplecov/formatter/json_formatter.rb +40 -0
  126. data/lib/simplecov/formatter/multi_formatter.rb +8 -10
  127. data/lib/simplecov/formatter/simple_formatter.rb +12 -15
  128. data/lib/simplecov/formatter.rb +21 -1
  129. data/lib/simplecov/group_names.rb +33 -0
  130. data/lib/simplecov/history.rb +115 -0
  131. data/lib/simplecov/last_run.rb +16 -5
  132. data/lib/simplecov/lines_classifier.rb +67 -19
  133. data/lib/simplecov/load_global_config.rb +12 -4
  134. data/lib/simplecov/parallel_adapters/base.rb +50 -0
  135. data/lib/simplecov/parallel_adapters/generic.rb +33 -0
  136. data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
  137. data/lib/simplecov/parallel_adapters.rb +54 -0
  138. data/lib/simplecov/parallel_coordination.rb +124 -0
  139. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  140. data/lib/simplecov/parallel_result_merger.rb +171 -0
  141. data/lib/simplecov/process.rb +34 -12
  142. data/lib/simplecov/production/error.rb +11 -0
  143. data/lib/simplecov/production/file_sink.rb +130 -0
  144. data/lib/simplecov/production.rb +249 -0
  145. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  146. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  147. data/lib/simplecov/profiles/rails.rb +13 -10
  148. data/lib/simplecov/profiles/root_filter.rb +2 -5
  149. data/lib/simplecov/profiles/strict.rb +22 -0
  150. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  151. data/lib/simplecov/profiles.rb +26 -19
  152. data/lib/simplecov/report_deferral.rb +45 -0
  153. data/lib/simplecov/report_stamp.rb +25 -0
  154. data/lib/simplecov/result/filter_config.rb +19 -0
  155. data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
  156. data/lib/simplecov/result/serialization.rb +43 -0
  157. data/lib/simplecov/result/source_file_builder.rb +46 -0
  158. data/lib/simplecov/result.rb +123 -47
  159. data/lib/simplecov/result_adapter.rb +130 -11
  160. data/lib/simplecov/result_merger/contexts.rb +22 -0
  161. data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
  162. data/lib/simplecov/result_merger/resultset_file.rb +67 -0
  163. data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
  164. data/lib/simplecov/result_merger/resultset_store.rb +62 -0
  165. data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
  166. data/lib/simplecov/result_merger.rb +120 -124
  167. data/lib/simplecov/result_processing.rb +188 -0
  168. data/lib/simplecov/run_identity.rb +75 -0
  169. data/lib/simplecov/simulate_coverage.rb +72 -17
  170. data/lib/simplecov/source_file/branch.rb +8 -35
  171. data/lib/simplecov/source_file/branch_builder.rb +95 -0
  172. data/lib/simplecov/source_file/builder_context.rb +28 -0
  173. data/lib/simplecov/source_file/line.rb +17 -32
  174. data/lib/simplecov/source_file/line_builder.rb +37 -0
  175. data/lib/simplecov/source_file/method.rb +54 -0
  176. data/lib/simplecov/source_file/method_builder.rb +51 -0
  177. data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
  178. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  179. data/lib/simplecov/source_file/source_loader.rb +89 -0
  180. data/lib/simplecov/source_file/statistics.rb +76 -0
  181. data/lib/simplecov/source_file.rb +64 -260
  182. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
  183. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
  184. data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
  185. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
  186. data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
  187. data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
  188. data/lib/simplecov/static_coverage_extractor.rb +78 -0
  189. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  190. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  191. data/lib/simplecov/test_tracker/delta.rb +69 -0
  192. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  193. data/lib/simplecov/test_tracker.rb +162 -0
  194. data/lib/simplecov/unloaded_file_injector.rb +62 -0
  195. data/lib/simplecov/useless_results_remover.rb +17 -7
  196. data/lib/simplecov/version.rb +1 -1
  197. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  198. data/lib/simplecov/view_coverage.rb +55 -0
  199. data/lib/simplecov-html.rb +5 -0
  200. data/lib/simplecov.rb +143 -394
  201. data/lib/simplecov_json_formatter.rb +5 -0
  202. data/man/simplecov.1 +354 -0
  203. data/sig/simplecov.rbs +1710 -0
  204. metadata +183 -61
  205. data/CHANGELOG.md +0 -191
  206. data/doc/alternate-formatters.md +0 -71
  207. data/doc/commercial-services.md +0 -25
  208. data/doc/editor-integration.md +0 -18
  209. data/lib/simplecov/combine/files_combiner.rb +0 -24
  210. data/lib/simplecov/default_formatter.rb +0 -20
@@ -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,33 @@
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
+ def annotate(stdout, files, criterion, kind)
24
+ root = "#{File.expand_path(SimpleCov.root)}/"
25
+ diagnostics = files.flat_map do |fname, _pct, _covered, _total, missed|
26
+ Annotations.diagnostics(fname.delete_prefix(root), missed, criterion)
27
+ end
28
+ Annotations.emit(stdout, kind, diagnostics)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "annotations"
6
+ require_relative "command_helpers"
7
+ require_relative "patch/output"
8
+ require_relative "show/annotator"
9
+ require_relative "uncovered/misses"
10
+
11
+ module SimpleCov
12
+ module CLI
13
+ # `simplecov uncovered`: the lowest-coverage files by the chosen criterion,
14
+ # ascending, so a developer can answer "where should I add tests next?"
15
+ # without opening a browser.
16
+ module Uncovered
17
+ extend CommandHelpers
18
+
19
+ DEFAULT_TOP = 10
20
+
21
+ extend self
22
+
23
+ def run(args, stdout:, stderr:, **)
24
+ opts = parse(args)
25
+ issue = Annotations.issue(opts)
26
+ return error(stderr, issue) if issue
27
+
28
+ keys = CoverageFile::CRITERIA[opts.fetch(:criterion)]
29
+ return unknown_criterion(opts.fetch(:criterion), stderr) unless keys
30
+
31
+ opts[:missing] = true if opts.fetch(:annotate)
32
+ report(opts, keys, stdout, stderr)
33
+ end
34
+
35
+ def report(opts, keys, stdout, stderr)
36
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "uncovered", stderr: stderr)
37
+ return 1 unless coverage
38
+
39
+ emit(stdout, rank(coverage, opts, keys).first(opts.fetch(:top)), opts)
40
+ 0
41
+ end
42
+
43
+ def unknown_criterion(criterion, stderr)
44
+ stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
45
+ 1
46
+ end
47
+
48
+ def emit(stdout, files, opts)
49
+ kind = opts.fetch(:annotate)
50
+ return Misses.annotate(stdout, files, opts.fetch(:criterion), kind) if kind
51
+ return stdout.puts(empty_message(opts.fetch(:json))) if files.empty?
52
+
53
+ opts.fetch(:json) ? emit_json(stdout, files) : emit_text(stdout, files, CLI.color_enabled?(opts, stdout))
54
+ end
55
+
56
+ def parse(args)
57
+ opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line,
58
+ missing: false, annotate: nil) { |o, options| own_options(o, options) }
59
+ opts
60
+ end
61
+
62
+ def own_options(parser, options)
63
+ parser.on("--threshold N", Float) { |v| options[:threshold] = v }
64
+ parser.on("--top N", Integer) { |v| options[:top] = validate_top(v) }
65
+ parser.on("--criterion C") { |v| options[:criterion] = v.to_sym }
66
+ parser.on("--missing") { options[:missing] = true }
67
+ parser.on("--annotate KIND") { |v| options[:annotate] = v }
68
+ end
69
+
70
+ # A negative count would raise from `Array#first`, so it is reported as the
71
+ # parse error it is. `--top 0` is allowed and shows nothing. OptionParser
72
+ # prepends the offending option's name, so the raise carries only the reason.
73
+ def validate_top(value)
74
+ raise OptionParser::InvalidArgument, "must not be negative" if value.negative?
75
+
76
+ value
77
+ end
78
+
79
+ def emit_text(stdout, files, color)
80
+ files.each do |fname, pct, covered, total, missed|
81
+ row = format_row(fname, pct, covered, total, color)
82
+ row += " missing #{Patch::Output.ranges(missed, ",")}" if missed&.any?
83
+ stdout.puts(row)
84
+ end
85
+ end
86
+
87
+ def emit_json(stdout, files)
88
+ rows = files.map do |fname, pct, covered, total, missed|
89
+ row = {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
90
+ row["missing"] = missed if missed
91
+ row
92
+ end
93
+ stdout.puts(JSON.pretty_generate(rows))
94
+ end
95
+
96
+ def empty_message(json)
97
+ json ? "[]" : "simplecov uncovered: nothing to report"
98
+ end
99
+
100
+ def rank(coverage_hash, opts, keys)
101
+ rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, opts, keys) }
102
+ rows.sort_by { |_fname, pct, _c, _t| pct }
103
+ end
104
+
105
+ def row_for(fname, payload, opts, keys)
106
+ return unless payload.instance_of?(Hash) && payload[keys.fetch(:total)].to_i.positive?
107
+
108
+ pct = payload[keys.fetch(:percent)].to_f
109
+ return if pct >= opts.fetch(:threshold)
110
+
111
+ build_row(fname, pct, payload, opts, keys)
112
+ end
113
+
114
+ def build_row(fname, pct, payload, opts, keys)
115
+ row = [fname, pct, payload[keys.fetch(:covered)].to_i, payload.fetch(keys.fetch(:total)).to_i]
116
+ row << Misses.missed_for(payload, opts.fetch(:criterion)) if opts.fetch(:missing)
117
+ row
118
+ end
119
+
120
+ def format_row(fname, pct, covered, total, color)
121
+ format("%<pct>s %<covered>d/%<total>d %<fname>s",
122
+ pct: Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
123
+ covered: covered, total: total, fname: fname)
124
+ end
125
+ end
126
+ end
127
+ end