simplecov 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -5
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/badge/svg.rb +70 -0
  13. data/lib/simplecov/cli/badge.rb +62 -0
  14. data/lib/simplecov/cli/clean.rb +23 -24
  15. data/lib/simplecov/cli/command_helpers.rb +55 -19
  16. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  17. data/lib/simplecov/cli/completions.rb +83 -0
  18. data/lib/simplecov/cli/coverage.rb +25 -28
  19. data/lib/simplecov/cli/coverage_file.rb +58 -14
  20. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  21. data/lib/simplecov/cli/dead_code.rb +139 -0
  22. data/lib/simplecov/cli/diff/output.rb +45 -0
  23. data/lib/simplecov/cli/diff.rb +35 -67
  24. data/lib/simplecov/cli/dotfile.rb +83 -38
  25. data/lib/simplecov/cli/git.rb +41 -0
  26. data/lib/simplecov/cli/history/output.rb +135 -0
  27. data/lib/simplecov/cli/history.rb +67 -0
  28. data/lib/simplecov/cli/merge.rb +34 -39
  29. data/lib/simplecov/cli/open.rb +12 -15
  30. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  31. data/lib/simplecov/cli/patch/output.rb +135 -0
  32. data/lib/simplecov/cli/patch.rb +169 -0
  33. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  34. data/lib/simplecov/cli/ratchet.rb +83 -0
  35. data/lib/simplecov/cli/report.rb +18 -24
  36. data/lib/simplecov/cli/run.rb +8 -9
  37. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  38. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  39. data/lib/simplecov/cli/serve.rb +27 -24
  40. data/lib/simplecov/cli/show/annotator.rb +87 -0
  41. data/lib/simplecov/cli/show/sweep.rb +36 -0
  42. data/lib/simplecov/cli/show.rb +133 -0
  43. data/lib/simplecov/cli/status/facts.rb +87 -0
  44. data/lib/simplecov/cli/status.rb +93 -0
  45. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  46. data/lib/simplecov/cli/tests.rb +148 -0
  47. data/lib/simplecov/cli/uncovered/misses.rb +37 -0
  48. data/lib/simplecov/cli/uncovered.rb +70 -32
  49. data/lib/simplecov/cli/usage.rb +152 -0
  50. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  51. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  52. data/lib/simplecov/cli/watch/poller.rb +45 -0
  53. data/lib/simplecov/cli/watch/session.rb +134 -0
  54. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  55. data/lib/simplecov/cli/watch.rb +85 -0
  56. data/lib/simplecov/cli.rb +43 -92
  57. data/lib/simplecov/color.rb +19 -37
  58. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  59. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  60. data/lib/simplecov/combine/identity_interner.rb +10 -13
  61. data/lib/simplecov/combine/interned_counts.rb +7 -9
  62. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  63. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  64. data/lib/simplecov/combine/results_combiner.rb +4 -21
  65. data/lib/simplecov/combine.rb +0 -6
  66. data/lib/simplecov/command_guesser.rb +20 -34
  67. data/lib/simplecov/configuration/baseline.rb +31 -0
  68. data/lib/simplecov/configuration/coverage.rb +91 -79
  69. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  70. data/lib/simplecov/configuration/deprecations.rb +21 -0
  71. data/lib/simplecov/configuration/eval_coverage.rb +6 -10
  72. data/lib/simplecov/configuration/filters.rb +40 -73
  73. data/lib/simplecov/configuration/formatting.rb +74 -83
  74. data/lib/simplecov/configuration/groups.rb +19 -13
  75. data/lib/simplecov/configuration/history.rb +44 -0
  76. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  77. data/lib/simplecov/configuration/merging.rb +94 -62
  78. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  79. data/lib/simplecov/configuration/production.rb +23 -0
  80. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  81. data/lib/simplecov/configuration/thresholds.rb +33 -62
  82. data/lib/simplecov/configuration/view_coverage.rb +38 -0
  83. data/lib/simplecov/configuration.rb +70 -76
  84. data/lib/simplecov/configuration_error.rb +5 -0
  85. data/lib/simplecov/context_map/union.rb +81 -0
  86. data/lib/simplecov/context_map.rb +187 -0
  87. data/lib/simplecov/coverage_json.rb +2 -5
  88. data/lib/simplecov/coverage_statistics.rb +14 -24
  89. data/lib/simplecov/coverage_violations.rb +157 -64
  90. data/lib/simplecov/current_run.rb +61 -0
  91. data/lib/simplecov/defaults.rb +4 -23
  92. data/lib/simplecov/deprecation.rb +45 -27
  93. data/lib/simplecov/directive.rb +46 -62
  94. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  95. data/lib/simplecov/exit_codes/check.rb +17 -11
  96. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  97. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  98. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  99. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  100. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  101. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  102. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  103. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  104. data/lib/simplecov/exit_codes.rb +16 -10
  105. data/lib/simplecov/exit_handling.rb +94 -59
  106. data/lib/simplecov/file_list.rb +20 -51
  107. data/lib/simplecov/filter.rb +43 -66
  108. data/lib/simplecov/formatter/base.rb +37 -55
  109. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  110. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  111. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  112. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  113. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  114. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  115. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  116. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  117. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  118. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  119. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  120. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  121. data/lib/simplecov/formatter.rb +14 -10
  122. data/lib/simplecov/group_names.rb +13 -12
  123. data/lib/simplecov/history.rb +115 -0
  124. data/lib/simplecov/last_run.rb +6 -9
  125. data/lib/simplecov/lines_classifier.rb +31 -16
  126. data/lib/simplecov/load_global_config.rb +7 -7
  127. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  128. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -55
  131. data/lib/simplecov/parallel_coordination.rb +39 -49
  132. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  133. data/lib/simplecov/parallel_result_merger.rb +67 -126
  134. data/lib/simplecov/process.rb +14 -32
  135. data/lib/simplecov/production/error.rb +11 -0
  136. data/lib/simplecov/production/file_sink.rb +130 -0
  137. data/lib/simplecov/production.rb +249 -0
  138. data/lib/simplecov/profiles/rails.rb +1 -12
  139. data/lib/simplecov/profiles/root_filter.rb +0 -4
  140. data/lib/simplecov/profiles/strict.rb +9 -19
  141. data/lib/simplecov/profiles.rb +11 -33
  142. data/lib/simplecov/report_deferral.rb +11 -15
  143. data/lib/simplecov/report_stamp.rb +6 -9
  144. data/lib/simplecov/result/filter_config.rb +19 -0
  145. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  146. data/lib/simplecov/result/serialization.rb +43 -0
  147. data/lib/simplecov/result/source_file_builder.rb +7 -12
  148. data/lib/simplecov/result.rb +68 -117
  149. data/lib/simplecov/result_adapter.rb +56 -68
  150. data/lib/simplecov/result_merger/contexts.rb +22 -0
  151. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  152. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  153. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  154. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  155. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  156. data/lib/simplecov/result_merger.rb +59 -58
  157. data/lib/simplecov/result_processing.rb +74 -119
  158. data/lib/simplecov/run_identity.rb +16 -18
  159. data/lib/simplecov/simulate_coverage.rb +34 -50
  160. data/lib/simplecov/source_file/branch.rb +8 -33
  161. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  162. data/lib/simplecov/source_file/builder_context.rb +6 -13
  163. data/lib/simplecov/source_file/line.rb +13 -33
  164. data/lib/simplecov/source_file/line_builder.rb +9 -15
  165. data/lib/simplecov/source_file/method.rb +3 -7
  166. data/lib/simplecov/source_file/method_builder.rb +6 -15
  167. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  168. data/lib/simplecov/source_file/skip_chunks.rb +19 -28
  169. data/lib/simplecov/source_file/source_loader.rb +45 -35
  170. data/lib/simplecov/source_file/statistics.rb +10 -9
  171. data/lib/simplecov/source_file.rb +21 -51
  172. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
  173. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  174. data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
  175. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  176. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  177. data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
  178. data/lib/simplecov/static_coverage_extractor.rb +46 -62
  179. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  180. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  181. data/lib/simplecov/test_tracker/delta.rb +69 -0
  182. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  183. data/lib/simplecov/test_tracker.rb +162 -0
  184. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  185. data/lib/simplecov/useless_results_remover.rb +7 -6
  186. data/lib/simplecov/version.rb +1 -1
  187. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  188. data/lib/simplecov/view_coverage.rb +55 -0
  189. data/lib/simplecov-html.rb +1 -0
  190. data/lib/simplecov.rb +57 -113
  191. data/lib/simplecov_json_formatter.rb +1 -0
  192. data/man/simplecov.1 +349 -0
  193. data/sig/simplecov.rbs +523 -595
  194. metadata +72 -5
