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
@@ -5,38 +5,68 @@ module SimpleCov
5
5
  #
6
6
  # Combine two different lines coverage results on same file
7
7
  #
8
- # Should be called through `SimpleCov.combine`.
8
+ # Should be called through `CoverageAccumulator`.
9
9
  module LinesCombiner
10
10
  module_function
11
11
 
12
- def combine(coverage_a, coverage_b)
13
- coverage_a
14
- .zip(coverage_b)
15
- .map do |coverage_a_val, coverage_b_val|
16
- merge_line_coverage(coverage_a_val, coverage_b_val)
17
- end
18
- end
19
-
20
- # Return depends on coverage in a specific line
12
+ # Folds `source` into `target` rather than building a third array.
13
+ # Only for a `target` the caller owns outright: a caller holding a
14
+ # reference to it (e.g. the parsed `coverage` key of a resultset
15
+ # hash being passed into a second merge) would see it change.
16
+ # `source` is never touched.
21
17
  #
22
- # @param [Integer || nil] first_val
23
- # @param [Integer || nil] second_val
18
+ # Two runs of the same source file should agree on which lines
19
+ # are coverage-relevant (`nil` for comments / whitespace, `0`+
20
+ # for executable). When they don't, treat "relevant on either
21
+ # side" as relevant rather than masking a real `0` as `nil`,
22
+ # which would silently drop an uncovered line from the
23
+ # denominator and inflate the percentage:
24
24
  #
25
- # Logic:
26
- #
27
- # => nil + 0 = nil
28
25
  # => nil + nil = nil
29
- # => int + int = int
26
+ # => nil + int = int (preserves a relevant-but-uncovered 0)
27
+ # => int + int = int (sum)
28
+ #
29
+ # The loop is written out rather than dispatching a block per
30
+ # element because this is the innermost loop of a merge: a
31
+ # 160-worker run over ~1,800 files folds tens of millions of line
32
+ # counts through it, and a call per count is a measurable share of
33
+ # that.
30
34
  #
31
- # @return [Integer || nil]
32
- def merge_line_coverage(first_val, second_val)
33
- sum = first_val.to_i + second_val.to_i
35
+ # @return [Array] the array to keep — `target` itself once there is one
36
+ def merge_into(target, source)
37
+ return target unless source
38
+ return source.dup unless target
34
39
 
35
- if sum.zero? && (first_val.nil? || second_val.nil?)
36
- nil
37
- else
38
- sum
40
+ size = source.size
41
+ target.concat(Array.new(size - target.size)) if target.size < size
42
+ sum_into(target, source, size)
43
+ end
44
+
45
+ # Split out only to keep `merge_into` short; it is the same loop.
46
+ #
47
+ # The `Integer` tests coerce malformed counts (a "3" written by a
48
+ # hand-edited or foreign resultset, a JSON float) with `to_i`, so
49
+ # external input merges to a wrong answer instead of raising out of
50
+ # the middle of a merge. An `is_a?` per count is far cheaper than
51
+ # the block dispatch this loop exists to avoid, and the well-formed
52
+ # fast path stays branch-for-branch what it was.
53
+ def sum_into(target, source, size)
54
+ index = 0
55
+ while index < size
56
+ if (value = source[index])
57
+ value = value.to_i unless value.is_a?(Integer)
58
+ existing = target[index]
59
+ target[index] = existing.is_a?(Integer) ? existing + value : coerce_add(existing, value)
60
+ end
61
+ index += 1
39
62
  end
63
+ target
64
+ end
65
+
66
+ # The rare arm: `existing` is nil (line not yet relevant in the
67
+ # target) or malformed external input.
68
+ def coerce_add(existing, value)
69
+ existing.nil? ? value : existing.to_i + value
40
70
  end
41
71
  end
