Skip to content

Commit 7702ea4

Browse files
committed
Allow per-path overrides in minimum_coverage_by_file
`minimum_coverage_by_file` now accepts String and Regexp keys alongside the existing Symbol-keyed per-criterion defaults. String keys match the project-relative path exactly, except those ending in `/`, which match as a directory prefix; Regexp keys are tested against the project path. Per-path values may be a Numeric (primary criterion) or a per-criterion Hash, e.g. `minimum_coverage_by_file line: 70, 'app/critical.rb' => 100`. For each file, the effective threshold is the Symbol-keyed defaults merged with every matching override — later overrides win per criterion, and any override wins over the default for the same criterion. A file that matches no override keeps the global defaults, so adding an override only ever raises the bar for the files it touches. The new overrides surface in `coverage.json` under the existing `errors.minimum_coverage_by_file` block. The public getter `SimpleCov.minimum_coverage_by_file` still returns just the Symbol-keyed defaults, preserving the existing return shape; a new `SimpleCov.minimum_coverage_by_file_overrides` reader exposes the per-path map. The setter, `CoverageViolations.minimum_by_file`, the `MinimumCoverageByFileCheck` constructor, the `CoverageLimits` struct, and the JSON formatter were updated to carry overrides through. Resolves #575.
1 parent 5e11578 commit 7702ea4

14 files changed

Lines changed: 354 additions & 23 deletions

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Metrics/ClassLength:
5959

6060
Metrics/ModuleLength:
6161
Description: Avoid modules longer than 100 lines of code.
62-
Max: 300
62+
Max: 320
6363
Exclude:
6464
- "lib/simplecov.rb"
6565

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Unreleased
1515
* `# :nocov:` toggle comments (and the configurable `SimpleCov.nocov_token` / `SimpleCov.skip_token`) are deprecated in favor of the new `# simplecov:disable` / `# simplecov:enable` directives. Each file that still uses `# :nocov:` emits a one-time deprecation warning to stderr at load time pointing at the recommended replacement, and any call to `SimpleCov.nocov_token` or `SimpleCov.skip_token` (getter or setter) likewise warns. The directive will be removed in a future release.
1616

