simplecov 1.0.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -1451
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +23 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -47
  9. data/lib/simplecov/cli/dotfile.rb +21 -10
  10. data/lib/simplecov/cli/merge.rb +17 -13
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +38 -25
  13. data/lib/simplecov/cli/run.rb +1 -1
  14. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  15. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  16. data/lib/simplecov/cli/serve.rb +45 -99
  17. data/lib/simplecov/cli/uncovered.rb +20 -24
  18. data/lib/simplecov/cli.rb +15 -2
  19. data/lib/simplecov/color.rb +10 -1
  20. data/lib/simplecov/combine/branches_combiner.rb +43 -18
  21. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  22. data/lib/simplecov/combine/identity_interner.rb +30 -0
  23. data/lib/simplecov/combine/interned_counts.rb +30 -0
  24. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  25. data/lib/simplecov/combine/methods_combiner.rb +53 -5
  26. data/lib/simplecov/combine/results_combiner.rb +12 -36
  27. data/lib/simplecov/combine.rb +5 -23
  28. data/lib/simplecov/command_guesser.rb +66 -9
  29. data/lib/simplecov/configuration/coverage.rb +17 -16
  30. data/lib/simplecov/configuration/coverage_criteria.rb +37 -39
  31. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  32. data/lib/simplecov/configuration/filters.rb +11 -44
  33. data/lib/simplecov/configuration/formatting.rb +33 -14
  34. data/lib/simplecov/configuration/groups.rb +42 -0
  35. data/lib/simplecov/configuration/merging.rb +8 -7
  36. data/lib/simplecov/configuration/thresholds.rb +27 -22
  37. data/lib/simplecov/configuration.rb +21 -42
  38. data/lib/simplecov/coverage_json.rb +24 -0
  39. data/lib/simplecov/coverage_statistics.rb +5 -2
  40. data/lib/simplecov/coverage_violations.rb +17 -6
  41. data/lib/simplecov/defaults.rb +10 -6
  42. data/lib/simplecov/directive.rb +9 -4
  43. data/lib/simplecov/exit_codes/check.rb +33 -0
  44. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  45. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +4 -17
  46. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  47. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  48. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +7 -20
  49. data/lib/simplecov/exit_codes.rb +17 -0
  50. data/lib/simplecov/exit_handling.rb +13 -43
  51. data/lib/simplecov/file_list.rb +14 -12
  52. data/lib/simplecov/filter.rb +43 -9
  53. data/lib/simplecov/formatter/base.rb +18 -1
  54. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  55. data/lib/simplecov/formatter/html_formatter/public/index.html +36 -5
  56. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +71 -45
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -50
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  61. data/lib/simplecov/formatter/json_formatter.rb +5 -46
  62. data/lib/simplecov/formatter/multi_formatter.rb +8 -3
  63. data/lib/simplecov/formatter/simple_formatter.rb +8 -3
  64. data/lib/simplecov/formatter.rb +12 -0
  65. data/lib/simplecov/group_names.rb +32 -0
  66. data/lib/simplecov/last_run.rb +16 -9
  67. data/lib/simplecov/lines_classifier.rb +29 -8
  68. data/lib/simplecov/load_global_config.rb +5 -2
  69. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  70. data/lib/simplecov/parallel_adapters/generic.rb +9 -8
  71. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  72. data/lib/simplecov/parallel_adapters.rb +4 -2
  73. data/lib/simplecov/parallel_coordination.rb +6 -1
  74. data/lib/simplecov/parallel_result_merger.rb +230 -0
  75. data/lib/simplecov/process.rb +22 -0
  76. data/lib/simplecov/report_deferral.rb +49 -0
  77. data/lib/simplecov/report_stamp.rb +28 -0
  78. data/lib/simplecov/result.rb +40 -10
  79. data/lib/simplecov/result_adapter.rb +79 -10
  80. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  81. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  82. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  83. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  84. data/lib/simplecov/result_merger.rb +69 -42
  85. data/lib/simplecov/result_processing.rb +85 -46
  86. data/lib/simplecov/run_identity.rb +77 -0
  87. data/lib/simplecov/simulate_coverage.rb +37 -12
  88. data/lib/simplecov/source_file/line.rb +2 -2
  89. data/lib/simplecov/source_file/method.rb +8 -2
  90. data/lib/simplecov/source_file/method_builder.rb +3 -1
  91. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  92. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  93. data/lib/simplecov/source_file/source_loader.rb +25 -9
  94. data/lib/simplecov/source_file/statistics.rb +24 -16
  95. data/lib/simplecov/source_file.rb +2 -2
  96. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  97. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  98. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  99. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  100. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  101. data/lib/simplecov/static_coverage_extractor/visitor.rb +74 -62
  102. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  103. data/lib/simplecov/version.rb +1 -1
  104. data/lib/simplecov.rb +37 -17
  105. data/sig/simplecov.rbs +1777 -0
  106. metadata +32 -16
  107. data/doc/alternate-formatters.md +0 -66
  108. data/doc/commercial-services.md +0 -25
  109. data/doc/editor-integration.md +0 -18
  110. data/lib/simplecov/combine/files_combiner.rb +0 -30
  111. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  112. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  113. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  114. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  115. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  116. data/schemas/coverage-v1.0.schema.json +0 -306
  117. data/schemas/coverage.schema.json +0 -306
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "command_helpers"
4
5
 
