simplecov 0.22.0 → 1.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -1
  3. data/LICENSE +1 -1
  4. data/README.md +1009 -511
  5. data/doc/alternate-formatters.md +0 -5
  6. data/doc/commercial-services.md +5 -5
  7. data/exe/simplecov +11 -0
  8. data/lib/minitest/simplecov_plugin.rb +13 -5
  9. data/lib/simplecov/autostart.rb +11 -0
  10. data/lib/simplecov/cli/clean.rb +47 -0
  11. data/lib/simplecov/cli/coverage.rb +91 -0
  12. data/lib/simplecov/cli/diff.rb +151 -0
  13. data/lib/simplecov/cli/dotfile.rb +100 -0
  14. data/lib/simplecov/cli/merge.rb +116 -0
  15. data/lib/simplecov/cli/open.rb +50 -0
  16. data/lib/simplecov/cli/report.rb +84 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve.rb +139 -0
  19. data/lib/simplecov/cli/uncovered.rb +107 -0
  20. data/lib/simplecov/cli.rb +150 -0
  21. data/lib/simplecov/color.rb +74 -0
  22. data/lib/simplecov/combine/branches_combiner.rb +3 -2
  23. data/lib/simplecov/combine/files_combiner.rb +7 -1
  24. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  25. data/lib/simplecov/combine/methods_combiner.rb +26 -0
  26. data/lib/simplecov/combine/results_combiner.rb +5 -4
  27. data/lib/simplecov/command_guesser.rb +46 -32
  28. data/lib/simplecov/configuration/coverage.rb +171 -0
  29. data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
  30. data/lib/simplecov/configuration/filters.rb +195 -0
  31. data/lib/simplecov/configuration/formatting.rb +119 -0
  32. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  33. data/lib/simplecov/configuration/merging.rb +74 -0
  34. data/lib/simplecov/configuration/thresholds.rb +174 -0
  35. data/lib/simplecov/configuration.rb +79 -405
  36. data/lib/simplecov/coverage_statistics.rb +12 -9
  37. data/lib/simplecov/coverage_violations.rb +148 -0
  38. data/lib/simplecov/defaults.rb +27 -20
  39. data/lib/simplecov/directive.rb +162 -0
  40. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  41. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  42. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  43. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  45. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  46. data/lib/simplecov/exit_codes.rb +3 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +61 -17
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +101 -0
  51. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  53. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +79 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +127 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +77 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +4 -5
  63. data/lib/simplecov/formatter/simple_formatter.rb +9 -11
  64. data/lib/simplecov/formatter.rb +4 -0
  65. data/lib/simplecov/last_run.rb +10 -3
  66. data/lib/simplecov/lines_classifier.rb +26 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +51 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +42 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +77 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +95 -0
  73. data/lib/simplecov/process.rb +20 -14
  74. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  75. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  76. data/lib/simplecov/profiles/rails.rb +24 -10
  77. data/lib/simplecov/profiles/root_filter.rb +6 -5
  78. data/lib/simplecov/profiles/strict.rb +32 -0
  79. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  80. data/lib/simplecov/profiles.rb +32 -3
  81. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  82. data/lib/simplecov/result/source_file_builder.rb +51 -0
  83. data/lib/simplecov/result.rb +97 -19
  84. data/lib/simplecov/result_adapter.rb +68 -6
  85. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  86. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  87. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  88. data/lib/simplecov/result_merger.rb +46 -90
  89. data/lib/simplecov/result_processing.rb +162 -0
  90. data/lib/simplecov/simulate_coverage.rb +54 -8
  91. data/lib/simplecov/source_file/branch.rb +1 -3
  92. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  93. data/lib/simplecov/source_file/builder_context.rb +28 -0
  94. data/lib/simplecov/source_file/line.rb +7 -2
  95. data/lib/simplecov/source_file/line_builder.rb +43 -0
  96. data/lib/simplecov/source_file/method.rb +52 -0
  97. data/lib/simplecov/source_file/method_builder.rb +58 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +77 -0
  100. data/lib/simplecov/source_file/source_loader.rb +63 -0
  101. data/lib/simplecov/source_file/statistics.rb +57 -0
  102. data/lib/simplecov/source_file.rb +66 -232
  103. data/lib/simplecov/static_coverage_extractor/visitor.rb +193 -0
  104. data/lib/simplecov/static_coverage_extractor.rb +111 -0
  105. data/lib/simplecov/useless_results_remover.rb +16 -7
  106. data/lib/simplecov/version.rb +1 -1
  107. data/lib/simplecov-html.rb +4 -0
  108. data/lib/simplecov.rb +131 -377
  109. data/lib/simplecov_json_formatter.rb +4 -0
  110. data/schemas/coverage-v1.0.schema.json +300 -0
  111. data/schemas/coverage.schema.json +300 -0
  112. metadata +88 -56
  113. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # Computes coverage threshold violations for a given result. Shared by
