simplecov 0.22.0 → 1.1.1

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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module SimpleCov
7
+ # Replaces an artifact through a collision-safe temporary file in the
8
+ # destination directory, so readers see either the old or complete new file.
9
+ module AtomicFile
10
+ DEFAULT_MODE = 0o666
11
+ private_constant :DEFAULT_MODE
12
+
13
+ def self.write(path, content, binary: false)
14
+ directory = File.dirname(path)
15
+ FileUtils.mkdir_p(directory)
16
+ mode = destination_mode(path)
17
+
18
+ Tempfile.create([".simplecov-", ".tmp"], directory) do |temp|
19
+ replace(temp, path, content, mode, binary: binary)
20
+ end
21
+
22
+ nil
23
+ end
24
+
25
+ def self.replace(temp, path, content, mode, binary:)
26
+ temp.binmode if binary
27
+ temp.write(content)
28
+ temp.close
29
+ File.chmod(mode, temp.path)
30
+ rename_over(temp.path, path)
31
+ ensure
32
+ temp.close unless temp.closed?
33
+ FileUtils.rm_f(temp.path)
34
+ end
35
+ private_class_method :replace
36
+
37
+ # On POSIX systems rename replaces the destination atomically. On
38
+ # Windows it fails with EACCES when the destination is open in a
39
+ # reader or mid-replacement by a concurrent writer, so retry briefly
40
+ # there before giving up.
41
+ def self.rename_over(temp_path, path)
42
+ attempts = 0
43
+ begin
44
+ File.rename(temp_path, path)
45
+ rescue Errno::EACCES
46
+ # simplecov:disable branch — which arm runs is fixed by the platform
47
+ raise unless Gem.win_platform?
48
+
49
+ # simplecov:enable branch
50
+ # simplecov:disable — Windows-only; unreachable where dogfood runs
51
+ attempts += 1
52
+ raise if attempts >= 10
53
+
54
+ sleep(0.01)
55
+ retry
56
+ # simplecov:enable
57
+ end
58
+ end
59
+ private_class_method :rename_over
60
+
61
+ def self.destination_mode(path)
62
+ return DEFAULT_MODE & ~File.umask if File.symlink?(path)
63
+
64
+ File.stat(path).mode & 0o777
65
+ rescue Errno::ENOENT
66
+ DEFAULT_MODE & ~File.umask
67
+ end
68
+ private_class_method :destination_mode
69
+ end
70
+ 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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "command_helpers"
5
+
6
+ module SimpleCov
7
+ module CLI
8
+ # `simplecov clean [--dry-run]` — remove the coverage report
9
+ # directory (or whatever `SimpleCov.coverage_dir` resolves to). The
10
+ # `--dry-run` flag prints what would be deleted without touching
11
+ # disk, for when you're not sure what's in there.
12
+ module Clean
13
+ extend CommandHelpers
14
+
15
+ module_function
16
+
17
+ def run(args, stdout:, stderr:, **)
18
+ opts = parse(args)
19
+ dir = SimpleCov::CLI.coverage_dir
20
+ return error(stderr, "refusing to remove unsafe coverage directory #{dir.inspect}") unless safe_to_remove?(dir)
21
+ return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
22
+
23
+ sweep(dir, opts, stdout)
24
+ 0
25
+ end
26
+
27
+ def sweep(dir, opts, stdout)
28
+ if opts[:dry_run]
29
+ announce(stdout, opts, "would remove #{dir} (#{entry_count(dir)} entries)")
30
+ else
31
+ require "fileutils"
32
+ FileUtils.rm_rf(dir)
33
+ announce(stdout, opts, "removed #{dir}")
34
+ end
35
+ end
36
+
37
+ # FNM_DOTMATCH so the dotfiles rm_rf will actually delete
38
+ # (.resultset.json, .last_run.json) count too; the glob still
39
+ # yields the directory's own "." entry, which rm_rf does not
40
+ # remove separately.
41
+ def entry_count(dir)
42
+ Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).count { |path| !path.end_with?("/.") }
43
+ end
44
+
45
+ def announce(stdout, opts, message)
46
+ stdout.puts("simplecov #{command_name}: #{message}") unless opts[:quiet]
47
+ end
48
+
49
+ def safe_to_remove?(dir)
50
+ target = canonical_path(dir)
51
+ # A filesystem root is its own dirname ("/" everywhere, drive
52
+ # roots like "D:/" on Windows). The descendant check below only
53
+ # protects roots that happen to contain the cwd or project root,
54
+ # which misses other drives, so refuse roots outright.
55
+ return false if File.dirname(target) == target
56
+
57
+ protected_paths.none? { |path| path == target || descendant_of?(path, target) }
58
+ end
59
+
60
+ def protected_paths
61
+ project_root = SimpleCov::CLI::Dotfile.find
62
+ [Dir.pwd, (File.dirname(project_root) if project_root)].compact.map { |path| canonical_path(path) }.uniq
63
+ end
64
+
65
+ # Canonical paths only carry a trailing separator on a filesystem
66
+ # root, and roots are refused before this check runs.
67
+ def descendant_of?(path, possible_ancestor)
68
+ path.start_with?("#{possible_ancestor}#{File::SEPARATOR}")
69
+ end
70
+
71
+ def canonical_path(path)
72
+ File.realpath(path)
73
+ rescue SystemCallError
74
+ File.expand_path(path)
75
+ end
76
+
77
+ def parse(args)
78
+ opts = {dry_run: false, quiet: false}
79
+ OptionParser.new do |o|
80
+ o.on("--dry-run") { opts[:dry_run] = true }
81
+ o.on("-q", "--quiet") { opts[:quiet] = true }
82
+ end.parse(args)
83
+ opts
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # Plumbing the subcommands repeat, which their modules `extend`: the
8
+ # standard option trio the read-only commands share, the parse
9
+ # scaffold around it, the one-line error helper (prefixed with the
10
+ # command's own name), and the human-readable stats row `coverage`
11
+ # and `report` both print.
12
+ module CommandHelpers
13
+ STATS_ROW_FORMAT = " %<label>-7s %<pct>s (%<covered>d / %<total>d)"
14
+
15
+ # "simplecov merge: ..." — derived from the extending module's
16
+ # name. The self cast collapses the mixin's view of `self` to the
17
+ # Module that extends it, which is what carries `name`.
18
+ def command_name
19
+ (_ = self).name.split("::").last.downcase
20
+ end
21
+
22
+ def error(stderr, message)
23
+ stderr.puts("simplecov #{command_name}: #{message}")
24
+ 1
25
+ end
26
+
27
+ # The --input / --json / --no-color trio every read-only
28
+ # subcommand accepts.
29
+ def common_options(parser, opts)
30
+ parser.on("--input PATH") { |v| opts[:input] = v }
31
+ parser.on("--json") { opts[:json] = true }
32
+ parser.on("--no-color") { opts[:no_color] = true }
33
+ end
34
+
35
+ # The parse scaffold every read-only subcommand repeats: seed the
36
+ # shared defaults (plus the command's own), wire up the common
37
+ # option trio, yield the parser for command-specific flags, and
38
+ # return the parsed opts alongside the positional arguments.
39
+ def parse_common(args, defaults = {})
40
+ opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false} #: Hash[Symbol, untyped]
41
+ opts.merge!(defaults)
42
+ rest =
43
+ OptionParser.new do |parser|
44
+ common_options(parser, opts)
45
+ yield parser, opts if block_given?
46
+ end.parse(args)
47
+ [opts, rest]
48
+ end
49
+
50
+ def stats_row(label, percent_text, covered, total)
51
+ format(STATS_ROW_FORMAT, label: "#{label}:", pct: percent_text, covered: covered.to_i, total: total.to_i)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov coverage <path>` — print per-criterion stats for one
10
+ # file from a JSONFormatter coverage.json.
11
+ module Coverage
12
+ extend CommandHelpers
13
+
14
+ module_function
15
+
16
+ def run(args, stdout:, stderr:)
17
+ opts = parse(args, stderr: stderr)
18
+ return 1 unless opts
19
+
20
+ match = locate_match(opts, stderr)
21
+ return 1 unless match
22
+
23
+ emit(match, opts, stdout)
24
+ 0
25
+ end
26
+
27
+ def parse(args, stderr:)
28
+ opts, rest = parse_common(args)
29
+ return stderr.puts("simplecov coverage: missing file argument") && nil if rest.empty?
30
+
31
+ opts[:path] = rest.first
32
+ opts
33
+ end
34
+
35
+ def locate_match(opts, stderr)
36
+ coverage = CoverageFile.load_coverage(opts[:input], command: "coverage", stderr: stderr)
37
+ return unless coverage
38
+
39
+ match = lookup(coverage, opts[:path])
40
+ if match.nil?
41
+ stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
42
+ return nil
43
+ end
44
+ # A wrong-typed entry used to escape here and crash print_human.
45
+ return match if match.last.is_a?(Hash)
46
+
47
+ CoverageFile.report_invalid(stderr, "coverage", opts[:input], "entry for #{opts[:path]} must be an object")
48
+ end
49
+
50
+ # Match either the absolute path, the literal string passed, or
51
+ # any coverage entry whose absolute filename ends with "/<path>".
52
+ # That covers the three natural ways a user types a path: relative
53
+ # to project root ("app/foo.rb"), absolute, or basename-only.
54
+ def lookup(coverage_hash, path)
55
+ absolute = File.expand_path(path)
56
+ suffix = "/#{path}"
57
+ coverage_hash.find { |fname, _| fname == absolute || fname == path || fname.end_with?(suffix) }
58
+ end
59
+
60
+ def emit(match, opts, stdout)
61
+ filename, payload = match
62
+ if opts[:json]
63
+ entry = {filename => payload} #: Hash[untyped, untyped]
64
+ stdout.puts(JSON.pretty_generate(entry))
65
+ else
66
+ print_human(filename, payload, stdout, SimpleCov::CLI.color_enabled?(opts, stdout))
67
+ end
68
+ end
69
+
70
+ def print_human(filename, payload, stdout, color)
71
+ stdout.puts(filename)
72
+ CoverageFile::CRITERIA.each_value { |c| emit_criterion(stdout, payload, c, color) }
73
+ end
74
+
75
+ def emit_criterion(stdout, payload, criterion, color)
76
+ return unless payload.key?(criterion[:percent])
77
+
78
+ pct = payload[criterion[:percent]].to_f
79
+ stdout.puts(stats_row(criterion[:label],
80
+ SimpleCov::Color.colorize_percent(pct, enabled: color),
81
+ payload[criterion[:covered]],
82
+ payload[criterion[:total]]))
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../coverage_json"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ # Shared input boundary for commands that consume JSONFormatter's
8
+ # coverage.json. It validates only the stable outer shape so older schema
9
+ # versions remain readable.
10
+ module CoverageFile
11
+ # The coverage.json fields backing each criterion, keyed by the
12
+ # criterion's singular name. The one table the coverage, uncovered,
13
+ # and diff subcommands all read from, so a schema change lands in
14
+ # exactly one place.
15
+ CRITERIA = {
16
+ line: {label: "Line", percent: "lines_covered_percent",
17
+ covered: "covered_lines", total: "total_lines"},
18
+ branch: {label: "Branch", percent: "branches_covered_percent",
19
+ covered: "covered_branches", total: "total_branches"},
20
+ method: {label: "Method", percent: "methods_covered_percent",
21
+ covered: "covered_methods", total: "total_methods"}
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def load_document(path, command:, stderr:)
27
+ SimpleCov::CoverageJSON.load(path)
28
+ rescue Errno::ENOENT
29
+ report_missing(stderr, command, path)
30
+ rescue SimpleCov::CoverageJSON::Error => e
31
+ report_invalid(stderr, command, path, e.message)
32
+ rescue SystemCallError => e
33
+ report_unreadable(stderr, command, path, e.message)
34
+ end
35
+
36
+ def load_coverage(path, command:, stderr:)
37
+ document = load_document(path, command: command, stderr: stderr)
38
+ return unless document
39
+
40
+ none = {} #: Hash[String, untyped]
41
+ coverage = document.fetch("coverage", none)
42
+ return coverage if coverage.is_a?(Hash)
43
+
44
+ report_invalid(stderr, command, path, '"coverage" must be an object')
45
+ end
46
+
47
+ def report_invalid(stderr, command, path, reason)
48
+ detail = reason.lines.first.to_s.strip
49
+ stderr.puts("simplecov #{command}: input file #{path.inspect} isn't valid JSON (#{detail})")
50
+ nil
51
+ end
52
+
53
+ def report_missing(stderr, command, path)
54
+ stderr.puts("simplecov #{command}: #{path} not found")
55
+ nil
56
+ end
57
+
58
+ def report_unreadable(stderr, command, path, reason)
59
+ detail = reason.lines.first.to_s.strip
60
+ stderr.puts("simplecov #{command}: cannot read #{path.inspect} (#{detail})")
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+
7
+ module SimpleCov
8
+ module CLI
9
+ # `simplecov diff <baseline>` — print per-file coverage deltas between
10
+ # coverage.json (--input) and a baseline coverage.json
11
+ # checked in alongside the suite. Only files whose coverage moved
12
+ # are listed; --fail-on-drop exits non-zero when any file regressed,
13
+ # so this composes with CI as a "coverage of this PR didn't drop"
14
+ # gate. Resolves the long-standing "diff coverage" feature request.
15
+ module Diff
16
+ extend CommandHelpers
17
+
18
+ EPSILON = 0.005 # tolerance below which a delta is considered noise
19
+
20
+ STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
21
+
22
+ module_function
23
+
24
+ def run(args, stdout:, stderr:, **)
25
+ opts = parse(args, stderr)
26
+ return 1 unless opts
27
+
28
+ rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
29
+ rows.sort_by! { |row| row[:line_delta] }
30
+ if opts[:json]
31
+ stdout.puts(JSON.pretty_generate(rows))
32
+ else
33
+ emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
34
+ end
35
+ opts[:fail_on_drop] && coverage_drop?(rows) ? 1 : 0
36
+ end
37
+
38
+ def parse(args, stderr)
39
+ opts = parse_flags(args)
40
+ return stderr.puts("simplecov diff: missing baseline argument") && nil if opts[:rest].empty?
41
+
42
+ opts[:baseline] = load_coverage(opts[:rest].first, stderr) or return nil
43
+ opts[:current] = load_coverage(opts[:input], stderr) or return nil
44
+ opts
45
+ end
46
+
47
+ def parse_flags(args)
48
+ opts, rest = parse_common(args, fail_on_drop: false, threshold: 0.0) do |o, options|
49
+ o.on("--fail-on-drop") { options[:fail_on_drop] = true }
50
+ o.on("--threshold N", Float) { |v| options[:threshold] = v }
51
+ end
52
+ opts.merge(rest: rest)
53
+ end
54
+
55
+ def load_coverage(path, stderr)
56
+ coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
57
+ return unless coverage
58
+
59
+ normalize_keys(coverage)
60
+ end
61
+
62
+ # Strip a leading slash so coverage.json files written before the
63
+ # `project_filename` change (keys like "/lib/foo.rb") still diff
64
+ # cleanly against newer reports (keys like "lib/foo.rb").
65
+ def normalize_keys(coverage)
66
+ coverage.transform_keys { |key| key.delete_prefix("/") }
67
+ end
68
+
69
+ def compute_rows(current, baseline, threshold)
70
+ files = current.keys | baseline.keys
71
+ files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
72
+ end
73
+
74
+ # EPSILON keeps float noise out regardless of threshold; the
75
+ # threshold itself is inclusive, so a file that moved exactly N%
76
+ # is listed under `--threshold N`, matching the "at least N%" the
77
+ # usage text promises.
78
+ def compute_row(fname, current_payload, baseline_payload, threshold)
79
+ deltas = compute_deltas(current_payload, baseline_payload)
80
+ floor = threshold.abs
81
+ return nil unless deltas.values.any? { |delta| delta.abs > EPSILON && delta.abs >= floor }
82
+
83
+ {file: fname, status: status_for(current_payload, baseline_payload),
84
+ line_delta: deltas[:line], branch_delta: deltas[:branch], method_delta: deltas[:method]}
85
+ end
86
+
87
+ def compute_deltas(current_payload, baseline_payload)
88
+ CoverageFile::CRITERIA.transform_values do |fields|
89
+ pct_for(fields, current_payload) - pct_for(fields, baseline_payload)
90
+ end
91
+ end
92
+
93
+ def status_for(current_payload, baseline_payload)
94
+ return "added" if baseline_payload.nil?
95
+ return "removed" if current_payload.nil?
96
+
97
+ "changed"
98
+ end
99
+
100
+ def pct_for(fields, payload)
101
+ return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
102
+
103
+ payload[fields[:percent]].to_f
104
+ end
105
+
106
+ def emit_text(stdout, rows, color)
107
+ return stdout.puts("simplecov diff: no per-file coverage changes") if rows.empty?
108
+
109
+ rows.each { |row| stdout.puts(format_row(row, color)) }
110
+ end
111
+
112
+ def format_row(row, color)
113
+ line = " #{delta_parts(row, color).join(' ')} #{row[:file]}"
114
+ suffix = STATUS_SUFFIX[row[:status]]
115
+ suffix ? "#{line} #{suffix}" : line
116
+ end
117
+
118
+ def delta_parts(row, color)
119
+ [
120
+ format_delta(row[:line_delta], "lines", color),
121
+ (format_delta(row[:branch_delta], "branches", color) if row[:branch_delta].abs > EPSILON),
122
+ (format_delta(row[:method_delta], "methods", color) if row[:method_delta].abs > EPSILON)
123
+ ].compact
124
+ end
125
+
126
+ # A removed file's deltas are all -baseline%, but deleting a
127
+ # covered file (dead code cleanup) is not a coverage regression,
128
+ # so removed rows never trip the --fail-on-drop gate. Drops are
129
+ # gated on EPSILON like row inclusion and display: a row listed
130
+ # for a gain in one criterion must not fail the run over float
131
+ # noise in another, invisible in the printed output.
132
+ def coverage_drop?(rows)
133
+ rows.reject { |row| row[:status] == "removed" }
134
+ .any? { |row| row.values_at(:line_delta, :branch_delta, :method_delta).min < -EPSILON }
135
+ end
136
+
137
+ # Deltas are sign-based, not threshold-based: a +5% bump is good
138
+ # (green) and a -5% drop is bad (red), regardless of where the
139
+ # absolute coverage level lands.
140
+ def format_delta(delta, label, color)
141
+ sign = delta.positive? ? "+" : ""
142
+ text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
143
+ SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,111 @@
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 ScriptError, StandardError => e
22
+ # simplecov:disable — defensive fallback for a bad dotfile; never
23
+ # fires in the project's own dogfood run. ScriptError covers the
24
+ # SyntaxError a malformed dotfile raises from `load` (it is not a
25
+ # StandardError) along with LoadError; StandardError covers the
26
+ # rest (EACCES, errors raised by the dotfile's own code, etc.).
27
+ warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
28
+ "coverage"
29
+ # simplecov:enable
30
+ end
31
+
32
+ # Load the dotfile, snapshot+restore `SimpleCov.coverage_dir` so we
33
+ # don't quietly clobber it in a host process that's already
34
+ # configured (e.g. when the CLI is exercised inline by simplecov's
35
+ # own spec suite). The snapshot is intentionally narrow: a dotfile
36
+ # can still mutate other SimpleCov configuration (filters, groups,
37
+ # formatters, command_name, ...) via `SimpleCov.configure` or
38
+ # `SimpleCov.start { ... }` blocks. The CLI normally runs as a
39
+ # top-level process where that's harmless; callers driving it from
40
+ # inside a Ruby host that cares about isolation should arrange that
41
+ # themselves.
42
+ def read_from(dotfile)
43
+ snapshot = SimpleCov.instance_variable_get(:@coverage_dir) #: String?
44
+ load_with_start_neutered(dotfile)
45
+ SimpleCov.coverage_dir
46
+ ensure
47
+ # Restore even when the load raises, so a bad dotfile doesn't
48
+ # leave a host process's configured dir clobbered.
49
+ # @type var snapshot: String?
50
+ SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
51
+ end
52
+
53
+ def find
54
+ dir = Pathname.new(Dir.pwd)
55
+ loop do
56
+ candidate = dir.join(".simplecov")
57
+ return candidate.to_s if candidate.exist?
58
+ break if dir.root?
59
+
60
+ dir = dir.parent
61
+ end
62
+ nil
63
+ end
64
+
65
+ def with_simplecov_loaded
66
+ previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil) #: String?
67
+ previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
68
+ ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
69
+ # SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
70
+ # CLI-only behavior — e.g. simplecov itself sets `coverage_dir`
71
+ # to the dogfood path here but skips that for descendants.
72
+ ENV["SIMPLECOV_CLI"] = "1"
73
+ require "simplecov"
74
+ yield
75
+ ensure
76
+ # @type var previous_no_defaults: String?
77
+ # @type var previous_cli: String?
78
+ ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
79
+ ENV["SIMPLECOV_CLI"] = previous_cli
80
+ end
81
+
82
+ # Load `path` with `SimpleCov.start` and the at_exit installer
83
+ # turned into no-ops, so a project whose dotfile calls
84
+ # `SimpleCov.start` doesn't trigger Coverage just because we asked
85
+ # for `coverage_dir`. Config inside any `SimpleCov.start { ... }`
86
+ # block still runs.
87
+ def load_with_start_neutered(path)
88
+ klass = SimpleCov.singleton_class
89
+ names = %i[start_tracking install_at_exit_hook]
90
+ stash = names.to_h { |name| [name, klass.instance_method(name)] } #: Hash[Symbol, UnboundMethod]
91
+ # define_method over an existing method emits a "method redefined"
92
+ # warning under $VERBOSE; the override and restore are intentional.
93
+ silence_verbose { names.each { |name| klass.define_method(name) { nil } } }
94
+ load path
95
+ ensure
96
+ # @type var stash: Hash[Symbol, UnboundMethod]
97
+ # @type var klass: Class
98
+ silence_verbose { stash.each { |name, method| klass.define_method(name, method) } }
99
+ end
100
+
101
+ def silence_verbose
102
+ previous = $VERBOSE #: bool?
103
+ $VERBOSE = nil
104
+ yield
105
+ ensure
106
+ # @type var previous: bool?
107
+ $VERBOSE = previous
108
+ end
109
+ end
110
+ end
111
+ end