1717
## Enhancements
18+
* `SimpleCov.minimum_coverage_by_file` now accepts per-path overrides alongside the existing per-criterion defaults: pass String or Regexp keys to declare file- or directory-specific thresholds, e.g. `minimum_coverage_by_file line: 70, 'app/mailers/request_mailer.rb' => 100`. A String ending in `/` matches as a directory prefix; otherwise it must equal the project-relative path. Regexp keys match against the project-relative path. Per-path values may be a Numeric (primary criterion) or a per-criterion Hash; for each file the effective threshold is the defaults merged with any matching overrides (later overrides win per criterion, overrides win over defaults). The new overrides surface in `coverage.json` under the existing `errors.minimum_coverage_by_file` block. See #575.
1819
* Added `SimpleCov.maximum_coverage` (and the convenience `SimpleCov.expected_coverage`, which sets `minimum_coverage` and `maximum_coverage` to the same value) so the suite can be pinned to an exact coverage figure. A drop fails per the minimum; an unexpected increase also fails, prompting you to bump the threshold up rather than silently absorbing the improvement. Accepts the same Numeric / per-criterion Hash forms as `minimum_coverage`. Exits with status 4 (`SimpleCov::ExitCodes::MAXIMUM_COVERAGE`) when violated, and surfaces in `coverage.json` under `errors.maximum_coverage`. Comparisons floor the actual percent to two decimal places, so `expected_coverage 95.42` still passes when the actual is e.g. 95.4287. See #187.
1920
* Added a bundled `strict` profile (`SimpleCov.start "strict"`) that enables line, branch, and method coverage and pins the minimum threshold for each at 100%. Drops to line-only on engines without branch/method support (JRuby). See #1061.
2021
* `SimpleCov.coverage_path` is now explicitly settable rather than always computed from `SimpleCov.root + SimpleCov.coverage_dir`. Setting it pins the report destination regardless of later `root` / `coverage_dir` changes — useful for out-of-tree build directories (CMake/CTest etc.) where the coverage report doesn't live under the source root. See #716.

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,20 @@ SimpleCov.minimum_coverage_by_file line: 80
933933
SimpleCov.minimum_coverage_by_file line: 90, branch: 80
934934
```
935935

936+
You can also raise the bar for specific files or directories by passing String or Regexp keys alongside the Symbol-keyed defaults. The String form does an exact match against the project-relative path, or a directory-prefix match when it ends in `/`; the Regexp form is matched against the project-relative path. Per-path values may be a single number (applied to the primary criterion) or a per-criterion Hash. For each file, the effective threshold is the defaults merged with any matching overrides — later overrides win per criterion, and overrides themselves win over defaults. See #575.
937+
938+
```ruby
939+
# 70% line coverage everywhere — but require 100% for one critical file
940+
SimpleCov.minimum_coverage_by_file line: 70, 'app/mailers/request_mailer.rb' => 100
941+
942+
# Directory prefix + Regexp; per-criterion override for the payments code
943+
SimpleCov.minimum_coverage_by_file(
944+
line: 70,
945+
'lib/auth/' => 95,
946+
%r{\Alib/payments/} => { line: 100, branch: 90 }
947+
)
948+
```
949+
936950
### Minimum coverage by group
937951

938952
You can define the minimum coverage percentage expected for specific groups. SimpleCov will return non-zero if unmet,

features/minimum_coverage_by_file.feature

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,49 @@ Feature:
7070
And the output should contain "Branch coverage by file (50.00%) is below the expected minimum coverage (70.00%) in lib/faked_project/some_class.rb."
7171
And the output should not contain "Line coverage ("
7272
And the output should contain "SimpleCov failed with exit 2"
73+
74+
Scenario: Per-path override raises the bar for a specific file
75+
Given SimpleCov for Test/Unit is configured with:
76+
"""
77+
require 'simplecov'
78+
SimpleCov.start do
79+
add_filter 'test.rb'
80+
minimum_coverage_by_file line: 70, 'lib/faked_project/framework_specific.rb' => 100
81+
end
82+
"""
83+
84+
When I run `bundle exec rake test`
85+
Then the exit status should not be 0
86+
And the output should contain "Line coverage by file (75.00%) is below the expected minimum coverage (100.00%) in lib/faked_project/framework_specific.rb."
87+
And the output should contain "SimpleCov failed with exit 2"
88+
89+
Scenario: Per-path override does not flag files that pass the default but not the override
90+
# framework_specific.rb is at 75% — passes the default 70%; some_class.rb is
91+
# at 80% — passes both. The directory-prefix override raises the bar to 90%
92+
# for lib/faked_project/, so framework_specific.rb fails its override.
93+
Given SimpleCov for Test/Unit is configured with:
94+
"""
95+
require 'simplecov'
96+
SimpleCov.start do
97+
add_filter 'test.rb'
98+
minimum_coverage_by_file line: 70, 'lib/faked_project/' => 90
99+
end
100+
"""
101+
102+
When I run `bundle exec rake test`
103+
Then the exit status should not be 0
104+
And the output should contain "Line coverage by file (75.00%) is below the expected minimum coverage (90.00%) in lib/faked_project/framework_specific.rb."
105+
And the output should contain "SimpleCov failed with exit 2"
106+
107+
Scenario: Per-path override that no file matches has no effect
108+
Given SimpleCov for Test/Unit is configured with:
109+
"""
110+
require 'simplecov'
111+
SimpleCov.start do
112+
add_filter 'test.rb'
113+
minimum_coverage_by_file line: 70, 'lib/nonexistent.rb' => 100
114+
end
115+
"""
116+
117+
When I run `bundle exec rake test`
118+
Then the exit status should be 0

lib/simplecov.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,16 +314,20 @@ def process_result(result)
314314
CoverageLimits = Struct.new(
315315
:minimum_coverage,
316316
:minimum_coverage_by_file,
317+
:minimum_coverage_by_file_overrides,
317318
:minimum_coverage_by_group,
318319
:maximum_coverage,
319320
:maximum_coverage_drop,
320321
keyword_init: true
321322
)
322323
def result_exit_status(result)
323324
coverage_limits = CoverageLimits.new(
324-
minimum_coverage: minimum_coverage, minimum_coverage_by_file: minimum_coverage_by_file,
325+
minimum_coverage: minimum_coverage,
326+
minimum_coverage_by_file: minimum_coverage_by_file,
327+
minimum_coverage_by_file_overrides: minimum_coverage_by_file_overrides,
325328
minimum_coverage_by_group: minimum_coverage_by_group,
326-
maximum_coverage: maximum_coverage, maximum_coverage_drop: maximum_coverage_drop
329+
maximum_coverage: maximum_coverage,
330+
maximum_coverage_drop: maximum_coverage_drop
327331
)
328332

329333
ExitCodes::ExitCodeHandling.call(result, coverage_limits: coverage_limits)

lib/simplecov/configuration.rb

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,18 +426,33 @@ def maximum_coverage_drop(coverage_drop = nil)
426426
#
427427
# Defines the minimum coverage per file required for the testsuite to pass.
428428
# SimpleCov will return non-zero if the current coverage of the least covered file
429-
# is below this threshold.
429+
# is below this threshold. Default is 0% (disabled).
430430
#
431-
# Default is 0% (disabled)
431+
# Accepts a Numeric (global threshold on the primary criterion), a Symbol-keyed
432+
# Hash (per-criterion globals), or a Hash mixing Symbol keys with String / Regexp
433+
# keys to declare per-path overrides. For each file the effective threshold is
434+
# the Symbol-keyed defaults merged with any matching overrides — later overrides
435+
# win per criterion, overrides win over defaults. See the README and #575.
432436
#
433437
def minimum_coverage_by_file(coverage = nil)
434438
return @minimum_coverage_by_file ||= {} unless coverage
435439

436440
coverage = {primary_coverage => coverage} if coverage.is_a?(Numeric)
437441

438-
raise_on_invalid_coverage(coverage, "minimum_coverage_by_file")
442+
defaults, overrides = partition_per_file_thresholds(coverage)
443+
444+
raise_on_invalid_coverage(defaults, "minimum_coverage_by_file")
445+
overrides.each_value { |criteria| raise_on_invalid_coverage(criteria, "minimum_coverage_by_file") }
439446

440-
@minimum_coverage_by_file = coverage
447+
@minimum_coverage_by_file = defaults
448+
@minimum_coverage_by_file_overrides = overrides
449+
end
450+
451+
# Returns the per-path overrides set via `minimum_coverage_by_file`,
452+
# as an ordered Hash mapping each pattern (String or Regexp) to its
453+
# per-criterion thresholds Hash. Defaults to an empty Hash.
454+
def minimum_coverage_by_file_overrides
455+
@minimum_coverage_by_file_overrides ||= {}
441456
end
442457

443458
#
@@ -681,6 +696,24 @@ def minimum_possible_coverage_exceeded(coverage_option)
681696
warn "The coverage you set for #{coverage_option} is greater than 100%"
682697
end
683698

699+
# Split a `minimum_coverage_by_file` argument into Symbol-keyed criterion
700+
# defaults and String/Regexp-keyed per-path overrides; normalize Numeric
701+
# override values to `{primary_coverage => N}` so downstream code only
702+
# has one shape to handle.
703+
def partition_per_file_thresholds(coverage)
704+
coverage.each_key { |key| validate_per_file_key(key) }
705+
defaults, raw = coverage.partition { |key, _| key.is_a?(Symbol) }.map(&:to_h)
706+
overrides = raw.transform_values { |value| value.is_a?(Numeric) ? {primary_coverage => value} : value }
707+
[defaults, overrides]
708+
end
709+
710+
def validate_per_file_key(key)
711+
return if key.is_a?(Symbol) || key.is_a?(String) || key.is_a?(Regexp)
712+
713+
raise SimpleCov::ConfigurationError,
714+
"minimum_coverage_by_file keys must be Symbol (criterion), String, or Regexp; got #{key.inspect}"
715+
end
716+
684717
# Copy instance variables from block_context into self, saving any of ours
685718
# that would be clobbered. Returns the saved values for later restoration.
686719
def swap_ivars_from(block_context)

lib/simplecov/coverage_violations.rb

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,15 @@ def maximum_overall(result, thresholds)
3030
end
3131

3232
# @return [Array<Hash>] {:criterion, :expected, :actual, :filename, :project_filename}
33-
def minimum_by_file(result, thresholds)
34-
thresholds.flat_map do |criterion, expected|
35-
result.files.filter_map { |file| file_minimum_violation(file, criterion, expected) }
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) }
3642
end
3743
end
3844

@@ -70,6 +76,31 @@ def percent_for(stats_source, criterion)
7076
round(stats.percent) if stats
7177
end
7278

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+
73104
def file_minimum_violation(file, criterion, expected)
74105
actual = percent_for(file, criterion) or return
75106
return unless actual < expected

lib/simplecov/exit_codes/exit_code_handling.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ def call(result, coverage_limits:)
2222
def coverage_checks(result, coverage_limits)
2323
[
2424
MinimumOverallCoverageCheck.new(result, coverage_limits.minimum_coverage),
25-
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+
),
2628
MinimumCoverageByGroupCheck.new(result, coverage_limits.minimum_coverage_by_group),
2729
MaximumOverallCoverageCheck.new(result, coverage_limits.maximum_coverage),
2830
MaximumCoverageDropCheck.new(result, coverage_limits.maximum_coverage_drop)

lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ module ExitCodes
55
# Fails when any individual file falls below the configured minimum
66
# coverage for any criterion.
77
class MinimumCoverageByFileCheck
8-
def initialize(result, minimum_coverage_by_file)
8+
def initialize(result, minimum_coverage_by_file, overrides = {})
99
@result = result
1010
@minimum_coverage_by_file = minimum_coverage_by_file
11+
@overrides = overrides
1112
end
1213

1314
def failing?
@@ -34,7 +35,9 @@ def exit_code
3435
private
3536

3637
def violations
37-
@violations ||= SimpleCov::CoverageViolations.minimum_by_file(@result, @minimum_coverage_by_file)
38+
@violations ||= SimpleCov::CoverageViolations.minimum_by_file(
39+
@result, @minimum_coverage_by_file, @overrides
40+
)
3841
end
3942
end
4043
end

lib/simplecov/formatter/json_formatter/result_hash_formatter.rb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,18 @@ def format_minimum_coverage_errors
6262
end
6363

6464
def format_minimum_coverage_by_file_errors
65-
violations = SimpleCov::CoverageViolations.minimum_by_file(@result, SimpleCov.minimum_coverage_by_file)
66-
violations.each do |violation|
67-
key = CRITERION_KEYS.fetch(SimpleCov.coverage_statistics_key(violation.fetch(:criterion)))
68-
bucket = formatted_result[:errors][:minimum_coverage_by_file] ||= {}
69-
criterion_errors = bucket[key] ||= {}
70-
criterion_errors[violation.fetch(:project_filename)] =
71-
{expected: violation.fetch(:expected), actual: violation.fetch(:actual)}
72-
end
65+
violations = SimpleCov::CoverageViolations.minimum_by_file(
66+
@result, SimpleCov.minimum_coverage_by_file, SimpleCov.minimum_coverage_by_file_overrides
67+
)
68+
violations.each { |violation| record_minimum_coverage_by_file_error(violation) }
69+
end
70+
71+
def record_minimum_coverage_by_file_error(violation)
72+
key = CRITERION_KEYS.fetch(SimpleCov.coverage_statistics_key(violation.fetch(:criterion)))
73+
bucket = formatted_result[:errors][:minimum_coverage_by_file] ||= {}
74+
criterion_errors = bucket[key] ||= {}
75+
criterion_errors[violation.fetch(:project_filename)] =
76+
{expected: violation.fetch(:expected), actual: violation.fetch(:actual)}
7377
end
7478

7579
def format_minimum_coverage_by_group_errors

0 commit comments

Comments
 (0)