5
+ # the exit-code checks and the JSON formatter's `errors` section.
6
+ #
7
+ # Each method returns an array of violation hashes. All percents are
8
+ # rounded via `SimpleCov.round_coverage` so downstream consumers don't
9
+ # need to round again.
10
+ module CoverageViolations
11
+ class << self
12
+ # @return [Array<Hash>] {:criterion, :expected, :actual}
13
+ def minimum_overall(result, thresholds)
14
+ thresholds.filter_map do |criterion, expected|
15
+ actual = percent_for(result, criterion) or next
16
+ {criterion: criterion, expected: expected, actual: actual} if actual < expected
17
+ end
18
+ end
19
+
20
+ # @return [Array<Hash>] {:criterion, :expected, :actual}
21
+ # Tolerance: `percent_for` floors the actual percent to two decimal
22
+ # places (matching the existing minimum-coverage behavior), so an
23
+ # actual of e.g. 95.4287 is treated as 95.42 — meaning a maximum of
24
+ # 95.42 still passes. See issue #187 for the rationale.
25
+ def maximum_overall(result, thresholds)
26
+ thresholds.filter_map do |criterion, expected|
27
+ actual = percent_for(result, criterion) or next
28
+ {criterion: criterion, expected: expected, actual: actual} if actual > expected
29
+ end
30
+ end
31
+
32
+ # @return [Array<Hash>] {:criterion, :expected, :actual, :filename, :project_filename}
33
+ #
34
+ # `defaults` is the criterion-keyed Hash applied to every file.
35
+ # `overrides` is an ordered Hash<pattern, criterion_thresholds> of per-path
36
+ # overrides; for each file, defaults are merged with every matching override
37
+ # (later wins per criterion, overrides win over defaults).
38
+ def minimum_by_file(result, defaults, overrides = {})
39
+ result.files.flat_map do |file|
40
+ effective = effective_per_file_thresholds(file, defaults, overrides)
41
+ effective.filter_map { |criterion, expected| file_minimum_violation(file, criterion, expected) }
42
+ end
43
+ end
44
+
45
+ # @return [Array<Hash>] {:group_name, :criterion, :expected, :actual}
46
+ def minimum_by_group(result, thresholds)
47
+ thresholds.flat_map do |group_name, minimums|
48
+ group = lookup_group(result, group_name)
49
+ group ? group_minimum_violations(group_name, group, minimums) : []
50
+ end
51
+ end
52
+
53
+ # @return [Array<Hash>] {:criterion, :maximum, :actual} where `actual`
54
+ # is the observed drop (in percentage points) vs. the last run.
55
+ def maximum_drop(result, thresholds, last_run: SimpleCov::LastRun.read)
56
+ return [] unless last_run
57
+
58
+ thresholds.filter_map do |criterion, maximum|
59
+ actual = compute_drop(criterion, result, last_run)
60
+ {criterion: criterion, maximum: maximum, actual: actual} if actual && actual > maximum
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ # Look up a criterion's percent on any coverage_statistics-bearing
67
+ # object (Result, SourceFile, FileList). Returns nil — and the
68
+ # caller silently skips — when the criterion was configured but not
69
+ # actually measured by the runtime (e.g. `minimum_coverage branch:
70
+ # 100` under the "strict" profile on JRuby, where the Coverage
71
+ # module doesn't emit branch data). The config-time
72
+ # `raise_if_criterion_disabled` check still catches the genuine
73
+ # "forgot to enable the criterion" mistake before we ever get here.
74
+ def percent_for(stats_source, criterion)
75
+ stats = stats_source.coverage_statistics[SimpleCov.coverage_statistics_key(criterion)]
76
+ round(stats.percent) if stats
77
+ end
78
+
79
+ # Walk the overrides in declaration order, merging each one that matches
80
+ # the file's project path into the running effective threshold (so the
81
+ # most-specific or latest-declared override wins per criterion). Returns
82
+ # the defaults Hash unchanged when nothing matches.
83
+ def effective_per_file_thresholds(file, defaults, overrides)
84
+ return defaults if overrides.empty?
85
+
86
+ path = file.project_filename
87
+ overrides.reduce(defaults) do |acc, (pattern, criterion_thresholds)|
88
+ path_matches?(path, pattern) ? acc.merge(criterion_thresholds) : acc
89
+ end
90
+ end
91
+
92
+ # Per-path matching for `minimum_coverage_by_file` overrides. Strings
93
+ # ending in `/` are treated as directory prefixes; otherwise they must
94
+ # match `project_filename` exactly. Regexps are tested via `match?`.
95
+ # The configuration setter rejects anything other than String/Regexp,
96
+ # so no dead `else` branch is needed here.
97
+ def path_matches?(project_filename, pattern)
98
+ return project_filename.match?(pattern) if pattern.is_a?(Regexp)
99
+ return project_filename.start_with?(pattern) if pattern.end_with?("/")
100
+
101
+ project_filename == pattern
102
+ end
103
+
104
+ def file_minimum_violation(file, criterion, expected)
105
+ actual = percent_for(file, criterion) or return
106
+ return unless actual < expected
107
+
108
+ {
109
+ criterion: criterion,
110
+ expected: expected,
111
+ actual: actual,
112
+ filename: file.filename,
113
+ project_filename: file.project_filename
114
+ }
115
+ end
116
+
117
+ def group_minimum_violations(group_name, group, minimums)
118
+ minimums.filter_map do |criterion, expected|
119
+ actual = percent_for(group, criterion) or next
120
+ {group_name: group_name, criterion: criterion, expected: expected, actual: actual} if actual < expected
121
+ end
122
+ end
123
+
124
+ def lookup_group(result, group_name)
125
+ group = result.groups[group_name]
126
+ unless group
127
+ warn "minimum_coverage_by_group: no group named '#{group_name}' exists. " \
128
+ "Available groups: #{result.groups.keys.join(', ')}"
129
+ end
130
+ group
131
+ end
132
+
133
+ def compute_drop(criterion, result, last_run)
134
+ stats_key = SimpleCov.coverage_statistics_key(criterion)
135
+ last_coverage_percent = last_run.dig(:result, stats_key)
136
+ last_coverage_percent ||= last_run.dig(:result, :covered_percent) if stats_key == :line
137
+ return unless last_coverage_percent
138
+
139
+ current = percent_for(result, criterion) or return
140
+ (last_coverage_percent - current).floor(10)
141
+ end
142
+
143
+ def round(percent)
144
+ SimpleCov.round_coverage(percent)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -1,35 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Load default formatter gem
3
+ require "English"
4
4
  require "pathname"