5
6
  module SimpleCov
6
7
  module CLI
@@ -9,11 +10,14 @@ module SimpleCov
9
10
  # `--dry-run` flag prints what would be deleted without touching
10
11
  # disk, for when you're not sure what's in there.
11
12
  module Clean
13
+ extend CommandHelpers
14
+
12
15
  module_function
13
16
 
14
- def run(args, stdout:, **)
17
+ def run(args, stdout:, stderr:, **)
15
18
  opts = parse(args)
16
19
  dir = SimpleCov::CLI.coverage_dir
20
+ return error(stderr, "refusing to remove unsafe coverage directory #{dir.inspect}") unless safe_to_remove?(dir)
17
21
  return announce(stdout, opts, "#{dir} doesn't exist; nothing to do") || 0 unless File.directory?(dir)
18
22
 
19
23
  sweep(dir, opts, stdout)
@@ -22,7 +26,7 @@ module SimpleCov
22
26
 
23
27
  def sweep(dir, opts, stdout)
24
28
  if opts[:dry_run]
25
- announce(stdout, opts, "would remove #{dir} (#{Dir["#{dir}/**/*"].size} entries)")
29
+ announce(stdout, opts, "would remove #{dir} (#{entry_count(dir)} entries)")
26
30
  else
27
31
  require "fileutils"
28
32
  FileUtils.rm_rf(dir)
@@ -30,8 +34,44 @@ module SimpleCov
30
34
  end
31
35
  end
32
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
+
33
45
  def announce(stdout, opts, message)
34
- stdout.puts("simplecov clean: #{message}") unless opts[:quiet]
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)
35
75
  end
36
76
 
37
77
  def parse(args)
@@ -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
@@ -2,17 +2,14 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
+ require_relative "command_helpers"
5
6
 
6
7
  module SimpleCov
7
8
  module CLI
8
9
  # `simplecov coverage <path>` — print per-criterion stats for one
9
10
  # file from a JSONFormatter coverage.json.
10
11
  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
12
+ extend CommandHelpers
16
13
 
17
14
  module_function
18
15
 
@@ -28,13 +25,7 @@ module SimpleCov
28
25
  end
29
26
 
30
27
  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)
28
+ opts, rest = parse_common(args)
38
29
  return stderr.puts("simplecov coverage: missing file argument") && nil if rest.empty?
39
30
 
40
31
  opts[:path] = rest.first
@@ -42,14 +33,18 @@ module SimpleCov
42
33
  end