@@ -0,0 +1,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
@@ -3,50 +3,52 @@
3
3
  require "json"
4
4
  require "optparse"
5
5
  require_relative "command_helpers"
6
+ require_relative "diff/output"
6
7
 
7
8
  module SimpleCov
8
9
  module CLI
9
- # `simplecov diff <baseline>` — print per-file coverage deltas between
10
- # coverage.json (--input) and a baseline coverage.json
11
- # checked in alongside the suite. Only files whose coverage moved
12
- # are listed; --fail-on-drop exits non-zero when any file regressed,
13
- # so this composes with CI as a "coverage of this PR didn't drop"
14
- # gate. Resolves the long-standing "diff coverage" feature request.
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
15
  module Diff
16
16
  extend CommandHelpers
17
17
 
18
- EPSILON = 0.005 # tolerance below which a delta is considered noise
18
+ # Tolerance below which a delta is considered noise.
19
+ EPSILON = 0.005
19
20
 
20
- STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
21
-
22
- module_function
21
+ extend self
23
22
 
24
23
  def run(args, stdout:, stderr:, **)
25
24
  opts = parse(args, stderr)
26
25
  return 1 unless opts
27
26
 
28
- rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
29
- rows.sort_by! { |row| row[:line_delta] }
30
- if opts[:json]
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)
31
30
  stdout.puts(JSON.pretty_generate(rows))