42
72
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source_file/ruby_data_parser"
4
+ require_relative "identity_interner"
5
+ require_relative "interned_counts"
6
+
7
+ module SimpleCov
8
+ module Combine
9
+ #
10
+ # Combine different method coverage results on a single file.
11
+ #
12
+ # Should be called through `CoverageAccumulator`.
13
+ module MethodsCombiner
14
+ module_function
15
+
16
+ #
17
+ # Return merged methods or the existing methods if other is missing.
18
+ #
19
+ # Method coverage maps `[class, name, start_line, start_col, end_line,
20
+ # end_col]` keys to hit counts. Keys are matched on their SOURCE
21
+ # identity — the location, ignoring the class and name elements —
22
+ # because Ruby records one entry per defined method: the same
23
+ # `define_method` block defined onto different classes, or under
24
+ # different names, in different processes arrives with different
25
+ # receivers or names for the same source method, and matching on the
26
+ # full key would keep both, letting a never-called copy's 0 shadow a
27
+ # covered method after merge (issue #1234). Combining sums the hit
28
+ # counts for matching methods and preserves methods that only appear
29
+ # in one result.
30
+ #
31
+ # @return [Hash]
32
+ #
33
+ def combine(coverage_a, coverage_b)
34
+ merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Integer]]
35
+ materialize(absorb(merged, coverage_b))
36
+ end
37
+
38
+ # Folds `coverage` into `target`, an interned table keyed by method
39
+ # source identity. See `BranchesCombiner.absorb` for why a fold keeps
40
+ # its accumulator interned.
41
+ #
42
+ # A `nil` `target` stays `nil` until some resultset actually carries
43
+ # methods, so a merge can tell "no method data anywhere" apart from
44
+ # "method data that covers nothing".
45
+ #
46
+ # @return [Hash, nil] `target`, created on the first coverage seen
47
+ def absorb(target, coverage)
48
+ return target unless coverage
49
+
50
+ target ||= {} #: Hash[untyped, [untyped, Integer]]
51
+ InternedCounts.absorb_counts(target, coverage, identities)
52
+ end
53
+
54
+ # Turns an interned table back into the tuple-keyed hash the rest of
55
+ # SimpleCov reads. Done once, at the end of a fold.
56
+ #
57
+ # @return [Hash]
58
+ def materialize(target)
59
+ target.values.to_h
60
+ end
61
+
62
+ # Bounded by the project's method count, like `RubyDataParser`'s parse
63
+ # cache.
64
+ def identities
65
+ @identities ||= IdentityInterner.build { |key| source_identity(key) }
66
+ end
67
+
68
+ def source_identity(key)
69
+ _class_name, _method_name, *location = SourceFile::RubyDataParser.call(key)
70
+ location.freeze
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "coverage_accumulator"
4
+
3
5
  module SimpleCov
4
6
  module Combine
5
7
  # There might be reports from different kinds of tests,
@@ -12,48 +14,23 @@ module SimpleCov
12
14
 
13
15
  #
14
16
  # Combine process explanation
15
- # => ResultCombiner: define all present files between results and start combine on file level.
16
- # ==> FileCombiner: collect result of next combine levels lines and branches.
17
+ # => ResultsCombiner: hand every result to one accumulator.
18
+ # ==> CoverageAccumulator::MergedFile: hold one file's merged coverage.
17
19
  # ===> LinesCombiner: combine lines results.
18
20
  # ===> BranchesCombiner: combine branches results.
21
+ # ===> MethodsCombiner: combine methods results.
19
22
  #
20
- # @return [Hash]
21
- #
22
- def combine(*results)
23
- results.reduce({}) do |combined_results, next_result|
24
- combine_result_sets(combined_results, next_result)
25
- end
26
- end
27
-
28
- #
29
- # Manage combining results on files level
30
- #
31
- # @param [Hash] combined_results
32
- # @param [Hash] result
23
+ # Callers that read their results one at a time (`ResultMerger`, to
24
+ # keep a big CI run's resultsets out of memory all at once) should
25
+ # drive a `CoverageAccumulator` directly instead of collecting the
26
+ # results to pass here.
33
27
  #
34
28
  # @return [Hash]
35
29
  #
36
- def combine_result_sets(combined_results, result)
37
- results_files = combined_results.keys | result.keys
38
-
39
- results_files.each_with_object({}) do |file_name, file_combination|
40
- file_combination[file_name] = combine_file_coverage(
41
- combined_results[file_name],
42
- result[file_name]
43
- )
44
- end
45
- end
46
-
47
- #
48
- # Combine two files coverage results
49
- #
50
- # @param [Hash] coverage_a
51
- # @param [Hash] coverage_b
52
- #
53
- # @return [Hash]
54
- #
55
- def combine_file_coverage(coverage_a, coverage_b)
56
- Combine.combine(Combine::FilesCombiner, coverage_a, coverage_b)
30
+ def combine(*results)
31
+ accumulator = CoverageAccumulator.new
32
+ results.each { |result| accumulator.absorb(result) }
33
+ accumulator.result || {}
57
34
  end