43
34
 
44
35
  def locate_match(opts, stderr)
45
- return stderr.puts("simplecov coverage: #{opts[:input]} not found") && nil unless File.exist?(opts[:input])
36
+ coverage = CoverageFile.load_coverage(opts[:input], command: "coverage", stderr: stderr)
37
+ return unless coverage
46
38
 
47
- data = JSON.parse(File.read(opts[:input]))
48
- match = lookup(data.fetch("coverage", {}), opts[:path])
49
- return match if match
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)
50
46
 
51
- stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
52
- nil
47
+ CoverageFile.report_invalid(stderr, "coverage", opts[:input], "entry for #{opts[:path]} must be an object")
53
48
  end
54
49
 
55
50
  # Match either the absolute path, the literal string passed, or
@@ -65,7 +60,8 @@ module SimpleCov
65
60
  def emit(match, opts, stdout)
66
61
  filename, payload = match
67
62
  if opts[:json]
68
- stdout.puts(JSON.pretty_generate(filename => payload))
63
+ entry = {filename => payload} #: Hash[untyped, untyped]
64
+ stdout.puts(JSON.pretty_generate(entry))
69
65
  else
70
66
  print_human(filename, payload, stdout, SimpleCov::CLI.color_enabled?(opts, stdout))
71
67
  end
@@ -73,18 +69,17 @@ module SimpleCov
73
69
 
74
70
  def print_human(filename, payload, stdout, color)
75
71
  stdout.puts(filename)
76
- CRITERIA.each { |c| emit_criterion(stdout, payload, c, color) }
72
+ CoverageFile::CRITERIA.each_value { |c| emit_criterion(stdout, payload, c, color) }
77
73
  end
78
74
 
79
75
  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))
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]]))
88
83
  end
89
84
  end
90
85
  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
@@ -2,28 +2,20 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
+ require_relative "command_helpers"
5
6
 
6
7
  module SimpleCov
7
8
  module CLI
8
- # `simplecov diff <baseline>` — print the per-file line-coverage
9
- # delta between coverage.json (--input) and a baseline coverage.json
9
+ # `simplecov diff <baseline>` — print per-file coverage deltas between
10
+ # coverage.json (--input) and a baseline coverage.json
10
11
  # checked in alongside the suite. Only files whose coverage moved
11
12
  # are listed; --fail-on-drop exits non-zero when any file regressed,
12
13
  # so this composes with CI as a "coverage of this PR didn't drop"
13
14
  # gate. Resolves the long-standing "diff coverage" feature request.
14
15
  module Diff
15
- EPSILON = 0.005 # tolerance below which a delta is considered noise
16
+ extend CommandHelpers
16
17
 
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
18
+ EPSILON = 0.005 # tolerance below which a delta is considered noise
27
19
 
28
20
  STATUS_SUFFIX = {"added" => "(new file)", "removed" => "(removed)"}.freeze
29
21
 
@@ -36,11 +28,11 @@ module SimpleCov
36
28
  rows = compute_rows(opts[:current], opts[:baseline], opts[:threshold])
37
29
  rows.sort_by! { |row| row[:line_delta] }
38
30
  if opts[:json]
39
- emit_json(stdout, rows)
31
+ stdout.puts(JSON.pretty_generate(rows))
40
32
  else
41
33
  emit_text(stdout, rows, SimpleCov::CLI.color_enabled?(opts, stdout))
42
34
  end
43
- opts[:fail_on_drop] && rows.any? { |row| row[:line_delta].negative? } ? 1 : 0
35
+ opts[:fail_on_drop] && coverage_drop?(rows) ? 1 : 0
44
36
  end
45
37
 
46
38
  def parse(args, stderr)
@@ -53,25 +45,18 @@ module SimpleCov
53
45
  end
54
46
 
55
47
  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 }
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 }
67
51
  end
52
+ opts.merge(rest: rest)
68
53
  end
69
54
 
