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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -1
- data/LICENSE +1 -1
- data/README.md +1009 -511
- data/doc/alternate-formatters.md +0 -5
- data/doc/commercial-services.md +5 -5
- data/exe/simplecov +11 -0
- data/lib/minitest/simplecov_plugin.rb +13 -5
- data/lib/simplecov/autostart.rb +11 -0
- data/lib/simplecov/cli/clean.rb +47 -0
- data/lib/simplecov/cli/coverage.rb +91 -0
- data/lib/simplecov/cli/diff.rb +151 -0
- data/lib/simplecov/cli/dotfile.rb +100 -0
- data/lib/simplecov/cli/merge.rb +116 -0
- data/lib/simplecov/cli/open.rb +50 -0
- data/lib/simplecov/cli/report.rb +84 -0
- data/lib/simplecov/cli/run.rb +36 -0
- data/lib/simplecov/cli/serve.rb +139 -0
- data/lib/simplecov/cli/uncovered.rb +107 -0
- data/lib/simplecov/cli.rb +150 -0
- data/lib/simplecov/color.rb +74 -0
- data/lib/simplecov/combine/branches_combiner.rb +3 -2
- data/lib/simplecov/combine/files_combiner.rb +7 -1
- data/lib/simplecov/combine/lines_combiner.rb +19 -17
- data/lib/simplecov/combine/methods_combiner.rb +26 -0
- data/lib/simplecov/combine/results_combiner.rb +5 -4
- data/lib/simplecov/command_guesser.rb +46 -32
- data/lib/simplecov/configuration/coverage.rb +171 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
- data/lib/simplecov/configuration/filters.rb +195 -0
- data/lib/simplecov/configuration/formatting.rb +119 -0
- data/lib/simplecov/configuration/ignored_entries.rb +63 -0
- data/lib/simplecov/configuration/merging.rb +74 -0
- data/lib/simplecov/configuration/thresholds.rb +174 -0
- data/lib/simplecov/configuration.rb +79 -405
- data/lib/simplecov/coverage_statistics.rb +12 -9
- data/lib/simplecov/coverage_violations.rb +148 -0
- data/lib/simplecov/defaults.rb +27 -20
- data/lib/simplecov/directive.rb +162 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
- data/lib/simplecov/exit_codes.rb +3 -0
- data/lib/simplecov/exit_handling.rb +158 -0
- data/lib/simplecov/file_list.rb +61 -17
- data/lib/simplecov/filter.rb +69 -24
- data/lib/simplecov/formatter/base.rb +101 -0
- data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
- data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
- data/lib/simplecov/formatter/html_formatter.rb +79 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +127 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
- data/lib/simplecov/formatter/json_formatter.rb +77 -0
- data/lib/simplecov/formatter/multi_formatter.rb +4 -5
- data/lib/simplecov/formatter/simple_formatter.rb +9 -11
- data/lib/simplecov/formatter.rb +4 -0
- data/lib/simplecov/last_run.rb +10 -3
- data/lib/simplecov/lines_classifier.rb +26 -13
- data/lib/simplecov/load_global_config.rb +9 -4
- data/lib/simplecov/parallel_adapters/base.rb +51 -0
- data/lib/simplecov/parallel_adapters/generic.rb +42 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +77 -0
- data/lib/simplecov/parallel_adapters.rb +83 -0
- data/lib/simplecov/parallel_coordination.rb +95 -0
- data/lib/simplecov/process.rb +20 -14
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +24 -10
- data/lib/simplecov/profiles/root_filter.rb +6 -5
- data/lib/simplecov/profiles/strict.rb +32 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +32 -3
- data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
- data/lib/simplecov/result/source_file_builder.rb +51 -0
- data/lib/simplecov/result.rb +97 -19
- data/lib/simplecov/result_adapter.rb +68 -6
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
- data/lib/simplecov/result_merger/resultset_file.rb +38 -0
- data/lib/simplecov/result_merger/resultset_store.rb +50 -0
- data/lib/simplecov/result_merger.rb +46 -90
- data/lib/simplecov/result_processing.rb +162 -0
- data/lib/simplecov/simulate_coverage.rb +54 -8
- data/lib/simplecov/source_file/branch.rb +1 -3
- data/lib/simplecov/source_file/branch_builder.rb +114 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +7 -2
- data/lib/simplecov/source_file/line_builder.rb +43 -0
- data/lib/simplecov/source_file/method.rb +52 -0
- data/lib/simplecov/source_file/method_builder.rb +58 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
- data/lib/simplecov/source_file/skip_chunks.rb +77 -0
- data/lib/simplecov/source_file/source_loader.rb +63 -0
- data/lib/simplecov/source_file/statistics.rb +57 -0
- data/lib/simplecov/source_file.rb +66 -232
- data/lib/simplecov/static_coverage_extractor/visitor.rb +193 -0
- data/lib/simplecov/static_coverage_extractor.rb +111 -0
- data/lib/simplecov/useless_results_remover.rb +16 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov-html.rb +4 -0
- data/lib/simplecov.rb +131 -377
- data/lib/simplecov_json_formatter.rb +4 -0
- data/schemas/coverage-v1.0.schema.json +300 -0
- data/schemas/coverage.schema.json +300 -0
- metadata +88 -56
- 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
|
data/lib/simplecov/defaults.rb
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "English"
|
|
4
4
|
require "pathname"
|
|
5
|
-
require_relative "
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
coverage_drop_violations.any?
|
|
14
|
+
violations.any?
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def report
|
|
18
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
15
|
+
violations.any?
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def report
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"%<criterion>s coverage by file (%<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|