simplecov 0.22.0 → 1.0.2

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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +1058 -508
  4. data/doc/alternate-formatters.md +0 -5
  5. data/doc/commercial-services.md +5 -5
  6. data/exe/simplecov +11 -0
  7. data/lib/minitest/simplecov_plugin.rb +13 -5
  8. data/lib/simplecov/autostart.rb +11 -0
  9. data/lib/simplecov/cli/clean.rb +47 -0
  10. data/lib/simplecov/cli/coverage.rb +93 -0
  11. data/lib/simplecov/cli/diff.rb +152 -0
  12. data/lib/simplecov/cli/dotfile.rb +105 -0
  13. data/lib/simplecov/cli/merge.rb +118 -0
  14. data/lib/simplecov/cli/open.rb +50 -0
  15. data/lib/simplecov/cli/report.rb +89 -0
  16. data/lib/simplecov/cli/run.rb +36 -0
  17. data/lib/simplecov/cli/serve.rb +149 -0
  18. data/lib/simplecov/cli/uncovered.rb +108 -0
  19. data/lib/simplecov/cli.rb +150 -0
  20. data/lib/simplecov/color.rb +79 -0
  21. data/lib/simplecov/combine/branches_combiner.rb +28 -5
  22. data/lib/simplecov/combine/files_combiner.rb +47 -1
  23. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  24. data/lib/simplecov/combine/methods_combiner.rb +49 -0
  25. data/lib/simplecov/combine/results_combiner.rb +7 -5
  26. data/lib/simplecov/command_guesser.rb +46 -32
  27. data/lib/simplecov/configuration/coverage.rb +175 -0
  28. data/lib/simplecov/configuration/coverage_criteria.rb +158 -0
  29. data/lib/simplecov/configuration/filters.rb +197 -0
  30. data/lib/simplecov/configuration/formatting.rb +129 -0
  31. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  32. data/lib/simplecov/configuration/merging.rb +164 -0
  33. data/lib/simplecov/configuration/thresholds.rb +178 -0
  34. data/lib/simplecov/configuration.rb +105 -412
  35. data/lib/simplecov/coverage_statistics.rb +15 -9
  36. data/lib/simplecov/coverage_violations.rb +149 -0
  37. data/lib/simplecov/defaults.rb +29 -22
  38. data/lib/simplecov/deprecation.rb +45 -0
  39. data/lib/simplecov/directive.rb +167 -0
  40. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  41. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  42. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  43. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  45. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  46. data/lib/simplecov/exit_codes.rb +15 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +70 -19
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +102 -0
  51. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  53. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +85 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +90 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +128 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +89 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +11 -7
  63. data/lib/simplecov/formatter/simple_formatter.rb +11 -11
  64. data/lib/simplecov/formatter.rb +4 -0
  65. data/lib/simplecov/last_run.rb +10 -3
  66. data/lib/simplecov/lines_classifier.rb +25 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +61 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +44 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +129 -0
  73. data/lib/simplecov/process.rb +52 -12
  74. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  75. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  76. data/lib/simplecov/profiles/rails.rb +24 -10
  77. data/lib/simplecov/profiles/root_filter.rb +6 -5
  78. data/lib/simplecov/profiles/strict.rb +32 -0
  79. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  80. data/lib/simplecov/profiles.rb +32 -3
  81. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  82. data/lib/simplecov/result/source_file_builder.rb +51 -0
  83. data/lib/simplecov/result.rb +108 -19
  84. data/lib/simplecov/result_adapter.rb +105 -8
  85. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  86. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  87. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  88. data/lib/simplecov/result_merger.rb +59 -91
  89. data/lib/simplecov/result_processing.rb +179 -0
  90. data/lib/simplecov/simulate_coverage.rb +54 -8
  91. data/lib/simplecov/source_file/branch.rb +1 -3
  92. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  93. data/lib/simplecov/source_file/builder_context.rb +28 -0
  94. data/lib/simplecov/source_file/line.rb +9 -4
  95. data/lib/simplecov/source_file/line_builder.rb +43 -0
  96. data/lib/simplecov/source_file/method.rb +52 -0
  97. data/lib/simplecov/source_file/method_builder.rb +60 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +75 -0
  100. data/lib/simplecov/source_file/source_loader.rb +63 -0
  101. data/lib/simplecov/source_file/statistics.rb +57 -0
  102. data/lib/simplecov/source_file.rb +66 -232
  103. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +49 -0
  104. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +247 -0
  105. data/lib/simplecov/static_coverage_extractor/method_collector.rb +55 -0
  106. data/lib/simplecov/static_coverage_extractor/value_position.rb +82 -0
  107. data/lib/simplecov/static_coverage_extractor/visitor.rb +196 -0
  108. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  109. data/lib/simplecov/useless_results_remover.rb +16 -7
  110. data/lib/simplecov/version.rb +1 -1
  111. data/lib/simplecov-html.rb +4 -0
  112. data/lib/simplecov.rb +178 -380
  113. data/lib/simplecov_json_formatter.rb +4 -0
  114. data/schemas/coverage-v1.0.schema.json +306 -0
  115. data/schemas/coverage.schema.json +306 -0
  116. data/sig/simplecov.rbs +1638 -0
  117. metadata +94 -57
  118. data/CHANGELOG.md +0 -191
  119. data/lib/simplecov/default_formatter.rb +0 -20