58
35
  end
59
36
  end
@@ -1,30 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCov
4
- # Functionally for combining coverage results
4
+ # Namespace for the pieces that combine coverage results.
5
+ #
6
+ # `CoverageAccumulator` is the entry point: it folds any number of
7
+ # resultsets together, delegating one file's lines, branches and methods to
8
+ # the combiner modules alongside it.
5
9
  #
6
10
  module Combine
7
- module_function
8
-
9
- #
10
- # Combine two coverage based on the given combiner_module.
11
- #
12
- # Combiners should always be called through this interface,
13
- # as it takes care of short-circuiting of one of the coverages is nil.
14
- #
15
- # @return [Hash]
16
- def combine(combiner_module, coverage_a, coverage_b)
17
- return existing_coverage(coverage_a, coverage_b) if empty_coverage?(coverage_a, coverage_b)
18
-
19
- combiner_module.combine(coverage_a, coverage_b)
20
- end
21
-
22
- def empty_coverage?(coverage_a, coverage_b)
23
- !(coverage_a && coverage_b)
24
- end
25
-
26
- def existing_coverage(coverage_a, coverage_b)
27
- coverage_a || coverage_b
28
- end
29
11
  end
30
12
  end
@@ -11,53 +11,124 @@ module SimpleCov
11
11
  # have a habit of tampering with ARGV, which makes i.e. the automatic distinction
12
12
  # between rails unit/functional/integration tests impossible without this cached
13
13
  # item.
14
- attr_accessor :original_run_command
14
+ attr_reader :original_run_command
15
+
16
+ # Assigning the flattened command clears any separately recorded program
17
+ # name so the two can never describe different runs. Callers that know the
18
+ # real one assign it immediately afterwards, as `defaults.rb` does.
19
+ def original_run_command=(command)
20
+ @original_program_name = nil
21
+ @original_run_command = command
22
+ end
23
+
24
+ # The invoked program on its own. `original_run_command` joins
25
+ # `$PROGRAM_NAME` and `ARGV` with a space, so a program path containing
26
+ # one (`/opt/My Ruby/bin/rspec`) cannot be recovered from it afterwards.
27
+ # Falls back to the first token for callers that set only the flattened
28
+ # command, which is every caller outside `defaults.rb`.
29
+ attr_writer :original_program_name
30
+
31
+ def original_program_name
32
+ @original_program_name || original_run_command.to_s[/\A\s*(\S+)/, 1]
33
+ end
15
34
 
16
35
  def guess
17
- from_env || from_command_line_options || from_defined_constants
36
+ framework = from_executable_name || from_command_line_options || from_defined_constants
37
+ [framework, parallel_data].compact.join(" ")
18
38
  end
19
39
 
20
40
  private
21
41
 
22
- def from_env
23
- # If being run from inside parallel_tests set the command name according to the process number
24
- return unless ENV["PARALLEL_TEST_GROUPS"] && ENV["TEST_ENV_NUMBER"]
42
+ # When parallel_tests (or a compatible runner) is driving the suite,
43
+ # tag the command name with this worker's position in the pool.
44
+ def parallel_data
45
+ groups, number = ENV.values_at("PARALLEL_TEST_GROUPS", "TEST_ENV_NUMBER")
46
+ return unless groups && number
25
47
 
26
- number = ENV["TEST_ENV_NUMBER"]
48
+ # parallel_tests sets the first worker's TEST_ENV_NUMBER to "" rather
49
+ # than "1"; restore the position so the rendered label reads cleanly.
27
50
  number = "1" if number.empty?
28
- "(#{number}/#{ENV['PARALLEL_TEST_GROUPS']})"
51
+ "(#{number}/#{groups})"
29
52
  end
30
53
 
