simplecov 1.0.2 → 1.1.0

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1425
  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 +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +46 -21
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +18 -9
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +12 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_adapter.rb +48 -16
  76. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  77. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  78. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  79. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  80. data/lib/simplecov/result_merger.rb +69 -42
  81. data/lib/simplecov/result_processing.rb +82 -43
  82. data/lib/simplecov/run_identity.rb +77 -0
  83. data/lib/simplecov/simulate_coverage.rb +35 -11
  84. data/lib/simplecov/source_file/method.rb +7 -1
  85. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  86. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  87. data/lib/simplecov/source_file/source_loader.rb +23 -7
  88. data/lib/simplecov/source_file/statistics.rb +24 -16
  89. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  90. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  91. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  92. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  93. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  94. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  95. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  96. data/lib/simplecov/version.rb +1 -1
  97. data/lib/simplecov.rb +11 -5
  98. data/schemas/coverage-v1.0.schema.json +2 -2
  99. data/schemas/coverage.schema.json +2 -2
  100. data/sig/simplecov.rbs +206 -67
  101. metadata +28 -14
  102. data/doc/alternate-formatters.md +0 -66
  103. data/doc/commercial-services.md +0 -25
  104. data/doc/editor-integration.md +0 -18
  105. data/lib/simplecov/combine/files_combiner.rb +0 -70
  106. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  107. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  110. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -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} #: Hash[Symbol, untyped]
32
- rest =
33
- OptionParser.new do |o|
34
- o.on("--input PATH") { |v| opts[:input] = v }
35
- o.on("--json") { opts[:json] = true }
36
- o.on("--no-color") { opts[:no_color] = true }
37
- end.parse(args)
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,15 +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
- none = {} #: Hash[String, untyped]
49
- match = lookup(data.fetch("coverage", none), opts[:path])
50
- 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)
51
46
 
52
- stderr.puts("simplecov coverage: no entry for #{opts[:path]} in #{opts[:input]}")
53
- nil
47
+ CoverageFile.report_invalid(stderr, "coverage", opts[:input], "entry for #{opts[:path]} must be an object")
54
48
  end
55
49
 
56
50
  # Match either the absolute path, the literal string passed, or
@@ -75,18 +69,17 @@ module SimpleCov
75
69
 
76
70
  def print_human(filename, payload, stdout, color)
77
71
  stdout.puts(filename)
78
- CRITERIA.each { |c| emit_criterion(stdout, payload, c, color) }
72
+ CoverageFile::CRITERIA.each_value { |c| emit_criterion(stdout, payload, c, color) }
79
73
  end
80
74
 
81
75
  def emit_criterion(stdout, payload, criterion, color)
82
- return unless payload.key?(criterion[:pct])
83
-
84
- pct = payload[criterion[:pct]].to_f
85
- stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
86
- label: "#{criterion[:label]}:",
87
- pct: SimpleCov::Color.colorize_percent(pct, enabled: color),
88
- covered: payload[criterion[:cov]] || 0,
89
- total: payload[criterion[:tot]] || 0))
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]]))
90
83
  end
91
84
  end
92
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,26 +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
- no_coverage = {} #: Hash[String, untyped]
72
- return normalize_keys(JSON.parse(File.read(path)).fetch("coverage", no_coverage)) if File.exist?(path)
56
+ coverage = CoverageFile.load_coverage(path, command: "diff", stderr: stderr)
57
+ return unless coverage
73
58
 
74
- stderr.puts("simplecov diff: #{path} not found")
75
- nil
59
+ normalize_keys(coverage)
76
60
  end
77
61
 
78
62
  # Strip a leading slash so coverage.json files written before the
@@ -87,18 +71,23 @@ module SimpleCov
87
71
  files.filter_map { |fname| compute_row(fname, current[fname], baseline[fname], threshold) }
88
72
  end
89
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.
90
78
  def compute_row(fname, current_payload, baseline_payload, threshold)
