simplecov 0.22.0 → 1.0.0.rc1

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -1
  3. data/LICENSE +1 -1
  4. data/README.md +1009 -511
  5. data/doc/alternate-formatters.md +0 -5
  6. data/doc/commercial-services.md +5 -5
  7. data/exe/simplecov +11 -0
  8. data/lib/minitest/simplecov_plugin.rb +13 -5
  9. data/lib/simplecov/autostart.rb +11 -0
  10. data/lib/simplecov/cli/clean.rb +47 -0
  11. data/lib/simplecov/cli/coverage.rb +91 -0
  12. data/lib/simplecov/cli/diff.rb +151 -0
  13. data/lib/simplecov/cli/dotfile.rb +100 -0
  14. data/lib/simplecov/cli/merge.rb +116 -0
  15. data/lib/simplecov/cli/open.rb +50 -0
  16. data/lib/simplecov/cli/report.rb +84 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve.rb +139 -0
  19. data/lib/simplecov/cli/uncovered.rb +107 -0
  20. data/lib/simplecov/cli.rb +150 -0
  21. data/lib/simplecov/color.rb +74 -0
  22. data/lib/simplecov/combine/branches_combiner.rb +3 -2
  23. data/lib/simplecov/combine/files_combiner.rb +7 -1
  24. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  25. data/lib/simplecov/combine/methods_combiner.rb +26 -0
  26. data/lib/simplecov/combine/results_combiner.rb +5 -4
  27. data/lib/simplecov/command_guesser.rb +46 -32
  28. data/lib/simplecov/configuration/coverage.rb +171 -0
  29. data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
  30. data/lib/simplecov/configuration/filters.rb +195 -0
  31. data/lib/simplecov/configuration/formatting.rb +119 -0
  32. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  33. data/lib/simplecov/configuration/merging.rb +74 -0
  34. data/lib/simplecov/configuration/thresholds.rb +174 -0
  35. data/lib/simplecov/configuration.rb +79 -405
  36. data/lib/simplecov/coverage_statistics.rb +12 -9
  37. data/lib/simplecov/coverage_violations.rb +148 -0
  38. data/lib/simplecov/defaults.rb +27 -20
  39. data/lib/simplecov/directive.rb +162 -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 +3 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +61 -17
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +101 -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 +79 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +127 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +77 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +4 -5
  63. data/lib/simplecov/formatter/simple_formatter.rb +9 -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 +26 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +51 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +42 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +77 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +95 -0
  73. data/lib/simplecov/process.rb +20 -14
  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 +97 -19
  84. data/lib/simplecov/result_adapter.rb +68 -6
  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 +46 -90
  89. data/lib/simplecov/result_processing.rb +162 -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 +7 -2
  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 +58 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +77 -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/visitor.rb +193 -0
  104. data/lib/simplecov/static_coverage_extractor.rb +111 -0
  105. data/lib/simplecov/useless_results_remover.rb +16 -7
  106. data/lib/simplecov/version.rb +1 -1
  107. data/lib/simplecov-html.rb +4 -0
  108. data/lib/simplecov.rb +131 -377
  109. data/lib/simplecov_json_formatter.rb +4 -0
  110. data/schemas/coverage-v1.0.schema.json +300 -0
  111. data/schemas/coverage.schema.json +300 -0
  112. metadata +88 -56
  113. 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,91 @@
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}
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
+ match = lookup(data.fetch("coverage", {}), opts[:path])
49
+ return match if match
50
+
51
+ stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
52
+ nil
53
+ end
54
+
55
+ # Match either the absolute path, the literal string passed, or
56
+ # any coverage entry whose absolute filename ends with "/<path>".
57
+ # That covers the three natural ways a user types a path: relative
58
+ # to project root ("app/foo.rb"), absolute, or basename-only.
59
+ def lookup(coverage_hash, path)
60
+ absolute = File.expand_path(path)
61
+ suffix = "/#{path}"
62
+ coverage_hash.find { |fname, _| fname == absolute || fname == path || fname.end_with?(suffix) }
63
+ end
64
+
65
+ def emit(match, opts, stdout)
66
+ filename, payload = match
67
+ if opts[:json]
68
+ stdout.puts(JSON.pretty_generate(filename => payload))
69
+ else
70
+ print_human(filename, payload, stdout, SimpleCov::CLI.color_enabled?(opts, stdout))
71
+ end
72
+ end
73
+
74
+ def print_human(filename, payload, stdout, color)
75
+ stdout.puts(filename)
76
+ CRITERIA.each { |c| emit_criterion(stdout, payload, c, color) }
77
+ end
78
+
79
+ def emit_criterion(stdout, payload, criterion, color)
80
+ return unless payload.key?(criterion[:pct])
81
+
82
+ pct = payload[criterion[:pct]].to_f
83
+ stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
84
+ label: "#{criterion[:label]}:",
85
+ pct: SimpleCov::Color.colorize_percent(pct, enabled: color),
86
+ covered: payload[criterion[:cov]] || 0,
87
+ total: payload[criterion[:tot]] || 0))
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,151 @@
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
+ return normalize_keys(JSON.parse(File.read(path)).fetch("coverage", {})) if File.exist?(path)
72
+
73
+ stderr.puts("simplecov diff: #{path} not found")
74
+ nil
75
+ end
76
+
77
+ # Strip a leading slash so coverage.json files written before the
78
+ # `project_filename` change (keys like "/lib/foo.rb") still diff
79
+ # cleanly against newer reports (keys like "lib/foo.rb").
80
+ def normalize_keys(coverage)
81
+ coverage.transform_keys { |key| key.delete_prefix("/") }
82
+ end
83
+
84
+ def compute_rows(current, baseline, threshold)
85
+ files = current.keys | baseline.keys
86
+ files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
87
+ end
88
+
89
+ def compute_row(fname, current_payload, baseline_payload, threshold)
90
+ deltas = CRITERIA.to_h { |c| [c, pct_for(c, current_payload) - pct_for(c, baseline_payload)] }
91
+ floor = [threshold.abs, EPSILON].max
92
+ return nil unless deltas.values.any? { |delta| delta.abs > floor }
93
+
94
+ {
95
+ file: fname,
96
+ status: status_for(current_payload, baseline_payload),
97
+ line_delta: deltas[:lines],
98
+ branch_delta: deltas[:branches],
99
+ method_delta: deltas[:methods]
100
+ }
101
+ end
102
+
103
+ def status_for(current_payload, baseline_payload)
104
+ return "added" if baseline_payload.nil?
105
+ return "removed" if current_payload.nil?
106
+
107
+ "changed"
108
+ end
109
+
110
+ def pct_for(criterion, payload)
111
+ fields = CRITERION_FIELDS.fetch(criterion)
112
+ return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
113
+
114
+ payload[fields[:pct]].to_f
115
+ end
116
+
117
+ def emit_text(stdout, rows, color)
118
+ return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?
119
+
120
+ rows.each { |row| stdout.puts(format_row(row, color)) }
121
+ end
122
+
123
+ def format_row(row, color)
124
+ line = " #{delta_parts(row, color).join(' ')} #{row[:file]}"
125
+ suffix = STATUS_SUFFIX[row[:status]]
126
+ suffix ? "#{line} #{suffix}" : line
127
+ end
128
+
129
+ def delta_parts(row, color)
130
+ [
131
+ format_delta(row[:line_delta], "lines", color),
132
+ (format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
133
+ (format_delta(row[:method_delta], "methods", color) if row[:method_delta].abs > EPSILON)
134
+ ].compact
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)
144
+ end
145
+
146
+ def emit_json(stdout, rows)
147
+ stdout.puts(JSON.pretty_generate(rows))
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,100 @@
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)
61
+ previous_cli = ENV.fetch("SIMPLECOV_CLI", nil)
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
+ ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
71
+ ENV["SIMPLECOV_CLI"] = previous_cli
72
+ end
73
+
74
+ # Load `path` with `SimpleCov.start` and the at_exit installer
75
+ # turned into no-ops, so a project whose dotfile calls
76
+ # `SimpleCov.start` doesn't trigger Coverage just because we asked
77
+ # for `coverage_dir`. Config inside any `SimpleCov.start { ... }`
78
+ # block still runs.
79
+ def load_with_start_neutered(path)
80
+ klass = SimpleCov.singleton_class
81
+ names = %i[start_tracking install_at_exit_hook]
82
+ stash = names.to_h { |name| [name, klass.instance_method(name)] }
83
+ # define_method over an existing method emits a "method redefined"
84
+ # warning under $VERBOSE; the override and restore are intentional.
85
+ silence_verbose { names.each { |name| klass.define_method(name) { nil } } }
86
+ load path
87
+ ensure
88
+ silence_verbose { stash.each { |name, method| klass.define_method(name, method) } }
89
+ end
90
+
91
+ def silence_verbose
92
+ previous = $VERBOSE
93
+ $VERBOSE = nil
94
+ yield
95
+ ensure
96
+ $VERBOSE = previous
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,116 @@
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
+ files.each_with_object({}) do |path, memo|
61
+ data = parse_input(path, stderr) or return nil
62
+
63
+ memo[path] = data
64
+ end
65
+ end
66
+
67
+ def parse_input(path, stderr)
68
+ return parse_input_error(stderr, path, "not found") unless File.exist?(path)
69
+
70
+ data = JSON.parse(File.read(path))
71
+ return data if data.is_a?(Hash) && !data.empty?
72
+
73
+ parse_input_error(stderr, path, "has no resultset entries")
74
+ rescue JSON::ParserError => e
75
+ parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
76
+ end
77
+
78
+ def parse_input_error(stderr, path, reason)
79
+ stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
80
+ nil
81
+ end
82
+
83
+ # When two input files share a command_name, ResultMerger folds
84
+ # them together with last-write-wins on the timestamp — easy to
85
+ # mistake for "no merge happened." Surface the overlap so the
86
+ # operator can rename the workers or accept the merge knowingly.
87
+ def warn_about_duplicate_command_names(parsed, stderr)
88
+ files_per_command = parsed.each_with_object({}) do |(path, data), memo|
89
+ data.each_key { |command_name| (memo[command_name] ||= []) << path }
90
+ end
91
+ files_per_command.each do |command_name, paths|
92
+ next if paths.size < 2
93
+
94
+ stderr.puts(duplicate_warning(command_name, paths))
95
+ end
96
+ end
97
+
98
+ def duplicate_warning(command_name, paths)
99
+ "simplecov merge: warning — command_name #{command_name.inspect} " \
100
+ "appears in #{paths.size} input files (#{paths.join(', ')}); " \
101
+ "entries will be merged"
102
+ end
103
+
104
+ def write(path, result)
105
+ require "fileutils"
106
+ FileUtils.mkdir_p(File.dirname(path))
107
+ File.write(path, JSON.pretty_generate(result.to_hash))
108
+ end
109
+
110
+ def error(stderr, message)
111
+ stderr.puts("simplecov merge: #{message}")
112
+ 1
113
+ end
114
+ end
115
+ end
116
+ 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