simplecov 0.22.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,159 @@
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
+ none = [] # : Array[Hash[Symbol, untyped]]
50
+ group ? group_minimum_violations(group_name, group, minimums) : none
51
+ end
52
+ end
53
+
54
+ # @return [Array<Hash>] {:criterion, :maximum, :actual} where `actual`
55
+ # is the observed drop (in percentage points) vs. the last run.
56
+ def maximum_drop(result, thresholds, last_run: SimpleCov::LastRun.read)
57
+ return [] unless last_run
58
+
59
+ thresholds.filter_map do |criterion, maximum|
60
+ actual = compute_drop(criterion, result, last_run)
61
+ {criterion: criterion, maximum: maximum, actual: actual} if actual && actual > maximum
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ # Look up a criterion's percent on any coverage_statistics-bearing
68
+ # object (Result, SourceFile, FileList). Returns nil — and the
69
+ # caller silently skips — when the criterion was configured but not
70
+ # actually measured by the runtime (e.g. `minimum_coverage branch:
71
+ # 100` under the "strict" profile on JRuby, where the Coverage
72
+ # module doesn't emit branch data). The config-time
73
+ # `raise_if_criterion_disabled` check still catches the genuine
74
+ # "forgot to enable the criterion" mistake before we ever get here.
75
+ def percent_for(stats_source, criterion)
76
+ stats = stats_source.coverage_statistics[SimpleCov.coverage_statistics_key(criterion)]
77
+ round(stats.percent) if stats
78
+ end
79
+
80
+ # Walk the overrides in declaration order, merging each one that matches
81
+ # the file's project path into the running effective threshold (so the
82
+ # most-specific or latest-declared override wins per criterion). Returns
83
+ # the defaults Hash unchanged when nothing matches.
84
+ def effective_per_file_thresholds(file, defaults, overrides)
85
+ return defaults if overrides.empty?
86
+
87
+ path = file.project_filename
88
+ overrides.reduce(defaults) do |acc, (pattern, criterion_thresholds)|
89
+ path_matches?(path, pattern) ? acc.merge(criterion_thresholds) : acc
90
+ end
91
+ end
92
+
93
+ # Per-path matching for `minimum_coverage_by_file` overrides. Strings
94
+ # ending in `/` are treated as directory prefixes; otherwise they must
95
+ # match `project_filename` exactly. Regexps are tested via `match?`.
96
+ # The configuration setter rejects anything other than String/Regexp,
97
+ # so no dead `else` branch is needed here.
98
+ def path_matches?(project_filename, pattern)
99
+ return project_filename.match?(pattern) if pattern.is_a?(Regexp)
100
+ return project_filename.start_with?(pattern) if pattern.end_with?("/")
101
+
102
+ project_filename == pattern
103
+ end
104
+
105
+ def file_minimum_violation(file, criterion, expected)
106
+ actual = percent_for(file, criterion) or return
107
+ return unless actual < expected
108
+
109
+ {
110
+ criterion: criterion,
111
+ expected: expected,
112
+ actual: actual,
113
+ filename: file.filename,
114
+ project_filename: file.project_filename
115
+ }
116
+ end
117
+
118
+ def group_minimum_violations(group_name, group, minimums)
119
+ minimums.filter_map do |criterion, expected|
120
+ actual = percent_for(group, criterion) or next
121
+ {group_name: group_name, criterion: criterion, expected: expected, actual: actual} if actual < expected
122
+ end
123
+ end
124
+
125
+ # The misconfiguration notice is enforcement output, not a Ruby
126
+ # warning: like every other enforcement message it must survive
127
+ # `-W0` and `Warning.warn` hooks (see ExitCodes.print_error) and
128
+ # honor the `print_errors` opt-out.
129
+ def lookup_group(result, group_name)
130
+ group = result.groups[group_name]
131
+ if group.nil? && SimpleCov.print_errors
132
+ ExitCodes.print_error "minimum_coverage_by_group: no group named '#{group_name}' exists. " \
133
+ "Available groups: #{result.groups.keys.join(', ')}"
134
+ end
135
+ group
136
+ end
137
+
138
+ def compute_drop(criterion, result, last_run)
139
+ stats_key = SimpleCov.coverage_statistics_key(criterion)
140
+ last_coverage_percent = last_run.dig(:result, stats_key)
141
+ if last_coverage_percent.nil? && stats_key == :line
142
+ last_coverage_percent = last_run.dig(:result, :covered_percent)
143
+ end
144
+ # A hand-edited .last_run.json can carry any value type, and
145
+ # LastRun.read only vouches for the top level being a Hash.
146
+ # Treat a non-numeric percent like a missing one instead of
147
+ # raising out of the at_exit hook.
148
+ return unless last_coverage_percent.is_a?(Numeric)
149
+
150
+ current = percent_for(result, criterion) or return
151
+ (last_coverage_percent - current).floor(10)
152
+ end
153
+
154
+ def round(percent)
155
+ SimpleCov.round_coverage(percent)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -1,34 +1,34 @@
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
- # Gotta stash this a-s-a-p, see the CommandGuesser class and i.e. #110 for further info
27
+ # Gotta stash this a-s-a-p, see the CommandGuesser class and i.e. #110 for further info.
28
+ # The program name is kept separately as well: the join below is lossy for a
29
+ # path containing a space, and the guesser reads the executable from it.
25
30
  SimpleCov::CommandGuesser.original_run_command = "#{$PROGRAM_NAME} #{ARGV.join(' ')}"
