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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SimpleCov
6
+ module CLI
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.
12
+ module Dotfile
13
+ extend self
14
+
15
+ def coverage_dir
16
+ dotfile = find
17
+ return "coverage" unless dotfile
18
+
19
+ with_simplecov_loaded { read_from(dotfile) }
20
+ rescue ScriptError, StandardError => e
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}"
25
+ "coverage"
26
+ end
27
+
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.
75
+ def read_from(dotfile)
76
+ snapshot = SimpleCov.instance_variable_get(:@coverage_dir) #: String?
77
+ load_with_start_neutered(dotfile)
78
+ SimpleCov.coverage_dir
79
+ ensure
80
+ # Restored even when the load raises, so a bad dotfile doesn't leave a
81
+ # host process's configured dir clobbered.
82
+ # @type var snapshot: String?
83
+ SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
84
+ end
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.
88
+ def find
89
+ dir = Pathname.new(Dir.pwd)
90
+ loop do
91
+ candidate = dir.join(".simplecov")
92
+ return candidate.to_s if candidate.exist?
93
+ break if dir.root?
94
+
95
+ dir = dir.parent
96
+ end
97
+ end
98
+
99
+ def with_simplecov_loaded
100
+ previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil) #: String?
101
+ previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
102
+ ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
103
+ # SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
104
+ # CLI-only behavior: simplecov itself sets `coverage_dir` to the dogfood
105
+ # path here but skips that for descendants.
106
+ ENV["SIMPLECOV_CLI"] = "1"
107
+ load_simplecov
108
+ yield
109
+ ensure
110
+ # @type var previous_no_defaults: String?
111
+ # @type var previous_cli: String?
112
+ ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
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"
121
+ end
122
+
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.
132
+ def load_with_start_neutered(path)
133
+ klass = SimpleCov.singleton_class
134
+ names = %i[start_tracking install_at_exit_hook]
135
+ stash = names.to_h { |name| [name, klass.instance_method(name)] } #: Hash[Symbol, UnboundMethod]
136
+ # define_method over an existing method emits a "method redefined"
137
+ # warning under $VERBOSE; the override and restore are intentional.
138
+ silence_verbose { names.each { |name| klass.define_method(name, &(_ = NEUTERED)) } }
139
+ load path
140
+ ensure
141
+ # @type var stash: Hash[Symbol, UnboundMethod]
142
+ # @type var klass: Class
143
+ silence_verbose { stash.each { |name, method| klass.define_method(name, method) } }
144
+ end
145
+
146
+ def silence_verbose
147
+ previous = $VERBOSE #: bool?
148
+ $VERBOSE = nil
149
+ yield
150
+ ensure
151
+ # @type var previous: bool?
152
+ $VERBOSE = previous
153
+ end
154
+ end
155
+ end
156
+ end
@@ -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
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../atomic_file"
6
+ require_relative "command_helpers"
7
+
8
+ module SimpleCov
9
+ module CLI
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.
13
+ module Merge
14
+ extend CommandHelpers
15
+
16
+ extend self
17
+
18
+ def run(args, stdout:, stderr:, **)
19
+ opts = parse(args)
20
+ return error(stderr, "missing input files") if opts.fetch(:files).empty?
21
+
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))
27
+ return error(stderr, "no mergeable results in input files") unless result
28
+
29
+ commit(opts, result, stdout)
30
+ 0
31
+ end
32
+
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
+ end
39
+
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)
44
+ end
45
+
46
+ def parse(args)
47
+ opts = {output: CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
48
+ files =
49
+ build_parser do |o|
50
+ o.on("--output PATH") { |v| opts[:output] = v }
51
+ o.on("--honor-timeout") { opts[:honor_timeout] = true }
52
+ o.on("--dry-run") { opts[:dry_run] = true }
53
+ quiet_option(o, opts)
54
+ end.parse(args)
55
+ opts.merge(files: files)
56
+ end
57
+
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.
60
+ def parse_inputs(files, stderr)
61
+ parsed = {} #: Hash[String, Hash[String, untyped]]
62
+ files.each_with_object(parsed) do |path, memo|
63
+ data = parse_input(path, stderr) or return nil
64
+
65
+ memo[path] = data
66
+ end
67
+ end
68
+
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.
72
+ def parse_input(path, stderr)
73
+ data = JSON.parse(File.read(path))
74
+ return data if data.instance_of?(Hash) && !data.empty?
75
+
76
+ parse_input_error(stderr, path, "has no resultset entries")
77
+ rescue Errno::ENOENT
78
+ parse_input_error(stderr, path, "not found")
79
+ rescue JSON::ParserError => e
80
+ parse_input_error(stderr, path, "isn't valid JSON (#{e})")
81
+ rescue SystemCallError => e
82
+ parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.rstrip})")
83
+ end
84
+
85
+ def parse_input_error(stderr, path, reason)
86
+ stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
87
+ end
88
+
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.
92
+ def warn_about_duplicate_command_names(parsed, stderr)
93
+ files_per_command = {} #: Hash[String, Array[String]]
94
+ parsed.each do |path, data|
95
+ data.each_key { |command_name| (files_per_command[command_name] ||= []) << path }
96
+ end
97
+ files_per_command.each do |command_name, paths|
98
+ next if paths.size < 2
99
+
100
+ stderr.puts(duplicate_warning(command_name, paths))
101
+ end
102
+ end
103
+
104
+ def duplicate_warning(command_name, paths)
105
+ "simplecov merge: warning — command_name #{command_name.inspect} " \
106
+ "appears in #{paths.size} input files (#{paths.join(", ")}); " \
107
+ "entries will be merged"
108
+ end
109
+
110
+ def write(path, result)
111
+ AtomicFile.write(path, JSON.pretty_generate(result.to_hash))
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ module Open
9
+ extend CommandHelpers
10
+
11
+ extend self
12
+
13
+ def run(args, stderr:, **)
14
+ path = parse(args)
15
+ return error(stderr, "#{path} not found") unless File.exist?(path)
16
+
17
+ opener = browser_opener
18
+ return error(stderr, "no known opener for #{RbConfig::CONFIG.fetch("host_os")}") unless opener
19
+
20
+ system(*opener, path) ? 0 : 1
21
+ end
22
+
23
+ def parse(args)
24
+ path = CLI.default_report
25
+ build_parser do |o|
26
+ o.on("--report PATH") { |v| path = v }
27
+ end.parse(args)
28
+ path
29
+ end
30
+
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.
36
+ def browser_opener
37
+ case RbConfig::CONFIG.fetch("host_os")
38
+ when /darwin/ then ["open"]
39
+ when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
40
+ when /linux|bsd|solaris/ then ["xdg-open"]
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end