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
@@ -7,59 +7,56 @@ require_relative "command_helpers"
7
7
 
8
8
  module SimpleCov
9
9
  module CLI
10
- # `simplecov merge <files...>` wrap SimpleCov::ResultMerger so a
11
- # CI matrix that produces one .resultset.json per worker can stitch
12
- # them together from the shell instead of dropping a Rake task into
13
- # every project. Requires the full simplecov library to be on the
14
- # load path; lazy-required so the read-only subcommands above don't
15
- # pay for ResultMerger (and its Coverage runtime guard).
10
+ # `simplecov merge <files...>`: wraps `SimpleCov::ResultMerger` so a CI
11
+ # matrix that produces one .resultset.json per worker can stitch them
12
+ # together from the shell instead of dropping a Rake task into every project.
16
13
  module Merge
17
14
  extend CommandHelpers
18
15
 
19
- module_function
16
+ extend self
20
17
 
21
18
  def run(args, stdout:, stderr:, **)
22
19
  opts = parse(args)
23
- return error(stderr, "missing input files") if opts[:files].empty?
24
- return 1 unless valid_inputs?(opts[:files], stderr)
20
+ return error(stderr, "missing input files") if opts.fetch(:files).empty?
25
21
 
26
- require "simplecov"
27
- result = SimpleCov::ResultMerger.merge_results(*opts[:files], ignore_timeout: !opts[:honor_timeout])
22
+ parsed = parse_inputs(opts.fetch(:files), stderr)
23
+ return 1 unless parsed
24
+
25
+ warn_about_duplicate_command_names(parsed, stderr)
26
+ result = result_merger.merge_results(*opts.fetch(:files), ignore_timeout: !opts.fetch(:honor_timeout))
28
27
  return error(stderr, "no mergeable results in input files") unless result
29
28
 
30
29
  commit(opts, result, stdout)
31
30
  0
32
31
  end
33
32
 
34
- def commit(opts, result, stdout)
35
- verb = opts[:dry_run] ? "would write" : "wrote"
36
- write(opts[:output], result) unless opts[:dry_run]
37
- stdout.puts("simplecov merge: #{verb} #{opts[:output]}") unless opts[:quiet]
33
+ # Loaded here rather than at the top of the file, so the read-only
34
+ # subcommands never pay for ResultMerger and its Coverage runtime guard.
35
+ def result_merger
36
+ require "simplecov"
37
+ ResultMerger
38
38
  end
39
39
 
40
- def valid_inputs?(files, stderr)
41
- parsed = parse_inputs(files, stderr) or return false
42
-
43
- warn_about_duplicate_command_names(parsed, stderr)
44
- true
40
+ def commit(opts, result, stdout)
41
+ verb = opts.fetch(:dry_run) ? "would write" : "wrote"
42
+ write(opts.fetch(:output), result) unless opts.fetch(:dry_run)
43
+ stdout.puts("simplecov merge: #{verb} #{opts.fetch(:output)}") unless opts.fetch(:quiet)
45
44
  end
46
45
 
47
46
  def parse(args)
48
- opts = {output: SimpleCov::CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
47
+ opts = {output: CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
49
48
  files =
50
- OptionParser.new do |o|
49
+ build_parser do |o|
51
50
  o.on("--output PATH") { |v| opts[:output] = v }
52
51
  o.on("--honor-timeout") { opts[:honor_timeout] = true }
53
52
  o.on("--dry-run") { opts[:dry_run] = true }
54
- o.on("-q", "--quiet") { opts[:quiet] = true }
53
+ quiet_option(o, opts)
55
54
  end.parse(args)
56
55
  opts.merge(files: files)
57
56
  end
58
57
 
59
- # Validate every input file up-front and return a {path => parsed}
60
- # hash. Surfacing per-file errors here turns ResultMerger's
61
- # generic "no mergeable results" into a message that points at
62
- # the specific input causing the failure.
58
+ # Validating every input up front turns ResultMerger's generic "no mergeable
59
+ # results" into a message that points at the specific input causing it.
63
60
  def parse_inputs(files, stderr)
64
61
  parsed = {} #: Hash[String, Hash[String, untyped]]
65
62
  files.each_with_object(parsed) do |path, memo|
@@ -69,31 +66,29 @@ module SimpleCov
69
66
  end
70
67
  end
71
68
 
72
- # Read first, classify by the exception: an exist?-then-read pair
73
- # is racy, and rescuing only ENOENT crashed with a backtrace on a
74
- # directory (EISDIR) or an unreadable file (EACCES).
69
+ # Read first and classify by the exception: an exist?-then-read pair is racy,
70
+ # and rescuing only ENOENT crashed with a backtrace on a directory or an
71
+ # unreadable file.
75
72
  def parse_input(path, stderr)
76
73
  data = JSON.parse(File.read(path))
77
- return data if data.is_a?(Hash) && !data.empty?
74
+ return data if data.instance_of?(Hash) && !data.empty?
78
75
 
79
76
  parse_input_error(stderr, path, "has no resultset entries")
80
77
  rescue Errno::ENOENT
81
78
  parse_input_error(stderr, path, "not found")
82
79
  rescue JSON::ParserError => e
83
- parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
80
+ parse_input_error(stderr, path, "isn't valid JSON (#{e})")
84
81
  rescue SystemCallError => e
85
- parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.strip})")
82
+ parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.rstrip})")
86
83
  end