32
31
  else
33
- emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
32
+ Output.emit_text(stdout, rows, CLI.color_enabled?(opts, stdout))
34
33
  end
35
- opts[:fail_on_drop] && coverage_drop?(rows) ? 1 : 0
34
+ (opts.fetch(:fail_on_drop) && coverage_drop?(rows)) ? 1 : 0
36
35
  end
37
36
 
38
37
  def parse(args, stderr)
39
38
  opts = parse_flags(args)
40
- return stderr.puts("simplecov diff: missing baseline argument") && nil if opts[:rest].empty?
39
+ if opts.fetch(:rest).empty?
40
+ stderr.puts("simplecov diff: missing baseline argument")
41
+ return nil
42
+ end
41
43
 
42
- opts[:baseline] = load_coverage(opts[:rest].first, stderr) or return nil
43
- opts[:current] = load_coverage(opts[:input], stderr) or return nil
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
44
46
  opts
45
47
  end
46
48
 
47
49
  def parse_flags(args)
48
50
  opts, rest = parse_common(args, fail_on_drop: false, threshold: 0.0) do |o, options|
49
- o.on("--fail-on-drop") { options[:fail_on_drop] = true }
51
+ o.on("--fail-on-drop") { options[:fail_on_drop] = true }
50
52
  o.on("--threshold N", Float) { |v| options[:threshold] = v }
51
53
  end
52
54
  opts.merge(rest: rest)
@@ -59,9 +61,8 @@ module SimpleCov
59
61
  normalize_keys(coverage)
60
62
  end
61
63
 
62
- # Strip a leading slash so coverage.json files written before the
63
- # `project_filename` change (keys like "/lib/foo.rb") still diff
64
- # cleanly against newer reports (keys like "lib/foo.rb").
64
+ # Strips a leading slash so coverage.json files written before the
65
+ # `project_filename` change still diff cleanly against newer reports.
65
66
  def normalize_keys(coverage)
66
67
  coverage.transform_keys { |key| key.delete_prefix("/") }
67
68
  end
@@ -71,17 +72,15 @@ module SimpleCov
71
72
  files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
72
73
  end
73
74
 
74
- # EPSILON keeps float noise out regardless of threshold; the
75
- # threshold itself is inclusive, so a file that moved exactly N%
76
- # is listed under `--threshold N`, matching the "at least N%" the
77
- # usage text promises.
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.
78
77
  def compute_row(fname, current_payload, baseline_payload, threshold)
79
78
  deltas = compute_deltas(current_payload, baseline_payload)
80
79
  floor = threshold.abs
