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,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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../annotations"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ module Patch
9
+ module Output
10
+ OPTIONAL_CRITERIA = %i[branch method].freeze
11
+
12
+ CRITERIA = %i[line branch method].freeze
13
+
14
+ extend self
15
+
16
+ # One diagnostic per contiguous missed range per measured criterion, in the
17
+ # CI host's own annotation form. Totals and the empty-change notice stay
18
+ # off stdout: the exit status under --minimum is the verdict.
19
+ def annotate(stdout, rows, kind)
20
+ diagnostics = rows.flat_map do |row|
21
+ CRITERIA.flat_map do |criterion|
22
+ stats = row.fetch(criterion)
23
+ stats ? Annotations.diagnostics(row.fetch(:file), stats.fetch(:missing), criterion) : []
24
+ end
25
+ end
26
+ Annotations.emit(stdout, kind, diagnostics)
27
+ end
28
+
29
+ def emit(stdout, rows, opts)
30
+ if opts.fetch(:json)
31
+ stdout.puts(JSON.pretty_generate(json_rows(rows)))
32
+ else
33
+ emit_text(stdout, rows, CLI.color_enabled?(opts, stdout))
34
+ end
35
+ end
36
+
37
+ def emit_text(stdout, rows, color)
38
+ return stdout.puts("simplecov patch: no coverable lines changed") if rows.empty?
39
+
40
+ rows.sort_by! { |row| [pct(row.fetch(:line)), row.fetch(:file)] }
41
+ rows.each { |row| stdout.puts(format_row(row, color)) }
42
+ stdout.puts(format_total(rows, color))
43
+ end
44
+
45
+ def format_row(row, color)
46
+ line = " #{criterion_cells(row, color).join(" ")} #{row.fetch(:file)}"
47
+ note = missing_note(row)
48
+ note.empty? ? line : "#{line} #{note}"
49
+ end
50
+
51
+ def criterion_cells(row, color)
52
+ cells = [criterion_cell("lines", row.fetch(:line), color)]
53
+ cells << criterion_cell("branches", row.fetch(:branch), color) if measured?(row.fetch(:branch))
54
+ cells << criterion_cell("methods", row.fetch(:method), color) if measured?(row.fetch(:method))
55
+ cells
56
+ end
57
+
58
+ # A "lines" cell always, "branches" and "methods" cells only when the
59
+ # touched lines actually carried one: a hollow 0/0 cell is noise.
60
+ def criterion_cell(label, stats, color)
61
+ percent = pct(stats)
62
+ cell = Color.colorize(format("%6.2f%%", percent), (percent >= 100) ? :green : :red, enabled: color)
63
+ "#{cell} (#{stats.fetch(:covered)}/#{stats.fetch(:relevant)}) #{label}"
64
+ end
65
+
66
+ def missing_note(row)
67
+ parts = [] #: Array[String]
68
+ parts << "missing #{ranges(row.fetch(:line).fetch(:missing))}" if row.fetch(:line).fetch(:missing).any?
69
+ OPTIONAL_CRITERIA.each do |criterion|
70
+ stats = row.fetch(criterion)
71
+ parts << "#{criterion} #{ranges(stats.fetch(:missing))}" if measured?(stats) && stats.fetch(:missing).any?
72
+ end
73
+ parts.join(" ")
74
+ end
75
+
76
+ def format_total(rows, color)
77
+ totals = {line: sum_stats(rows, :line), branch: sum_stats(rows, :branch), method: sum_stats(rows, :method)}
78
+ " Patch coverage: #{criterion_cells(totals, color).join(", ")}"
79
+ end
80
+
81
+ def json_rows(rows)
82
+ rows.map do |row|
83
+ data = {file: row.fetch(:file), line: row.fetch(:line).merge(percent: pct(row.fetch(:line)))}
84
+ OPTIONAL_CRITERIA.each do |criterion|
85
+ stats = row.fetch(criterion)
86
+ data[criterion] = stats.merge(percent: pct(stats)) if measured?(stats)
87
+ end
88
+ data
89
+ end
90
+ end
91
+
92
+ # Branch and method stats are nil when the report never measured them, and
93
+ # 0/0 when the change touched none.
94
+ def measured?(stats)
95
+ stats.is_a?(Hash) && stats.fetch(:relevant).positive?
96
+ end
97
+
98
+ # Collapses a sorted line list into ranges: [41, 42, 43, 47] -> "41-43, 47".
99
+ # The show subcommand borrows this with a bare-comma separator for its
100
+ # greppable `path:40,52-58,71` form.
101
+ def ranges(numbers, separator = ", ")
102
+ numbers.slice_when { |prev, curr| curr > prev + 1 }.each_with_object(+"") do |run, out|
103
+ out << separator unless out.empty?
104
+ out << (run.one? ? only(run) : span(run))
105
+ end
106
+ end
107
+
108
+ # mutant:disable
109
+ def only(run)
110
+ format("%<only>d", only: run.first)
111
+ end
112
+
113
+ def span(run)
114
+ format("%<from>d-%<to>d", from: run.first, to: run.last)
115
+ end
116
+
117
+ def sum_stats(rows, criterion)
118
+ stats = rows.filter_map { |row| row.fetch(criterion) }
119
+ {covered: stats.sum { |stat| stat.fetch(:covered) }, relevant: stats.sum { |stat| stat.fetch(:relevant) }}
120
+ end
121
+
122
+ def pct(stats)
123
+ relevant = stats.fetch(:relevant)
124
+ return 100.0 if relevant.zero?
125
+
126
+ (stats.fetch(:covered).to_f / relevant * 100).round(2)
127
+ end
128
+
129
+ # No --minimum is report-only. With one, every measured criterion must clear
130
+ # the floor, and `short?` never fails an unmeasured one.
131
+ def gate(rows, minimum)
132
+ return 0 unless minimum
133
+
134
+ below = %i[line branch method].any? { |criterion| short?(sum_stats(rows, criterion), minimum) }
135
+ below ? 1 : 0
136
+ end
137
+
138
+ # Cross-multiplied rather than compared as percentages so neither rounding
139
+ # nor float division can shift the verdict at the boundary: reading the
140
+ # displayed `pct` would round a 19_999/20_000 patch up to 100 and pass it
141
+ # against `--minimum 100`, while dividing makes 23/40 compute as 57.4999...
142
+ # and fail an exactly-57.5% patch. The minimum becomes an exact Rational
143
+ # from its own text, and `covered * 100` is an integer, so the comparison is
144
+ # exact. A criterion with nothing relevant never falls short.
145
+ def short?(stats, minimum)
146
+ stats.fetch(:covered) * 100 < Rational(minimum.to_s) * stats.fetch(:relevant)
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "annotations"
4
+ require_relative "command_helpers"
5
+ require_relative "patch/changed_lines"
6
+ require_relative "patch/output"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov patch [--base REF]`: line coverage over only the lines a
11
+ # change touched. Where `diff` asks "did the number move" and needs a
12
+ # baseline artifact, `patch` asks the question a reviewer actually asks: is
13
+ # the code in this change tested? A project sitting at 40% cannot move its
14
+ # global number in one pull request, but it can insist that every line it
15
+ # adds is covered.
16
+ #
17
+ # Only files the report already carries are scored: a changed file
18
+ # SimpleCov never tracked is out of scope, and a line `LinesClassifier`
19
+ # considers never relevant stays out of the denominator the same way it
20
+ # stays out of the file total.
21
+ #
22
+ module Patch
23
+ extend CommandHelpers
24
+
25
+ extend self
26
+
27
+ def run(args, stdout:, stderr:, **)
28
+ opts = parse(args, stderr)
29
+ return 1 unless opts
30
+
31
+ # An omitted --base resolves through origin's HEAD, so master and trunk
32
+ # repositories work bare; in CI, pass the pull request's target branch
33
+ # explicitly.
34
+ opts[:base] ||= Git.default_base
35
+ diffed = ChangedLines.call(opts.fetch(:base), find_renames: opts.fetch(:find_renames), stderr: stderr)
36
+ return 1 unless diffed
37
+
38
+ rows = compute_rows(opts.fetch(:coverage), diffed, stderr)
39
+ kind = opts.fetch(:annotate)
40
+ kind ? Output.annotate(stdout, rows, kind) : Output.emit(stdout, rows, opts)
41
+ Output.gate(rows, opts.fetch(:minimum))
42
+ end
43
+
44
+ def parse(args, stderr)
45
+ # No `base:` default: the run fills it in from the repository when the
46
+ # option is left out.
47
+ opts, rest = parse_common(args, find_renames: false, minimum: nil, annotate: nil) do |parser, options|
48
+ parser.on("--base REF") { |v| options[:base] = v }
49
+ parser.on("--minimum N", Float) { |v| options[:minimum] = v }
50
+ parser.on("--find-renames") { options[:find_renames] = true }
51
+ parser.on("--annotate KIND") { |v| options[:annotate] = v }
52
+ end
53
+ return unless positional_ok?(rest, stderr)
54
+
55
+ issue = Annotations.issue(opts)
56
+ return error_nil(stderr, issue) if issue
57
+
58
+ opts[:coverage] = CoverageFile.load_coverage(opts.fetch(:input), command: "patch", stderr: stderr) or return nil
59
+ opts
60
+ end
61
+
62
+ # A stray positional looks exactly like a ref, so a forgotten `--base`
63
+ # (`simplecov patch feature-x`) would otherwise diff against the default
64
+ # and gate the wrong change in silence.
65
+ def positional_ok?(rest, stderr)
66
+ return true if rest.empty?
67
+
68
+ error(stderr, "unexpected argument #{rest.first.inspect} (did you mean `--base #{rest.first}`?)")
69
+ false
70
+ end
71
+
72
+ # Everything below reads values that came out of a coverage.json, which
73
+ # carries plain objects, arrays and integers and never a subclass of one,
74
+ # so the shape checks ask about the class itself.
75
+
76
+ # Diff paths are exact root-relative names, so they resolve exactly against
77
+ # the report. The suffix fallback `CoverageFile.lookup` offers interactive
78
+ # commands could only ever bind a changed file the report doesn't carry to
79
+ # some other file's hits and score the wrong entry.
80
+ def compute_rows(coverage, diffed, stderr)
81
+ index = CoverageFile.exact_index(coverage)
82
+ diffed.fetch(:changes).filter_map do |path, lines|
83
+ payload = index[File.expand_path(path, diffed.fetch(:root))] || index[path]
84
+ next unless payload.instance_of?(Hash) # file the report doesn't carry -> out of scope
85
+
86
+ changed = changed_for(lines, payload)
87
+ warn_stale(path, payload, changed, stderr)
88
+ row = build_row(path, payload, changed)
89
+ row if scored?(row) # nothing coverable changed in this file
90
+ end
91
+ end
92
+
93
+ # An untracked file appears in no diff, so `:all` stands in for its line
94
+ # numbers: every line the report knows is this change's work.
95
+ def changed_for(lines, payload)
96
+ return lines.uniq unless lines.equal?(:all)
97
+
98
+ hits = payload["lines"]
99
+ hits.instance_of?(Array) ? (1..hits.length).to_a : []
100
+ end
101
+
102
+ # A changed line past the end of the report's lines array reads as
103
+ # never-relevant and silently drops out of the denominator, which is right
104
+ # for a fresh report and wrong for a stale one, so say which is likelier
105
+ # out loud instead of letting a `--minimum` gate pass vacuously.
106
+ def warn_stale(path, payload, changed, stderr)
107
+ hits = payload["lines"]
108
+ return unless hits.instance_of?(Array)
109
+ return if changed.empty? || changed.max <= hits.length
110
+
111
+ stderr.puts("simplecov patch: #{path} changed beyond the #{hits.length}-line entry in the " \
112
+ "report (is the report stale? regenerate it and rerun)")
113
+ end
114
+
115
+ def build_row(path, payload, changed)
116
+ {
117
+ file: path,
118
+ line: line_stats(payload["lines"], changed),
119
+ branch: entry_stats(payload["branches"], changed),
120
+ method: entry_stats(payload["methods"], changed)
121
+ }
122
+ end
123
+
124
+ # A line counts only where the report gives it an Integer hit count; a
125
+ # never-relevant line (nil) or a `:nocov:` line ("ignored") stays out of
126
+ # the denominator, the same rule the file total uses.
127
+ def line_stats(hits, changed)
128
+ covered = 0
129
+ missing = [] #: Array[Integer]
130
+ return {covered: covered, relevant: 0, missing: missing} unless hits.instance_of?(Array)
131
+
132
+ changed.each do |number|
133
+ hit = hits.at(number - 1)
134
+ # nil / "ignored" (nocov) / any non-Integer -> never relevant, skipped.
135
+ next unless hit.instance_of?(Integer)
136
+
137
+ hit.positive? ? (covered += 1) : (missing << number)
138
+ end
139
+ {covered: covered, relevant: covered + missing.size, missing: missing.sort}
140
+ end
141
+
142
+ # Branches and methods share the report shape (a reported line and an
143
+ # integer hit count), so one scorer serves both. nil when the report
144
+ # carries no data for the criterion, so the output and gate skip it
145
+ # rather than reporting a hollow 0/0. A miss is recorded at the reported
146
+ # line so the note points where the source is.
147
+ def entry_stats(entries, changed)
148
+ return nil unless entries.instance_of?(Array)
149
+
150
+ covered = 0
151
+ missing = [] #: Array[Integer]
152
+ each_touched(entries, changed) do |line, hits|
153
+ hits.positive? ? (covered += 1) : (missing << line)
154
+ end
155
+ {covered: covered, relevant: covered + missing.size, missing: missing.uniq.sort}
156
+ end
157
+
158
+ # "ignored" (nocov) entries and entries off the change are skipped.
159
+ def each_touched(entries, changed)
160
+ entries.each do |entry|
161
+ next unless entry.instance_of?(Hash)
162
+
163
+ line = entry["report_line"] || entry["start_line"]
164
+ hits = entry["coverage"]
165
+ yield line, hits if changed.include?(line) && hits.instance_of?(Integer)
166
+ end
167
+ end
168
+
169
+ def scored?(row)
170
+ row.fetch(:line).fetch(:relevant).positive? ||
171
+ Output.measured?(row.fetch(:branch)) || Output.measured?(row.fetch(:method))
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Ratchet
6
+ module Output
7
+ extend CommandHelpers
8
+
9
+ extend self
10
+
11
+ def emit(stdout, opts, outcome, generated)
12
+ return stdout.puts(JSON.generate(json_summary(opts, outcome, generated))) if opts.fetch(:json)
13
+
14
+ verb = opts.fetch(:dry_run) ? "would write" : "wrote"
15
+ stdout.puts("simplecov ratchet: #{verb} #{opts.fetch(:baseline)} (#{change_summary(outcome, generated)})")
16
+ report_regressed(stdout, outcome.regressed)
17
+ end
18
+
19
+ def change_summary(outcome, generated)
20
+ files = outcome.baseline.entries.size
21
+ return "#{files} #{one?(files) ? "file" : "files"}" if generated
22
+
23
+ "#{outcome.tightened.size} tightened, #{outcome.pruned.size} pruned, #{outcome.unchanged.size} unchanged"
24
+ end
25
+
26
+ def report_regressed(stdout, regressed)
27
+ return if regressed.empty?
28
+
29
+ noun = one?(regressed.size) ? "1 file below its floor" : "#{regressed.size} files below their floors"
30
+ stdout.puts("simplecov ratchet: #{noun}, entries kept unchanged")
31
+ end
32
+
33
+ def json_summary(opts, outcome, generated)
34
+ {
35
+ written: !opts.fetch(:dry_run), path: opts.fetch(:baseline), generated: generated,
36
+ files: outcome.baseline.entries.size,
37
+ tightened: outcome.tightened, pruned: outcome.pruned,
38
+ regressed: outcome.regressed, unchanged: outcome.unchanged
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atomic_file"
4
+ require_relative "command_helpers"
5
+ require_relative "coverage_file"
6
+ require_relative "ratchet/output"
7
+
8
+ module SimpleCov
9
+ module CLI
10
+ # `simplecov ratchet`: rewrite the checked-in per-file coverage baseline
11
+ # (#1268) from the current report. Floors only ever tighten, entries for
12
+ # files the report no longer carries are pruned, and files without an entry
13
+ # are never given one, so new code stays answerable to the global thresholds
14
+ # rather than to a floor cut at whatever it launched with. `--init`
15
+ # regenerates the whole file from the current state.
16
+ #
17
+ # The baseline path follows the project's `.simplecov` `baseline_file`
18
+ # setting when one is present, the same way the read-only commands follow
19
+ # `coverage_dir`.
20
+ module Ratchet
21
+ extend CommandHelpers
22
+
23
+ extend self
24
+
25
+ def run(args, stdout:, stderr:, **)
26
+ require "simplecov"
27
+ opts = parse(args, stderr) or return 1
28
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "ratchet", stderr: stderr) or return 1
29
+
30
+ outcome, generated = compute(opts, current_floors(coverage))
31
+ AtomicFile.write(opts.fetch(:baseline), outcome.baseline.to_yaml) unless opts.fetch(:dry_run)
32
+ Output.emit(stdout, opts, outcome, generated)
33
+ 0
34
+ rescue ConfigurationError => e
35
+ error(stderr, e)
36
+ end
37
+
38
+ def parse(args, stderr)
39
+ opts, rest = parse_common(args, init: false, dry_run: false) do |parser, options|
40
+ parser.on("--baseline PATH") { |v| options[:baseline] = v }
41
+ parser.on("--init") { options[:init] = true }
42
+ parser.on("--dry-run") { options[:dry_run] = true }
43
+ end
44
+ return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
45
+
46
+ opts[:baseline] ||= Dotfile.baseline_file
47
+ opts
48
+ end
49
+
50
+ # Only criteria the report carries for a file appear, so a line-only report
51
+ # leaves existing branch floors alone.
52
+ def current_floors(coverage)
53
+ initial = {} #: Baseline::current_floors
54
+ coverage.each_with_object(initial) do |(path, entry), floors|
55
+ criteria = CoverageFile::CRITERIA.filter_map do |criterion, keys|
56
+ percent = entry[keys.fetch(:percent)]
57
+ missed = entry[keys.fetch(:missed)]
58
+ [criterion, {percent: SimpleCov.round_coverage(percent), missed: missed}] if usable?(percent, missed)
59
+ end.to_h
60
+ floors[path] = criteria unless criteria.empty?
61
+ end
62
+ end
63
+
64
+ # A report row from another tool may be missing counts; only a complete pair
65
+ # can become a floor.
66
+ def usable?(percent, missed)
67
+ percent.is_a?(Numeric) && missed.instance_of?(Integer)
68
+ end
69
+
70
+ def compute(opts, current)
71
+ existing = Baseline.read_if_exists(opts.fetch(:baseline)) unless opts.fetch(:init)
72
+ return [existing.ratchet(current), false] if existing
73
+
74
+ none = [] #: Array[String]
75
+ outcome = Baseline::Outcome.new(
76
+ baseline: Baseline.generate(current),
77
+ tightened: none, pruned: none, regressed: none, unchanged: none
78
+ )
79
+ [outcome, true]
80
+ end
81
+ end
82
+ end
83
+ end