87
84
 
88
85
  def parse_input_error(stderr, path, reason)
89
86
  stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
90
- nil
91
87
  end
92
88
 
93
- # When two input files share a command_name, ResultMerger folds
94
- # them together with last-write-wins on the timestamp easy to
95
- # mistake for "no merge happened." Surface the overlap so the
96
- # operator can rename the workers or accept the merge knowingly.
89
+ # ResultMerger folds entries that share a command_name together with
90
+ # last-write-wins on the timestamp, which is easy to mistake for "no merge
91
+ # happened", so the overlap is surfaced.
97
92
  def warn_about_duplicate_command_names(parsed, stderr)
98
93
  files_per_command = {} #: Hash[String, Array[String]]
99
94
  parsed.each do |path, data|
@@ -108,7 +103,7 @@ module SimpleCov
108
103
 
109
104
  def duplicate_warning(command_name, paths)
110
105
  "simplecov merge: warning — command_name #{command_name.inspect} " \
111
- "appears in #{paths.size} input files (#{paths.join(', ')}); " \
106
+ "appears in #{paths.size} input files (#{paths.join(", ")}); " \
112
107
  "entries will be merged"
113
108
  end
114
109
 
@@ -5,42 +5,39 @@ require_relative "command_helpers"
5
5
 
6
6
  module SimpleCov
7
7
  module CLI
8
- # `simplecov open [--report PATH]` — open the HTML report in the
9
- # platform's default browser. Tiny QoL wrapper around `xdg-open` /
10
- # `open` / `start` so users don't have to type a file:// URL.
11
8
  module Open
12
9
  extend CommandHelpers
13
10
 
14
- module_function
11
+ extend self
15
12
 
16
13
  def run(args, stderr:, **)
17
14
  path = parse(args)
18
15
  return error(stderr, "#{path} not found") unless File.exist?(path)
19
16
 
20
17
  opener = browser_opener
21
- return error(stderr, "no known opener for #{RbConfig::CONFIG['host_os']}") unless opener
18
+ return error(stderr, "no known opener for #{RbConfig::CONFIG.fetch("host_os")}") unless opener
22
19
 
23
20
  system(*opener, path) ? 0 : 1
24
21
  end
25
22
 
26
23
  def parse(args)
27
- path = SimpleCov::CLI.default_report
28
- OptionParser.new do |o|
24
+ path = CLI.default_report
25
+ build_parser do |o|
29
26
  o.on("--report PATH") { |v| path = v }
30
27
  end.parse(args)
31
28
  path
32
29
  end
33
30
 
34
- # Returns the argv for the platform's "open this file" command, or
35
- # nil if the host OS isn't recognized. On Windows, `start` is a
36
- # cmd.exe builtin (not an executable), so route through `cmd /c`;
37
- # the empty string is the window-title positional `start` takes
38
- # before the path so a quoted path isn't mis-parsed as the title.
31
+ # The argv for the platform's "open this file" command, or nil if the host OS
32
+ # isn't recognized. On Windows, `start` is a cmd.exe builtin rather than an
33
+ # executable, so it routes through `cmd /c`; the empty string is the
34
+ # window-title positional `start` takes before the path, so a quoted path
35
+ # isn't mis-parsed as the title.
39
36
  def browser_opener