81
80
  return nil unless deltas.values.any? { |delta| delta.abs > EPSILON && delta.abs >= floor }
82
81
 
83
82
  {file: fname, status: status_for(current_payload, baseline_payload),
84
- line_delta: deltas[:line], branch_delta: deltas[:branch], method_delta: deltas[:method]}
83
+ line_delta: deltas.fetch(:line), branch_delta: deltas.fetch(:branch), method_delta: deltas.fetch(:method)}
85
84
  end
86
85
 
87
86
  def compute_deltas(current_payload, baseline_payload)
@@ -91,56 +90,25 @@ module SimpleCov
91
90
  end
92
91
 
93
92
  def status_for(current_payload, baseline_payload)
94
- return "added" if baseline_payload.nil?
93
+ return "added" if baseline_payload.nil?
95
94
  return "removed" if current_payload.nil?
96
95
 
97
96
  "changed"
98
97
  end
99
98
 
100
99
  def pct_for(fields, payload)
101
- return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
102
-
103
- payload[fields[:percent]].to_f
104
- end
105
-
106
- def emit_text(stdout, rows, color)
107
- return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?
100
+ return 0.0 unless payload.instance_of?(Hash) && payload[fields.fetch(:total)].to_i.positive?
108
101
 
109
- rows.each { |row| stdout.puts(format_row(row, color)) }
102
+ payload[fields.fetch(:percent)].to_f
110
103
  end
111
104
 