70
55
  def load_coverage(path, stderr)
71
- return normalize_keys(JSON.parse(File.read(path)).fetch("coverage", {})) if File.exist?(path)
56
+ coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
57
+ return unless coverage
72
58
 
73
- stderr.puts("simplecov diff: #{path} not found")
74
- nil
59
+ normalize_keys(coverage)
75
60
  end
76
61
 
77
62
  # Strip a leading slash so coverage.json files written before the
@@ -86,18 +71,23 @@ module SimpleCov
86
71
  files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
87
72
  end
88
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.
89
78
  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
- }
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
101
91
  end
102
92
 
103
93
  def status_for(current_payload, baseline_payload)
@@ -107,11 +97,10 @@ module SimpleCov
107
97
  "changed"
108
98
  end
109
99
 
110
- def pct_for(criterion, payload)
111
- fields = CRITERION_FIELDS.fetch(criterion)
100
+ def pct_for(fields, payload)
112
101
  return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
113
102
 
114
- payload[fields[:pct]].to_f
103
+ payload[fields[:percent]].to_f
115
104
  end
116
105
 
117
106
  def emit_text(stdout, rows, color)
@@ -134,6 +123,17 @@ module SimpleCov
134
123
  ].compact
135
124
  end
136
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
137
  # Deltas are sign-based, not threshold-based: a +5% bump is good
138
138
  # (green) and a -5% drop is bad (red), regardless of where the
139
139
  # absolute coverage level lands.
@@ -142,10 +142,6 @@ module SimpleCov
142
142
  text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
143
143
  SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
144
144
  end
145
-
146
- def emit_json(stdout, rows)
147
- stdout.puts(JSON.pretty_generate(rows))
148
- end
149
145
  end
150
146
  end
151
147
  end
@@ -18,9 +18,12 @@ module SimpleCov
18
18
  return "coverage" unless dotfile
19
19
 
20
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
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.).
24
27
  warn "simplecov: failed to read coverage_dir from #{dotfile}: #{e.class}: #{e.message}"
25
28
  "coverage"
26
29
  # simplecov:enable
@@ -37,11 +40,14 @@ module SimpleCov
37
40
  # inside a Ruby host that cares about isolation should arrange that
38
41
  # themselves.
39
42
  def read_from(dotfile)
40
- snapshot = SimpleCov.instance_variable_get(:@coverage_dir)
43
+ snapshot = SimpleCov.instance_variable_get(:@coverage_dir) #: String?
41
44
  load_with_start_neutered(dotfile)
42
- dir = SimpleCov.coverage_dir
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?
43
50
  SimpleCov.instance_variable_set(:@coverage_dir, snapshot)
44
- dir
45
51
  end
46
52
 
47
53
  def find
@@ -57,8 +63,8 @@ module SimpleCov
57
63
  end
58
64
 
59
65
  def with_simplecov_loaded
60
- previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil)
61
- previous_cli = ENV.fetch("SIMPLECOV_CLI", nil)
66
+ previous_no_defaults = ENV.fetch("SIMPLECOV_NO_DEFAULTS", nil) #: String?
67
+ previous_cli = ENV.fetch("SIMPLECOV_CLI", nil) #: String?
62
68
  ENV["SIMPLECOV_NO_DEFAULTS"] = "1"
63
69
  # SIMPLECOV_CLI lets a project's `.simplecov` opt some config into
64
70
  # CLI-only behavior — e.g. simplecov itself sets `coverage_dir`
@@ -67,6 +73,8 @@ module SimpleCov
67
73
  require "simplecov"
68
74
  yield
69
75
  ensure
76
+ # @type var previous_no_defaults: String?
77
+ # @type var previous_cli: String?
70
78
  ENV["SIMPLECOV_NO_DEFAULTS"] = previous_no_defaults
71
79
  ENV["SIMPLECOV_CLI"] = previous_cli
72
80
  end