91
- deltas = CRITERIA.to_h { |c| [c, pct_for(c, current_payload) - pct_for(c, baseline_payload)] }
92
- floor = [threshold.abs, EPSILON].max
93
- return nil unless deltas.values.any? { |delta| delta.abs > floor }
94
-
95
- {
96
- file: fname,
97
- status: status_for(current_payload, baseline_payload),
98
- line_delta: deltas[:lines],
99
- branch_delta: deltas[:branches],
100
- method_delta: deltas[:methods]
101
- }
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
102
91
  end
103
92
 
104
93
  def status_for(current_payload, baseline_payload)
@@ -108,11 +97,10 @@ module SimpleCov
108
97
  "changed"
109
98
  end
110
99
 
111
- def pct_for(criterion, payload)
112
- fields = CRITERION_FIELDS.fetch(criterion)
100
+ def pct_for(fields, payload)
113
101
  return 0.0 unless payload.is_a?(Hash) && payload[fields[:total]].to_i.positive?
114
102
 
115
- payload[fields[:pct]].to_f
103
+ payload[fields[:percent]].to_f
116
104
  end
117
105
 
118
106
  def emit_text(stdout, rows, color)
@@ -135,6 +123,17 @@ module SimpleCov
135
123
  ].compact
136
124
  end
137
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
+
138
137
  # Deltas are sign-based, not threshold-based: a +5% bump is good
139
138
  # (green) and a -5% drop is bad (red), regardless of where the
140
139
  # absolute coverage level lands.
@@ -143,10 +142,6 @@ module SimpleCov
143
142
  text = format("%<sign>s%<delta>6.2f%% %<label>s", sign: sign, delta: delta, label: label)
144
143
  SimpleCov::Color.colorize(text, delta.negative? ? :red : :green, enabled: color)
145
144
  end
146
-
147
- def emit_json(stdout, rows)
148
- stdout.puts(JSON.pretty_generate(rows))
149
- end
150
145
  end
151
146
  end
152
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
@@ -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:, **)
@@ -65,15 +69,20 @@ module SimpleCov
65
69
  end
66
70
  end
67
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).
68
75
  def parse_input(path, stderr)
69
- return parse_input_error(stderr, path, "not found") unless File.exist?(path)
70
-
71
76
  data = JSON.parse(File.read(path))
72
77
  return data if data.is_a?(Hash) && !data.empty?
73
78
 
74
79
  parse_input_error(stderr, path, "has no resultset entries")
80
+ rescue Errno::ENOENT
81
+ parse_input_error(stderr, path, "not found")
75
82
  rescue JSON::ParserError => e
76
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})")
77
86
  end
78
87
 
79
88
  def parse_input_error(stderr, path, reason)
@@ -104,14 +113,7 @@ module SimpleCov
104
113
  end
105
114
 
106
115
  def write(path, result)
107
- require "fileutils"
108
- FileUtils.mkdir_p(File.dirname(path))
109
- File.write(path, JSON.pretty_generate(result.to_hash))
110
- end
111
-
112
- def error(stderr, message)
113
- stderr.puts("simplecov merge: #{message}")
114
- 1
116
+ AtomicFile.write(path, JSON.pretty_generate(result.to_hash))
115
117
  end
116
118
  end
117
119
  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
@@ -8,6 +9,8 @@ module SimpleCov
8
9
  # platform's default browser. Tiny QoL wrapper around `xdg-open` /
9
10
  # `open` / `start` so users don't have to type a file:// URL.
10
11
  module Open
12
+ extend CommandHelpers
13
+
11
14
  module_function
12
15
 
13
16
  def run(args, stderr:, **)
@@ -20,11 +23,6 @@ module SimpleCov
20
23
  system(*opener, path) ? 0 : 1
21
24
  end
22
25
 
23
- def error(stderr, message)
24
- stderr.puts("simplecov open: #{message}")
25
- 1
26
- end
27
-
28
26
  def parse(args)
29
27
  path = SimpleCov::CLI.default_report
30
28
  OptionParser.new do |o|