@@ -64,8 +64,3 @@ t_wada AA formatter for SimpleCov
64
64
  *by [Chiefpansancolt](https://github.com/chiefpansancolt)*
65
65
 
66
66
  A TailwindCSS & TailwindUI Designed HTML formatter with clean and easy search of files with a tabular left Navigation.
67
-
68
- #### [simplecov-material](https://github.com/chiefpansancolt/simplecov-material)
69
- *by [Chiefpansancolt](https://github.com/chiefpansancolt)*
70
-
71
- A Material Designed HTML formatter with clean and easy search of files with a tabular left Navigation.
@@ -9,11 +9,6 @@ these integrations with their respective owners.
9
9
 
10
10
  Upload coverage reports to [codacy.com](https://www.codacy.com/), a hosted (or self-hosted) software quality analysis platform that also includes coverage reporting.
11
11
 
12
- #### [codeclimate](https://github.com/codeclimate/ruby-test-reporter)
13
- *by [Code Climate](https://codeclimate.com/)*
14
-
15
- Upload coverage reports to [codeclimate.com](https://codeclimate.com/), a hosted software quality analysis and that also includes coverage reporting.
16
-
17
12
  #### [codecov](https://github.com/codecov/codecov-ruby)
18
13
  *by [Codecov](https://codecov.io/)*
19
14
 
@@ -23,3 +18,8 @@ Upload coverage reports to [codecov.io](https://codecov.io/), a hosted coverage
23
18
  *by [Coveralls](https://coveralls.io/)*
24
19
 
25
20
  Upload coverage reports to [coveralls.io](https://coveralls.io/), a hosted coverage reporting solution.
21
+
22
+ #### [Qlty Cloud](https://github.com/qltysh/qlty)
23
+ *by [Qlty Software](https://qlty.sh/)*
24
+
25
+ Upload coverage reports to [Qlty Cloud](https://qlty.sh/), a hosted software quality analysis and that also includes coverage reporting.
data/exe/simplecov ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Make `./exe/simplecov` work from a fresh checkout, not only via
5
+ # `bundle exec`. RubyGems and Bundler already inject lib/ for an
6
+ # installed gem; this is a no-op in that case.
7
+ lib = File.expand_path("../lib", __dir__)
8
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
9
+
10
+ require "simplecov/cli"
11
+ exit SimpleCov::CLI.run(ARGV)
@@ -2,14 +2,22 @@
2
2
 
3
3
  # How minitest plugins. See https://github.com/simplecov-ruby/simplecov/pull/756 for why we need this.
4
4
  # https://github.com/seattlerb/minitest#writing-extensions
5
+ #
6
+ # Handles the SimpleCov-first / Minitest-second ordering: SimpleCov.start
7
+ # runs before `require "minitest/autorun"`, so the SimpleCov.start-time
8
+ # detection in `install_at_exit_hook` can't see Minitest yet. By the time
9
+ # this plugin fires (inside `Minitest.run`), Minitest is loaded and we
10
+ # can flip the same switch. The opposite ordering (Minitest first) is
11
+ # handled in `SimpleCov.install_at_exit_hook` — see `#minitest_autorun_pending?`.
5
12
  module Minitest
6
13
  def self.plugin_simplecov_init(_options)
7
- if defined?(SimpleCov)
8
- SimpleCov.external_at_exit = true
14
+ return unless defined?(SimpleCov)
15
+ return if SimpleCov.external_at_exit?
9
16
 
10
- Minitest.after_run do
11
- SimpleCov.at_exit_behavior
12
- end
17
+ SimpleCov.external_at_exit = true
18
+
19
+ Minitest.after_run do
20
+ SimpleCov.at_exit_behavior
13
21
  end
14
22
  end
15
23
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded via `RUBYOPT="-rsimplecov/autostart"` from `simplecov run`. The
4
+ # `require "simplecov"` here also auto-loads `~/.simplecov` and the
5
+ # project's `.simplecov` (per simplecov/defaults.rb), which may already
6
+ # call `SimpleCov.start`. `SimpleCov.start` is idempotent — it won't
7
+ # restart Coverage if it's already running, and `install_at_exit_hook`
8
+ # guards against double-installing — so calling it unconditionally is
9
+ # the safe way to ensure the report is formatted at exit.
10
+ require "simplecov"
11
+ SimpleCov.start
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # `simplecov clean [--dry-run]` — remove the coverage report
8
+ # directory (or whatever `SimpleCov.coverage_dir` resolves to). The
9
+ # `--dry-run` flag prints what would be deleted without touching
10
+ # disk, for when you're not sure what's in there.
11
+ module Clean
12
+ module_function
13
+
14
+ def run(args, stdout:, **)
15
+ opts = parse(args)
16
+ dir = SimpleCov::CLI.coverage_dir
17
+ return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
18
+
19
+ sweep(dir, opts, stdout)
20
+ 0
21
+ end
22
+
23
+ def sweep(dir, opts, stdout)
24
+ if opts[:dry_run]
25
+ announce(stdout, opts, "would remove #{dir} (#{Dir["#{dir}/**/*"].size} entries)")
26
+ else
27
+ require "fileutils"
28
+ FileUtils.rm_rf(dir)
29
+ announce(stdout, opts, "removed #{dir}")
30
+ end
31
+ end
32
+
33
+ def announce(stdout, opts, message)
34
+ stdout.puts("simplecov clean: #{message}") unless opts[:quiet]
35
+ end
36
+
37
+ def parse(args)
38
+ opts = {dry_run: false, quiet: false}
39
+ OptionParser.new do |o|
40
+ o.on("--dry-run") { opts[:dry_run] = true }
41
+ o.on("-q", "--quiet") { opts[:quiet] = true }
42
+ end.parse(args)
43
+ opts
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # `simplecov coverage <path>` — print per-criterion stats for one
9
+ # file from a JSONFormatter coverage.json.
10
+ module Coverage
11
+ CRITERIA = [
12
+ {label: "Line", pct: "lines_covered_percent", cov: "covered_lines", tot: "total_lines"},
13
+ {label: "Branch", pct: "branches_covered_percent", cov: "covered_branches", tot: "total_branches"},
14
+ {label: "Method", pct: "methods_covered_percent", cov: "covered_methods", tot: "total_methods"}
15
+ ].freeze
16
+
17
+ module_function
18
+
19
+ def run(args, stdout:, stderr:)
20
+ opts = parse(args, stderr: stderr)
21
+ return 1 unless opts
22
+
23
+ match = locate_match(opts, stderr)
24
+ return 1 unless match
25
+
26
+ emit(match, opts, stdout)
27
+ 0
28
+ end
29
+
30
+ def parse(args, stderr:)
31
+ opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false} #: Hash[Symbol, untyped]
32
+ rest =
33
+ OptionParser.new do |o|
34
+ o.on("--input PATH") { |v| opts[:input] = v }
35
+ o.on("--json") { opts[:json] = true }
36
+ o.on("--no-color") { opts[:no_color] = true }
37
+ end.parse(args)
38
+ return stderr.puts("simplecov coverage: missing file argument") && nil if rest.empty?
39
+
40
+ opts[:path] = rest.first
41
+ opts
42
+ end
43
+
44
+ def locate_match(opts, stderr)
45
+ return stderr.puts("simplecov coverage: #{opts[:input]} not found") && nil unless File.exist?(opts[:input])
46
+
47
+ data = JSON.parse(File.read(opts[:input]))
48
+ none = {} #: Hash[String, untyped]
49
+ match = lookup(data.fetch("coverage", none), opts[:path])
50
+ return match if match
51
+
52
+ stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
53
+ nil
54
+ end
55
+
56
+ # Match either the absolute path, the literal string passed, or
57
+ # any coverage entry whose absolute filename ends with "/<path>".
58
+ # That covers the three natural ways a user types a path: relative
59
+ # to project root ("app/foo.rb"), absolute, or basename-only.
60
+ def lookup(coverage_hash, path)
61
+ absolute = File.expand_path(path)
62
+ suffix = "/#{path}"
63
+ coverage_hash.find { |fname, _| fname == absolute || fname == path || fname.end_with?(suffix) }
64
+ end
65
+
66
+ def emit(match, opts, stdout)
67
+ filename, payload = match
68
+ if opts[:json]
69
+ entry = {filename => payload} #: Hash[untyped, untyped]
70
+ stdout.puts(JSON.pretty_generate(entry))
71
+ else
72
+ print_human(filename, payload, stdout, SimpleCov::CLI.color_enabled?(opts, stdout))
73
+ end
74
+ end
75
+
76
+ def print_human(filename, payload, stdout, color)
77
+ stdout.puts(filename)
78
+ CRITERIA.each { |c| emit_criterion(stdout, payload, c, color) }
79
+ end
80
+
81
+ def emit_criterion(stdout, payload, criterion, color)
82
+ return unless payload.key?(criterion[:pct])
83
+
84
+ pct = payload[criterion[:pct]].to_f
85
+ stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
86
+ label: "#{criterion[:label]}:",
87
+ pct: SimpleCov::Color.colorize_percent(pct, enabled: color),
88
+ covered: payload[criterion[:cov]] || 0,
89
+ total: payload[criterion[:tot]] || 0))
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # `simplecov diff <baseline>` — print the per-file line-coverage
9
+ # delta between coverage.json (--input) and a baseline coverage.json
10
+ # checked in alongside the suite. Only files whose coverage moved
11
+ # are listed; --fail-on-drop exits non-zero when any file regressed,
12
+ # so this composes with CI as a "coverage of this PR didn't drop"
13
+ # gate. Resolves the long-standing "diff coverage" feature request.
14
+ module Diff
15
+ EPSILON = 0.005 # tolerance below which a delta is considered noise
16
+
17
+ # Per-criterion key map. coverage.json carries `lines_covered_percent`
18
+ # plus `branches_covered_percent` / `methods_covered_percent` when
19
+ # the corresponding criterion is enabled, so the diff can describe
20
+ # whichever criteria the baseline + current both report on.
21
+ CRITERIA = %i[lines branches methods].freeze
22
+ CRITERION_FIELDS = {
23
+ lines: {pct: "lines_covered_percent", total: "total_lines"},
24
+ branches: {pct: "branches_covered_percent", total: "total_branches"},
25
+ methods: {pct: "methods_covered_percent", total: "total_methods"}
26
+ }.freeze
27
+
28
+ STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
29
+
30
+ module_function
31
+
32
+ def run(args, stdout:, stderr:, **)
33
+ opts = parse(args, stderr)
34
+ return 1 unless opts
35
+
36
+ rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
37
+ rows.sort_by! { |row| row[:line_delta] }
38
+ if opts[:json]
39
+ emit_json(stdout, rows)
40
+ else
41
+ emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
42
+ end
43
+ opts[:fail_on_drop] && rows.any? { |row| row[:line_delta].negative? } ? 1 : 0
44
+ end
45
+
46
+ def parse(args, stderr)
47
+ opts = parse_flags(args)
48
+ return stderr.puts("simplecov diff: missing baseline argument") && nil if opts[:rest].empty?
49
+
50
+ opts[:baseline] = load_coverage(opts[:rest].first, stderr) or return nil
51
+ opts[:current] = load_coverage(opts[:input], stderr) or return nil
52
+ opts
53
+ end
54
+
55
+ def parse_flags(args)
56
+ opts = {input: SimpleCov::CLI.default_input, fail_on_drop: false, json: false, threshold: 0.0, no_color: false}
57
+ opts.merge(rest: option_parser(opts).parse(args))
58
+ end
59
+
60
+ def option_parser(opts)
61
+ OptionParser.new do |o|
62
+ o.on("--input PATH") { |v| opts[:input] = v }
63
+ o.on("--fail-on-drop") { opts[:fail_on_drop] = true }
64
+ o.on("--json") { opts[:json] = true }
65
+ o.on("--threshold N", Float) { |v| opts[:threshold] = v }
66
+ o.on("--no-color") { opts[:no_color] = true }
67
+ end
68
+ end
69
+
70
+ def load_coverage(path, stderr)
71
+ no_coverage = {} #: Hash[String, untyped]
72
+ return normalize_keys(JSON.parse(File.read(path)).fetch("coverage", no_coverage)) if File.exist?(path)
73
+
74
+ stderr.puts("simplecov diff: #{path} not found")
75
+ nil
76
+ end
77
+
78
+ # Strip a leading slash so coverage.json files written before the
79
+ # `project_filename` change (keys like "/lib/foo.rb") still diff
80
+ # cleanly against newer reports (keys like "lib/foo.rb").
81
+ def normalize_keys(coverage)
82
+ coverage.transform_keys { |key| key.delete_prefix("/") }
83
+ end
84
+
85
+ def compute_rows(current, baseline, threshold)
86
+ files = current.keys | baseline.keys
87
+ files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
88
+ end
89
+
90
+ def compute_row(fname, current_payload, baseline_payload, threshold)
91
+ deltas = CRITERIA.to_h { |c| [c, pct_for(c, current_payload) - pct_for(c, baseline_payload)] }
92
+ floor = [threshold.abs, EPSILON].max
93
+ return nil unless deltas.values.any? { |delta| delta.abs > floor }
94
+
95
+ {
96
+ file: fname,
97
+ status: status_for(current_payload, baseline_payload),
98
+ line_delta: deltas[:lines],
99
+ branch_delta: deltas[:branches],
100
+ method_delta: deltas[:methods]
101
+ }
102
+ end
103
+
104
+ def status_for(current_payload, baseline_payload)
105
+ return "added" if baseline_payload.nil?
106
+ return "removed" if current_payload.nil?
107
+
108
+ "changed"
109
+ end
110
+
111
+ def pct_for(criterion, payload)
112
+ fields = CRITERION_FIELDS.fetch(criterion)
113
+ return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
114
+
115
+ payload[fields[:pct]].to_f
116
+ end
117
+
118
+ def emit_text(stdout, rows, color)
119
+ return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?
120
+
121
+ rows.each { |row| stdout.puts(format_row(row, color)) }
122
+ end
123
+
124
+ def format_row(row, color)
125
+ line = " #{delta_parts(row, color).join(' ')} #{row[:file]}"
126
+ suffix = STATUS_SUFFIX[row[:status]]
127
+ suffix ? "#{line} #{suffix}" : line
128
+ end
129
+
130
+ def delta_parts(row, color)
131
+ [
132
+ format_delta(row[:line_delta], "lines", color),
133
+ (format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
134
+ (format_delta(row[:method_delta], "methods", color) if row[:method_delta].abs > EPSILON)
135
+ ].compact
136
+ end
137
+
138
+ # Deltas are sign-based, not threshold-based: a +5% bump is good
139
+ # (green) and a -5% drop is bad (red), regardless of where the
140
+ # absolute coverage level lands.
141
+ def format_delta(delta, label, color)
142
+ sign = delta.positive? ? "+" : ""
143
+ text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
144
+ SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
145
+ end
146
+
147
+ def emit_json(stdout, rows)
148
+ stdout.puts(JSON.pretty_generate(rows))
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,105 @@
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
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.
13
+ module Dotfile
14
+ module_function
15
+
16
+ def coverage_dir
17
+ dotfile = find
18
+ return "coverage" unless dotfile
19
+
20
+ with_simplecov_loaded { read_from(dotfile) }
21
+ rescue LoadError, StandardError => e
22
+ # simplecov:disable — defensive fallback for a bad dotfile (parse
23
+ # error, EACCES, etc.); never fires in the project's own dogfood run
24
+ warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
25
+ "coverage"
26
+ # simplecov:enable
27
+ end
28
+
29
+ # Load the dotfile, snapshot+restore `SimpleCov.coverage_dir` so we
30
+ # don't quietly clobber it in a host process that's already
31
+ # configured (e.g. when the CLI is exercised inline by simplecov's
32
+ # own spec suite). The snapshot is intentionally narrow: a dotfile
33
+ # can still mutate other SimpleCov configuration (filters, groups,
34
+ # formatters, command_name, ...) via `SimpleCov.configure` or
35
+ # `SimpleCov.start { ... }` blocks. The CLI normally runs as a
36
+ # top-level process where that's harmless; callers driving it from
37
+ # inside a Ruby host that cares about isolation should arrange that
38
+ # themselves.
39
+ def read_from(dotfile)
40
+ snapshot = SimpleCov.instance_variable_get(:@coverage_dir)
41
+ load_with_start_neutered(dotfile)
42
+ dir = SimpleCov.coverage_dir
43
+ SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
44
+ dir
45
+ end
46
+
47
+ def find
48
+ dir = Pathname.new(Dir.pwd)
49
+ loop do
50
+ candidate = dir.join(".simplecov")
51
+ return candidate.to_s if candidate.exist?
52
+ break if dir.root?
53
+
54
+ dir = dir.parent
55
+ end
56
+ nil
57
+ end
58
+
59
+ def with_simplecov_loaded
60
+ previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil) #: String?
61
+ previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
62
+ ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
63
+ # SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
64
+ # CLI-only behavior — e.g. simplecov itself sets `coverage_dir`
65
+ # to the dogfood path here but skips that for descendants.
66
+ ENV["SIMPLECOV_CLI"] = "1"
67
+ require "simplecov"
68
+ yield
69
+ ensure
70
+ # @type var previous_no_defaults: String?
71
+ # @type var previous_cli: String?
72
+ ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
73
+ ENV["SIMPLECOV_CLI"] = previous_cli
74
+ end
75
+
76
+ # Load `path` with `SimpleCov.start` and the at_exit installer
77
+ # turned into no-ops, so a project whose dotfile calls
78
+ # `SimpleCov.start` doesn't trigger Coverage just because we asked
79
+ # for `coverage_dir`. Config inside any `SimpleCov.start { ... }`
80
+ # block still runs.
81
+ def load_with_start_neutered(path)
82
+ klass = SimpleCov.singleton_class
83
+ names = %i[start_tracking install_at_exit_hook]
84
+ stash = names.to_h { |name| [name, klass.instance_method(name)] } #: Hash[Symbol, UnboundMethod]
85
+ # define_method over an existing method emits a "method redefined"
86
+ # warning under $VERBOSE; the override and restore are intentional.
87
+ silence_verbose { names.each { |name| klass.define_method(name) { nil } } }
88
+ load path
89
+ ensure
90
+ # @type var stash: Hash[Symbol, UnboundMethod]
91
+ # @type var klass: Class
92
+ silence_verbose { stash.each { |name, method| klass.define_method(name, method) } }
93
+ end
94
+
95
+ def silence_verbose
96
+ previous = $VERBOSE #: bool?
97
+ $VERBOSE = nil
98
+ yield
99
+ ensure
100
+ # @type var previous: bool?
101
+ $VERBOSE = previous
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # `simplecov merge <files...>` — wrap SimpleCov::ResultMerger so a
9
+ # CI matrix that produces one .resultset.json per worker can stitch
10
+ # them together from the shell instead of dropping a Rake task into
11
+ # every project. Requires the full simplecov library to be on the
12
+ # load path; lazy-required so the read-only subcommands above don't
13
+ # pay for ResultMerger (and its Coverage runtime guard).
14
+ module Merge
15
+ module_function
16
+
17
+ def run(args, stdout:, stderr:, **)
18
+ opts = parse(args)
19
+ return error(stderr, "missing input files") if opts[:files].empty?
20
+ return 1 unless valid_inputs?(opts[:files], stderr)
21
+
22
+ require "simplecov"
23
+ result = SimpleCov::ResultMerger.merge_results(*opts[:files], ignore_timeout: !opts[:honor_timeout])
24
+ return error(stderr, "no mergeable results in input files") unless result
25
+
26
+ commit(opts, result, stdout)
27
+ 0
28
+ end
29
+
30
+ def commit(opts, result, stdout)
31
+ verb = opts[:dry_run] ? "would write" : "wrote"
32
+ write(opts[:output], result) unless opts[:dry_run]
33
+ stdout.puts("simplecov merge: #{verb} #{opts[:output]}") unless opts[:quiet]
34
+ end
35
+
36
+ def valid_inputs?(files, stderr)
37
+ parsed = parse_inputs(files, stderr) or return false
38
+
39
+ warn_about_duplicate_command_names(parsed, stderr)
40
+ true
41
+ end
42
+
43
+ def parse(args)
44
+ opts = {output: SimpleCov::CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
45
+ files =
46
+ OptionParser.new do |o|
47
+ o.on("--output PATH") { |v| opts[:output] = v }
48
+ o.on("--honor-timeout") { opts[:honor_timeout] = true }
49
+ o.on("--dry-run") { opts[:dry_run] = true }
50
+ o.on("-q", "--quiet") { opts[:quiet] = true }
51
+ end.parse(args)
52
+ opts.merge(files: files)
53
+ end
54
+
55
+ # Validate every input file up-front and return a {path => parsed}
56
+ # hash. Surfacing per-file errors here turns ResultMerger's
57
+ # generic "no mergeable results" into a message that points at
58
+ # the specific input causing the failure.
59
+ def parse_inputs(files, stderr)
60
+ parsed = {} #: Hash[String, Hash[String, untyped]]
61
+ files.each_with_object(parsed) do |path, memo|
62
+ data = parse_input(path, stderr) or return nil
63
+
64
+ memo[path] = data
65
+ end
66
+ end
67
+
68
+ def parse_input(path, stderr)
69
+ return parse_input_error(stderr, path, "not found") unless File.exist?(path)
70
+
71
+ data = JSON.parse(File.read(path))
72
+ return data if data.is_a?(Hash) && !data.empty?
73
+
74
+ parse_input_error(stderr, path, "has no resultset entries")
75
+ rescue JSON::ParserError => e
76
+ parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
77
+ end
78
+
79
+ def parse_input_error(stderr, path, reason)
80
+ stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
81
+ nil
82
+ end
83
+
84
+ # When two input files share a command_name, ResultMerger folds
85
+ # them together with last-write-wins on the timestamp — easy to
86
+ # mistake for "no merge happened." Surface the overlap so the
87
+ # operator can rename the workers or accept the merge knowingly.
88
+ def warn_about_duplicate_command_names(parsed, stderr)
89
+ files_per_command = {} #: Hash[String, Array[String]]
90
+ parsed.each do |path, data|
91
+ data.each_key { |command_name| (files_per_command[command_name] ||= []) << path }
92
+ end
93
+ files_per_command.each do |command_name, paths|
94
+ next if paths.size < 2
95
+
96
+ stderr.puts(duplicate_warning(command_name, paths))
97
+ end
98
+ end
99
+
100
+ def duplicate_warning(command_name, paths)
101
+ "simplecov merge: warning — command_name #{command_name.inspect} " \
102
+ "appears in #{paths.size} input files (#{paths.join(', ')}); " \
103
+ "entries will be merged"
104
+ end
105
+
106
+ def write(path, result)
107
+ require "fileutils"
108
+ FileUtils.mkdir_p(File.dirname(path))
109
+ File.write(path, JSON.pretty_generate(result.to_hash))
110
+ end
111
+
112
+ def error(stderr, message)
113
+ stderr.puts("simplecov merge: #{message}")
114
+ 1
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # `simplecov open [--report PATH]` — open the HTML report in the
8
+ # platform's default browser. Tiny QoL wrapper around `xdg-open` /
9
+ # `open` / `start` so users don't have to type a file:// URL.
10
+ module Open
11
+ module_function
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['host_os']}") unless opener
19
+
20
+ system(*opener, path) ? 0 : 1
21
+ end
22
+
23
+ def error(stderr, message)
24
+ stderr.puts("simplecov open: #{message}")
25
+ 1
26
+ end
27
+
28
+ def parse(args)
29
+ path = SimpleCov::CLI.default_report
30
+ OptionParser.new do |o|
31
+ o.on("--report PATH") { |v| path = v }
32
+ end.parse(args)
33
+ path
34
+ end
35
+
36
+ # Returns the argv for the platform's "open this file" command, or
37
+ # nil if the host OS isn't recognized. On Windows, `start` is a
38
+ # cmd.exe builtin (not an executable), so route through `cmd /c`;
39
+ # the empty string is the window-title positional `start` takes
40
+ # before the path so a quoted path isn't mis-parsed as the title.
41
+ def browser_opener
42
+ case RbConfig::CONFIG["host_os"]
43
+ when /darwin/ then ["open"]
44
+ when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
45
+ when /linux|bsd|solaris/ then ["xdg-open"]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end