@@ -79,20 +87,23 @@ module SimpleCov
79
87
  def load_with_start_neutered(path)
80
88
  klass = SimpleCov.singleton_class
81
89
  names = %i[start_tracking install_at_exit_hook]
82
- stash = names.to_h { |name| [name, klass.instance_method(name)] }
90
+ stash = names.to_h { |name| [name, klass.instance_method(name)] } #: Hash[Symbol, UnboundMethod]
83
91
  # define_method over an existing method emits a "method redefined"
84
92
  # warning under $VERBOSE; the override and restore are intentional.
85
93
  silence_verbose { names.each { |name| klass.define_method(name) { nil } } }
86
94
  load path
87
95
  ensure
96
+ # @type var stash: Hash[Symbol, UnboundMethod]
97
+ # @type var klass: Class
88
98
  silence_verbose { stash.each { |name, method| klass.define_method(name, method) } }
89
99
  end
90
100
 
91
101
  def silence_verbose
92
- previous = $VERBOSE
102
+ previous = $VERBOSE #: bool?
93
103
  $VERBOSE = nil
94
104
  yield
95
105
  ensure
106
+ # @type var previous: bool?
96
107
  $VERBOSE = previous
97
108
  end
98
109
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
+ require_relative "../atomic_file"
6
+ require_relative "command_helpers"
5
7
 
6
8
  module SimpleCov
7
9
  module CLI
@@ -12,6 +14,8 @@ module SimpleCov
12
14
  # load path; lazy-required so the read-only subcommands above don't
13
15
  # pay for ResultMerger (and its Coverage runtime guard).
14
16
  module Merge
17
+ extend CommandHelpers
18
+
15
19
  module_function
16
20
 
17
21
  def run(args, stdout:, stderr:, **)
@@ -57,22 +61,28 @@ module SimpleCov
57
61
  # generic "no mergeable results" into a message that points at
58
62
  # the specific input causing the failure.
59
63
  def parse_inputs(files, stderr)
60
- files.each_with_object({}) do |path, memo|
64
+ parsed = {} #: Hash[String, Hash[String, untyped]]
65
+ files.each_with_object(parsed) do |path, memo|
61
66
  data = parse_input(path, stderr) or return nil
62
67
 
63
68
  memo[path] = data
64
69
  end
65
70
  end
66
71
 
72
+ # Read first, classify by the exception: an exist?-then-read pair
73
+ # is racy, and rescuing only ENOENT crashed with a backtrace on a
74
+ # directory (EISDIR) or an unreadable file (EACCES).
67
75
  def parse_input(path, stderr)
68
- return parse_input_error(stderr, path, "not found") unless File.exist?(path)
69
-
70
76
  data = JSON.parse(File.read(path))
71
77
  return data if data.is_a?(Hash) && !data.empty?
72
78
 
73
79
  parse_input_error(stderr, path, "has no resultset entries")
80
+ rescue Errno::ENOENT
81
+ parse_input_error(stderr, path, "not found")
74
82
  rescue JSON::ParserError => e
75
83
  parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
84
+ rescue SystemCallError => e
85
+ parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.strip})")
76
86
  end
77
87
 
78
88
  def parse_input_error(stderr, path, reason)
@@ -85,8 +95,9 @@ module SimpleCov
85
95
  # mistake for "no merge happened." Surface the overlap so the
86
96
  # operator can rename the workers or accept the merge knowingly.
87
97
  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 }
98
+ files_per_command = {} #: Hash[String, Array[String]]
99
+ parsed.each do |path, data|
100
+ data.each_key { |command_name| (files_per_command[command_name] ||= []) << path }
90
101
  end
91
102
  files_per_command.each do |command_name, paths|
92
103
  next if paths.size < 2
@@ -102,14 +113,7 @@ module SimpleCov
102
113
  end
103
114
 
104
115
  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
116
+ AtomicFile.write(path, JSON.pretty_generate(result.to_hash))
113
117
  end
114
118
  end
115
119
  end