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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ module Coverage
10
+ extend CommandHelpers
11
+
12
+ extend self
13
+
14
+ def run(args, stdout:, stderr:)
15
+ opts = parse(args, stderr: stderr)
16
+ return 1 unless opts
17
+
18
+ match = locate_match(opts, stderr)
19
+ return 1 unless match
20
+
21
+ emit(match, opts, stdout)
22
+ 0
23
+ end
24
+
25
+ def parse(args, stderr:)
26
+ opts, rest = parse_common(args)
27
+ if rest.empty?
28
+ stderr.puts("simplecov coverage: missing file argument")
29
+ return nil
30
+ end
31
+
32
+ opts[:path] = rest.first
33
+ opts
34
+ end
35
+
36
+ def locate_match(opts, stderr)
37
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "coverage", stderr: stderr)
38
+ return unless coverage
39
+
40
+ match = lookup(coverage, opts.fetch(:path))
41
+ return report_not_found(stderr, coverage, opts) if match.nil?
42
+ return match if match.last.instance_of?(Hash)
43
+
44
+ CoverageFile.report_invalid(stderr, "coverage", opts.fetch(:input),
45
+ "entry for #{opts.fetch(:path)} must be an object")
46
+ end
47
+
48
+ def report_not_found(stderr, coverage, opts)
49
+ message = CoverageFile.not_found_message(coverage, opts.fetch(:path), opts.fetch(:input))
50
+ stderr.puts("simplecov coverage: #{message}")
51
+ end
52
+
53
+ def lookup(coverage_hash, path)
54
+ CoverageFile.lookup(coverage_hash, path)
55
+ end
56
+
57
+ def emit(match, opts, stdout)
58
+ filename, payload = match
59
+ if opts.fetch(:json)
60
+ entry = {filename => payload} #: Hash[untyped, untyped]
61
+ stdout.puts(JSON.pretty_generate(entry))
62
+ else
63
+ print_human(filename, payload, stdout, CLI.color_enabled?(opts, stdout))
64
+ end
65
+ end
66
+
67
+ def print_human(filename, payload, stdout, color)
68
+ stdout.puts(filename)
69
+ CoverageFile::CRITERIA.each_value { |c| emit_criterion(stdout, payload, c, color) }
70
+ end
71
+
72
+ def emit_criterion(stdout, payload, criterion, color)
73
+ return unless payload.key?(criterion.fetch(:percent))
74
+
75
+ pct = payload.fetch(criterion.fetch(:percent)).to_f
76
+ stdout.puts(stats_row(criterion.fetch(:label),
77
+ Color.colorize_percent(pct, enabled: color),
78
+ payload[criterion.fetch(:covered)],
79
+ payload[criterion.fetch(:total)]))
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../coverage_json"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # Shared input boundary for commands that consume JSONFormatter's
8
+ # coverage.json. It validates only the stable outer shape, so older schema
9
+ # versions remain readable.
10
+ module CoverageFile
11
+ CRITERIA = {
12
+ line: {label: "Line", percent: "lines_covered_percent",
13
+ covered: "covered_lines", missed: "missed_lines", total: "total_lines"},
14
+ branch: {label: "Branch", percent: "branches_covered_percent",
15
+ covered: "covered_branches", missed: "missed_branches", total: "total_branches"},
16
+ method: {label: "Method", percent: "methods_covered_percent",
17
+ covered: "covered_methods", missed: "missed_methods", total: "total_methods"}
18
+ }.freeze
19
+
20
+ extend self
21
+
22
+ # An exact match, the absolute path or the literal string passed, wins over
23
+ # a suffix match, so a nested "vendor/lib/foo.rb" can't shadow the real
24
+ # "lib/foo.rb" just by sitting earlier in the report. A subpath can end more
25
+ # than one key, and a collision is left as "not found" for the caller rather
26
+ # than scoring whichever key happens to sit first.
27
+ def lookup(coverage_hash, path)
28
+ absolute = File.expand_path(path)
29
+ return [absolute, coverage_hash.fetch(absolute)] if coverage_hash.key?(absolute)
30
+ return [path, coverage_hash.fetch(path)] if coverage_hash.key?(path)
31
+
32
+ matches = suffix_matches(coverage_hash, path)
33
+ matches.first if matches.one?
34
+ end
35
+
36
+ # The suffix's leading slash keeps "foo.rb" from matching "barfoo.rb", and
37
+ # hoisting it keeps the scan from allocating a string per entry.
38
+ def suffix_matches(coverage_hash, path)
39
+ suffix = "/#{path}"
40
+ coverage_hash.select { |fname, _| fname.end_with?(suffix) }
41
+ end
42
+
43
+ # The report's entries under exact, realpath-normalized keys, for
44
+ # programmatic resolution where a suffix fallback could bind a path to the
45
+ # wrong entry. The realpath step keeps a symlinked project root from
46
+ # splitting a resolved path apart from the report's keys.
47
+ def exact_index(coverage_hash)
48
+ index = {} #: Hash[String, untyped]
49
+ coverage_hash.each do |key, payload|
50
+ index[key] ||= payload
51
+ index[normalize(key)] ||= payload
52
+ end
53
+ index
54
+ end
55
+
56
+ # A key whose file no longer exists keeps its literal spelling.
57
+ def normalize(key)
58
+ File.realdirpath(key)
59
+ rescue SystemCallError
60
+ key
61
+ end
62
+
63
+ # An ambiguous subpath names its candidates: "no entry" would send the user
64
+ # hunting for a typo in a path that exists twice.
65
+ def not_found_message(coverage_hash, path, input)
66
+ matches = suffix_matches(coverage_hash, path).keys
67
+ return "no entry for #{path} in #{input}" if matches.size < 2
68
+
69
+ "#{path} matches #{matches.size} files in #{input}: #{matches.sort.join(", ")} " \
70
+ "(use a longer path to pick one)"
71
+ end
72
+
73
+ def load_document(path, command:, stderr:)
74
+ CoverageJSON.load(path)
75
+ rescue Errno::ENOENT
76
+ report_missing(stderr, command, path)
77
+ rescue CoverageJSON::Error => e
78
+ report_invalid(stderr, command, path, e.message)
79
+ rescue SystemCallError => e
80
+ report_unreadable(stderr, command, path, e.message)
81
+ end
82
+
83
+ def load_coverage(path, command:, stderr:)
84
+ document = load_document(path, command: command, stderr: stderr)
85
+ return unless document
86
+
87
+ none = {} #: Hash[String, untyped]
88
+ coverage = document.fetch("coverage", none)
89
+ return coverage if coverage.is_a?(Hash)
90
+
91
+ report_invalid(stderr, command, path, '"coverage" must be an object')
92
+ end
93
+
94
+ def report_invalid(stderr, command, path, reason)
95
+ detail = reason.lines.first.to_s.strip
96
+ stderr.puts("simplecov #{command}: input file #{path.inspect} isn't valid JSON (#{detail})")
97
+ end
98
+
99
+ def report_missing(stderr, command, path)
100
+ stderr.puts("simplecov #{command}: #{path} not found")
101
+ end
102
+
103
+ def report_unreadable(stderr, command, path, reason)
104
+ detail = reason.lines.first.to_s.strip
105
+ stderr.puts("simplecov #{command}: cannot read #{path.inspect} (#{detail})")
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../patch/output"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module DeadCode
8
+ module Output
9
+ extend CommandHelpers
10
+
11
+ extend self
12
+
13
+ def emit(stdout, opts, matrix, production)
14
+ return stdout.puts(JSON.generate(json_payload(matrix, production))) if opts.fetch(:json)
15
+
16
+ stdout.puts("Production coverage: #{opts.fetch(:production)}#{window_suffix(production)}")
17
+ stdout.puts
18
+ last_seen = production.fetch("last_seen")
19
+ opts.fetch(:untested) ? emit_untested(stdout, matrix, last_seen) : emit_dead(stdout, matrix, last_seen)
20
+ end
21
+
22
+ def emit_dead(stdout, matrix, last_seen)
23
+ rows = section(stdout, "Dead code (not run in production, not covered by tests):", matrix, :dead,
24
+ last_seen)
25
+ rows += section(stdout, "Possibly dead (not run in production, covered only by tests):", matrix,
26
+ :possibly_dead, last_seen)
27
+ return stdout.puts("No dead code found.") if rows.zero?
28
+
29
+ dead = count(matrix, :dead)
30
+ possibly = count(matrix, :possibly_dead)
31
+ stdout.puts("#{pluralize(dead, "dead line")}, #{pluralize(possibly, "possibly dead line")}")
32
+ end
33
+
34
+ def emit_untested(stdout, matrix, last_seen)
35
+ rows = section(stdout, "Untested code running in production:", matrix, :untested_in_production, last_seen)
36
+ return stdout.puts("No untested production code found.") if rows.zero?
37
+
38
+ stdout.puts("#{pluralize(count(matrix, :untested_in_production), "untested line")} running in production")
39
+ end
40
+
41
+ def section(stdout, heading, matrix, bucket, last_seen)
42
+ rows = matrix.fetch(bucket).sort
43
+ return 0 if rows.empty?
44
+
45
+ stdout.puts(heading)
46
+ rows.each do |file, lines|
47
+ stdout.puts(" #{file}:#{Patch::Output.ranges(lines, ",")}#{markers(matrix, file, last_seen)}")
48
+ end
49
+ stdout.puts
50
+ rows.size
51
+ end
52
+
53
+ # The row's parenthesized evidence. "entire file" says every relevant line
54
+ # skipped production; "last run" dates the store's last sighting of the file,
55
+ # which for a dead or possibly dead row means other lines of the file ran
56
+ # then, and its absence means the window never saw the file at all. Both can
57
+ # hold at once.
58
+ def markers(matrix, file, last_seen)
59
+ markers = [] #: Array[String]
60
+ markers << "entire file" if matrix.fetch(:entire).include?(file)
61
+ stamp = last_seen[file]
62
+ markers << "last run #{stamp[0, 10]}" if stamp.instance_of?(String)
63
+ " (#{markers.join(", ")})" unless markers.empty?
64
+ end
65
+
66
+ def count(matrix, bucket)
67
+ matrix.fetch(bucket).sum { |_file, lines| lines.size }
68
+ end
69
+
70
+ def pluralize(number, noun)
71
+ "#{number} #{noun}#{"s" unless one?(number)}"
72
+ end
73
+
74
+ def window_suffix(production)
75
+ started = production["started_at"]
76
+ updated = production["updated_at"]
77
+ " (window #{started} to #{updated})" if started && updated
78
+ end
79
+
80
+ def json_payload(matrix, production)
81
+ last_seen = production.fetch("last_seen")
82
+ payload = {window: {started_at: production["started_at"], updated_at: production["updated_at"]}}
83
+ %i[dead possibly_dead untested_in_production].each do |bucket|
84
+ payload[bucket] = matrix.fetch(bucket).sort.map { |file, lines| json_entry(file, lines, last_seen) }
85
+ end
86
+ payload
87
+ end
88
+
89
+ # JSON carries the full stamp where the text views print its date, and omits
90
+ # the key for a file the window never saw.
91
+ def json_entry(file, lines, last_seen)
92
+ entry = {file: file, lines: lines} #: Hash[Symbol, untyped]
93
+ stamp = last_seen[file]
94
+ entry[:last_seen] = stamp if stamp.instance_of?(String)
95
+ entry
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_helpers"
4
+ require_relative "coverage_file"
5
+ require_relative "../production/file_sink"
6
+ require_relative "dead_code/output"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov dead-code --production PATH` — cross the coverage a
11
+ # `SimpleCov::Production` sink accumulated with the test report, and
12
+ # answer the question neither answers alone. Per line:
13
+ #
14
+ # production | tests | meaning
15
+ # yes | yes | normal
16
+ # yes | no | untested code real users are running
17
+ # no | yes | possibly dead, a deletion candidate
18
+ # no | no | dead
19
+ #
20
+ # The default view prints the bottom two rows (the deletion candidates);
21
+ # `--untested-in-production` prints the second (the highest-value place to
22
+ # add a test). Coverage over a long enough window is far better evidence
23
+ # for deleting Ruby than any static analysis of it, which is why the header
24
+ # names the window the production data spans.
25
+ module DeadCode
26
+ extend CommandHelpers
27
+
28
+ extend self
29
+
30
+ # The module-name derivation would say "deadcode"; the command is
31
+ # hyphenated.
32
+ def command_name
33
+ "dead-code"
34
+ end
35
+
36
+ def run(args, stdout:, stderr:, **)
37
+ opts = parse(args, stderr) or return 1
38
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: command_name, stderr: stderr) or return 1
39
+ production = load_production(opts.fetch(:production), stderr) or return 1
40
+
41
+ matrix = cross(coverage, production.fetch("coverage"))
42
+ Output.emit(stdout, opts, matrix, production)
43
+ 0
44
+ end
45
+
46
+ def parse(args, stderr)
47
+ # `production` needs no default: it is filled in below from the project's
48
+ # configured store whenever the flag went unused.
49
+ opts, rest = parse_common(args, untested: false) do |parser, options|
50
+ parser.on("--production PATH") { |v| options[:production] = v }
51
+ parser.on("--untested-in-production") { options[:untested] = true }
52
+ end
53
+ return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
54
+
55
+ # The project's configured store fills in for the flag, the way ratchet
56
+ # reads `baseline_file`, so the DSL stays the single source of truth for
57
+ # where production coverage lives.
58
+ opts[:production] ||= Dotfile.production_coverage
59
+ opts.fetch(:production) ? opts : missing_production(stderr)
60
+ end
61
+
62
+ def missing_production(stderr)
63
+ error_nil(stderr,
64
+ "missing --production PATH (the file a SimpleCov::Production sink wrote, " \
65
+ "or configure SimpleCov.production_coverage in .simplecov)")
66
+ end
67
+
68
+ def load_production(path, stderr)
69
+ Production::FileSink.read(path)
70
+ rescue Errno::ENOENT
71
+ error_nil(stderr, "#{path} not found")
72
+ rescue SystemCallError, Production::Error => e
73
+ # The exception stands for its own message in `error`'s interpolation.
74
+ error_nil(stderr, e)
75
+ end
76
+
77
+ # Classifies every relevant line of the report against the production set,
78
+ # and sweeps in production files the report never tracked (nothing tested
79
+ # them, so every recorded line is untested-in-production). Lines the
80
+ # report deems irrelevant or deliberately ignored stay out of every
81
+ # bucket.
82
+ def cross(coverage, production_coverage)
83
+ matrix = {dead: {}, possibly_dead: {}, untested_in_production: {}, entire: Set.new} #: Hash[Symbol, untyped]
84
+ coverage.each do |file, entry|
85
+ lines = entry["lines"]
86
+ classify_file(matrix, file, lines, production_coverage[file] || []) if lines.instance_of?(Array)
87
+ end
88
+ # Files go in unsorted: both the text sections and the JSON payload sort
89
+ # what they print.
90
+ (production_coverage.keys - coverage.keys).each do |file|
91
+ matrix.fetch(:untested_in_production)[file] = production_coverage.fetch(file).sort
92
+ end
93
+ matrix
94
+ end
95
+
96
+ # mutant:disable -- the production lines are held as a Set for the cost of
97
+ # asking, and a list answers `include?` the same way, so the conversion
98
+ # has no witness at any call site it could live at.
99
+ def classify_file(matrix, file, lines, production_lines)
100
+ production = production_lines.to_set
101
+ buckets = {dead: [], possibly_dead: [], untested_in_production: []} #: Hash[Symbol, Array[Integer]]
102
+ relevant = 0
103
+ lines.each_with_index do |value, index|
104
+ next unless value.is_a?(Numeric)
105
+
106
+ relevant += 1
107
+ bucket = classify_line(value.positive?, production.include?(index + 1))
108
+ buckets.fetch(bucket) << (index + 1) if bucket
109
+ end
110
+ record(matrix, file, buckets, relevant)
111
+ end
112
+
113
+ # The matrix's four cells; the yes/yes cell is the one nothing needs
114
+ # reporting about.
115
+ def classify_line(tested, in_production)
116
+ return :untested_in_production if in_production && !tested
117
+ return nil if in_production
118
+
119
+ tested ? :possibly_dead : :dead
120
+ end
121
+
122
+ # mutant:disable -- the count of relevant lines is checked before a file is
123
+ # called entirely dead, but a file with no relevant lines lands in no
124
+ # bucket, so the mark is never read back. The guard says what is meant
125
+ # rather than what can be seen.
126
+ def record(matrix, file, buckets, relevant)
127
+ buckets.each { |bucket, found| matrix.fetch(bucket)[file] = found unless found.empty? }
128
+ # Counted down to nothing rather than compared for equality: two counts
129
+ # that are equal are equal through every spelling of the comparison.
130
+ unhit = buckets.fetch(:dead).size + buckets.fetch(:possibly_dead).size
131
+ return unless relevant.positive? && (relevant - unhit).zero?
132
+
133
+ # Every relevant line unhit in production: the deletion candidate is the
134
+ # whole file.
135
+ matrix.fetch(:entire) << file
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Diff
6
+ module Output
7
+ STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
8
+
9
+ extend self
10
+
11
+ def emit_text(stdout, rows, color)
12
+ if rows.empty?
13
+ stdout.puts("simplecov diff: no per-file coverage changes")
14
+ else
15
+ rows.each { |row| stdout.puts(format_row(row, color)) }
16
+ end
17
+ end
18
+
19
+ def format_row(row, color)
20
+ line = " #{delta_parts(row, color).join(" ")} #{row.fetch(:file)}"
21
+ suffix = STATUS_SUFFIX[row.fetch(:status)]
22
+ suffix ? "#{line} #{suffix}" : line
23
+ end
24
+
25
+ def delta_parts(row, color)
26
+ [
27
+ format_delta(row.fetch(:line_delta), "lines", color),
28
+ (format_delta(row.fetch(:branch_delta), "branches", color) if row.fetch(:branch_delta).abs > EPSILON),
29
+ (format_delta(row.fetch(:method_delta), "methods", color) if row.fetch(:method_delta).abs > EPSILON)
30
+ ].compact
31
+ end
32
+
33
+ # Deltas are sign-based, not threshold-based: a +5% bump is green and a -5%
34
+ # drop is red, regardless of where the absolute coverage level lands. A drop
35
+ # carries its own minus, so the sign is a prefix a rise has and a drop has
36
+ # not.
37
+ def format_delta(delta, label, color)
38
+ sign = "+" if delta.positive?
39
+ text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
40
+ Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+ require_relative "diff/output"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov diff <baseline>`: per-file coverage deltas between coverage.json
11
+ # and a baseline coverage.json checked in alongside the suite. Only files
12
+ # whose coverage moved are listed, and `--fail-on-drop` exits non-zero when
13
+ # any file regressed, so this composes as a "coverage of this PR didn't drop"
14
+ # gate.
15
+ module Diff
16
+ extend CommandHelpers
17
+
18
+ # Tolerance below which a delta is considered noise.
19
+ EPSILON = 0.005
20
+
21
+ extend self
22
+
23
+ def run(args, stdout:, stderr:, **)
24
+ opts = parse(args, stderr)
25
+ return 1 unless opts
26
+
27
+ rows = compute_rows(opts.fetch(:current), opts.fetch(:baseline), opts.fetch(:threshold))
28
+ rows.sort_by! { |row| row.fetch(:line_delta) }
29
+ if opts.fetch(:json)
30
+ stdout.puts(JSON.pretty_generate(rows))
31
+ else
32
+ Output.emit_text(stdout, rows, CLI.color_enabled?(opts, stdout))
33
+ end
34
+ (opts.fetch(:fail_on_drop) && coverage_drop?(rows)) ? 1 : 0
35
+ end
36
+
37
+ def parse(args, stderr)
38
+ opts = parse_flags(args)
39
+ if opts.fetch(:rest).empty?
40
+ stderr.puts("simplecov diff: missing baseline argument")
41
+ return nil
42
+ end
43
+
44
+ opts[:baseline] = load_coverage(opts.fetch(:rest).first, stderr) or return nil
45
+ opts[:current] = load_coverage(opts.fetch(:input), stderr) or return nil
46
+ opts
47
+ end
48
+
49
+ def parse_flags(args)
50
+ opts, rest = parse_common(args, fail_on_drop: false, threshold: 0.0) do |o, options|
51
+ o.on("--fail-on-drop") { options[:fail_on_drop] = true }
52
+ o.on("--threshold N", Float) { |v| options[:threshold] = v }
53
+ end
54
+ opts.merge(rest: rest)
55
+ end
56
+
57
+ def load_coverage(path, stderr)
58
+ coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
59
+ return unless coverage
60
+
61
+ normalize_keys(coverage)
62
+ end
63
+
64
+ # Strips a leading slash so coverage.json files written before the
65
+ # `project_filename` change still diff cleanly against newer reports.
66
+ def normalize_keys(coverage)
67
+ coverage.transform_keys { |key| key.delete_prefix("/") }
68
+ end
69
+
70
+ def compute_rows(current, baseline, threshold)
71
+ files = current.keys | baseline.keys
72
+ files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
73
+ end
74
+
75
+ # The threshold is inclusive, so a file that moved exactly N% is listed under
76
+ # `--threshold N`, matching the "at least N%" the usage text promises.
77
+ def compute_row(fname, current_payload, baseline_payload, threshold)
78
+ deltas = compute_deltas(current_payload, baseline_payload)
79
+ floor = threshold.abs
80
+ return nil unless deltas.values.any? { |delta| delta.abs > EPSILON && delta.abs >= floor }
81
+
82
+ {file: fname, status: status_for(current_payload, baseline_payload),
83
+ line_delta: deltas.fetch(:line), branch_delta: deltas.fetch(:branch), method_delta: deltas.fetch(:method)}
84
+ end
85
+
86
+ def compute_deltas(current_payload, baseline_payload)
87
+ CoverageFile::CRITERIA.transform_values do |fields|
88
+ pct_for(fields, current_payload) - pct_for(fields, baseline_payload)
89
+ end
90
+ end
91
+
92
+ def status_for(current_payload, baseline_payload)
93
+ return "added" if baseline_payload.nil?
94
+ return "removed" if current_payload.nil?
95
+
96
+ "changed"
97
+ end
98
+
99
+ def pct_for(fields, payload)
100
+ return 0.0 unless payload.instance_of?(Hash) && payload[fields.fetch(:total)].to_i.positive?
101
+
102
+ payload[fields.fetch(:percent)].to_f
103
+ end
104
+
105
+ # A removed file's deltas are all -baseline%, but deleting a covered file is
106
+ # not a coverage regression, so removed rows never trip the gate. Drops are
107
+ # gated on EPSILON like row inclusion and display, so a row listed for a gain
108
+ # in one criterion cannot fail the run over float noise in another.
109
+ def coverage_drop?(rows)
110
+ rows.reject { |row| row.fetch(:status).eql?("removed") }
111
+ .any? { |row| row.fetch_values(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
112
+ end
113
+ end
114
+ end
115
+ end