112
- def format_row(row, color)
113
- line = " #{delta_parts(row, color).join(' ')} #{row[:file]}"
114
- suffix = STATUS_SUFFIX[row[:status]]
115
- suffix ? "#{line} #{suffix}" : line
116
- end
117
-
118
- def delta_parts(row, color)
119
- [
120
- format_delta(row[:line_delta], "lines", color),
121
- (format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
122
- (format_delta(row[:method_delta], "methods", color) if row[:method_delta].abs > EPSILON)
123
- ].compact
124
- end
125
-
126
- # A removed file's deltas are all -baseline%, but deleting a
127
- # covered file (dead code cleanup) is not a coverage regression,
128
- # so removed rows never trip the --fail-on-drop gate. Drops are
129
- # gated on EPSILON like row inclusion and display: a row listed
130
- # for a gain in one criterion must not fail the run over float
131
- # noise in another, invisible in the printed output.
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.
132
109
  def coverage_drop?(rows)
133
- rows.reject { |row| row[:status] == "removed" }
134
- .any? { |row| row.values_at(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
135
- end
136
-
137
- # Deltas are sign-based, not threshold-based: a +5% bump is good
138
- # (green) and a -5% drop is bad (red), regardless of where the
139
- # absolute coverage level lands.
140
- def format_delta(delta, label, color)
141
- sign = delta.positive? ? "+" : ""
142
- text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
143
- SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
110
+ rows.reject { |row| row.fetch(:status).eql?("removed") }
111
+ .any? { |row| row.fetch_values(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
144
112
  end
145
113
  end
146
114
  end
@@ -4,14 +4,13 @@ require "pathname"
4
4
 
5
5
  module SimpleCov
6
6
  module CLI
7
- # Loads a project's `.simplecov` config file purely to read
8
- # `coverage_dir` from it, with `SimpleCov.start` and the at_exit
9
- # hook installer neutered so the load doesn't trigger coverage
10
- # tracking. Used by the CLI to default `--input` / `--report`
11
- # paths to whatever the project's dotfile declares, without making
12
- # every read-only subcommand pay for actually starting Coverage.
7
+ # Loads a project's `.simplecov` config file purely to read configuration
8
+ # from it, with `SimpleCov.start` and the at_exit hook installer neutered so
9
+ # the load doesn't trigger coverage tracking. Used by the CLI to default
10
+ # paths to whatever the project's dotfile declares, without making every
11
+ # read-only subcommand pay for actually starting Coverage.
13
12
  module Dotfile
14
- module_function
13
+ extend self
15
14
 
16
15
  def coverage_dir
17
16
  dotfile = find
@@ -19,37 +18,73 @@ module SimpleCov
19
18
 
20
19
  with_simplecov_loaded { read_from(dotfile) }
21
20
  rescue ScriptError, StandardError => e
22
- # simplecov:disable defensive fallback for a bad dotfile; never
23
- # fires in the project's own dogfood run. ScriptError covers the
24
- # SyntaxError a malformed dotfile raises from `load` (it is not a
25
- # StandardError) along with LoadError; StandardError covers the
26
- # rest (EACCES, errors raised by the dotfile's own code, etc.).
27
- warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
21
+ # ScriptError covers the SyntaxError a malformed dotfile raises from
22
+ # `load` (it is not a StandardError) along with LoadError; StandardError
23
+ # covers the rest.
24
+ warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e}"
28
25
  "coverage"
29
- # simplecov:enable
30
26
  end
31
27
 
32
- # Load the dotfile, snapshot+restore `SimpleCov.coverage_dir` so we
33
- # don't quietly clobber it in a host process that's already
34
- # configured (e.g. when the CLI is exercised inline by simplecov's
35
- # own spec suite). The snapshot is intentionally narrow: a dotfile
36
- # can still mutate other SimpleCov configuration (filters, groups,
37
- # formatters, command_name, ...) via `SimpleCov.configure` or
38
- # `SimpleCov.start { ... }` blocks. The CLI normally runs as a
39
- # top-level process where that's harmless; callers driving it from
40
- # inside a Ruby host that cares about isolation should arrange that
41
- # themselves.
28
+ # Read from `.simplecov` the way `coverage_dir` is, so a project that moved
29
+ # its baseline doesn't need `--baseline` on every ratchet.
30
+ def baseline_file
31
+ dotfile = find
32
+ return Baseline::DEFAULT_FILENAME unless dotfile
33
+
34
+ with_simplecov_loaded { read_baseline_file_from(dotfile) }
35
+ rescue ScriptError, StandardError => e
36
+ warn "simplecov: failed to read baseline_file from #{dotfile}: #{e.class}: #{e}"
37
+ Baseline::DEFAULT_FILENAME
38
+ end
39
+
40
+ # Nil when there is no dotfile, the dotfile names no store, or it cannot be
41
+ # read: unlike the other reads there is no sensible fallback path, and the
42
+ # command's own missing-store error is the right answer.
43
+ def production_coverage
44
+ dotfile = find
45
+ return nil unless dotfile
46
+
47
+ with_simplecov_loaded { read_production_coverage_from(dotfile) }
48
+ rescue ScriptError, StandardError => e
49
+ # `warn` answers nil, which is this reader's whole fallback.
50
+ warn "simplecov: failed to read production_coverage from #{dotfile}: #{e.class}: #{e}"
51
+ end
52
+
53
+ def read_production_coverage_from(dotfile)
54
+ snapshot = SimpleCov.instance_variable_get(:@production_coverage) #: String?
55
+ load_with_start_neutered(dotfile)
56
+ SimpleCov.production_coverage
57
+ ensure
58
+ # @type var snapshot: String?
59
+ SimpleCov.instance_variable_set(:@production_coverage, snapshot)
60
+ end
61
+
62
+ def read_baseline_file_from(dotfile)
63
+ snapshot = SimpleCov.instance_variable_get(:@baseline_file) #: String?
64
+ load_with_start_neutered(dotfile)
65
+ SimpleCov.baseline_file
66
+ ensure
67
+ # @type var snapshot: String?
68
+ SimpleCov.instance_variable_set(:@baseline_file, snapshot)
69
+ end
70
+
71
+ # Snapshot and restore `SimpleCov.coverage_dir` so we don't quietly clobber
72
+ # it in a host process that's already configured. The snapshot is
73
+ # intentionally narrow: a dotfile can still mutate other configuration
74
+ # via `SimpleCov.configure` or `SimpleCov.start { ... }` blocks.
42
75
  def read_from(dotfile)
43
76
  snapshot = SimpleCov.instance_variable_get(:@coverage_dir) #: String?
44
77
  load_with_start_neutered(dotfile)
45
78
  SimpleCov.coverage_dir
46
79
  ensure
47
- # Restore even when the load raises, so a bad dotfile doesn't
48
- # leave a host process's configured dir clobbered.
80
+ # Restored even when the load raises, so a bad dotfile doesn't leave a
81
+ # host process's configured dir clobbered.
49
82
  # @type var snapshot: String?
50
83
  SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
51
84
  end
52
85
 
86
+ # The nearest `.simplecov` at or above the working directory, or nil, which
87
+ # is what the walk itself answers once it breaks at the filesystem root.
53
88
  def find
54
89
  dir = Pathname.new(Dir.pwd)
55
90
  loop do
@@ -59,38 +94,48 @@ module SimpleCov
59
94
 
60
95
  dir = dir.parent
61
96
  end
62
- nil
63
97
  end
64
98
 
65
99
  def with_simplecov_loaded
66
100
  previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil) #: String?
67
- previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
101
+ previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
68
102
  ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
69
103
  # SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
70
- # CLI-only behavior — e.g. simplecov itself sets `coverage_dir`
71
- # to the dogfood path here but skips that for descendants.
104
+ # CLI-only behavior: simplecov itself sets `coverage_dir` to the dogfood
105
+ # path here but skips that for descendants.
72
106
  ENV["SIMPLECOV_CLI"] = "1"
73
- require "simplecov"
107
+ load_simplecov
74
108
  yield
75
109
  ensure
76
110
  # @type var previous_no_defaults: String?
77
111
  # @type var previous_cli: String?
78
112
  ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
79
- ENV["SIMPLECOV_CLI"] = previous_cli
113
+ ENV["SIMPLECOV_CLI"] = previous_cli
114
+ end
115
+
116
+ # The deferred require exists for a standalone CLI process that has not
117
+ # loaded simplecov yet. It must happen inside the env guard above, so a
118
+ # first load here skips the default profile chain.
119
+ def load_simplecov
120
+ require "simplecov"
80
121
  end
81
122
 
82
- # Load `path` with `SimpleCov.start` and the at_exit installer
83
- # turned into no-ops, so a project whose dotfile calls
84
- # `SimpleCov.start` doesn't trigger Coverage just because we asked
85
- # for `coverage_dir`. Config inside any `SimpleCov.start { ... }`
86
- # block still runs.
123
+ # A constant rather than a block written at the call site, where an empty
124
+ # block and one answering nil are the same thing to every caller and so
125
+ # to every test.
126
+ NEUTERED = proc {}
127
+
128
+ # Load `path` with `SimpleCov.start` and the at_exit installer turned into
129
+ # no-ops, so a project whose dotfile calls `SimpleCov.start` doesn't
130
+ # trigger Coverage just because we asked for `coverage_dir`. Config
131
+ # inside any `SimpleCov.start { ... }` block still runs.
87
132
  def load_with_start_neutered(path)
88
133
  klass = SimpleCov.singleton_class
89
134
  names = %i[start_tracking install_at_exit_hook]
90
135
  stash = names.to_h { |name| [name, klass.instance_method(name)] } #: Hash[Symbol, UnboundMethod]
91
136
  # define_method over an existing method emits a "method redefined"
92
137
  # warning under $VERBOSE; the override and restore are intentional.
93
- silence_verbose { names.each { |name| klass.define_method(name) { nil } } }
138
+ silence_verbose { names.each { |name| klass.define_method(name, &(_ = NEUTERED)) } }
94
139
  load path
95
140
  ensure
96
141
  # @type var stash: Hash[Symbol, UnboundMethod]
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # The git plumbing the change-aware subcommands share: safe capture, the
8
+ # repository toplevel, ref hygiene, and the default base ref.
9
+ module Git
10
+ extend self
11
+
12
+ # [stdout, first stderr line, success]. A spawn failure reads as an
13
+ # unsuccessful run whose stdout is nil, so callers can tell "git ran and
14
+ # refused" from "git never ran".
15
+ def capture(*argv)
16
+ stdout, stderr, status = Open3.capture3("git", *argv)
17
+ [stdout, stderr.lines.first.to_s.strip, status.success?]
18
+ rescue => e
19
+ [nil, e.message, false]
20
+ end
21
+
22
+ def toplevel
23
+ stdout, _detail, success = capture("rev-parse", "--show-toplevel")
24
+ (_ = stdout).chomp if success
25
+ end
26
+
27
+ # A git ref can never begin with "-", so one that does would be read by git
28
+ # as an option instead of a revision.
29
+ def option_like_ref?(ref)
30
+ ref.start_with?("-")
31
+ end
32
+
33
+ # The branch origin's HEAD points at, so master and trunk repositories work
34
+ # bare, falling back to main where no origin HEAD is recorded.
35
+ def default_base
36
+ stdout, _detail, success = capture("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
37
+ success ? (_ = stdout).chomp.delete_prefix("origin/") : "main"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module History
6
+ # Renders the trend: Unicode sparklines scaled to each series' own range,
7
+ # with the numbers beside them carrying the absolute scale.
8
+ module Output
9
+ BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
10
+
11
+ CRITERIA_ORDER = %w[line branch method].freeze
12
+
13
+ extend self
14
+
15
+ def emit(stdout, opts, entries, color:)
16
+ return emit_json(stdout, opts, entries) if opts.fetch(:json)
17
+ return stdout.puts("simplecov history: no recorded runs in #{opts.fetch(:input)}") if entries.empty?
18
+
19
+ if opts.fetch(:file)
20
+ emit_file(stdout, opts, entries, color)
21
+ else
22
+ emit_totals(stdout, opts, entries, color)
23
+ end
24
+ end
25
+
26
+ def emit_totals(stdout, opts, entries, color)
27
+ stdout.puts("Coverage history: #{opts.fetch(:input)} (#{pluralize(entries.length, "run")})")
28
+ stdout.puts
29
+ emit_criteria_views(stdout, entries, color, "totals")
30
+ end
31
+
32
+ def emit_file(stdout, opts, entries, color)
33
+ file = opts.fetch(:file)
34
+ stdout.puts("Coverage history for #{file} (#{pluralize(entries.length, "run")})")
35
+ stdout.puts
36
+ emit_criteria_views(stdout, entries, color, "files", file)
37
+ end
38
+
39
+ def emit_criteria_views(stdout, entries, color, *path)
40
+ criteria = measured_criteria(entries, path)
41
+ emit_sparklines(stdout, entries, criteria, color, path)
42
+ stdout.puts
43
+ emit_rows(stdout, entries) { |entry| percents_cell(entry, criteria, path) }
44
+ end
45
+
46
+ def emit_sparklines(stdout, entries, criteria, color, path)
47
+ width = criteria.map(&:length).max
48
+ criteria.each do |criterion|
49
+ series = entries.map { |entry| numeric(entry.dig(*path, criterion)) }
50
+ stdout.puts(" #{criterion.ljust(width)} #{sparkline(series)} #{trend(series, color)}")
51
+ end
52
+ end
53
+
54
+ def emit_rows(stdout, entries)
55
+ width = entries.map { |entry| branch_of(entry).length }.max
56
+ entries.each do |entry|
57
+ commit = (entry["commit"] || "-")[0, 7].ljust(7)
58
+ stdout.puts(" #{entry["created_at"]} #{branch_of(entry).ljust(width)} #{commit} #{yield(entry)}")
59
+ end
60
+ end
61
+
62
+ def branch_of(entry)
63
+ entry["branch"] || "-"
64
+ end
65
+
66
+ def percents_cell(entry, criteria, path)
67
+ cell = criteria.filter_map do |criterion|
68
+ value = numeric(entry.dig(*path, criterion))
69
+ "#{criterion} #{value}%" if value
70
+ end.join(" ")
71
+ cell.empty? ? "-" : cell
72
+ end
73
+
74
+ # Min-max scaled block characters, one per run, a space where the series has
75
+ # no value for that run. A flat series renders at mid height: direction of
76
+ # travel is the message, and a flat line says it plainly. A series with no
77
+ # numbers at all is all gaps, and answering that outright keeps the scale off
78
+ # a range it doesn't have.
79
+ def sparkline(series)
80
+ numeric = series.compact
81
+ return " " * series.length if numeric.empty?
82
+
83
+ min = numeric.min
84
+ span = numeric.max - min.to_f
85
+ series.map { |value| value.nil? ? " " : bar(value, min, span) }.join
86
+ end
87
+
88
+ def bar(value, min, span)
89
+ span.zero? ? BARS.fetch(3) : BARS.fetch(((value - min) / span * (BARS.length - 1)).round)
90
+ end
91
+
92
+ def trend(series, color)
93
+ numeric = series.compact
94
+ first = numeric.first
95
+ last = numeric.last
96
+ delta = (last - first).round(2)
97
+ sign = "+" unless delta.negative?
98
+ delta_text = Color.colorize("(#{sign}#{delta})", delta.negative? ? :red : :green, enabled: color)
99
+ "#{first}% → #{last}% #{delta_text}"
100
+ end
101
+
102
+ def emit_json(stdout, opts, entries)
103
+ return stdout.puts(JSON.generate(entries)) unless opts.fetch(:file)
104
+
105
+ rows = entries.map do |entry|
106
+ percents = entry.dig("files", opts.fetch(:file))
107
+ {created_at: entry["created_at"], branch: entry["branch"], commit: entry["commit"],
108
+ percents: (percents if percents.instance_of?(Hash))}
109
+ end
110
+ stdout.puts(JSON.generate(rows))
111
+ end
112
+
113
+ # Criteria some run recorded a number for, in canonical order, so a criterion
114
+ # enabled midway still gets its sparkline. One no run recorded is left out:
115
+ # an all-gaps sparkline has no trend to read off it.
116
+ def measured_criteria(entries, path)
117
+ CRITERIA_ORDER.select do |criterion|
118
+ entries.any? do |entry|
119
+ percents = entry.dig(*path)
120
+ percents.instance_of?(Hash) && numeric(percents[criterion])
121
+ end
122
+ end
123
+ end
124
+
125
+ def numeric(value)
126
+ value if value.is_a?(Numeric)
127
+ end
128
+
129
+ def pluralize(number, noun)
130
+ "#{number} #{noun}#{"s" unless number.eql?(1)}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_helpers"
4
+ require_relative "history/output"
5
+ require_relative "../history"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov history`: the recorded coverage trend in the terminal, a
10
+ # sparkline per measured criterion with the run rows beneath, and
11
+ # `--file PATH` for one file's trajectory instead of the totals.
12
+ module History
13
+ extend CommandHelpers
14
+
15
+ extend self
16
+
17
+ def run(args, stdout:, stderr:, **)
18
+ opts = parse(args, stderr) or return 1
19
+ entries = load_entries(opts.fetch(:input), stderr) or return 1
20
+ return 1 unless file_recorded?(entries, opts, stderr)
21
+
22
+ Output.emit(stdout, opts, entries, color: CLI.color_enabled?(opts, stdout))
23
+ 0
24
+ end
25
+
26
+ def parse(args, stderr)
27
+ opts, rest = parse_common(args, input: default_input, file: nil) do |parser, options|
28
+ parser.on("--file PATH") { |v| options[:file] = v }
29
+ end
30
+ return error_nil(stderr, "unexpected argument #{rest.first.inspect}") unless rest.empty?
31
+
32
+ opts
33
+ end
34
+
35
+ # The history file, not coverage.json: this command reads the sibling
36
+ # artifact the exit tasks append to.
37
+ def default_input
38
+ File.join(CLI.coverage_dir, ".history.json")
39
+ end
40
+
41
+ def load_entries(path, stderr)
42
+ document = JSON.parse(File.read(path))
43
+ entries = document.dig(SimpleCov::History::ENVELOPE, "entries") if document.instance_of?(Hash)
44
+ return entries if entries.instance_of?(Array)
45
+
46
+ error_nil(stderr, "#{path} is not a SimpleCov history file")
47
+ rescue Errno::ENOENT
48
+ error_nil(stderr, "#{path} not found (the history is recorded automatically each time a suite reports)")
49
+ rescue JSON::ParserError => e
50
+ error_nil(stderr, "#{path} is not valid JSON (#{e.message.lines.first.to_s.strip})")
51
+ rescue SystemCallError => e
52
+ error_nil(stderr, "#{path} could not be read (#{e})")
53
+ end
54
+
55
+ # A `--file` for a path no entry recorded deserves a loud answer, not an
56
+ # empty sparkline that reads as "0% forever".
57
+ def file_recorded?(entries, opts, stderr)
58
+ file = opts.fetch(:file)
59
+ return true unless file
60
+ return true if entries.any? { |entry| entry.instance_of?(Hash) && entry.dig("files", file).instance_of?(Hash) }
61
+
62
+ error(stderr, "no recorded coverage for #{file} in #{opts.fetch(:input)}")
63
+ false
64
+ end
65
+ end
66
+ end
67
+ end