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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Completions
6
+ # The per-shell renderers. Each receives the same parsed usage data and
7
+ # returns the complete script text. The completions command's own shell-name
8
+ # argument is the one thing the usage tables don't carry, so each renderer
9
+ # wires it in by hand.
10
+ module Scripts
11
+ extend self
12
+
13
+ def fish(commands, options)
14
+ lines = ["# Completions for simplecov. Generated by `simplecov completions fish`."] #: Array[String]
15
+ commands.each do |name, desc|
16
+ lines << "complete -c simplecov -f -n __fish_use_subcommand -a #{name} -d #{fish_quote(desc)}"
17
+ end
18
+ options.each { |name, list| list.each { |option| lines << fish_option(name, option) } }
19
+ lines << "complete -c simplecov -f -n '__fish_seen_subcommand_from completions' -a 'fish bash zsh'"
20
+ lines.join("\n")
21
+ end
22
+
23
+ def fish_option(name, option)
24
+ parts = ["complete -c simplecov -n '__fish_seen_subcommand_from #{name}'"] #: Array[String]
25
+ parts << "-s #{(_ = option.fetch(:short)).delete_prefix("-")}" if option.fetch(:short)
26
+ parts << "-l #{option.fetch(:long).delete_prefix("--")}"
27
+ parts << "-r" if option.fetch(:arg)
28
+ parts << "-d #{fish_quote(option.fetch(:desc))}"
29
+ parts.join(" ")
30
+ end
31
+
32
+ def fish_quote(text)
33
+ "'#{text.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }}'"
34
+ end
35
+
36
+ BASH_TAIL = [
37
+ " esac",
38
+ ' [[ "$cur" == -* ]] && COMPREPLY=( $(compgen -W "$opts" -- "$cur") )',
39
+ "}",
40
+ "complete -o default -F _simplecov simplecov"
41
+ ].freeze
42
+
43
+ def bash(commands, options)
44
+ (bash_head(commands) + bash_cases(options) + BASH_TAIL).join("\n")
45
+ end
46
+
47
+ def bash_head(commands)
48
+ [
49
+ "# Completions for simplecov. Generated by `simplecov completions bash`.",
50
+ "_simplecov() {",
51
+ ' local cur="${COMP_WORDS[COMP_CWORD]}" opts=""',
52
+ ' if [ "$COMP_CWORD" -eq 1 ]; then',
53
+ " COMPREPLY=( $(compgen -W \"#{commands.collect(&:first).join(" ")}\" -- \"$cur\") ); return",
54
+ " fi",
55
+ ' case "${COMP_WORDS[1]}" in'
56
+ ]
57
+ end
58
+
59
+ def bash_cases(options)
60
+ shells = ' completions) COMPREPLY=( $(compgen -W "fish bash zsh -h --help" -- "$cur") ); return;;'
61
+ cases = [shells] #: Array[String]
62
+ options.each do |name, list|
63
+ next if name.eql?("completions")
64
+
65
+ words = list.flat_map { |option| [option.fetch(:short), option.fetch(:long)] }.compact.join(" ")
66
+ cases << " #{name}) opts=\"#{words}\";;"
67
+ end
68
+ cases
69
+ end
70
+
71
+ ZSH_MIDDLE = [
72
+ " )",
73
+ " if (( CURRENT == 2 )); then",
74
+ " _describe -t commands 'simplecov command' commands",
75
+ " return",
76
+ " fi",
77
+ ' case "$words[2]" in'
78
+ ].freeze
79
+
80
+ ZSH_TAIL = [" esac", "}", '_simplecov "$@"'].freeze
81
+
82
+ def zsh(commands, options)
83
+ head = ["#compdef simplecov", "# Completions for simplecov. Generated by `simplecov completions zsh`.",
84
+ "_simplecov() {", " local -a commands", " commands=("]
85
+ rows = commands.collect { |name, desc| " '#{name}:#{zsh_quote(desc)}'" }
86
+ (head + rows + ZSH_MIDDLE + zsh_cases(options) + ZSH_TAIL).join("\n")
87
+ end
88
+
89
+ def zsh_cases(options)
90
+ options.collect do |name, list|
91
+ specs = +""
92
+ specs << "'1:shell:(fish bash zsh)' " if name.eql?("completions")
93
+ list.each { |option| zsh_specs(option).each { |spec| specs << spec << " " } }
94
+ " #{name}) _arguments #{specs.chop};;"
95
+ end
96
+ end
97
+
98
+ # `'--input=[desc]'`: the `=` marks an argument-taking switch, which optparse
99
+ # accepts in that spelling too. Brackets would end the description early, so
100
+ # they are dropped from it.
101
+ def zsh_specs(option)
102
+ desc = "[#{zsh_quote(option.fetch(:desc).delete("[]"))}]"
103
+ specs = [] #: Array[String]
104
+ specs << "'#{option.fetch(:short)}#{desc}'" if option.fetch(:short)
105
+ specs << "'#{option.fetch(:long)}#{"=" if option.fetch(:arg)}#{desc}'"
106
+ end
107
+
108
+ def zsh_quote(text)
109
+ text.gsub("'") { "'\\''" }
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+ require_relative "usage"
6
+ require_relative "completions/scripts"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov completions <shell>`: completion scripts for fish, bash, and
11
+ # zsh, generated from the usage document rather than a hand-kept table, so a
12
+ # new command or switch shows up in completions the moment it is documented.
13
+ module Completions
14
+ extend CommandHelpers
15
+
16
+ extend self
17
+
18
+ SHELLS = %w[fish bash zsh].freeze
19
+
20
+ # " -q, --quiet Suppress...": optional short, long, optional
21
+ # argument placeholder, then the description after the column gap. A single
22
+ # space never separates the switch from its description, so one non-space
23
+ # token before a two-space run can only be an argument.
24
+ OPTION_ROW = /\A\s*(?:(-\w), )?(--[\w-]+)(?: (\S+))?\s{2,}(\S.*)\z/
25
+ COMMAND_ROW = /\A(\S+)(?: \S+)*?\s{2,}(\S.*)\z/
26
+
27
+ def run(args, stdout:, stderr:)
28
+ shell, = build_parser.parse(args)
29
+ return error(stderr, "missing shell (expected fish, bash, or zsh)") unless shell
30
+ unless SHELLS.include?(shell)
31
+ return error(stderr, "unknown shell #{shell.inspect} (expected fish, bash, or zsh)")
32
+ end
33
+
34
+ stdout.puts(script_for(shell))
35
+ 0
36
+ end
37
+
38
+ def script_for(shell)
39
+ case shell
40
+ when "fish" then Scripts.fish(commands, options_by_command)
41
+ when "bash" then Scripts.bash(commands, options_by_command)
42
+ else Scripts.zsh(commands, options_by_command)
43
+ end
44
+ end
45
+
46
+ def commands
47
+ Usage.text(CLI).split("\n\n").fetch(1).lines.filter_map do |row|
48
+ match = row.strip.match(COMMAND_ROW)
49
+ match && [(_ = match[1]), (_ = match[2])]
50
+ end
51
+ end
52
+
53
+ def options_by_command
54
+ commands.filter_map do |name, _desc|
55
+ list = options_for(name)
56
+ [name, list] unless list.empty?
57
+ end.to_h
58
+ end
59
+
60
+ # `run` takes no options of its own, since everything after it belongs to the
61
+ # command being run, so it must not even offer --help.
62
+ def options_for(command)
63
+ return [] if command.eql?("run")
64
+
65
+ sections = Usage.text(CLI).split("\n\n").select { |section| Usage.section_for?(section, command) }
66
+ options = sections.flat_map { |section| section_options(section) }
67
+ options << {short: "-h", long: "--help", arg: nil, desc: "Show this command's usage"}
68
+ end
69
+
70
+ # Rows that don't parse as options are continuations of the previous row's
71
+ # description, wrapped for the terminal.
72
+ def section_options(section)
73
+ section.lines.each_with_object([]) do |line, options|
74
+ if (match = line.chomp.match(OPTION_ROW))
75
+ options << {short: match[1], long: match[2], arg: match[3], desc: (_ = match[4])}
76
+ elsif (previous = options.last)
77
+ previous[:desc] = "#{previous.fetch(:desc)} #{line.strip}"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -6,12 +6,10 @@ require_relative "command_helpers"
6
6
 
7
7
  module SimpleCov
8
8
  module CLI
9
- # `simplecov coverage <path>` — print per-criterion stats for one
10
- # file from a JSONFormatter coverage.json.
11
9
  module Coverage
12
10
  extend CommandHelpers
13
11
 
14
- module_function
12
+ extend self
15
13
 
16
14
  def run(args, stdout:, stderr:)
17
15
  opts = parse(args, stderr: stderr)
@@ -26,44 +24,43 @@ module SimpleCov
26
24
 
27
25
  def parse(args, stderr:)
28
26
  opts, rest = parse_common(args)
29
- return stderr.puts("simplecov coverage: missing file argument") && nil if rest.empty?
27
+ if rest.empty?
28
+ stderr.puts("simplecov coverage: missing file argument")
29
+ return nil
30
+ end
30
31
 
31
32
  opts[:path] = rest.first
32
33
  opts
33
34
  end
34
35
 
35
36
  def locate_match(opts, stderr)
36
- coverage = CoverageFile.load_coverage(opts[:input], command: "coverage", stderr: stderr)
37
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "coverage", stderr: stderr)
37
38
  return unless coverage
38
39
 
39
- match = lookup(coverage, opts[:path])
40
- if match.nil?
41
- stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
42
- return nil
43
- end
44
- # A wrong-typed entry used to escape here and crash print_human.
45
- return match if match.last.is_a?(Hash)
40
+ match = lookup(coverage, opts.fetch(:path))
41
+ return report_not_found(stderr, coverage, opts) if match.nil?
42
+ return match if match.last.instance_of?(Hash)
43
+
44
+ CoverageFile.report_invalid(stderr, "coverage", opts.fetch(:input),
45
+ "entry for #{opts.fetch(:path)} must be an object")
46
+ end
46
47
 
47
- CoverageFile.report_invalid(stderr, "coverage", opts[:input], "entry for #{opts[:path]} must be an object")
48
+ def report_not_found(stderr, coverage, opts)
49
+ message = CoverageFile.not_found_message(coverage, opts.fetch(:path), opts.fetch(:input))
50
+ stderr.puts("simplecov coverage: #{message}")
48
51
  end
49
52
 
50
- # Match either the absolute path, the literal string passed, or
51
- # any coverage entry whose absolute filename ends with "/<path>".
52
- # That covers the three natural ways a user types a path: relative
53
- # to project root ("app/foo.rb"), absolute, or basename-only.
54
53
  def lookup(coverage_hash, path)
55
- absolute = File.expand_path(path)
56
- suffix = "/#{path}"
57
- coverage_hash.find { |fname, _| fname == absolute || fname == path || fname.end_with?(suffix) }
54
+ CoverageFile.lookup(coverage_hash, path)
58
55
  end
59
56
 
60
57
  def emit(match, opts, stdout)
61
58
  filename, payload = match
62
- if opts[:json]
59
+ if opts.fetch(:json)
63
60
  entry = {filename => payload} #: Hash[untyped, untyped]
64
61
  stdout.puts(JSON.pretty_generate(entry))
65
62
  else
66
- print_human(filename, payload, stdout, SimpleCov::CLI.color_enabled?(opts, stdout))
63
+ print_human(filename, payload, stdout, CLI.color_enabled?(opts, stdout))
67
64
  end
68
65
  end
69
66
 
@@ -73,13 +70,13 @@ module SimpleCov
73
70
  end
74
71
 
75
72
  def emit_criterion(stdout, payload, criterion, color)
76
- return unless payload.key?(criterion[:percent])
73
+ return unless payload.key?(criterion.fetch(:percent))
77
74
 
78
- pct = payload[criterion[:percent]].to_f
79
- stdout.puts(stats_row(criterion[:label],
80
- SimpleCov::Color.colorize_percent(pct, enabled: color),
81
- payload[criterion[:covered]],
82
- payload[criterion[:total]]))
75
+ pct = payload.fetch(criterion.fetch(:percent)).to_f
76
+ stdout.puts(stats_row(criterion.fetch(:label),
77
+ Color.colorize_percent(pct, enabled: color),
78
+ payload[criterion.fetch(:covered)],
79
+ payload[criterion.fetch(:total)]))
83
80
  end
84
81
  end
85
82
  end
@@ -5,29 +5,76 @@ require_relative "../coverage_json"
5
5
  module SimpleCov
6
6
  module CLI
7
7
  # Shared input boundary for commands that consume JSONFormatter's
8
- # coverage.json. It validates only the stable outer shape so older schema
8
+ # coverage.json. It validates only the stable outer shape, so older schema
9
9
  # versions remain readable.
10
10
  module CoverageFile
11
- # The coverage.json fields backing each criterion, keyed by the
12
- # criterion's singular name. The one table the coverage, uncovered,
13
- # and diff subcommands all read from, so a schema change lands in
14
- # exactly one place.
15
11
  CRITERIA = {
16
12
  line: {label: "Line", percent: "lines_covered_percent",
17
- covered: "covered_lines", total: "total_lines"},
13
+ covered: "covered_lines", missed: "missed_lines", total: "total_lines"},
18
14
  branch: {label: "Branch", percent: "branches_covered_percent",
19
- covered: "covered_branches", total: "total_branches"},
15
+ covered: "covered_branches", missed: "missed_branches", total: "total_branches"},
20
16
  method: {label: "Method", percent: "methods_covered_percent",
21
- covered: "covered_methods", total: "total_methods"}
17
+ covered: "covered_methods", missed: "missed_methods", total: "total_methods"}
22
18
  }.freeze
23
19
 
24
- module_function
20
+ extend self
21
+
22
+ # An exact match, the absolute path or the literal string passed, wins over
23
+ # a suffix match, so a nested "vendor/lib/foo.rb" can't shadow the real
24
+ # "lib/foo.rb" just by sitting earlier in the report. A subpath can end more
25
+ # than one key, and a collision is left as "not found" for the caller rather
26
+ # than scoring whichever key happens to sit first.
27
+ def lookup(coverage_hash, path)
28
+ absolute = File.expand_path(path)
29
+ return [absolute, coverage_hash.fetch(absolute)] if coverage_hash.key?(absolute)
30
+ return [path, coverage_hash.fetch(path)] if coverage_hash.key?(path)
31
+
32
+ matches = suffix_matches(coverage_hash, path)
33
+ matches.first if matches.one?
34
+ end
35
+
36
+ # The suffix's leading slash keeps "foo.rb" from matching "barfoo.rb", and
37
+ # hoisting it keeps the scan from allocating a string per entry.
38
+ def suffix_matches(coverage_hash, path)
39
+ suffix = "/#{path}"
40
+ coverage_hash.select { |fname, _| fname.end_with?(suffix) }
41
+ end
42
+
43
+ # The report's entries under exact, realpath-normalized keys, for
44
+ # programmatic resolution where a suffix fallback could bind a path to the
45
+ # wrong entry. The realpath step keeps a symlinked project root from
46
+ # splitting a resolved path apart from the report's keys.
47
+ def exact_index(coverage_hash)
48
+ index = {} #: Hash[String, untyped]
49
+ coverage_hash.each do |key, payload|
50
+ index[key] ||= payload
51
+ index[normalize(key)] ||= payload
52
+ end
53
+ index
54
+ end
55
+
56
+ # A key whose file no longer exists keeps its literal spelling.
57
+ def normalize(key)
58
+ File.realdirpath(key)
59
+ rescue SystemCallError
60
+ key
61
+ end
62
+
63
+ # An ambiguous subpath names its candidates: "no entry" would send the user
64
+ # hunting for a typo in a path that exists twice.
65
+ def not_found_message(coverage_hash, path, input)
66
+ matches = suffix_matches(coverage_hash, path).keys
67
+ return "no entry for #{path} in #{input}" if matches.size < 2
68
+
69
+ "#{path} matches #{matches.size} files in #{input}: #{matches.sort.join(", ")} " \
70
+ "(use a longer path to pick one)"
71
+ end
25
72
 
26
73
  def load_document(path, command:, stderr:)
27
- SimpleCov::CoverageJSON.load(path)
74
+ CoverageJSON.load(path)
28
75
  rescue Errno::ENOENT
29
76
  report_missing(stderr, command, path)
30
- rescue SimpleCov::CoverageJSON::Error => e
77
+ rescue CoverageJSON::Error => e
31
78
  report_invalid(stderr, command, path, e.message)
32
79
  rescue SystemCallError => e
33
80
  report_unreadable(stderr, command, path, e.message)
@@ -47,18 +94,15 @@ module SimpleCov
47
94
  def report_invalid(stderr, command, path, reason)
48
95
  detail = reason.lines.first.to_s.strip
49
96
  stderr.puts("simplecov #{command}: input file #{path.inspect} isn't valid JSON (#{detail})")
50
- nil
51
97
  end
52
98
 
53
99
  def report_missing(stderr, command, path)
54
100
  stderr.puts("simplecov #{command}: #{path} not found")
55
- nil
56
101
  end
57
102
 
58
103
  def report_unreadable(stderr, command, path, reason)
59
104
  detail = reason.lines.first.to_s.strip
60
105
  stderr.puts("simplecov #{command}: cannot read #{path.inspect} (#{detail})")
61
- nil
62
106
  end
63
107
  end
64
108
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../patch/output"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module DeadCode
8
+ module Output
9
+ extend CommandHelpers
10
+
11
+ extend self
12
+
13
+ def emit(stdout, opts, matrix, production)
14
+ return stdout.puts(JSON.generate(json_payload(matrix, production))) if opts.fetch(:json)
15
+
16
+ stdout.puts("Production coverage: #{opts.fetch(:production)}#{window_suffix(production)}")
17
+ stdout.puts
18
+ last_seen = production.fetch("last_seen")
19
+ opts.fetch(:untested) ? emit_untested(stdout, matrix, last_seen) : emit_dead(stdout, matrix, last_seen)
20
+ end
21
+
22
+ def emit_dead(stdout, matrix, last_seen)
23
+ rows = section(stdout, "Dead code (not run in production, not covered by tests):", matrix, :dead,
24
+ last_seen)
25
+ rows += section(stdout, "Possibly dead (not run in production, covered only by tests):", matrix,
26
+ :possibly_dead, last_seen)
27
+ return stdout.puts("No dead code found.") if rows.zero?
28
+
29
+ dead = count(matrix, :dead)
30
+ possibly = count(matrix, :possibly_dead)
31
+ stdout.puts("#{pluralize(dead, "dead line")}, #{pluralize(possibly, "possibly dead line")}")
32
+ end
33
+
34
+ def emit_untested(stdout, matrix, last_seen)
35
+ rows = section(stdout, "Untested code running in production:", matrix, :untested_in_production, last_seen)
36
+ return stdout.puts("No untested production code found.") if rows.zero?
37
+
38
+ stdout.puts("#{pluralize(count(matrix, :untested_in_production), "untested line")} running in production")
39
+ end
40
+
41
+ def section(stdout, heading, matrix, bucket, last_seen)
42
+ rows = matrix.fetch(bucket).sort
43
+ return 0 if rows.empty?
44
+
45
+ stdout.puts(heading)
46
+ rows.each do |file, lines|
47
+ stdout.puts(" #{file}:#{Patch::Output.ranges(lines, ",")}#{markers(matrix, file, last_seen)}")
48
+ end
49
+ stdout.puts
50
+ rows.size
51
+ end
52
+
53
+ # The row's parenthesized evidence. "entire file" says every relevant line
54
+ # skipped production; "last run" dates the store's last sighting of the file,
55
+ # which for a dead or possibly dead row means other lines of the file ran
56
+ # then, and its absence means the window never saw the file at all. Both can
57
+ # hold at once.
58
+ def markers(matrix, file, last_seen)
59
+ markers = [] #: Array[String]
60
+ markers << "entire file" if matrix.fetch(:entire).include?(file)
61
+ stamp = last_seen[file]
62
+ markers << "last run #{stamp[0, 10]}" if stamp.instance_of?(String)
63
+ " (#{markers.join(", ")})" unless markers.empty?
64
+ end
65
+
66
+ def count(matrix, bucket)
67
+ matrix.fetch(bucket).sum { |_file, lines| lines.size }
68
+ end
69
+
70
+ def pluralize(number, noun)
71
+ "#{number} #{noun}#{"s" unless one?(number)}"
72
+ end
73
+
74
+ def window_suffix(production)
75
+ started = production["started_at"]
76
+ updated = production["updated_at"]
77
+ " (window #{started} to #{updated})" if started && updated
78
+ end
79
+
80
+ def json_payload(matrix, production)
81
+ last_seen = production.fetch("last_seen")
82
+ payload = {window: {started_at: production["started_at"], updated_at: production["updated_at"]}}
83
+ %i[dead possibly_dead untested_in_production].each do |bucket|
84
+ payload[bucket] = matrix.fetch(bucket).sort.map { |file, lines| json_entry(file, lines, last_seen) }
85
+ end
86
+ payload
87
+ end
88
+
89
+ # JSON carries the full stamp where the text views print its date, and omits
90
+ # the key for a file the window never saw.
91
+ def json_entry(file, lines, last_seen)
92
+ entry = {file: file, lines: lines} #: Hash[Symbol, untyped]
93
+ stamp = last_seen[file]
94
+ entry[:last_seen] = stamp if stamp.instance_of?(String)
95
+ entry
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_helpers"
4
+ require_relative "coverage_file"
5
+ require_relative "../production/file_sink"
6
+ require_relative "dead_code/output"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov dead-code --production PATH` — cross the coverage a
11
+ # `SimpleCov::Production` sink accumulated with the test report, and
12
+ # answer the question neither answers alone. Per line:
13
+ #
14
+ # production | tests | meaning
15
+ # yes | yes | normal
16
+ # yes | no | untested code real users are running
17
+ # no | yes | possibly dead, a deletion candidate
18
+ # no | no | dead
19
+ #
20
+ # The default view prints the bottom two rows (the deletion candidates);
21
+ # `--untested-in-production` prints the second (the highest-value place to
22
+ # add a test). Coverage over a long enough window is far better evidence
23
+ # for deleting Ruby than any static analysis of it, which is why the header
24
+ # names the window the production data spans.
25
+ module DeadCode
26
+ extend CommandHelpers
27
+
28
+ extend self
29
+
30
+ # The module-name derivation would say "deadcode"; the command is
31
+ # hyphenated.
32
+ def command_name
33
+ "dead-code"
34
+ end
35
+
36
+ def run(args, stdout:, stderr:, **)
37
+ opts = parse(args, stderr) or return 1
38
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: command_name, stderr: stderr) or return 1
39
+ production = load_production(opts.fetch(:production), stderr) or return 1
40
+
41
+ matrix = cross(coverage, production.fetch("coverage"))
42
+ Output.emit(stdout, opts, matrix, production)
43
+ 0
44
+ end
45
+
46
+ def parse(args, stderr)
47
+ # `production` needs no default: it is filled in below from the project's
48
+ # configured store whenever the flag went unused.
49
+ opts, rest = parse_common(args, untested: false) do |parser, options|
50
+ parser.on("--production PATH") { |v| options[:production] = v }
51
+ parser.on("--untested-in-production") { options[:untested] = true }
52
+ end
53
+ return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
54
+
55
+ # The project's configured store fills in for the flag, the way ratchet
56
+ # reads `baseline_file`, so the DSL stays the single source of truth for
57
+ # where production coverage lives.
58
+ opts[:production] ||= Dotfile.production_coverage
59
+ opts.fetch(:production) ? opts : missing_production(stderr)
60
+ end
61
+
62
+ def missing_production(stderr)
63
+ error_nil(stderr,
64
+ "missing --production PATH (the file a SimpleCov::Production sink wrote, " \
65
+ "or configure SimpleCov.production_coverage in .simplecov)")
66
+ end
67
+
68
+ def load_production(path, stderr)
69
+ Production::FileSink.read(path)
70
+ rescue Errno::ENOENT
71
+ error_nil(stderr, "#{path} not found")
72
+ rescue SystemCallError, Production::Error => e
73
+ # The exception stands for its own message in `error`'s interpolation.
74
+ error_nil(stderr, e)
75
+ end
76
+
77
+ # Classifies every relevant line of the report against the production set,
78
+ # and sweeps in production files the report never tracked (nothing tested
79
+ # them, so every recorded line is untested-in-production). Lines the
80
+ # report deems irrelevant or deliberately ignored stay out of every
81
+ # bucket.
82
+ def cross(coverage, production_coverage)
83
+ matrix = {dead: {}, possibly_dead: {}, untested_in_production: {}, entire: Set.new} #: Hash[Symbol, untyped]
84
+ coverage.each do |file, entry|
85
+ lines = entry["lines"]
86
+ classify_file(matrix, file, lines, production_coverage[file] || []) if lines.instance_of?(Array)
87
+ end
88
+ # Files go in unsorted: both the text sections and the JSON payload sort
89
+ # what they print.
90
+ (production_coverage.keys - coverage.keys).each do |file|
91
+ matrix.fetch(:untested_in_production)[file] = production_coverage.fetch(file).sort
92
+ end
93
+ matrix
94
+ end
95
+
96
+ # mutant:disable -- the production lines are held as a Set for the cost of
97
+ # asking, and a list answers `include?` the same way, so the conversion
98
+ # has no witness at any call site it could live at.
99
+ def classify_file(matrix, file, lines, production_lines)
100
+ production = production_lines.to_set
101
+ buckets = {dead: [], possibly_dead: [], untested_in_production: []} #: Hash[Symbol, Array[Integer]]
102
+ relevant = 0
103
+ lines.each_with_index do |value, index|
104
+ next unless value.is_a?(Numeric)
105
+
106
+ relevant += 1
107
+ bucket = classify_line(value.positive?, production.include?(index + 1))
108
+ buckets.fetch(bucket) << (index + 1) if bucket
109
+ end
110
+ record(matrix, file, buckets, relevant)
111
+ end
112
+
113
+ # The matrix's four cells; the yes/yes cell is the one nothing needs
114
+ # reporting about.
115
+ def classify_line(tested, in_production)
116
+ return :untested_in_production if in_production && !tested
117
+ return nil if in_production
118
+
119
+ tested ? :possibly_dead : :dead
120
+ end
121
+
122
+ # mutant:disable -- the count of relevant lines is checked before a file is
123
+ # called entirely dead, but a file with no relevant lines lands in no
124
+ # bucket, so the mark is never read back. The guard says what is meant
125
+ # rather than what can be seen.
126
+ def record(matrix, file, buckets, relevant)
127
+ buckets.each { |bucket, found| matrix.fetch(bucket)[file] = found unless found.empty? }
128
+ # Counted down to nothing rather than compared for equality: two counts
129
+ # that are equal are equal through every spelling of the comparison.
130
+ unhit = buckets.fetch(:dead).size + buckets.fetch(:possibly_dead).size
131
+ return unless relevant.positive? && (relevant - unhit).zero?
132
+
133
+ # Every relevant line unhit in production: the deletion candidate is the
134
+ # whole file.
135
+ matrix.fetch(:entire) << file
136
+ end
137
+ end
138
+ end
139
+ end