5
- require_relative "default_formatter"
6
- require_relative "profiles/root_filter"
7
- require_relative "profiles/test_frameworks"
8
- require_relative "profiles/bundler_filter"
9
- require_relative "profiles/hidden_filter"
10
- require_relative "profiles/rails"
5
+ require_relative "formatter/html_formatter"
11
6
 
12
- # Default configuration
7
+ # Default configuration. Profiles autoload on first reference via
8
+ # `SimpleCov.profiles.fetch_proc`; the unused ones (e.g. "rails")
9
+ # never get required unless a user opts in.
13
10
  SimpleCov.configure do
14
- formatter SimpleCov::Formatter::MultiFormatter.new(
15
- SimpleCov::Formatter.from_env(ENV)
16
- )
11
+ formatter SimpleCov::Formatter::HTMLFormatter
17
12
 
18
13
  load_profile "bundler_filter"
19
14
  load_profile "hidden_filter"
20
- # Exclude files outside of SimpleCov.root
15
+ # Exclude files outside of SimpleCov.root. Mirrors the early prune done
16
+ # by SimpleCov::UselessResultsRemover so the user-facing filter chain
17
+ # honors the same boundary; both share the regex.
21
18
  load_profile "root_filter"
19
+ # Exclude test framework directories (`test/`, `spec/`, `features/`,
20
+ # `autotest/`). The test suite runs 100% of the test files themselves,
21
+ # which inflates totals and obscures the application coverage that
22
+ # actually matters. Drop with `remove_filter %r{\A(test|features|spec|autotest)/}`
23
+ # if you want test files counted (e.g. to surface dead helpers).
24
+ load_profile "test_frameworks"
22
25
  end