26
-
27
- at_exit do
28
- next if SimpleCov.external_at_exit?
29
-
30
- SimpleCov.at_exit_behavior
31
- end
31
+ SimpleCov::CommandGuesser.original_program_name = $PROGRAM_NAME
32
32
 
33
33
  # Autoload config from ~/.simplecov if present
34
34
  require_relative "load_global_config"
@@ -40,14 +40,25 @@ config_path = Pathname.new(SimpleCov.root)
40
40
  loop do
41
41
  filename = config_path.join(".simplecov")
42
42
  if filename.exist?
43
- begin
44
- load filename
45
- rescue LoadError, StandardError
43
+ # `.simplecov` is a configuration file; SimpleCov.start calls inside
44
+ # it draw a deprecation warning, and tracking still begins for
45
+ # backward compatibility. See `SimpleCov.with_dot_simplecov_autoload`
46
+ # and issue #581.
47
+ SimpleCov.with_dot_simplecov_autoload do
48
+ load filename.to_s
49
+ rescue LoadError, StandardError => e
50
+ # simplecov:disable — only fires when .simplecov is unreadable
51
+ # or raises during load
46
52
  warn "Warning: Error occurred while trying to load #{filename}. " \
47
- "Error message: #{$!.message}"
53
+ "Error message: #{e.message}"
54
+ # simplecov:enable
48
55
  end
49
56
  break
50
57
  end
58
+ # simplecov:disable — only fires when no .simplecov is found up to
59
+ # the filesystem root; simplecov's own dogfood run finds the repo's
60
+ # .simplecov on the first iteration and breaks before getting here.
51
61
  config_path, = config_path.split
52
62
  break if config_path.root?
