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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # `simplecov report [--input PATH]` — pretty-print the overall
9
+ # totals row plus per-group totals from a JSONFormatter
10
+ # coverage.json. Same numbers as the HTML report's totals row, for
11
+ # contexts where opening a browser isn't an option (CI logs, ssh
12
+ # sessions, terminal-only workflows).
13
+ module Report
14
+ SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
15
+
16
+ module_function
17
+
18
+ def run(args, stdout:, stderr:)
19
+ opts = parse(args)
20
+ return 1 unless (data = load_data(opts[:input], stderr))
21
+
22
+ if opts[:json]
23
+ emit_json(stdout, data)
24
+ else
25
+ emit_text(stdout, data, SimpleCov::CLI.color_enabled?(opts, stdout))
26
+ end
27
+ 0
28
+ end
29
+
30
+ def parse(args)
31
+ opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false}
32
+ OptionParser.new do |o|
33
+ o.on("--input PATH") { |v| opts[:input] = v }
34
+ o.on("--json") { opts[:json] = true }
35
+ o.on("--no-color") { opts[:no_color] = true }
36
+ end.parse(args)
37
+ opts
38
+ end
39
+
40
+ def load_data(input, stderr)
41
+ return JSON.parse(File.read(input)) if File.exist?(input)
42
+
43
+ stderr.puts("simplecov report: #{input} not found")
44
+ nil
45
+ end
46
+
47
+ def emit_text(stdout, data, color)
48
+ emit_totals(stdout, "All Files", data.fetch("total", {}), color)
49
+ data.fetch("groups", {}).each { |name, group| emit_totals(stdout, name, group, color) }
50
+ end
51
+
52
+ def emit_totals(stdout, label, totals, color)
53
+ stdout.puts(label)
54
+ SECTIONS.each { |display, key| emit_section(stdout, display, totals[key], color) }
55
+ stdout.puts
56
+ end
57
+
58
+ def emit_section(stdout, display, section, color)
59
+ return unless section.is_a?(Hash) && section["total"].to_i.positive?
60
+
61
+ stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
62
+ label: "#{display}:",
63
+ pct: SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
64
+ covered: section["covered"] || 0,
65
+ total: section["total"] || 0))
66
+ end
67
+
68
+ def emit_json(stdout, data)
69
+ payload = {"All Files" => collect_section(data.fetch("total", {}))}
70
+ data.fetch("groups", {}).each { |name, group| payload[name] = collect_section(group) }
71
+ stdout.puts(JSON.pretty_generate(payload))
72
+ end
73
+
74
+ def collect_section(totals)
75
+ SECTIONS.each_with_object({}) do |(_, key), out|
76
+ section = totals[key]
77
+ next unless section.is_a?(Hash) && section["total"].to_i.positive?
78
+
79
+ out[key] = {"percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]}
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ # `simplecov run <command...>` — exec the given command with
6
+ # simplecov auto-loaded so a coverage report drops into the
7
+ # project's coverage/ directory at the end. Useful for projects
8
+ # without a test_helper that already calls SimpleCov.start (e.g.
9
+ # plain `bundle exec rake test` on an unconfigured library).
10
+ module Run
11
+ AUTOSTART = File.expand_path("../autostart", __dir__)
12
+
13
+ module_function
14
+
15
+ def run(args, stderr:, **)
16
+ cmd = args.first == "--" ? args.drop(1) : args
17
+ if cmd.empty?
18
+ stderr.puts("simplecov run: missing command")
19
+ return 1
20
+ end
21
+
22
+ Kernel.exec(rubyopt_env, *cmd)
23
+ rescue Errno::ENOENT => e
24
+ stderr.puts("simplecov run: #{e.message}")
25
+ 127
26
+ end
27
+
28
+ def rubyopt_env
29
+ existing = ENV["RUBYOPT"].to_s.strip
30
+ injection = "-r#{AUTOSTART}"
31
+ merged = existing.empty? ? injection : "#{existing} #{injection}"
32
+ ENV.to_hash.merge("RUBYOPT" => merged)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # `simplecov serve [--port N] [--host HOST]` — serve the coverage
8
+ # report over HTTP. A 30-line static file server backed by stdlib
9
+ # `socket`, so there's no extra dependency just for "view a local
10
+ # report on a CI box where `file://` doesn't work."
11
+ module Serve
12
+ MIME = {
13
+ ".html" => "text/html; charset=utf-8",
14
+ ".htm" => "text/html; charset=utf-8",
15
+ ".css" => "text/css",
16
+ ".js" => "application/javascript",
17
+ ".json" => "application/json",
18
+ ".svg" => "image/svg+xml",
19
+ ".png" => "image/png",
20
+ ".gif" => "image/gif",
21
+ ".jpg" => "image/jpeg",
22
+ ".jpeg" => "image/jpeg",
23
+ ".ico" => "image/x-icon",
24
+ ".txt" => "text/plain; charset=utf-8"
25
+ }.freeze
26
+ STATUS_TEXT = {
27
+ 200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
28
+ 404 => "Not Found", 405 => "Method Not Allowed"
29
+ }.freeze
30
+
31
+ module_function
32
+
33
+ def run(args, stdout:, stderr:, **)
34
+ opts = parse(args)
35
+ dir = SimpleCov::CLI.coverage_dir
36
+ return error(stderr, "#{dir} doesn't exist; run your test suite first") unless File.directory?(dir)
37
+
38
+ require "socket"
39
+ server = TCPServer.new(opts[:host], opts[:port])
40
+ announce(stdout, server, dir)
41
+ serve_loop(server, dir, stdout)
42
+ 0
43
+ ensure
44
+ server&.close
45
+ end
46
+
47
+ def parse(args)
48
+ opts = {port: 0, host: "127.0.0.1"}
49
+ OptionParser.new do |o|
50
+ o.on("--port N", Integer) { |v| opts[:port] = v }
51
+ o.on("--host HOST") { |v| opts[:host] = v }
52
+ end.parse(args)
53
+ opts
54
+ end
55
+
56
+ def announce(stdout, server, dir)
57
+ port = server.addr[1]
58
+ host = server.addr[3]
59
+ stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
60
+ stdout.puts("Press Ctrl-C to stop.")
61
+ end
62
+
63
+ def serve_loop(server, dir, stdout)
64
+ loop { handle_connection(server.accept, dir) }
65
+ rescue Interrupt
66
+ stdout.puts("\nsimplecov serve: stopping")
67
+ end
68
+
69
+ # Reads one HTTP request line, drains headers, serves the file or
70
+ # writes a status response. Wide rescue so a misbehaving client
71
+ # can't crash the server.
72
+ def handle_connection(client, root)
73
+ method, path = client.readline.split
74
+ drain_headers(client)
75
+ return respond(client, 405) unless method == "GET"
76
+
77
+ file = resolve(path, root)
78
+ return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
79
+
80
+ respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
81
+ rescue StandardError
82
+ # Misbehaving clients (truncated requests, connection resets,
83
+ # invalid encoding) shouldn't take the whole server down.
84
+ nil
85
+ ensure
86
+ # simplecov:disable — `client` is the parameter, never nil here;
87
+ # the `&.` is purely defensive in case of future refactors
88
+ client&.close
89
+ # simplecov:enable
90
+ end
91
+
92
+ def drain_headers(client)
93
+ loop { break if client.readline.strip.empty? }
94
+ end
95
+
96
+ # Returns the absolute path of the file to serve, :forbidden for
97
+ # a traversal attempt (including symlinks that escape root), or
98
+ # nil for "not found".
99
+ def resolve(request_path, root)
100
+ path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
101
+ absolute_root = File.realpath(root)
102
+ candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
103
+ # Reject `..` traversal and absolute-path attempts before
104
+ # touching disk so they're 403, not 404.
105
+ return :forbidden unless inside?(candidate, absolute_root)
106
+
107
+ candidate = File.join(candidate, "index.html") if File.directory?(candidate)
108
+ return nil unless File.file?(candidate)
109
+
110
+ # Resolve symlinks last and re-check: a file inside root could
111
+ # be a symlink pointing outside (e.g. /etc/passwd).
112
+ real = File.realpath(candidate)
113
+ inside?(real, absolute_root) ? real : :forbidden
114
+ rescue Errno::ENOENT
115
+ # simplecov:disable — TOCTOU: candidate vanished between
116
+ # File.file? and File.realpath. Treat as "not found".
117
+ nil
118
+ # simplecov:enable
119
+ end
120
+
121
+ def inside?(path, root)
122
+ path == root || path.start_with?(root + File::SEPARATOR)
123
+ end
124
+
125
+ def respond(client, status, body = "", content_type = "text/plain")
126
+ client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || 'Error'}\r\n",
127
+ "Content-Type: #{content_type || 'application/octet-stream'}\r\n",
128
+ "Content-Length: #{body.bytesize}\r\n",
129
+ "Connection: close\r\n\r\n")
130
+ client.write(body)
131
+ end
132
+
133
+ def error(stderr, message)
134
+ stderr.puts("simplecov serve: #{message}")
135
+ 1
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "report"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov uncovered [--threshold N] [--top N] [--criterion C]` — list
10
+ # the lowest-coverage files (by the chosen criterion, ascending), so a
11
+ # developer can answer "where should I add tests next?" without
12
+ # opening a browser. Reads coverage.json directly.
13
+ module Uncovered
14
+ DEFAULT_TOP = 10
15
+
16
+ # The coverage.json fields backing each criterion.
17
+ CRITERION_KEYS = {
18
+ line: {percent: "lines_covered_percent", covered: "covered_lines", total: "total_lines"},
19
+ branch: {percent: "branches_covered_percent", covered: "covered_branches", total: "total_branches"},
20
+ method: {percent: "methods_covered_percent", covered: "covered_methods", total: "total_methods"}
21
+ }.freeze
22
+
23
+ module_function
24
+
25
+ def run(args, stdout:, stderr:, **)
26
+ opts = parse(args)
27
+ keys = CRITERION_KEYS[opts[:criterion]]
28
+ return unknown_criterion(opts[:criterion], stderr) unless keys
29
+
30
+ report(opts, keys, stdout, stderr)
31
+ end
32
+
33
+ def report(opts, keys, stdout, stderr)
34
+ return 1 unless (data = Report.load_data(opts[:input], stderr))
35
+
36
+ files = rank(data.fetch("coverage", {}), opts[:threshold], keys).first(opts[:top])
37
+ return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
38
+
39
+ emit(stdout, files, opts)
40
+ 0
41
+ end
42
+
43
+ def unknown_criterion(criterion, stderr)
44
+ stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
45
+ 1
46
+ end
47
+
48
+ def emit(stdout, files, opts)
49
+ opts[:json] ? emit_json(stdout, files) : emit_text(stdout, files, SimpleCov::CLI.color_enabled?(opts, stdout))
50
+ end
51
+
52
+ def parse(args)
53
+ opts = {input: SimpleCov::CLI.default_input, threshold: 100.0, top: DEFAULT_TOP, criterion: :line, no_color: false}
54
+ build_parser(opts).parse(args)
55
+ opts
56
+ end
57
+
58
+ # Option parsing with per-flag coercions is inherently ABC-heavy; the
59
+ # metric is noise here.
60
+ def build_parser(opts) # rubocop:disable Metrics/AbcSize
61
+ OptionParser.new do |o|
62
+ o.on("--input PATH") { |v| opts[:input] = v }
63
+ o.on("--threshold N", Float) { |v| opts[:threshold] = v }
64
+ o.on("--top N", Integer) { |v| opts[:top] = v }
65
+ o.on("--criterion C") { |v| opts[:criterion] = v.to_sym }
66
+ o.on("--json") { opts[:json] = true }
67
+ o.on("--no-color") { opts[:no_color] = true }
68
+ end
69
+ end
70
+
71
+ def emit_text(stdout, files, color)
72
+ files.each { |fname, pct, covered, total| stdout.puts(format_row(fname, pct, covered, total, color)) }
73
+ end
74
+
75
+ def emit_json(stdout, files)
76
+ rows = files.map do |fname, pct, covered, total|
77
+ {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
78
+ end
79
+ stdout.puts(JSON.pretty_generate(rows))
80
+ end
81
+
82
+ def empty_message(json)
83
+ json ? "[]" : "simplecov uncovered: nothing to report"
84
+ end
85
+
86
+ def rank(coverage_hash, threshold, keys)
87
+ rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, threshold, keys) }
88
+ rows.sort_by { |_fname, pct, _c, _t| pct }
89
+ end
90
+
91
+ def row_for(fname, payload, threshold, keys)
92
+ return unless payload.is_a?(Hash) && payload[keys[:total]].to_i.positive?
93
+
94
+ pct = payload[keys[:percent]].to_f
95
+ return if pct >= threshold
96
+
97
+ [fname, pct, payload[keys[:covered]].to_i, payload[keys[:total]].to_i]
98
+ end
99
+
100
+ def format_row(fname, pct, covered, total, color)
101
+ format("%<pct>s %<covered>d/%<total>d %<fname>s",
102
+ pct: SimpleCov::Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
103
+ covered: covered, total: total, fname: fname)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "color"
4
+ require_relative "cli/dotfile"
5
+ require_relative "cli/clean"
6
+ require_relative "cli/coverage"
7
+ require_relative "cli/diff"
8
+ require_relative "cli/merge"
9
+ require_relative "cli/open"
10
+ require_relative "cli/report"
11
+ require_relative "cli/run"
12
+ require_relative "cli/serve"
13
+ require_relative "cli/uncovered"
14
+
15
+ module SimpleCov
16
+ # Lightweight command-line front-end. `run` dispatches a subcommand
17
+ # (`coverage`, `report`, `uncovered`, `merge`, `diff`, `open`, etc.) —
18
+ # see the `usage` text below for the full list, or run `simplecov help`.
19
+ #
20
+ # Read-only subcommands consume JSONFormatter output (`coverage.json`),
21
+ # which the bundled HTMLFormatter already drops alongside the HTML, so
22
+ # no runtime hooking is needed for those. Default paths follow the
23
+ # project's `.simplecov` `SimpleCov.coverage_dir` setting when one is
24
+ # present, so a project that writes its report somewhere other than
25
+ # `coverage/` doesn't have to pass `--input` / `--report` every
26
+ # invocation.
27
+ module CLI
28
+ COMMANDS = {
29
+ "coverage" => Coverage,
30
+ "run" => Run,
31
+ "open" => Open,
32
+ "report" => Report,
33
+ "uncovered" => Uncovered,
34
+ "merge" => Merge,
35
+ "diff" => Diff,
36
+ "serve" => Serve,
37
+ "clean" => Clean
38
+ }.freeze
39
+
40
+ module_function
41
+
42
+ # Resolved once per process. Walks up from cwd looking for a
43
+ # `.simplecov`; if present, the file is loaded with
44
+ # `SimpleCov.start` neutered so it can't trigger coverage tracking
45
+ # or an at_exit hook just because we asked it for a config value.
46
+ def coverage_dir
47
+ @coverage_dir ||= Dotfile.coverage_dir
48
+ end
49
+
50
+ def default_input
51
+ File.join(coverage_dir, "coverage.json")
52
+ end
53
+
54
+ # Resolve "should this subcommand colorize?" once per invocation.
55
+ # `--no-color` (opts[:no_color]) is the per-invocation kill-switch;
56
+ # otherwise we defer to `SimpleCov::Color.enabled?`, which honors
57
+ # `NO_COLOR` / `FORCE_COLOR` and falls back to `stream.tty?`.
58
+ def color_enabled?(opts, stream)
59
+ return false if opts[:no_color]
60
+
61
+ SimpleCov::Color.enabled?(stream)
62
+ end
63
+
64
+ def default_report
65
+ File.join(coverage_dir, "index.html")
66
+ end
67
+
68
+ def default_resultset
69
+ File.join(coverage_dir, ".resultset.json")
70
+ end
71
+
72
+ # Returns a process exit status (0 on success, non-zero on error).
73
+ def run(argv, stdout: $stdout, stderr: $stderr)
74
+ command, *rest = argv
75
+ handler = COMMANDS[command]
76
+ return handler.run(rest, stdout: stdout, stderr: stderr) if handler
77
+ return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
78
+
79
+ stderr.puts("simplecov: unknown command #{command.inspect}", usage)
80
+ 1
81
+ end
82
+
83
+ def usage
84
+ <<~USAGE
85
+ Usage: simplecov <command> [options]
86
+
87
+ Commands:
88
+ run <command...> Execute <command> with simplecov pre-loaded
89
+ (so a coverage report is generated even
90
+ when the project has no test_helper hook)
91
+ coverage <path> Print coverage stats for the given file
92
+ report Print the overall summary and group totals
93
+ uncovered List the lowest-coverage files
94
+ merge <files...> Merge multiple .resultset.json files
95
+ diff <baseline> Show per-file coverage delta vs baseline
96
+ open Open the HTML report in the default browser
97
+ serve Serve the coverage report over HTTP
98
+ clean Remove the coverage report directory
99
+ help Show this message
100
+
101
+ Default paths follow SimpleCov.coverage_dir from a project's
102
+ `.simplecov` when one is present (#{coverage_dir} for this run).
103
+
104
+ coverage / report / uncovered / diff options:
105
+ --input PATH Read from PATH instead of #{default_input}
106
+ --no-color Disable colorized percentages
107
+ (also honors NO_COLOR / FORCE_COLOR env)
108
+
109
+ coverage options:
110
+ --json Print the file's JSON entry verbatim
111
+
112
+ report options:
113
+ --json Emit totals and group sections as JSON
114
+
115
+ uncovered options:
116
+ --threshold N Only show files below N% coverage
117
+ --top N Show at most N files (default: 10)
118
+ --criterion C line, branch, or method (default: line)
119
+ --json Emit results as a JSON array (for CI)
120
+
121
+ merge options:
122
+ --output PATH Write merged resultset to PATH
123
+ (default: #{default_resultset})
124
+ --honor-timeout Drop entries older than merge_timeout
125
+ --dry-run Print what would be written without
126
+ actually writing
127
+ -q, --quiet Suppress the success status line
128
+
129
+ diff options:
130
+ --fail-on-drop Exit non-zero when any file's coverage
131
+ dropped vs the baseline
132
+ --json Emit results as a JSON array (for CI)
133
+ --threshold N Only show files whose absolute delta
134
+ in any criterion is at least N%
135
+
136
+ open options:
137
+ --report PATH Open PATH instead of #{default_report}
138
+
139
+ serve options:
140
+ --port N Bind to port N (default: random open port)
141
+ --host HOST Bind to HOST (default: 127.0.0.1)
142
+
143
+ clean options:
144
+ --dry-run Print what would be removed without
145
+ deleting anything
146
+ -q, --quiet Suppress status lines
147
+ USAGE
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # ANSI colorization for stderr diagnostics. Thresholds mirror the
5
+ # HTML formatter (>= 90 green, >= 75 yellow, otherwise red) so a
6
+ # team's mental model of "what's the cutoff" is the same whether
7
+ # they're reading the terminal output or the HTML report.
8
+ #
9
+ # Color precedence, highest first:
10
+ #
11
+ # - `SimpleCov.color = true` / `false` (programmatic override, wins
12
+ # over everything; default is `:auto` which falls through)
13
+ # - `NO_COLOR` env var (any non-empty value) → off (see no-color.org)
14
+ # - `FORCE_COLOR` env var (any non-empty value) → on
15
+ # - `stream.tty?` fallback
16
+ #
17
+ # `NO_COLOR` wins over `FORCE_COLOR` if both env vars are set.
18
+ module Color
19
+ GREEN_THRESHOLD = 90
20
+ YELLOW_THRESHOLD = 75
21
+
22
+ ANSI = {
23
+ red: "\e[31m",
24
+ yellow: "\e[33m",
25
+ green: "\e[32m",
26
+ reset: "\e[0m"
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ # `stream` is the IO that the colorized text is destined for. The
32
+ # formatter writes to stderr, so that's the default. CLI subcommands
33
+ # that print to stdout should pass `$stdout` so a redirected pipe
34
+ # doesn't get ANSI sequences. See the module-level comment for
35
+ # precedence.
36
+ def enabled?(stream = $stderr)
37
+ config = SimpleCov.color
38
+ return config if [true, false].include?(config)
39
+ return false if env_set?("NO_COLOR")
40
+ return true if env_set?("FORCE_COLOR")
41
+
42
+ stream.tty?
43
+ end
44
+
45
+ def for_percent(percent)
46
+ return :green if percent >= GREEN_THRESHOLD
47
+ return :yellow if percent >= YELLOW_THRESHOLD
48
+
49
+ :red
50
+ end
51
+
52
+ # Wrap `text` in the ANSI sequence for `color` (a key of ANSI).
53
+ # Returns the bare text if color is disabled. The `enabled:`
54
+ # keyword lets callers (e.g., CLI subcommands honoring `--no-color`)
55
+ # override the auto-detection without touching env vars.
56
+ def colorize(text, color, enabled: enabled?)
57
+ return text unless enabled
58
+
59
+ "#{ANSI.fetch(color)}#{text}#{ANSI.fetch(:reset)}"
60
+ end
61
+
62
+ # Render `percent` as a fixed "NN.NN%" string colored by which
63
+ # threshold band it falls into. Callers that want a different
64
+ # rendering of the number can pass the pre-rendered `text`.
65
+ def colorize_percent(percent, text = nil, enabled: enabled?)
66
+ colorize(text || format("%.2f%%", percent), for_percent(percent), enabled: enabled)
67
+ end
68
+
69
+ def env_set?(name)
70
+ value = ENV.fetch(name, nil)
71
+ value && !value.empty?
72
+ end
73
+ end
74
+ end
@@ -10,12 +10,13 @@ module SimpleCov
10
10
  module_function
11
11
 
12
12
  #
13
- # Return merged branches or the existed brach if other is missing.
13
+ # Return merged branches or the existed branch if other is missing.
14
14
  #
15
15
  # Branches inside files are always same if they exist, the difference only in coverage count.
16
16
  # Branch coverage report for any conditional case is built from hash, it's key is a condition and
17
17
  # it's body is a hash << keys from condition and value is coverage rate >>.
18
- # ex: branches =>{ [:if, 3, 8, 6, 8, 36] => {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36]=>2}, other conditions...}
18
+ # ex: branches => { [:if, 3, 8, 6, 8, 36] =>
19
+ # {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... }
19
20
  # We create copy of result and update it values depending on the combined branches coverage values.
20
21
  #
21
22
  # @return [Hash]
@@ -16,7 +16,13 @@ module SimpleCov
16
16
  #
17
17
  def combine(coverage_a, coverage_b)
18
18
  combination = {"lines" => Combine.combine(LinesCombiner, coverage_a["lines"], coverage_b["lines"])}
19
- combination["branches"] = Combine.combine(BranchesCombiner, coverage_a["branches"], coverage_b["branches"]) if SimpleCov.branch_coverage?
19
+ if SimpleCov.branch_coverage?
20
+ combined_branches = Combine.combine(BranchesCombiner, coverage_a["branches"], coverage_b["branches"])
21
+ combination["branches"] = combined_branches || {}
22
+ end
23
+ if SimpleCov.method_coverage?
24
+ combination["methods"] = Combine.combine(MethodsCombiner, coverage_a["methods"], coverage_b["methods"])
25
+ end
20
26
  combination
21
27
  end
22
28
  end
@@ -9,34 +9,36 @@ module SimpleCov
9
9
  module LinesCombiner
10
10
  module_function
11
11
 
12
+ # Build a fresh array sized to the longer input. The previous
13
+ # implementation mutated whichever input was longer in place,
14
+ # which could surprise callers holding a reference to that array
15
+ # (e.g. the parsed `coverage` key of a resultset hash being
16
+ # passed into a second merge).
12
17
  def combine(coverage_a, coverage_b)
13
- coverage_a
14
- .zip(coverage_b)
15
- .map do |coverage_a_val, coverage_b_val|
16
- merge_line_coverage(coverage_a_val, coverage_b_val)
17
- end
18
+ size = [coverage_a.size, coverage_b.size].max
19
+ Array.new(size) { |i| merge_line_coverage(coverage_a[i], coverage_b[i]) }
18
20
  end
19
21
 
20
- # Return depends on coverage in a specific line
21
- #
22
- # @param [Integer || nil] first_val
23
- # @param [Integer || nil] second_val
22
+ # Two runs of the same source file should agree on which lines
23
+ # are coverage-relevant (`nil` for comments / whitespace, `0`+
24
+ # for executable). When they don't, treat "relevant on either
25
+ # side" as relevant rather than masking a real `0` as `nil`,
26
+ # which would silently drop an uncovered line from the
27
+ # denominator and inflate the percentage.
24
28
  #
25
29
  # Logic:
26
30
  #
27
- # => nil + 0 = nil
28
31
  # => nil + nil = nil
29
- # => int + int = int
32
+ # => nil + int = int (preserves a relevant-but-uncovered 0)
33
+ # => int + int = int (sum)
30
34
  #
35
+ # @param [Integer || nil] first_val
36
+ # @param [Integer || nil] second_val
31
37
  # @return [Integer || nil]
32
38
  def merge_line_coverage(first_val, second_val)
33
- sum = first_val.to_i + second_val.to_i
39
+ return nil if first_val.nil? && second_val.nil?
34
40
 
35
- if sum.zero? && (first_val.nil? || second_val.nil?)
36
- nil
37
- else
38
- sum
39
- end
41
+ first_val.to_i + second_val.to_i
40
42
  end
41
43
  end
42
44
  end