23
26
 
24
27
  # Gotta stash this a-s-a-p, see the CommandGuesser class and i.e. #110 for further info
25
28
  SimpleCov::CommandGuesser.original_run_command = "#{$PROGRAM_NAME} #{ARGV.join(' ')}"
26
29
 
27
- at_exit do
28
- next if SimpleCov.external_at_exit?
29
-
30
- SimpleCov.at_exit_behavior
31
- end
32
-
33
30
  # Autoload config from ~/.simplecov if present
34
31
  require_relative "load_global_config"
35
32
 
@@ -40,14 +37,24 @@ config_path = Pathname.new(SimpleCov.root)
40
37
  loop do
41
38
  filename = config_path.join(".simplecov")
42
39
  if filename.exist?
43
- begin
40
+ # `.simplecov` is a configuration file; SimpleCov.start calls inside
41
+ # it are intercepted and converted to configuration, with a deprecation
42
+ # warning. See `SimpleCov.with_dot_simplecov_autoload` and issue #581.
43
+ SimpleCov.with_dot_simplecov_autoload do
44
44
  load filename
45
45
  rescue LoadError, StandardError
46
+ # simplecov:disable — only fires when .simplecov is unreadable
47
+ # or raises during load
46
48
  warn "Warning: Error occurred while trying to load #{filename}. " \
47
- "Error message: #{$!.message}"
49
+ "Error message: #{$ERROR_INFO.message}"
50
+ # simplecov:enable
48
51
  end
49
52
  break
50
53
  end
54
+ # simplecov:disable — only fires when no .simplecov is found up to
55
+ # the filesystem root; simplecov's own dogfood run finds the repo's
56
+ # .simplecov on the first iteration and breaks before getting here.
51
57
  config_path, = config_path.split
52
58
  break if config_path.root?