63
+ # simplecov:enable
53
64
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # Emits legacy-API deprecation warnings, deduplicated by the source
5
+ # location that triggered them. A deprecated method called in a loop —
6
+ # or a config block re-evaluated once per parallel worker / spec file —
7
+ # otherwise repeats the same notice until stderr is unreadable. Keying
8
+ # on the caller location collapses those repeats to a single line while
9
+ # still warning separately about each distinct call site the user needs
10
+ # to fix. See issue #1204.
11
+ module Deprecation
12
+ module_function
13
+
14
+ # Warn about a deprecated API. `message` is the notice without the
15
+ # `[DEPRECATION]` tag or location prefix (both are added here).
16
+ #
17
+ # `location` defaults to the caller of the deprecated method that
18
+ # called us — every shipped call site is a one-level alias such as
19
+ # `track_files`, so the frame two up is the user code. Pass `location:`
20
+ # explicitly when the relevant site isn't that frame (e.g. a source
21
+ # file and line discovered while parsing).
22
+ # `Array(...)` coerces a missing backtrace (nil) to `[]` so `.first`
23
+ # yields nil rather than raising — and, unlike `&.`, adds no branch for
24
+ # the unreachable no-caller case to the project's 100% coverage target.
25
+ def warn(message, location: Array(Kernel.caller(2..2)).first)
26
+ # Key on location when we have one (collapses a deprecated call in a
27
+ # loop to a single warning); fall back to the message so a missing
28
+ # backtrace never silently swallows every notice.
29
+ return unless emitted.add?(location || message)
30
+
31
+ Kernel.warn "#{"#{location}: " if location}[DEPRECATION] #{message}"
32
+ end
33
+
34
+ # Already-emitted dedup keys for this process. Parallel workers are
35
+ # separate processes with their own set, so each warns at most once.
36
+ def emitted
37
+ @emitted ||= Set.new
38
+ end
39
+
40
+ # @api private — reset emitted state between tests.
41
+ def reset!
42
+ @emitted = Set.new
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,167 @@
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})\b)?
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 do |category|
59
+ empty = [] # : Array[Range[Integer]]
60
+ [category, empty]
61
+ end
62
+ open_starts = {} # : Hash[Symbol, Integer]
63
+
64
+ directives_in(lines).each { |directive| directive.apply(ranges, open_starts) }
65
+ open_starts.each { |category, start| ranges[category] << (start..lines.size) }
66
+
67
+ ranges
68
+ end
69
+
70
+ # Extract every directive in the file, in source order. Comments inside
71
+ # string literals or heredocs are skipped because Ripper.lex doesn't tag
72
+ # them as :on_comment tokens.
73
+ def self.directives_in(lines)
74
+ return [] unless source_might_contain_directive?(lines)
75
+
76
+ comments_in(lines).filter_map do |line_number, column, text|
77
+ parse_comment(lines, line_number, column, text)
78
+ end
79
+ end
80
+
81
+ # Cheap pre-check so we don't tokenize files that obviously can't contain
82
+ # a directive.
83
+ def self.source_might_contain_directive?(lines)
84
+ lines.any? do |line|
85
+ line.include?("simplecov")
86
+ rescue ArgumentError, EncodingError
87
+ false # simplecov:disable — defensive guard for invalid byte sequences in source
88
+ end
89
+ end
90
+
91
+ def self.parse_comment(lines, line_number, column, text)
92
+ match = PATTERN.match(text)
93
+ return nil unless match
94
+
95
+ new(
96
+ line_number: line_number,
97
+ # `to_s` is a no-op: the pattern requires the `mode` group, so it
98
+ # is present whenever `match` is; it also satisfies the type checker.
99
+ mode: match[:mode].to_s.to_sym,
100
+ categories: parse_categories(match[:categories]),
101
+ inline: inline?(lines, line_number, column + match.begin(0))
102
+ )
103
+ rescue ArgumentError, EncodingError
104
+ # E.g., comment text contains an invalid byte sequence in UTF-8.
105
+ nil
106
+ end
107
+
108
+ def self.parse_categories(text)
109
+ return CATEGORIES.dup if text.nil?
110
+
111
+ text.split(/\s*,\s*/).map(&:to_sym)
112
+ end
113
+
114
+ # Whether the directive sits after non-whitespace content on its line.
115
+ # `column` is the byte column of the directive's `#` in the source line,
116
+ # adjusted for any prefix that may precede it within the comment token
117
+ # (e.g., `# prefix # simplecov:disable line`).
118
+ def self.inline?(lines, line_number, column)
119
+ line = lines[line_number - 1].to_s
120
+ !line.byteslice(0, column).to_s.strip.empty?
121
+ rescue ArgumentError, EncodingError
122
+ false # simplecov:disable — defensive guard for invalid byte sequences
123
+ end
124
+
125
+ def self.comments_in(lines)
126
+ source = lines.map { |line| line.end_with?("\n") ? line : "#{line}\n" }.join
127
+ Ripper.lex(source).filter_map do |(line_number, column), type, text|
128
+ [line_number, column, text] if type == :on_comment
129
+ end
130
+ rescue ArgumentError, EncodingError
131
+ [] # simplecov:disable — Ripper.lex can raise on invalid byte sequences
132
+ end
133
+
134
+ private_class_method :directives_in, :source_might_contain_directive?,
135
+ :parse_comment, :parse_categories, :inline?, :comments_in
136
+
137
+ def initialize(line_number:, mode:, categories:, inline:)
138
+ @line_number = line_number
139
+ @mode = mode
140
+ @categories = categories
141
+ @inline = inline
142
+ end
143
+
144
+ def disabled?
145
+ mode == :disable
146
+ end
147
+
148
+ def inline?
149
+ @inline
150
+ end
151
+
152
+ # Apply this directive's effect to the in-flight per-category state.
153
+ # Inline directives mark just their line; block disables open a region;
154
+ # block enables close one. Re-opening an already-open block is a no-op.
155
+ def apply(ranges, open_starts)
156
+ categories.each do |category|
157
+ if inline?
158
+ ranges[category] << (line_number..line_number) if disabled?
159
+ elsif disabled?
160
+ open_starts[category] ||= line_number
161
+ elsif (start = open_starts.delete(category))
162
+ ranges[category] << (start..line_number)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module ExitCodes
5
+ # Shared skeleton for the threshold checks: stash the result and the
6
+ # threshold configuration, fail when any violation exists, and
7
+ # report one message per violation. Subclasses supply `exit_code`,
8
+ # `compute_violations` (memoized here — `failing?` and `report` both
9
+ # consult it), and `report_violation`.
10
+ class Check
11
+ def initialize(result, thresholds)
12
+ @result = result
13
+ @thresholds = thresholds
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
+ private
25
+
26
+ attr_reader :result, :thresholds
27
+
28
+ def violations
29
+ @violations ||= compute_violations
30
+ end
31
+ end
32
+ end
33
+ 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,81 +2,34 @@
2
2
 