40
- case RbConfig::CONFIG["host_os"]
41
- when /darwin/ then ["open"]
37
+ case RbConfig::CONFIG.fetch("host_os")
38
+ when /darwin/ then ["open"]
42
39
  when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
43
- when /linux|bsd|solaris/ then ["xdg-open"]
40
+ when /linux|bsd|solaris/ then ["xdg-open"]
44
41
  end
45
42
  end
46
43
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../git"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module Patch
8
+ # Run `git diff --unified=0` against a base and parse the result into
9
+ # `{root:, changes: {path => [added line numbers] | :all}}`, where
10
+ # `root` is the repository toplevel the paths are relative to and
11
+ # `:all` marks an untracked file, whose every line is new.
12
+ module ChangedLines
13
+ # A `git diff --unified=0` hunk header. Only the new-file side is
14
+ # captured: removed lines cannot be covered.
15
+ HUNK_HEADER = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
16
+
17
+ UNQUOTE = {
18
+ "\\" => "\\", '"' => '"', "n" => "\n", "t" => "\t", "r" => "\r",
19
+ "a" => "\a", "b" => "\b", "f" => "\f", "v" => "\v"
20
+ }.freeze
21
+
22
+ extend self
23
+
24
+ # nil signals "could not diff" (already reported); empty changes are a
25
+ # valid result meaning the change touched no lines at all. Everything
26
+ # runs against the repository toplevel rather than the cwd, because
27
+ # `--relative` from a subdirectory would exclude every change outside
28
+ # that subdirectory from a `--minimum` gate.
29
+ def call(base, find_renames:, stderr:)
30
+ root = git_toplevel
31
+ return report_git_error(stderr, base, nil) unless root
32
+
33
+ output, detail = git_diff(root, base, find_renames: find_renames)
34
+ return report_git_error(stderr, base, detail) unless output
35
+
36
+ # `scrub` keeps a non-UTF-8 content line from blowing up the regexp
37
+ # parse; hunk headers are pure ASCII.
38
+ changes = parse_diff(output.scrub) #: Hash[String, untyped]
39
+ untracked_files(root).each { |path| changes[path] = :all }
40
+ {root: root, changes: changes}
41
+ end
42
+
43
+ def git_toplevel
44
+ Git.toplevel
45
+ end
46
+
47
+ # `--merge-base <base>` diffs the merge base of <base> and the working
48
+ # tree against the working tree: the change reads as its own work even
49
+ # when <base> has moved on independently, and uncommitted edits count
50
+ # too. The rest pins the output against a user's git config so it can't
51
+ # skew the numbers, run code, or throw off the parse: `--no-ext-diff` /
52
+ # `--no-textconv` (either runs a configured command, and the latter also
53
+ # renumbers lines), `--no-color`, `core.quotePath=false` (emit non-ASCII
54
+ # paths literally, so they still match report keys),
55
+ # `--inter-hunk-context=0` (never merge hunks over unchanged lines,
56
+ # which would score those lines as touched), fixed `a/`/`b/` prefixes
57
+ # (so the `diff_path` strip can't be fooled by `diff.noprefix`), and
58
+ # `--no-renames` unless asked (so a moved file reads as all-new).
59
+ # stderr is kept out of the parsed stream and its first line returned as
60
+ # the failure detail, so "unknown revision" reaches the user.
61
+ def git_diff(root, base, find_renames:)
62
+ # Refusing an option-like ref keeps a `--base` value from being read by
63
+ # git as an option instead of a revision (`--output=FILE` writes to
64
+ # disk, `--line-prefix=` empties the diff so a `--minimum` gate passes).
65
+ return [nil, "a ref cannot begin with \"-\""] if Git.option_like_ref?(base)
66
+
67
+ argv = ["-C", root, "-c", "core.quotePath=false", "diff", "--unified=0",
68
+ "--no-color", "--no-ext-diff", "--no-textconv", "--inter-hunk-context=0",
69
+ "--dst-prefix=b/",
70
+ find_renames ? "--find-renames" : "--no-renames", "--merge-base", base, "--"]
71
+ stdout, detail, success = Git.capture(*argv)
72
+ [(stdout if success), detail]
73
+ end
74
+
75
+ # A brand-new file that was never `git add`ed appears in no diff, but its
76
+ # lines are this change's lines all the same, and skipping it would pass
77
+ # a `--minimum` gate over entirely unscored new code. A failure here
78
+ # contributes nothing rather than failing the run.
79
+ def untracked_files(root)
80
+ stdout, _detail, success = Git.capture("-C", root, "ls-files", "--others", "--exclude-standard", "-z")
81
+ success ? (_ = stdout).scrub.split("\0") : []
82
+ end
83
+
84
+ def report_git_error(stderr, base, detail)
85
+ reason = detail.to_s.empty? ? "is this a git working tree, and does the ref exist?" : detail
86
+ stderr.puts("simplecov patch: could not run `git diff` against #{base.inspect} (#{reason})")
87
+ end
88
+
89
+ # Split into per-file sections first so a file's `+++` header, its first,
90
+ # before any hunk, is what names the path. Inside a hunk an added line is
91
+ # itself `+`-prefixed, so a touched line whose own text begins with
92
+ # `++ ` renders as `+++ ...`; reading only the section's first `+++`
93
+ # keeps that content line from standing in as the header.
94
+ def parse_diff(output)
95
+ changes = {} #: Hash[String, Array[Integer]]
96
+ sections = output.split(/^(?=diff --git )/)
97
+ sections.each do |section|
98
+ path = section_path(section) or next
99
+ section.each_line do |line|
100
+ match = HUNK_HEADER.match(line)
101
+ record_hunk(changes, path, match) if match
102
+ end
103
+ end
104
+ changes
105
+ end
106
+
107
+ # `+++ /dev/null` (a deletion) yields nil.
108
+ def section_path(section)
109
+ header = section[/^\+\+\+ .+/]
110
+ header && diff_path(header)
111
+ end
112
+
113
+ def record_hunk(changes, path, match)
114
+ start = Integer(match[1])
115
+ count = match[2] ? Integer(match[2]) : 1
116
+ return if count.zero? # a pure-deletion hunk adds nothing
117
+
118
+ (changes[path] ||= []).concat((start...(start + count)).to_a)
119
+ end
120
+
121
+ def diff_path(line)
122
+ raw = unquote(line[4..].to_s.chomp)
123
+ # git's own literal token for an absent side, not this host's null
124
+ # device, so File::NULL (which is "NUL" on Windows) would be wrong.
125
+ return nil if raw.eql?("/dev/null") # rubocop:disable Style/FileNull
126
+
127
+ raw.sub(%r{\A[ab]/}, "")
128
+ end
129
+
130
+ # A path carrying a quote, backslash, or control character is emitted as
131
+ # `"b/lib/we\"ird.rb"` even under `core.quotePath=false` (which only
132
+ # covers non-ASCII), and left quoted it matches no coverage key.
133
+ def unquote(raw)
134
+ return raw unless raw.length >= 2 && raw.start_with?('"') && raw.end_with?('"')
135
+
136
+ # Unescaped as raw bytes: an octal escape is a bare byte, and mixing one
137
+ # into a UTF-8 string would raise mid-gsub.
138
+ unquoted = (_ = raw.b[1..-2]).gsub(/\\(?:[0-7]{1,3}|.)/) { |escape| unescape(escape) }
139
+ unquoted.force_encoding(raw.encoding).scrub
140
+ end
141
+
142
+ def unescape(escape)
143
+ body = _ = escape[1..]
144
+ body.match?(/\A[0-7]/) ? Integer(body, 8).chr : UNQUOTE.fetch(body, body)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module Patch
8
+ module Output
9
+ OPTIONAL_CRITERIA = %i[branch method].freeze
10
+
11
+ extend self
12
+
13
+ def emit(stdout, rows, opts)
14
+ if opts.fetch(:json)
15
+ stdout.puts(JSON.pretty_generate(json_rows(rows)))
16
+ else
17
+ emit_text(stdout, rows, CLI.color_enabled?(opts, stdout))
18
+ end
19
+ end
20
+
21
+ def emit_text(stdout, rows, color)
22
+ return stdout.puts("simplecov patch: no coverable lines changed") if rows.empty?
23
+
24
+ rows.sort_by! { |row| [pct(row.fetch(:line)), row.fetch(:file)] }
25
+ rows.each { |row| stdout.puts(format_row(row, color)) }
26
+ stdout.puts(format_total(rows, color))
27
+ end
28
+
29
+ def format_row(row, color)
30
+ line = " #{criterion_cells(row, color).join(" ")} #{row.fetch(:file)}"
31
+ note = missing_note(row)
32
+ note.empty? ? line : "#{line} #{note}"
33
+ end
34
+
35
+ def criterion_cells(row, color)
36
+ cells = [criterion_cell("lines", row.fetch(:line), color)]
37
+ cells << criterion_cell("branches", row.fetch(:branch), color) if measured?(row.fetch(:branch))
38
+ cells << criterion_cell("methods", row.fetch(:method), color) if measured?(row.fetch(:method))
39
+ cells
40
+ end
41
+
42
+ # A "lines" cell always, "branches" and "methods" cells only when the
43
+ # touched lines actually carried one: a hollow 0/0 cell is noise.
44
+ def criterion_cell(label, stats, color)
45
+ percent = pct(stats)
46
+ cell = Color.colorize(format("%6.2f%%", percent), (percent >= 100) ? :green : :red, enabled: color)
47
+ "#{cell} (#{stats.fetch(:covered)}/#{stats.fetch(:relevant)}) #{label}"
48
+ end
49
+
50
+ def missing_note(row)
51
+ parts = [] #: Array[String]
52
+ parts << "missing #{ranges(row.fetch(:line).fetch(:missing))}" if row.fetch(:line).fetch(:missing).any?
53
+ OPTIONAL_CRITERIA.each do |criterion|
54
+ stats = row.fetch(criterion)
55
+ parts << "#{criterion} #{ranges(stats.fetch(:missing))}" if measured?(stats) && stats.fetch(:missing).any?
56
+ end
57
+ parts.join(" ")
58
+ end
59
+
60
+ def format_total(rows, color)
61
+ totals = {line: sum_stats(rows, :line), branch: sum_stats(rows, :branch), method: sum_stats(rows, :method)}
62
+ " Patch coverage: #{criterion_cells(totals, color).join(", ")}"
63
+ end
64
+
65
+ def json_rows(rows)
66
+ rows.map do |row|
67
+ data = {file: row.fetch(:file), line: row.fetch(:line).merge(percent: pct(row.fetch(:line)))}
68
+ OPTIONAL_CRITERIA.each do |criterion|
69
+ stats = row.fetch(criterion)
70
+ data[criterion] = stats.merge(percent: pct(stats)) if measured?(stats)
71
+ end
72
+ data
73
+ end
74
+ end
75
+
76
+ # Branch and method stats are nil when the report never measured them, and
77
+ # 0/0 when the change touched none.
78
+ def measured?(stats)
79
+ stats.is_a?(Hash) && stats.fetch(:relevant).positive?
80
+ end
81
+
82
+ # Collapses a sorted line list into ranges: [41, 42, 43, 47] -> "41-43, 47".
83
+ # The show subcommand borrows this with a bare-comma separator for its
84
+ # greppable `path:40,52-58,71` form.
85
+ def ranges(numbers, separator = ", ")
86
+ numbers.slice_when { |prev, curr| curr > prev + 1 }.each_with_object(+"") do |run, out|
87
+ out << separator unless out.empty?
88
+ out << (run.one? ? only(run) : span(run))
89
+ end
90
+ end
91
+
92
+ # mutant:disable
93
+ def only(run)
94
+ format("%<only>d", only: run.first)
95
+ end
96
+
97
+ def span(run)
98
+ format("%<from>d-%<to>d", from: run.first, to: run.last)
99
+ end
100
+
101
+ def sum_stats(rows, criterion)
102
+ stats = rows.filter_map { |row| row.fetch(criterion) }
103
+ {covered: stats.sum { |stat| stat.fetch(:covered) }, relevant: stats.sum { |stat| stat.fetch(:relevant) }}
104
+ end
105
+
106
+ def pct(stats)
107
+ relevant = stats.fetch(:relevant)
108
+ return 100.0 if relevant.zero?
109
+
110
+ (stats.fetch(:covered).to_f / relevant * 100).round(2)
111
+ end
112
+
113
+ # No --minimum is report-only. With one, every measured criterion must clear
114
+ # the floor, and `short?` never fails an unmeasured one.
115
+ def gate(rows, minimum)
116
+ return 0 unless minimum
117
+
118
+ below = %i[line branch method].any? { |criterion| short?(sum_stats(rows, criterion), minimum) }
119
+ below ? 1 : 0
120
+ end
121
+
122
+ # Cross-multiplied rather than compared as percentages so neither rounding
123
+ # nor float division can shift the verdict at the boundary: reading the
124
+ # displayed `pct` would round a 19_999/20_000 patch up to 100 and pass it
125
+ # against `--minimum 100`, while dividing makes 23/40 compute as 57.4999...
126
+ # and fail an exactly-57.5% patch. The minimum becomes an exact Rational
127
+ # from its own text, and `covered * 100` is an integer, so the comparison is
128
+ # exact. A criterion with nothing relevant never falls short.
129
+ def short?(stats, minimum)
130
+ stats.fetch(:covered) * 100 < Rational(minimum.to_s) * stats.fetch(:relevant)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_helpers"
4
+ require_relative "patch/changed_lines"
5
+ require_relative "patch/output"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov patch [--base REF]`: line coverage over only the lines a
10
+ # change touched. Where `diff` asks "did the number move" and needs a
11
+ # baseline artifact, `patch` asks the question a reviewer actually asks: is
12
+ # the code in this change tested? A project sitting at 40% cannot move its
13
+ # global number in one pull request, but it can insist that every line it
14
+ # adds is covered.
15
+ #
16
+ # Only files the report already carries are scored: a changed file
17
+ # SimpleCov never tracked is out of scope, and a line `LinesClassifier`
18
+ # considers never relevant stays out of the denominator the same way it
19
+ # stays out of the file total.
20
+ #
21
+ module Patch
22
+ extend CommandHelpers
23
+
24
+ extend self
25
+
26
+ def run(args, stdout:, stderr:, **)
27
+ opts = parse(args, stderr)
28
+ return 1 unless opts
29
+
30
+ # An omitted --base resolves through origin's HEAD, so master and trunk
31
+ # repositories work bare; in CI, pass the pull request's target branch
32
+ # explicitly.
33
+ opts[:base] ||= Git.default_base
34
+ diffed = ChangedLines.call(opts.fetch(:base), find_renames: opts.fetch(:find_renames), stderr: stderr)
35
+ return 1 unless diffed
36
+
37
+ rows = compute_rows(opts.fetch(:coverage), diffed, stderr)
38
+ Output.emit(stdout, rows, opts)
39
+ Output.gate(rows, opts.fetch(:minimum))
40
+ end
41
+
42
+ def parse(args, stderr)
43
+ # No `base:` default: the run fills it in from the repository when the
44
+ # option is left out.
45
+ opts, rest = parse_common(args, find_renames: false, minimum: nil) do |parser, options|
46
+ parser.on("--base REF") { |v| options[:base] = v }
47
+ parser.on("--minimum N", Float) { |v| options[:minimum] = v }
48
+ parser.on("--find-renames") { options[:find_renames] = true }
49
+ end
50
+ return unless positional_ok?(rest, stderr)
51
+
52
+ opts[:coverage] = CoverageFile.load_coverage(opts.fetch(:input), command: "patch", stderr: stderr) or return nil
53
+ opts
54
+ end
55
+
56
+ # A stray positional looks exactly like a ref, so a forgotten `--base`
57
+ # (`simplecov patch feature-x`) would otherwise diff against the default
58
+ # and gate the wrong change in silence.
59
+ def positional_ok?(rest, stderr)
60
+ return true if rest.empty?
61
+
62
+ error(stderr, "unexpected argument #{rest.first.inspect} (did you mean `--base #{rest.first}`?)")
63
+ false
64
+ end
65
+
66
+ # Everything below reads values that came out of a coverage.json, which
67
+ # carries plain objects, arrays and integers and never a subclass of one,
68
+ # so the shape checks ask about the class itself.
69
+
70
+ # Diff paths are exact root-relative names, so they resolve exactly against
71
+ # the report. The suffix fallback `CoverageFile.lookup` offers interactive
72
+ # commands could only ever bind a changed file the report doesn't carry to
73
+ # some other file's hits and score the wrong entry.
74
+ def compute_rows(coverage, diffed, stderr)
75
+ index = CoverageFile.exact_index(coverage)
76
+ diffed.fetch(:changes).filter_map do |path, lines|
77
+ payload = index[File.expand_path(path, diffed.fetch(:root))] || index[path]
78
+ next unless payload.instance_of?(Hash) # file the report doesn't carry -> out of scope
79
+
80
+ changed = changed_for(lines, payload)
81
+ warn_stale(path, payload, changed, stderr)
82
+ row = build_row(path, payload, changed)
83
+ row if scored?(row) # nothing coverable changed in this file
84
+ end
85
+ end
86
+
87
+ # An untracked file appears in no diff, so `:all` stands in for its line
88
+ # numbers: every line the report knows is this change's work.
89
+ def changed_for(lines, payload)
90
+ return lines.uniq unless lines.equal?(:all)
91
+
92
+ hits = payload["lines"]
93
+ hits.instance_of?(Array) ? (1..hits.length).to_a : []
94
+ end
95
+
96
+ # A changed line past the end of the report's lines array reads as
97
+ # never-relevant and silently drops out of the denominator, which is right
98
+ # for a fresh report and wrong for a stale one, so say which is likelier
99
+ # out loud instead of letting a `--minimum` gate pass vacuously.
100
+ def warn_stale(path, payload, changed, stderr)
101
+ hits = payload["lines"]
102
+ return unless hits.instance_of?(Array)
103
+ return if changed.empty? || changed.max <= hits.length
104
+
105
+ stderr.puts("simplecov patch: #{path} changed beyond the #{hits.length}-line entry in the " \
106
+ "report (is the report stale? regenerate it and rerun)")
107
+ end
108
+
109
+ def build_row(path, payload, changed)
110
+ {
111
+ file: path,
112
+ line: line_stats(payload["lines"], changed),
113
+ branch: entry_stats(payload["branches"], changed),
114
+ method: entry_stats(payload["methods"], changed)
115
+ }
116
+ end
117
+
118
+ # A line counts only where the report gives it an Integer hit count; a
119
+ # never-relevant line (nil) or a `:nocov:` line ("ignored") stays out of
120
+ # the denominator, the same rule the file total uses.
121
+ def line_stats(hits, changed)
122
+ covered = 0
123
+ missing = [] #: Array[Integer]
124
+ return {covered: covered, relevant: 0, missing: missing} unless hits.instance_of?(Array)
125
+
126
+ changed.each do |number|
127
+ hit = hits.at(number - 1)
128
+ # nil / "ignored" (nocov) / any non-Integer -> never relevant, skipped.
129
+ next unless hit.instance_of?(Integer)
130
+
131
+ hit.positive? ? (covered += 1) : (missing << number)
132
+ end
133
+ {covered: covered, relevant: covered + missing.size, missing: missing.sort}
134
+ end
135
+
136
+ # Branches and methods share the report shape (a reported line and an
137
+ # integer hit count), so one scorer serves both. nil when the report
138
+ # carries no data for the criterion, so the output and gate skip it
139
+ # rather than reporting a hollow 0/0. A miss is recorded at the reported
140
+ # line so the note points where the source is.
141
+ def entry_stats(entries, changed)
142
+ return nil unless entries.instance_of?(Array)
143
+
144
+ covered = 0
145
+ missing = [] #: Array[Integer]
146
+ each_touched(entries, changed) do |line, hits|
147
+ hits.positive? ? (covered += 1) : (missing << line)
148
+ end
149
+ {covered: covered, relevant: covered + missing.size, missing: missing.uniq.sort}
150
+ end
151
+
152
+ # "ignored" (nocov) entries and entries off the change are skipped.
153
+ def each_touched(entries, changed)
154
+ entries.each do |entry|
155
+ next unless entry.instance_of?(Hash)
156
+
157
+ line = entry["report_line"] || entry["start_line"]
158
+ hits = entry["coverage"]
159
+ yield line, hits if changed.include?(line) && hits.instance_of?(Integer)
160
+ end
161
+ end
162
+
163
+ def scored?(row)
164
+ row.fetch(:line).fetch(:relevant).positive? ||
165
+ Output.measured?(row.fetch(:branch)) || Output.measured?(row.fetch(:method))
166
+ end
167
+ end
168
+ end
169
+ end