54
+ # The command is `"#{$PROGRAM_NAME} #{ARGV.join(' ')}"`, so a keyword only
55
+ # carries meaning when it begins a path segment or an argument. Matching it
56
+ # as a bare substring lets any part of the *interpreter* path decide the
57
+ # suite name: a Ruby installed under `latest/bin` (as mise does) contains
58
+ # "test/", so every run through it was labelled "Unit Tests". The same
59
+ # applies within ARGV, where `spec/greatest/` used to match just as wrongly.
60
+ #
61
+ # A backslash bounds a segment too, or a Windows `spec\foo_spec.rb` would
62
+ # lose a match the bare substrings used to make. The `test/` patterns below
63
+ # still spell their separator as `/` only, so a backslash `test\functional\`
64
+ # goes unrecognised there, but that is how it has always been rather than
65
+ # something the anchoring took away.
66
+ SEGMENT_START = %r{(?<![^\s/\\])}
67
+ private_constant :SEGMENT_START
68
+ SEGMENT_END = %r{(?=[\s/\\]|\z)}
69
+ private_constant :SEGMENT_END
70
+
71
+ COMMAND_LINE_FRAMEWORKS = {
72
+ %r{#{SEGMENT_START}test/functional/} => "Functional Tests",
73
+ %r{#{SEGMENT_START}test/\{.*functional.*\}/} => "Functional Tests",
74
+ %r{#{SEGMENT_START}test/integration/} => "Integration Tests",
75
+ %r{#{SEGMENT_START}test/} => "Unit Tests",
76
+ /#{SEGMENT_START}spec#{SEGMENT_END}/ => "RSpec",
77
+ /#{SEGMENT_START}cucumber#{SEGMENT_END}/ => "Cucumber Features",
78
+ /#{SEGMENT_START}features#{SEGMENT_END}/ => "Cucumber Features"
79
+ }.freeze
80
+ private_constant :COMMAND_LINE_FRAMEWORKS
81
+
31
82
  def from_command_line_options
32
- case original_run_command
33
- when /test\/functional\//, /test\/\{.*functional.*\}\//
34
- "Functional Tests"
35
- when /test\/integration\//
36
- "Integration Tests"
37
- when /test\//
38
- "Unit Tests"
39
- when /spec/
40
- "RSpec"
41
- when /cucumber/, /features/
42
- "Cucumber Features"
43
- end
83
+ COMMAND_LINE_FRAMEWORKS.find { |pattern, _| pattern.match?(original_run_command.to_s) }&.last
44
84
  end
45
85
 
86
+ # Which binary was invoked outranks everything else on the command line.
87
+ # `rspec features` is an RSpec suite whose examples live in features/, not
88
+ # a Cucumber run, and only the executable name says so. Generic runners
89
+ # (`ruby`, `rake`, a bare `*_test.rb` script) are absent on purpose: they
90
+ # name no framework, so those runs fall through to the path patterns, which
91
+ # is what keeps the test/functional vs test/integration split working.
92
+ EXECUTABLE_FRAMEWORKS = {
93
+ "rspec" => "RSpec",
94
+ "cucumber" => "Cucumber Features"
95
+ }.freeze
96
+ private_constant :EXECUTABLE_FRAMEWORKS
97
+
98
+ def from_executable_name
99
+ program = original_program_name
100
+ return unless program
101
+
102
+ # Trim any extension so Windows' rspec.bat is recognized too.
103
+ EXECUTABLE_FRAMEWORKS[File.basename(program, ".*")]
104
+ end
105
+
106
+ # Inner array literals after the first are flagged uncovered by Ruby's
107
+ # Coverage module even though the constant evaluates as a whole — known
108
+ # quirk with multi-line array literals.
109
+ DEFINED_CONSTANT_FRAMEWORKS = [
110
+ ["RSpec", -> { defined?(::RSpec) }],
111
+ ["Unit Tests", -> { defined?(Test::Unit) }], # simplecov:disable
112
+ ["Minitest", -> { defined?(::Minitest) }], # simplecov:disable
113
+ ["MiniTest", -> { defined?(MiniTest) }] # simplecov:disable
114
+ ].freeze
115
+ private_constant :DEFINED_CONSTANT_FRAMEWORKS
116
+
117
+ # If the command regexps fail, let's try checking defined constants.
46
118
  def from_defined_constants
47
- # If the command regexps fail, let's try checking defined constants.
48
- if defined?(RSpec)
49
- "RSpec"
50
- elsif defined?(Test::Unit)
51
- "Unit Tests"
52
- elsif defined?(Minitest)
53
- "Minitest"
54
- elsif defined?(MiniTest)
55
- "MiniTest"
56
- else
57
- # TODO: Provide link to docs/wiki article
58
- warn "SimpleCov failed to recognize the test framework and/or suite used. Please specify manually using SimpleCov.command_name 'Unit Tests'."
59
- "Unknown Test Framework"
60
- end
119
+ # simplecov:disable branch — first iter returns when ::RSpec is defined; later branches unreachable
120
+ DEFINED_CONSTANT_FRAMEWORKS.each { |name, defined_check| return name if defined_check.call }
121
+ # simplecov:enable branch
122
+
123
+ # TODO: Provide link to docs/wiki article
124
+ # simplecov:disable — only fires when no framework is detected, which
125
+ # is impossible while our own specs are running under rspec
126
+ warn(
127
+ "SimpleCov failed to recognize the test framework and/or suite used. " \
128
+ "Please specify manually using SimpleCov.command_name 'Unit Tests'."
129
+ )
130
+ "Unknown Test Framework"
131
+ # simplecov:enable
61
132
  end
62
133
  end
63
134
  end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # The `coverage` configuration method configures each coverage criterion
5
+ # (`:line`, `:branch`, `:method`, `:eval`) uniformly in one place: naming a
6
+ # criterion enables it,
7
+ # and every threshold is declared with the same syntax regardless of which
8
+ # criterion it applies to. Because the criterion is fixed by the enclosing
9
+ # `coverage` call, threshold values are always plain percentages — there is
10
+ # no per-criterion Hash competing with the value for a slot.
11
+ #
12
+ # SimpleCov.start do
13
+ # coverage :line do
14
+ # minimum 90
15
+ # minimum_per_file 80
16
+ # minimum_per_file 100, only: "app/mailers/request_mailer.rb"
17
+ # maximum_drop 5
18
+ # end
19
+ #
20
+ # coverage :branch, minimum: 80
21
+ # coverage :method, minimum: 100
22
+ # end
23
+ #
24
+ # Line coverage is enabled by default, so `coverage :line` is only needed to
25
+ # set line thresholds or options. Thresholds feed the same internal stores
26
+ # as the flat `minimum_coverage` family, so enforcement is unchanged.
27
+ module Configuration
28
+ # One-liner keyword options `coverage` accepts, each forwarding to the
29
+ # `CoverageCriterion` verb of the same name. `minimum_per_group` is omitted
30
+ # because it needs an `only:` target, so it's block-only.
31
+ COVERAGE_THRESHOLD_OPTIONS = %i[minimum maximum exact maximum_drop minimum_per_file].freeze
32
+
33
+ #
34
+ # Configure (and, unless `enabled: false`, enable) a coverage criterion.
35
+ #
36
+ # Threshold options mirror the block verbs for one-liner use:
37
+ # coverage :branch, minimum: 80, maximum_drop: 5
38
+ #
39
+ # `primary: true` makes this the report's leading criterion (and the one a
40
+ # bare `minimum_coverage 90` targets). `oneshot: true` (valid only for
41
+ # `:line`) selects the faster oneshot-lines mode.
42
+ #
43
+ def coverage(criterion, primary: false, enabled: true, oneshot: false, **thresholds, &block)
44
+ criterion = enable_coverage_criterion(criterion, enabled: enabled, oneshot: oneshot)
45
+ # The cast admits :eval, which primary_coverage rejects at runtime
46
+ # (it is a standalone toggle, never in the enabled-criteria set).
47
+ primary_coverage(_ = criterion) if primary
48
+
49
+ configurator = CoverageCriterion.new(self, criterion)
50
+ apply_threshold_options(configurator, thresholds)
51
+ configurator.instance_eval(&block) if block
52
+
53
+ criterion
54
+ end
55
+
56
+ private
57
+
58
+ # Forward the one-liner threshold keywords (`coverage :branch, minimum: 80`)
59
+ # to the matching `CoverageCriterion` verbs, rejecting anything that isn't a
60
+ # recognized threshold option.
61
+ def apply_threshold_options(configurator, options)
62
+ options.each do |verb, value|
63
+ unless COVERAGE_THRESHOLD_OPTIONS.include?(verb)
64
+ raise SimpleCov::ConfigurationError,
65
+ "Unknown `coverage` option #{verb.inspect}. " \
66
+ "Supported options are #{COVERAGE_THRESHOLD_OPTIONS.inspect}."
67
+ end
68
+
69
+ configurator.public_send(verb, value)
70
+ end
71
+ end
72
+
73
+ # Enable or disable the criterion (or its oneshot / eval variant) and
74
+ # return the criterion symbol that thresholds should be stored under.
75
+ def enable_coverage_criterion(criterion, enabled:, oneshot:)
76
+ criterion = resolve_criterion_variant(criterion, oneshot)
77
+ enabled ? enable_coverage(criterion) : disable_coverage(criterion)
78
+ criterion
79
+ end
80
+
81
+ def resolve_criterion_variant(criterion, oneshot)
82
+ return criterion unless oneshot
83
+
84
+ unless criterion == :line
85
+ raise SimpleCov::ConfigurationError, "`oneshot: true` is only valid for `coverage :line`"
86
+ end
87
+
88
+ ONESHOT_LINE_COVERAGE_CRITERION
89
+ end
90
+
91
+ # @api private — threshold-store writers used by CoverageCriterion. They
92
+ # write the same `@minimum_coverage` / `@maximum_coverage` / ... hashes the
93
+ # flat threshold methods populate, so the exit-code checks are unchanged.
94
+ def store_overall_threshold(setting, criterion, percent)
95
+ raise_on_invalid_coverage({criterion => percent}, setting.to_s)
96
+ public_send(setting)[criterion] = percent
97
+ end
98
+
99
+ def store_minimum_per_file(criterion, percent, target)
100
+ raise_on_invalid_coverage({criterion => percent}, "minimum_coverage_by_file")
101
+ return minimum_coverage_by_file[criterion] = percent if target.nil?
102
+
103
+ unless target.is_a?(String) || target.is_a?(Regexp)
104
+ raise SimpleCov::ConfigurationError, "`only:` must be a String path or Regexp, got #{target.inspect}"
105
+ end
106
+
107
+ (minimum_coverage_by_file_overrides[target] ||= {})[criterion] = percent
108
+ end
109
+
110
+ def store_minimum_per_group(criterion, percent, group_name)
111
+ raise_on_invalid_coverage({criterion => percent}, "minimum_coverage_by_group")
112
+ # Normalize like `group` does, so `only: :Models` finds the group
113
+ # defined as `group "Models"` at check time instead of storing a
114
+ # Symbol key no lookup ever matches.
115
+ (minimum_coverage_by_group[GroupNames.normalize(group_name)] ||= {})[criterion] = percent
116
+ end
117
+
118
+ #
119
+ # Receiver for a `coverage <criterion> do ... end` block. Each verb writes a
120
+ # threshold for the single criterion the block configures, so the value is
121
+ # always a plain percentage (`minimum_per_file 100` is unambiguous) and the
122
+ # syntax is identical across line, branch, and method coverage.
123
+ #
124
+ class CoverageCriterion
125
+ def initialize(config, criterion)
126
+ @config = config
127
+ @criterion = criterion
128
+ end
129
+
130
+ # Overall (suite-wide) minimum for this criterion.
131
+ def minimum(percent)
132
+ @config.send(:store_overall_threshold, :minimum_coverage, @criterion, percent)
133
+ end
134
+
135
+ # Overall maximum: fails the build if coverage rises above it. Paired with
136
+ # `minimum` (or via `exact`) this pins coverage so an unexpected jump fails.
137
+ def maximum(percent)
138
+ @config.send(:store_overall_threshold, :maximum_coverage, @criterion, percent)
139
+ end
140
+
141
+ # Pin coverage to an exact figure (sets both `minimum` and `maximum`).
142
+ def exact(percent)
143
+ minimum(percent)
144
+ maximum(percent)
145
+ end
146
+
147
+ # Maximum allowed drop between runs (`maximum_drop 0` refuses any drop).
148
+ def maximum_drop(percent)
149
+ @config.send(:store_overall_threshold, :maximum_coverage_drop, @criterion, percent)
150
+ end
151
+
152
+ # Per-file minimum. With no `only:`, sets the default applied to every
153
+ # file; with `only:` (a String path or Regexp), overrides that default
154
+ # for the matching files.
155
+ def minimum_per_file(percent, only: nil)
156
+ @config.send(:store_minimum_per_file, @criterion, percent, only)
157
+ end
158
+
159
+ # Per-group minimum for the named group (defined via `group`).
160
+ def minimum_per_group(percent, only:)
161
+ @config.send(:store_minimum_per_group, @criterion, percent, only)
162
+ end
163
+
164
+ # Make this criterion the report's primary (leading) criterion.
165
+ def primary
166
+ # @criterion is Symbol-wide because this receiver is also built for
167
+ # :eval; primary_coverage validates at runtime.
168
+ @config.primary_coverage(_ = @criterion)
169
+ end
170
+ end
171
+ end
172
+ end