3
3
  module SimpleCov
4
4
  module ExitCodes
5
- class MaximumCoverageDropCheck
6
- def initialize(result, maximum_coverage_drop)
7
- @result = result
8
- @maximum_coverage_drop = maximum_coverage_drop
9
- end
10
-
11
- def failing?
12
- return false unless maximum_coverage_drop && last_run
13
-
14
- coverage_drop_violations.any?
15
- end
16
-
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
26
- end
27
-
5
+ # Fails when any coverage criterion has dropped by more than the
6
+ # configured maximum since the last recorded run.
7
+ class MaximumCoverageDropCheck < Check
28
8
  def exit_code
29
9
  SimpleCov::ExitCodes::MAXIMUM_COVERAGE_DROP
30
10
  end
31
11
 
32
12
  private
33
13
 
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
14
+ def compute_violations
15
+ SimpleCov::CoverageViolations.maximum_drop(result, thresholds)
57
16
  end
58
17
 
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)
18
+ # The "drop percent" is a delta, not a coverage level — it has no
19
+ # natural green/yellow/red mapping, so the whole line goes red to
20
+ # keep the failure visible at a glance.
21
+ def report_violation(violation)
22
+ ExitCodes.print_error SimpleCov::Color.colorize(message_for(violation), :red)
71
23
  end
72
24
 
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
25
+ def message_for(violation)
26
+ format(
27
+ "%<criterion>s coverage has dropped by %<drop_percent>.2f%% since the last time " \
28
+ "(maximum allowed: %<max_drop>.2f%%).",
29
+ criterion: violation.fetch(:criterion).capitalize,
30
+ drop_percent: violation.fetch(:actual),
31
+ max_drop: violation.fetch(:maximum)
32
+ )
80
33
  end
81
34
  end
82
35
  end
@@ -0,0 +1,32 @@
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 < Check
11
+ def exit_code
12
+ SimpleCov::ExitCodes::MAXIMUM_COVERAGE
13
+ end
14
+
15
+ private
16
+
17
+ def compute_violations
18
+ SimpleCov::CoverageViolations.maximum_overall(result, thresholds)
19
+ end
20
+
21
+ def report_violation(violation)
22
+ ExitCodes.print_error format(
23
+ "%<criterion>s coverage (%<actual>s) is above the expected maximum coverage (%<expected>.2f%%). " \
24
+ "Time to bump the threshold!",
25
+ criterion: violation.fetch(:criterion).capitalize,
26
+ actual: SimpleCov::Color.colorize_percent(violation.fetch(:actual)),
27
+ expected: violation.fetch(:expected)
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end