59
+ # simplecov:enable
53
60
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module SimpleCov
6
+ # Parses `# simplecov:disable` / `# simplecov:enable` directive comments.
7
+ #
8
+ # Two forms are supported:
9
+ #
10
+ # Block form (the directive is the entire comment on its own line) opens a
11
+ # region that runs until the matching `# simplecov:enable`:
12
+ #
13
+ # # simplecov:disable line
14
+ # ...
15
+ # # simplecov:enable line
16
+ #
17
+ # Inline form (the directive trails real code on the same line) only affects
18
+ # that single line and does not need to be re-enabled:
19
+ #
20
+ # raise "absurd" # simplecov:disable
21
+ #
22
+ # Categories are `:line`, `:branch`, and `:method`. They may be combined
23
+ # with commas. Omitting categories targets all three.
24
+ #
25
+ # Any text after the directive (and the optional category list) is treated
26
+ # as a free-form reason and discarded:
27
+ #
28
+ # # simplecov:disable line not worth testing this glue
29
+ #
30
+ # As a consequence, an unrecognised category name silently falls into the
31
+ # reason bucket. `# simplecov:disable cyclomatic` is parsed as the bare
32
+ # form (disable everything) with reason "cyclomatic" — a deliberate
33
+ # over-disable so the typo is visible in the report rather than silently
34
+ # disabling nothing.
35
+ #
36
+ # Comment extraction goes through `Ripper.lex` so directive markers inside
37
+ # string literals or heredocs are correctly ignored.
38
+ class Directive
39
+ CATEGORIES = %i[line branch method].freeze
40
+
41
+ CATEGORY_PATTERN = "(?:#{CATEGORIES.join('|')})".freeze
42
+ CATEGORIES_PATTERN = "(?:#{CATEGORY_PATTERN}(?:\\s*,\\s*#{CATEGORY_PATTERN})*)".freeze
43
+ PATTERN = /
44
+ \#\s*simplecov\s*:\s*
45
+ (?<mode>disable|enable)\b
46
+ (?:\s+(?<categories>#{CATEGORIES_PATTERN}))?
47
+ .*?
48
+ \s*\z
49
+ /x
50
+
51
+ attr_reader :line_number, :mode, :categories
52
+
53
+ # Walk an array of source lines and return the disabled line ranges per
54
+ # category as `{ line: [Range, ...], branch: [...], method: [...] }`.
55
+ # An unclosed `disable` block extends to the end of the file.
56
+ def self.disabled_ranges(src_lines)
57
+ lines = src_lines.to_a
58
+ ranges = CATEGORIES.to_h { |category| [category, []] }
59
+ open_starts = {}
60
+
61
+ directives_in(lines).each { |directive| directive.apply(ranges, open_starts) }
62
+ open_starts.each { |category, start| ranges[category] << (start..lines.size) }
63
+
64
+ ranges
65
+ end
66
+
67
+ # Extract every directive in the file, in source order. Comments inside
68
+ # string literals or heredocs are skipped because Ripper.lex doesn't tag
69
+ # them as :on_comment tokens.
70
+ def self.directives_in(lines)
71
+ return [] unless source_might_contain_directive?(lines)
72
+
73
+ comments_in(lines).filter_map do |line_number, column, text|
74
+ parse_comment(lines, line_number, column, text)
75
+ end
76
+ end
77
+
78
+ # Cheap pre-check so we don't tokenize files that obviously can't contain
79
+ # a directive.
80
+ def self.source_might_contain_directive?(lines)
81
+ lines.any? do |line|
82
+ line.include?("simplecov")
83
+ rescue ArgumentError, EncodingError
84
+ false # simplecov:disable — defensive guard for invalid byte sequences in source
85
+ end
86
+ end
87
+
88
+ def self.parse_comment(lines, line_number, column, text)
89
+ match = PATTERN.match(text)
90
+ return nil unless match
91
+
92
+ new(
93
+ line_number: line_number,
94
+ mode: match[:mode].to_sym,
95
+ categories: parse_categories(match[:categories]),
96
+ inline: inline?(lines, line_number, column + match.begin(0))
97
+ )
98
+ rescue ArgumentError, EncodingError
99
+ # E.g., comment text contains an invalid byte sequence in UTF-8.
100
+ nil
101
+ end
102
+
103
+ def self.parse_categories(text)
104
+ return CATEGORIES.dup if text.nil?
105
+
106
+ text.split(/\s*,\s*/).map(&:to_sym)
107
+ end
108
+
109
+ # Whether the directive sits after non-whitespace content on its line.
110
+ # `column` is the byte column of the directive's `#` in the source line,
111
+ # adjusted for any prefix that may precede it within the comment token
112
+ # (e.g., `# prefix # simplecov:disable line`).
113
+ def self.inline?(lines, line_number, column)
114
+ line = lines[line_number - 1].to_s
115
+ !line.byteslice(0, column).to_s.strip.empty?
116
+ rescue ArgumentError, EncodingError
117
+ false # simplecov:disable — defensive guard for invalid byte sequences
118
+ end
119
+
120
+ def self.comments_in(lines)
121
+ source = lines.map { |line| line.end_with?("\n") ? line : "#{line}\n" }.join
122
+ Ripper.lex(source).filter_map do |(line_number, column), type, text|
123
+ [line_number, column, text] if type == :on_comment
124
+ end
125
+ rescue ArgumentError, EncodingError
126
+ [] # simplecov:disable — Ripper.lex can raise on invalid byte sequences
127
+ end
128
+
129
+ private_class_method :directives_in, :source_might_contain_directive?,
130
+ :parse_comment, :parse_categories, :inline?, :comments_in
131
+
132
+ def initialize(line_number:, mode:, categories:, inline:)
133
+ @line_number = line_number
134
+ @mode = mode
135
+ @categories = categories
136
+ @inline = inline
137
+ end
138
+
139
+ def disabled?
140
+ mode == :disable
141
+ end
142
+
143
+ def inline?
144
+ @inline
145
+ end
146
+
147
+ # Apply this directive's effect to the in-flight per-category state.
148
+ # Inline directives mark just their line; block disables open a region;
149
+ # block enables close one. Re-opening an already-open block is a no-op.
150
+ def apply(ranges, open_starts)
151
+ categories.each do |category|
152
+ if inline?
153
+ ranges[category] << (line_number..line_number) if disabled?
154
+ elsif disabled?
155
+ open_starts[category] ||= line_number
156
+ elsif (start = open_starts.delete(category))
157
+ ranges[category] << (start..line_number)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SimpleCov
4
4
  module ExitCodes
5
+ # Runs every coverage check against the result and returns the exit
6
+ # code from the first failing one (or SUCCESS if all pass).
5
7
  module ExitCodeHandling
6
8
  module_function
7
9
 
@@ -10,7 +12,7 @@ module SimpleCov
10
12
 
11
13
  failing_check = checks.find(&:failing?)
12
14
  if failing_check
13
- failing_check.report
15
+ failing_check.report if SimpleCov.print_errors
14
16
  failing_check.exit_code
15
17
  else
16
18
  SimpleCov::ExitCodes::SUCCESS
@@ -20,7 +22,11 @@ module SimpleCov
20
22
  def coverage_checks(result, coverage_limits)
21
23
  [
22
24
  MinimumOverallCoverageCheck.new(result, coverage_limits.minimum_coverage),
23
- MinimumCoverageByFileCheck.new(result, coverage_limits.minimum_coverage_by_file),
25
+ MinimumCoverageByFileCheck.new(
26
+ result, coverage_limits.minimum_coverage_by_file, coverage_limits.minimum_coverage_by_file_overrides
27
+ ),
28
+ MinimumCoverageByGroupCheck.new(result, coverage_limits.minimum_coverage_by_group),
29
+ MaximumOverallCoverageCheck.new(result, coverage_limits.maximum_coverage),
24
30
  MaximumCoverageDropCheck.new(result, coverage_limits.maximum_coverage_drop)
25
31
  ]
26
32
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SimpleCov
4
4
  module ExitCodes
5
+ # Fails when any coverage criterion has dropped by more than the
6
+ # configured maximum since the last recorded run.
5
7
  class MaximumCoverageDropCheck
6
8
  def initialize(result, maximum_coverage_drop)
7
9
  @result = result
@@ -9,20 +11,11 @@ module SimpleCov
9
11
  end
10
12
 
11
13
  def failing?
12
- return false unless maximum_coverage_drop && last_run
13
-
14
- coverage_drop_violations.any?
14
+ violations.any?
15
15
  end
16
16
 
17
17
  def report
18
- coverage_drop_violations.each do |violation|
19
- $stderr.printf(
20
- "%<criterion>s coverage has dropped by %<drop_percent>.2f%% since the last time (maximum allowed: %<max_drop>.2f%%).\n",
21
- criterion: violation[:criterion].capitalize,
22
- drop_percent: SimpleCov.round_coverage(violation[:drop_percent]),
23
- max_drop: violation[:max_drop]
24
- )
25
- end
18
+ violations.each { |violation| warn SimpleCov::Color.colorize(message_for(violation), :red) }
26
19
  end
27
20
 
28
21
  def exit_code
@@ -31,52 +24,21 @@ module SimpleCov
31
24
 
32
25
  private
33
26
 
34
- attr_reader :result, :maximum_coverage_drop
35
-
36
- def last_run
37
- return @last_run if defined?(@last_run)
38
-
39
- @last_run = SimpleCov::LastRun.read
40
- end
41
-
42
- def coverage_drop_violations
43
- @coverage_drop_violations ||=
44
- compute_coverage_drop_data.select do |achieved|
45
- achieved.fetch(:max_drop) < achieved.fetch(:drop_percent)
46
- end
47
- end
48
-
49
- def compute_coverage_drop_data
50
- maximum_coverage_drop.map do |criterion, percent|
51
- {
52
- criterion: criterion,
53
- max_drop: percent,
54
- drop_percent: drop_percent(criterion)
55
- }
56
- end
57
- end
58
-
59
- # if anyone says "max_coverage_drop 0.000000000000000001" I appologize. Please don't.
60
- MAX_DROP_ACCURACY = 10
61
- def drop_percent(criterion)
62
- drop = last_coverage(criterion) -
63
- SimpleCov.round_coverage(
64
- result.coverage_statistics.fetch(criterion).percent
65
- )
66
-
67
- # floats, I tell ya.
68
- # irb(main):001:0* 80.01 - 80.0
69
- # => 0.010000000000005116
70
- drop.floor(MAX_DROP_ACCURACY)
71
- end
72
-
73
- def last_coverage(criterion)
74
- last_coverage_percent = last_run[:result][criterion]
75
-
76
- # fallback for old file format
77
- last_coverage_percent = last_run[:result][:covered_percent] if !last_coverage_percent && criterion == :line
78
-
79
- last_coverage_percent || 0
27
+ # The "drop percent" is a delta, not a coverage level — it has no
28
+ # natural green/yellow/red mapping. Callers color the whole line red
29
+ # so the failure is still visible at a glance.
30
+ def message_for(violation)
31
+ format(
32
+ "%<criterion>s coverage has dropped by %<drop_percent>.2f%% since the last time " \
33
+ "(maximum allowed: %<max_drop>.2f%%).",
34
+ criterion: violation.fetch(:criterion).capitalize,
35
+ drop_percent: violation.fetch(:actual),
36
+ max_drop: violation.fetch(:maximum)
37
+ )
38
+ end
39
+
40
+ def violations
41
+ @violations ||= SimpleCov::CoverageViolations.maximum_drop(@result, @maximum_coverage_drop)
80
42
  end
81
43
  end
82
44
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ExitCodes
5
+ # Fails when the overall (project-wide) coverage for any criterion is
6
+ # above the configured maximum. Pair with
7
+ # `SimpleCov::ExitCodes::MinimumOverallCoverageCheck` (or use
8
+ # `SimpleCov.expected_coverage`) to pin coverage to an exact value
9
+ # and surface unexpected increases instead of silently absorbing them.
10
+ class MaximumOverallCoverageCheck
11
+ def initialize(result, maximum_coverage)
12
+ @result = result
13
+ @maximum_coverage = maximum_coverage
14
+ end
15
+
16
+ def failing?
17
+ violations.any?
18
+ end
19
+
20
+ def report
21
+ violations.each { |violation| report_violation(violation) }
22
+ end
23
+
24
+ def exit_code
25
+ SimpleCov::ExitCodes::MAXIMUM_COVERAGE
26
+ end
27
+
28
+ private
29
+
30
+ def violations
31
+ @violations ||= SimpleCov::CoverageViolations.maximum_overall(@result, @maximum_coverage)
32
+ end
33
+
34
+ def report_violation(violation)
35
+ warn format(
36
+ "%<criterion>s coverage (%<actual>s) is above the expected maximum coverage (%<expected>.2f%%). " \
37
+ "Time to bump the threshold!",
38
+ criterion: violation.fetch(:criterion).capitalize,
39
+ actual: SimpleCov::Color.colorize_percent(violation.fetch(:actual)),
40
+ expected: violation.fetch(:expected)
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,23 +2,28 @@
2
2
 
3
3
  module SimpleCov
4
4
  module ExitCodes
5
+ # Fails when any individual file falls below the configured minimum
6
+ # coverage for any criterion.
5
7
  class MinimumCoverageByFileCheck
6
- def initialize(result, minimum_coverage_by_file)
8
+ def initialize(result, minimum_coverage_by_file, overrides = {})
7
9
  @result = result
8
10
  @minimum_coverage_by_file = minimum_coverage_by_file
11
+ @overrides = overrides
9
12
  end
10
13
 
11
14
  def failing?
12
- minimum_violations.any?
15
+ violations.any?
13
16
  end
14
17
 
15
18
  def report
16
- minimum_violations.each do |violation|
17
- $stderr.printf(
18
- "%<criterion>s coverage by file (%<covered>.2f%%) is below the expected minimum coverage (%<minimum_coverage>.2f%%).\n",
19
- covered: SimpleCov.round_coverage(violation.fetch(:actual)),
20
- minimum_coverage: violation.fetch(:minimum_expected),
21
- criterion: violation.fetch(:criterion).capitalize
19
+ violations.each do |violation|
20
+ warn format(
21
+ "%<criterion>s coverage by file (%<actual>s) is below the expected minimum coverage " \
22
+ "(%<expected>.2f%%) in %<filename>s.",
23
+ criterion: violation.fetch(:criterion).capitalize,
24
+ actual: SimpleCov::Color.colorize_percent(violation.fetch(:actual)),
25
+ expected: violation.fetch(:expected),
26
+ filename: violation.fetch(:project_filename)
22
27
  )
23
28
  end
24
29
  end
@@ -29,25 +34,10 @@ module SimpleCov
29
34
 
30
35
  private
31
36
 
32
- attr_reader :result, :minimum_coverage_by_file
33
-
34
- def minimum_violations
35
- @minimum_violations ||=
36
- compute_minimum_coverage_data.select do |achieved|
37
- achieved.fetch(:actual) < achieved.fetch(:minimum_expected)
38
- end
39
- end
40
-
41
- def compute_minimum_coverage_data
42
- minimum_coverage_by_file.flat_map do |criterion, expected_percent|
43
- result.coverage_statistics_by_file.fetch(criterion).map do |actual_coverage|
44
- {
45
- criterion: criterion,
46
- minimum_expected: expected_percent,
47
- actual: SimpleCov.round_coverage(actual_coverage.percent)
48
- }
49
- end
50
- end
37
+ def violations
38
+ @violations ||= SimpleCov::CoverageViolations.minimum_by_file(
39
+ @result, @minimum_coverage_by_file, @overrides
40
+ )
51
41
  end
52
42
  end
53
43
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ExitCodes
5
+ # Fails when any configured group falls below its minimum coverage
6
+ # threshold for any criterion.
7
+ class MinimumCoverageByGroupCheck
8
+ def initialize(result, minimum_coverage_by_group)
9
+ @result = result
10
+ @minimum_coverage_by_group = minimum_coverage_by_group
11
+ end
12
+
13
+ def failing?
14
+ violations.any?
15
+ end
16
+
17
+ def report
18
+ violations.each do |violation|
19
+ warn format(
20
+ "%<criterion>s coverage by group (%<actual>s) is below the expected minimum coverage " \
21
+ "(%<expected>.2f%%) in %<group_name>s.",
22
+ criterion: violation.fetch(:criterion).capitalize,
23
+ actual: SimpleCov::Color.colorize_percent(violation.fetch(:actual)),
24
+ expected: violation.fetch(:expected),
25
+ group_name: violation.fetch(:group_name)
26
+ )
27
+ end
28
+ end
29
+
30
+ def exit_code
31
+ SimpleCov::ExitCodes::MINIMUM_COVERAGE
32
+ end
33
+
34
+ private
35
+
36
+ def violations
37
+ @violations ||= SimpleCov::CoverageViolations.minimum_by_group(@result, @minimum_coverage_by_group)
38
+ end
39
+ end
40
+ end
41
+ end