simplecov-rspec 1.0.0 → 1.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66e26ea8073a8373f072d8b58d5a11825fc26798d76b9ecf13b225279edfa06f
4
- data.tar.gz: 59759b57e676fa16bbebd8d423dcf9852a3d4550c3b3864211c937003191c755
3
+ metadata.gz: fcdb582042f1b21ad1a1bd486c120fd590f143e73cb51b6b693e3d5755396546
4
+ data.tar.gz: 6a39d87792ea1505b4e0b9af8e4e81ec447541f8e1a0aea8413b49918cfa70d1
5
5
  SHA512:
6
- metadata.gz: 253e3bbfe90ac2bc476f7e81eb9926f2457fb0b9a1cf4a066d116cdad7c2db00f15a33fa9a4622265580b309eead7b30ccadb0ef6ddc37936a9b52ecb9d0f369
7
- data.tar.gz: 0b0c8be4624b3f85d3ede8bbb5f64b4a3e71d8287f27fd7a46e102b01318558c1c91b5b59fa2ff1c5505ff65513295da0a6495415b9a056c15fa736f06b63b41
6
+ metadata.gz: 27efb22e915daa0cf8a00692a0f65ecdcf659b1badb7cef5210a86aed05a264cb1c4aee1d89aefe39e53515b3d8edf85481d071ef23e31c93f87f616fe4485ec
7
+ data.tar.gz: 2883aab5cd82ddecbcc46e081cb03048e64dd0297abc0d06da1998cc178b5697e2813bf5cbe4489c4b5d2889425afa39cfc2804ec1e0409bbd857e3142a16422
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.0.0"
2
+ ".": "1.1.0"
3
3
  }
data/.rubocop.yml CHANGED
@@ -6,6 +6,12 @@ AllCops:
6
6
  # your project supports:
7
7
  TargetRubyVersion: 3.2
8
8
 
9
+ Metrics/ClassLength:
10
+ # SimpleCov::RSpec is a configuration surface: every option it accepts costs a reader
11
+ # method, an ENV-name constant, and an ivar. The default 100 is a smell threshold for
12
+ # classes that do work, not for one that mostly declares options.
13
+ Max: 120
14
+
9
15
  Style/StderrPuts:
10
16
  Enabled: false
11
17
 
data/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ Changes for each release are listed in this file.
4
4
 
5
5
  This project adheres to [Semantic Versioning](https://semver.org/) for its releases.
6
6
 
7
+ ## [1.1.0](https://github.com/main-branch/simplecov-rspec/compare/v1.0.0...v1.1.0) (2026-08-06)
8
+
9
+
10
+ ### Features
11
+
12
+ * Add :described to scope the listing to the code under test ([71fe0c7](https://github.com/main-branch/simplecov-rspec/commit/71fe0c715063582ff207a6ac15e9a2c6dbe26243))
13
+ * Add list_uncovered_files to scope the uncovered listing ([1981e65](https://github.com/main-branch/simplecov-rspec/commit/1981e6528a418340c4e211ff0b34ed745693151a))
14
+
7
15
  ## [1.0.0](https://github.com/main-branch/simplecov-rspec/compare/v0.4.4...v1.0.0) (2026-08-05)
8
16
 
9
17
 
data/README.md CHANGED
@@ -10,34 +10,67 @@ Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?log
10
10
 
11
11
  `simplecov-rspec` is a Ruby gem that integrates SimpleCov with RSpec. SimpleCov
12
12
  (`>= 1.0`) already enforces `minimum_coverage` for line, branch, and method coverage
13
- and fails the build when a threshold is missed. This gem layers three things on top
13
+ and fails the build when a threshold is missed. This gem layers four things on top
14
14
  that SimpleCov doesn't do on its own:
15
15
 
16
16
  1. Suppresses coverage failures when RSpec is run in dry-run mode (e.g. from an IDE).
17
17
  2. Lists (or summarizes) the individual uncovered lines, branches, and methods.
18
- 3. Lets all of the above be overridden from the environment, for CI.
18
+ 3. Scopes that listing to the files you name, or to the code the run described.
19
+ 4. Lets all of the above be overridden from the environment, for CI.
19
20
 
20
21
  When `simplecov-rspec` is used, RSpec will report an error if the percent of test
21
22
  coverage falls below a defined threshold:
22
23
 
23
24
  ```text
24
- Coverage report generated for RSpec to /Projects/example_project/coverage. 284 / 286 LOC (99.3%) covered.
25
-
26
- Line coverage (99.3%) is below the expected minimum coverage (100.00%).
25
+ Coverage report generated for RSpec to coverage/index.html
26
+ Line coverage: 284 / 286 (99.30%)
27
+ Line coverage (99.30%) is below the expected minimum coverage (100.00%).
28
+ Lowest-coverage files (line):
29
+ 99.30% lib/example_project.rb
30
+ SimpleCov failed with exit 2 due to a coverage related error
27
31
  ```
28
32
 
29
- If configured to list the items that were not covered by tests, RSpec will additionally output:
33
+ All of that comes from SimpleCov itself. If configured to list the items that were not
34
+ covered by tests, this gem adds its own listing between SimpleCov's summary and its
35
+ failure message:
30
36
 
31
37
  ```text
38
+ Coverage report generated for RSpec to coverage/index.html
39
+ Line coverage: 284 / 286 (99.30%)
40
+
32
41
  2 lines are not covered by tests:
33
42
  ./lib/example_project.rb:74
34
43
  ./lib/example_project.rb:75
44
+ Line coverage (99.30%) is below the expected minimum coverage (100.00%).
45
+ ```
46
+
47
+ Scoping the listing to particular files changes its shape again. It is marked off with a
48
+ header saying how much of the result it covers, and each criterion reports what the
49
+ scoped files cover directly above what they miss — see [Scoping the listing to specific
50
+ files](#scoping-the-listing-to-specific-files):
51
+
52
+ ```text
53
+ Coverage report generated for RSpec to coverage/index.html
54
+ Line coverage: 284 / 286 (99.30%)
55
+ Branch coverage: 138 / 150 (92.00%)
56
+
57
+ -- Reporting uncovered lines and branches for 1 of 12 files --
58
+
59
+ Scoped line coverage: 73 / 74 (98.64%)
60
+ 1 line is not covered by tests:
61
+ ./lib/example_project/parser.rb:74
62
+
63
+ Scoped branch coverage: 11 / 12 (91.66%)
64
+ 1 branch is not covered by tests:
65
+ ./lib/example_project/parser.rb:82 (then branch)
35
66
  ```
36
67
 
37
68
  - [Installation](#installation)
38
69
  - [Getting started](#getting-started)
39
70
  - [Basic setup](#basic-setup)
40
71
  - [Listing uncovered items](#listing-uncovered-items)
72
+ - [Scoping the listing to specific files](#scoping-the-listing-to-specific-files)
73
+ - [Scoping to the code under test](#scoping-to-the-code-under-test)
41
74
  - [Configuration block](#configuration-block)
42
75
  - [Configuration from environment variables](#configuration-from-environment-variables)
43
76
  - [Development](#development)
@@ -111,7 +144,8 @@ SimpleCov::RSpec.start(
111
144
  minimum_coverage: { line: 100 },
112
145
  fail_on_low_coverage: true,
113
146
  list_uncovered: false,
114
- list_uncovered_detail: true
147
+ list_uncovered_detail: true,
148
+ list_uncovered_files: nil
115
149
  )
116
150
  ```
117
151
 
@@ -142,13 +176,20 @@ SimpleCov::RSpec.start(minimum_coverage: { line: 100, branch: 90 }, list_uncover
142
176
  ```
143
177
 
144
178
  ```text
145
- 1 line is not covered by tests:
179
+ 2 lines are not covered by tests:
146
180
  ./lib/example_project.rb:74
181
+ ./lib/example_project.rb:75
147
182
 
148
183
  1 branch is not covered by tests:
149
184
  ./lib/example_project.rb:82 (else branch)
185
+
186
+ 1 method is not covered by tests:
187
+ ./lib/example_project.rb:96 ExampleProject#unused
150
188
  ```
151
189
 
190
+ A criterion with nothing uncovered is left out entirely, so `:all` prints fewer sections
191
+ than this when there is less to say.
192
+
152
193
  For a quieter CI log, set `list_uncovered_detail: false` to print only the count per
153
194
  criterion, along with a hint on how to see the details:
154
195
 
@@ -159,10 +200,125 @@ SimpleCov::RSpec.start(list_uncovered: :all, list_uncovered_detail: false)
159
200
  ```text
160
201
  2 lines are not covered by tests.
161
202
  1 branch is not covered by tests.
203
+ 1 method is not covered by tests.
204
+
205
+ Run with LIST_UNCOVERED_DETAIL=true to see the uncovered lines, branches and methods.
206
+ ```
207
+
208
+ ### Scoping the listing to specific files
209
+
210
+ By default the uncovered listing covers every file SimpleCov tracked. On a focused run
211
+ — one spec file, or one directory — that listing is mostly noise: `spec_helper`
212
+ requires the whole project, so nearly all of it is legitimately unexercised.
213
+
214
+ `list_uncovered_files` (available since version 1.1) narrows the listing to the files
215
+ you care about, given as `Dir.glob` patterns resolved against `SimpleCov.root`. An
216
+ absolute path is used as given:
217
+
218
+ ```ruby
219
+ SimpleCov::RSpec.start(list_uncovered: :all, list_uncovered_files: 'lib/example_project/parser.rb')
220
+ ```
221
+
222
+ This option scopes the listing that `list_uncovered` asks for; it does not ask for one.
223
+ `list_uncovered` defaults to `false`, which lists nothing, so setting only
224
+ `list_uncovered_files` produces no output at all. Set both.
225
+
226
+ ```text
227
+ -- Reporting uncovered lines, branches and methods for 1 of 218 files --
228
+
229
+ Scoped line coverage: 73 / 74 (98.64%)
230
+ 1 line is not covered by tests:
231
+ ./lib/example_project/parser.rb:74
232
+
233
+ Scoped branch coverage: 12 / 12 (100.00%)
234
+ Scoped method coverage: 8 / 8 (100.00%)
235
+ ```
236
+
237
+ Each criterion reports what the scoped files cover directly above what they miss, so a
238
+ count like "1 line is not covered" arrives with the denominator that makes it readable,
239
+ and a blank line always means "next criterion". These are deliberately labelled
240
+ differently from SimpleCov's own project-wide summary, and the report is marked off with
241
+ a header, because SimpleCov prints that summary a few lines earlier on the same stream
242
+ while counting different things.
243
+
244
+ This narrows **only the listing**. Coverage is still measured, enforced, and formatted
245
+ for the whole project, so the reported percentage and the HTML report mean the same
246
+ thing whether or not this option is set. There is one definition of "the coverage
247
+ number", and this option does not change it.
162
248
 
163
- Run with LIST_UNCOVERED_DETAIL=true to see the uncovered lines and branches.
249
+ A scoped report always prints something, and always says how much of the result it
250
+ covered, so it can never be mistaken for a clean run of the whole suite:
251
+
252
+ ```text
253
+ -- Reporting uncovered lines, branches and methods for 1 of 218 files --
254
+
255
+ Scoped line coverage: 74 / 74 (100.00%)
256
+ Scoped branch coverage: 12 / 12 (100.00%)
257
+ Scoped method coverage: 8 / 8 (100.00%)
258
+
259
+ No uncovered lines, branches and methods in this file.
260
+ ```
261
+
262
+ When it comes up empty, it says which of the three reasons applies, since only one of
263
+ them means you mistyped a pattern:
264
+
265
+ ```text
266
+ -- Reporting uncovered lines, branches and methods for 0 of 218 files --
267
+
268
+ No files matched, so no coverage was reported.
269
+ ```
270
+
271
+ ```text
272
+ -- Reporting uncovered lines, branches and methods for 0 of 218 files --
273
+
274
+ 1 file matched, but it is not in the coverage result. It may not have been loaded by
275
+ this run, or may be excluded by a SimpleCov filter.
276
+ ```
277
+
278
+ ```text
279
+ -- Reporting uncovered lines, branches and methods for 0 of 218 files --
280
+
281
+ No files were requested, so no coverage was reported.
164
282
  ```
165
283
 
284
+ ### Scoping to the code under test
285
+
286
+ Naming the files by hand is the awkward part of a focused run: the file you want is
287
+ whatever you happen to be testing right now. `:described` resolves to the source files
288
+ defining the classes the run described, so it follows you from run to run:
289
+
290
+ ```ruby
291
+ SimpleCov::RSpec.start(list_uncovered: :all, list_uncovered_files: :described)
292
+ ```
293
+
294
+ ```bash
295
+ # Report on lib/example_project/parser.rb, because that is what these specs describe
296
+ bundle exec rspec spec/example_project/parser_spec.rb
297
+ ```
298
+
299
+ It walks nested groups too, so a `describe` of one class inside another contributes
300
+ both. A group describing something that is not a class contributes nothing, as does one
301
+ whose class is anonymous, defined in C, or no longer reachable by name — there is no
302
+ source file to report on in those cases.
303
+
304
+ The scope is the classes the run *described*, not the ones it exercised. A class that a
305
+ described class delegates to is not included, and a group written as `describe 'the
306
+ parser' do` names no class at all. Where that matters, build the scope yourself:
307
+ `SimpleCov::RSpec.described_source_files` is public, and you can add to what it returns.
308
+
309
+ ```ruby
310
+ SimpleCov::RSpec.start(
311
+ list_uncovered: :all,
312
+ list_uncovered_files: -> { SimpleCov::RSpec.described_source_files + ['lib/example_project/lexer.rb'] }
313
+ )
314
+ ```
315
+
316
+ The lambda is doing real work there, and leaving it off is a mistake worth naming.
317
+ `SimpleCov::RSpec.start` runs before any example is defined, so calling
318
+ `described_source_files` at that point returns an empty list and you get a report scoped
319
+ to nothing. Any scope derived from the run has to be passed as a callable and resolved
320
+ afterwards — which is exactly what `:described` does for you.
321
+
166
322
  ### Configuration block
167
323
 
168
324
  A configuration block can be given to the `start` method to further configure
@@ -196,6 +352,14 @@ variables take precedence over the values passed to `SimpleCov::RSpec.start`.
196
352
  * **`LIST_UNCOVERED_DETAIL`**: Controls whether uncovered items are listed individually, or
197
353
  just summarized as a count per criterion. Set to 'true', 'yes', 'on', or '1' (case
198
354
  insensitive) to show individual items.
355
+ * **`LIST_UNCOVERED_FILES`**: Controls which files uncovered items are listed for. Set to a
356
+ comma-separated list of `Dir.glob` patterns, relative to `SimpleCov.root`; to
357
+ 'described', for the files defining the classes the run described; or to 'all'
358
+ (or 'false', 'no', 'off', '0', or empty) to list them for every file. Since the
359
+ separator is a comma, a brace pattern such as `lib/{a,b}.rb` cannot be used here —
360
+ give the alternatives separately, as `lib/a.rb,lib/b.rb`. Like `list_uncovered_files`,
361
+ this scopes the listing rather than asking for one: it has no effect unless
362
+ `LIST_UNCOVERED` (or `list_uncovered:`) names at least one criterion.
199
363
 
200
364
  For example, here is a bash script to run tests in an infinite loop while writing
201
365
  test output to `fail.txt`:
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class RSpec
5
+ # Finds the source files that define the classes an RSpec run described.
6
+ #
7
+ # This is the scope most people want from `list_uncovered_files:` on a focused run:
8
+ # "report on the code I am actually testing". Deriving it means reaching into
9
+ # `RSpec.world`, which is not part of RSpec's public API, so it lives here rather
10
+ # than in each project's `spec_helper.rb`, where it could not be fixed centrally.
11
+ #
12
+ # @api private
13
+ #
14
+ module DescribedSourceFiles
15
+ module_function
16
+
17
+ # The source files defining the classes the run described
18
+ #
19
+ # A group that describes something other than a Module contributes nothing, as
20
+ # does one whose described class is anonymous, defined in C, or no longer
21
+ # reachable by name. Such a group has no source file to report on, and saying so
22
+ # is not useful, so it is skipped silently.
23
+ #
24
+ # @param example_groups [Array<Class>] the run's top-level example groups
25
+ #
26
+ # @return [Array<String>] absolute paths, without duplicates
27
+ #
28
+ # @example
29
+ # DescribedSourceFiles.call # => ['/project/lib/parser.rb']
30
+ #
31
+ # @api private
32
+ #
33
+ def call(example_groups: ::RSpec.world.example_groups)
34
+ with_nested(example_groups).filter_map { |group| source_file(group) }.uniq
35
+ end
36
+
37
+ # The given example groups, plus every group nested within them
38
+ #
39
+ # A nested group can describe a different class than the group containing it, so
40
+ # the whole tree is walked rather than just the top level.
41
+ #
42
+ # @param groups [Array<Class>] the example groups to walk
43
+ #
44
+ # @return [Array<Class>]
45
+ #
46
+ # @example
47
+ # DescribedSourceFiles.with_nested(::RSpec.world.example_groups)
48
+ #
49
+ # @api private
50
+ #
51
+ def with_nested(groups)
52
+ groups.flat_map { |group| [group, *with_nested(group.children)] }
53
+ end
54
+
55
+ # The source file defining one group's described class, if it has one
56
+ #
57
+ # @param group [Class] the example group
58
+ #
59
+ # @return [String, nil] an absolute path, or nil if there is nothing to report on
60
+ #
61
+ # @example
62
+ # DescribedSourceFiles.source_file(group) # => '/project/lib/parser.rb'
63
+ #
64
+ # @api private
65
+ #
66
+ def source_file(group)
67
+ described_class = group.described_class
68
+ return nil unless described_class.is_a?(Module)
69
+
70
+ # A class defined in C answers [], one whose constant is gone answers nil
71
+ name = described_class.name
72
+ name && Object.const_source_location(name)&.first
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class RSpec
5
+ # Resolves the `list_uncovered_files:` option (plus its `LIST_UNCOVERED_FILES` ENV
6
+ # override) into a normalized Array of absolute paths, or nil for "every file".
7
+ #
8
+ # The option may be given as a callable so that it can be evaluated after the test
9
+ # run rather than when `SimpleCov::RSpec.start` is called. `start` runs before any
10
+ # example is defined, so a caller that wants to scope the report to the code under
11
+ # test cannot know which files those are yet.
12
+ #
13
+ # @api private
14
+ #
15
+ module ListUncoveredFilesOption
16
+ # LIST_UNCOVERED_FILES environment variable values that mean "every file"
17
+ ALL_ENV_VALUES = %w[all false no off 0].freeze
18
+
19
+ # The value that means "the files defining the classes this run described"
20
+ #
21
+ # Sugar over passing a callable that does the same thing: the scope is only
22
+ # knowable after the run, so it resolves to one.
23
+ #
24
+ # @see SimpleCov::RSpec.described_source_files
25
+ DESCRIBED = :described
26
+
27
+ module_function
28
+
29
+ # Resolve the effective file list, applying the ENV override if present
30
+ #
31
+ # @param value [nil, Symbol, String, Array<String>, #call] the `list_uncovered_files:` argument
32
+ # @param env [Hash] the environment variables
33
+ # @param env_var [String] the ENV var name that overrides `value`
34
+ # @param root [String] the directory that relative patterns are resolved against
35
+ #
36
+ # @return [Array<String>, nil] absolute paths, or nil to report on every file
37
+ #
38
+ # @raise [ArgumentError] if value is not one of the accepted forms
39
+ #
40
+ # @example
41
+ # ListUncoveredFilesOption.resolve('lib/a.rb', env: {}, env_var: 'X', root: '/p') # => ['/p/lib/a.rb']
42
+ #
43
+ def resolve(value, env:, env_var:, root:)
44
+ value = from_env(env.fetch(env_var)) if env.key?(env_var)
45
+ value = -> { ::SimpleCov::RSpec.described_source_files } if value == DESCRIBED
46
+ value = value.call if value.respond_to?(:call)
47
+ return nil if value.nil?
48
+
49
+ expand(normalize(value), root)
50
+ end
51
+
52
+ # Parse a raw LIST_UNCOVERED_FILES environment variable value
53
+ #
54
+ # A value that lists no patterns at all — `''`, `' '`, or separators alone such as
55
+ # `', ,'` — means "every file". Dropping the empty entries can empty the list even
56
+ # though the value was not empty to start with, so the check is made after parsing
57
+ # as well as before it.
58
+ #
59
+ # @param raw [String] the raw LIST_UNCOVERED_FILES environment variable value
60
+ # @return [nil, Symbol, Array<String>] nil for "every file", {DESCRIBED}, or the listed patterns
61
+ # @example
62
+ # ListUncoveredFilesOption.from_env('lib/a.rb,lib/b.rb') # => ['lib/a.rb', 'lib/b.rb']
63
+ #
64
+ def from_env(raw)
65
+ stripped = raw.strip
66
+ return nil if stripped.empty? || ALL_ENV_VALUES.include?(stripped.downcase)
67
+ return DESCRIBED if stripped.casecmp?(DESCRIBED.to_s)
68
+
69
+ patterns = stripped.split(',').map(&:strip).reject(&:empty?)
70
+ patterns.empty? ? nil : patterns
71
+ end
72
+
73
+ # Normalize a `list_uncovered_files:`-style value into an Array of Strings
74
+ #
75
+ # @param value [String, Array<String>] the pattern or patterns
76
+ # @return [Array<String>]
77
+ # @raise [ArgumentError] if value is not a String or an Array of Strings
78
+ # @example
79
+ # ListUncoveredFilesOption.normalize('lib/a.rb') # => ['lib/a.rb']
80
+ #
81
+ def normalize(value)
82
+ case value
83
+ when String then [value]
84
+ when Array then validate(value)
85
+ else
86
+ raise ArgumentError,
87
+ 'list_uncovered_files must be nil, :described, a String, an Array of Strings, or a callable ' \
88
+ "returning one of those; got #{value.inspect}"
89
+ end
90
+ end
91
+
92
+ # Raise unless every element of patterns is a String
93
+ #
94
+ # @param patterns [Array<String>]
95
+ # @return [Array<String>]
96
+ # @raise [ArgumentError] if patterns contains a non-String
97
+ # @example
98
+ # ListUncoveredFilesOption.validate(['lib/a.rb']) # => ['lib/a.rb']
99
+ #
100
+ def validate(patterns)
101
+ invalid = patterns.grep_v(String)
102
+ return patterns if invalid.empty?
103
+
104
+ raise ArgumentError, "list_uncovered_files entries must be Strings; got #{invalid.inspect}"
105
+ end
106
+
107
+ # Expand patterns to absolute paths, resolving relative patterns against root
108
+ #
109
+ # Each pattern is expanded with `Dir.glob`. A pattern that matches nothing on disk
110
+ # expands to itself, so the literal pattern is kept rather than dropped. No file in
111
+ # the coverage result can carry that path, so it adds nothing to the listing; it is
112
+ # kept because the report tests these paths against the file system to tell a
113
+ # pattern that matched nothing from one whose files SimpleCov never tracked.
114
+ #
115
+ # @param patterns [Array<String>] the patterns to expand
116
+ # @param root [String] the directory that relative patterns are resolved against
117
+ # @return [Array<String>] absolute paths, without duplicates
118
+ # @example
119
+ # ListUncoveredFilesOption.expand(['lib/*.rb'], '/p') # => ['/p/lib/a.rb', '/p/lib/b.rb']
120
+ #
121
+ def expand(patterns, root)
122
+ patterns.flat_map do |pattern|
123
+ absolute = File.absolute_path(pattern, root)
124
+ matches = Dir.glob(absolute)
125
+ matches.empty? ? [absolute] : matches
126
+ end.uniq
127
+ end
128
+ end
129
+ end
130
+ end
@@ -22,18 +22,26 @@ module SimpleCov
22
22
  # @param criteria [Array<Symbol>] which criteria (:line, :branch, :method) to report
23
23
  # @param detail [Boolean] list individual items, or just a count per criterion
24
24
  # @param detail_env_var [String] the ENV var name to suggest for switching to detail
25
+ # @param files [Array<String>, nil] absolute paths to report on, or nil for every file
25
26
  # @example
26
27
  # UncoveredReport.new(result: SimpleCov.result, criteria: [:line], detail: true, detail_env_var: 'X')
27
- def initialize(result:, criteria:, detail:, detail_env_var:)
28
+ def initialize(result:, criteria:, detail:, detail_env_var:, files: nil)
28
29
  @result = result
29
30
  @criteria = criteria
30
31
  @detail = detail
31
32
  @detail_env_var = detail_env_var
33
+ @files = files
32
34
  end
33
35
 
34
36
  # The formatted report text, or an empty string if there is nothing to report
37
+ #
38
+ # A scoped report (one where `files` is not nil) always produces text, even when
39
+ # every file it covers is fully covered. Silence there would be indistinguishable
40
+ # from a clean run of the whole suite, which is the opposite of what asking for a
41
+ # scope means.
42
+ #
35
43
  # @return [String]
36
- def to_s = detail ? detailed_text : summary_text
44
+ def to_s = files.nil? ? uncovered_text : scoped_text
37
45
 
38
46
  private
39
47
 
@@ -57,6 +65,161 @@ module SimpleCov
57
65
  # @api private
58
66
  attr_reader :detail_env_var
59
67
 
68
+ # The absolute paths to report on, or nil to report on every file in the result
69
+ # @return [Array<String>, nil]
70
+ # @api private
71
+ attr_reader :files
72
+
73
+ # The result's files, narrowed to `files` when a scope was given
74
+ # @return [Array<SimpleCov::SourceFile>]
75
+ def reported_files
76
+ @reported_files ||= files.nil? ? result.files : result.files.select { |file| files.include?(file.filename) }
77
+ end
78
+
79
+ # The uncovered items or counts, in the requested level of detail
80
+ # @return [String]
81
+ def uncovered_text = detail ? detailed_text : summary_text
82
+
83
+ # The report for a scope, which is always non-empty
84
+ #
85
+ # Names how much of the result the scope covered, then one section per criterion,
86
+ # then a closing statement.
87
+ #
88
+ # @return [String]
89
+ def scoped_text
90
+ return "#{scope_text}\n\n#{nothing_reported_text}" if reported_files.empty?
91
+
92
+ [scope_text, *criterion_blocks, closing_text].compact.join("\n\n")
93
+ end
94
+
95
+ # Why a scope produced nothing to report on
96
+ #
97
+ # Three different mistakes end up here, and only one of them is "your pattern
98
+ # matched nothing". Saying that when the pattern matched a real file that
99
+ # SimpleCov never saw sends the reader looking for a typo that isn't there.
100
+ #
101
+ # @return [String]
102
+ def nothing_reported_text
103
+ return 'No files were requested, so no coverage was reported.' if files.empty?
104
+ return 'No files matched, so no coverage was reported.' if matched_files.empty?
105
+
106
+ untracked_text
107
+ end
108
+
109
+ # The requested paths that name a file on disk
110
+ #
111
+ # A pattern that globbed successfully expands to paths that exist; one that
112
+ # matched nothing is kept as a literal path that does not.
113
+ #
114
+ # @return [Array<String>]
115
+ def matched_files = @matched_files ||= files.select { |path| File.file?(path) }
116
+
117
+ # The report for files that exist but are absent from the coverage result
118
+ # @return [String]
119
+ def untracked_text
120
+ count = matched_files.count
121
+ "#{count} #{pluralize(count, 'file matched, but it is not', 'files matched, but none are')} in the " \
122
+ "coverage result. #{pluralize(count, 'It', 'They')} may not have been loaded by this run, or may " \
123
+ 'be excluded by a SimpleCov filter.'
124
+ end
125
+
126
+ # The line stating how much of the result the scope covered
127
+ #
128
+ # Reported alongside the total so that a scoped report showing nothing uncovered
129
+ # cannot be misread as the whole suite being fully covered. Marked off as a section
130
+ # header because SimpleCov prints its own project-wide summary just above, on the
131
+ # same stream, counting different things.
132
+ #
133
+ # @return [String]
134
+ def scope_text
135
+ "-- Reporting uncovered #{noun_list(criteria)} for " \
136
+ "#{reported_files.count} of #{result.files.count} #{pluralize(result.files.count, 'file', 'files')} --"
137
+ end
138
+
139
+ # The body of a scoped report: what the files cover, then what they miss
140
+ #
141
+ # A criterion's coverage sits directly above its own listing, and criteria with
142
+ # nothing to list share a block, so that a blank line always means "next criterion"
143
+ # and never separates a heading from what it heads.
144
+ #
145
+ # @return [Array<String>]
146
+ def criterion_blocks
147
+ criteria.map { |criterion| [coverage_text(criterion), missing_text(criterion)] }
148
+ .chunk_while { |(_, missing), (_, next_missing)| missing.nil? && next_missing.nil? }
149
+ .map { |block| block.flatten.compact.join("\n") }
150
+ end
151
+
152
+ # A criterion's coverage across the scoped files
153
+ #
154
+ # Deliberately not labelled the way SimpleCov labels its own project-wide summary.
155
+ # The two appear within a few lines of each other and count different things, so
156
+ # sharing a label would make the narrower number look like a restatement of the
157
+ # broader one.
158
+ #
159
+ # @param criterion [Symbol]
160
+ # @return [String]
161
+ def coverage_text(criterion)
162
+ covered = covered_count(criterion)
163
+ total = covered + uncovered_count(criterion)
164
+ "Scoped #{criterion} coverage: #{covered} / #{total} (#{percent_text(covered, total)})"
165
+ end
166
+
167
+ # A covered-of-total ratio, formatted the way SimpleCov formats its own
168
+ #
169
+ # Truncated rather than rounded, through SimpleCov's own helper, because its
170
+ # project-wide summary prints a few lines above this one over the same kind of
171
+ # ratio. Two percentages differing in the last digit would read as a bug in one of
172
+ # them. A criterion with nothing to cover is 100%, which is also what SimpleCov says.
173
+ #
174
+ # @param covered [Integer] the number of covered items
175
+ # @param total [Integer] the number of items that could be covered
176
+ #
177
+ # @return [String]
178
+ def percent_text(covered, total)
179
+ percent = total.zero? ? 100.0 : covered * 100.0 / total
180
+ "#{format('%.2f', ::SimpleCov.round_coverage(percent))}%"
181
+ end
182
+
183
+ # A criterion's uncovered items or count
184
+ #
185
+ # Nil when the scoped files leave nothing uncovered for it.
186
+ #
187
+ # @param criterion [Symbol]
188
+ # @return [String, nil]
189
+ def missing_text(criterion)
190
+ return section(criterion) if detail
191
+
192
+ count = uncovered_count(criterion)
193
+ count.positive? ? "#{header(criterion, count)}." : nil
194
+ end
195
+
196
+ # The closing statement: that nothing is uncovered, or how to see what is
197
+ # @return [String, nil]
198
+ def closing_text
199
+ return nothing_uncovered_text if missing_criteria.empty?
200
+ return nil if detail
201
+
202
+ "Run with #{detail_env_var}=true to see the uncovered #{noun_list(missing_criteria)}."
203
+ end
204
+
205
+ # The criteria the scoped files leave something uncovered for
206
+ # @return [Array<Symbol>]
207
+ def missing_criteria = criteria.reject { |criterion| uncovered_count(criterion).zero? }
208
+
209
+ # The statement that a scope turned up no uncovered items
210
+ # @return [String]
211
+ def nothing_uncovered_text
212
+ "No uncovered #{noun_list(criteria)} in #{pluralize(reported_files.count, 'this file', 'these files')}."
213
+ end
214
+
215
+ # The count of covered items for a single criterion across the reported files
216
+ # @param criterion [Symbol]
217
+ # @return [Integer]
218
+ def covered_count(criterion)
219
+ plural = CRITERION_LABELS.fetch(criterion).last
220
+ reported_files.sum { |file| file.public_send(:"covered_#{plural}").count }
221
+ end
222
+
60
223
  # The full listing, one blank-line-separated section per criterion
61
224
  # @return [String]
62
225
  def detailed_text
@@ -99,7 +262,7 @@ module SimpleCov
99
262
  # @param criterion [Symbol]
100
263
  # @return [Array<String>]
101
264
  def uncovered_items(criterion)
102
- result.files.flat_map { |file| items_for(file, criterion) }
265
+ reported_files.flat_map { |file| items_for(file, criterion) }
103
266
  end
104
267
 
105
268
  # The count of uncovered items for a single criterion across all files
@@ -107,7 +270,7 @@ module SimpleCov
107
270
  # @param criterion [Symbol]
108
271
  # @return [Integer]
109
272
  def uncovered_count(criterion)
110
- result.files.sum { |file| count_for(file, criterion) }
273
+ reported_files.sum { |file| count_for(file, criterion) }
111
274
  end
112
275
 
113
276
  # The formatted, uncovered items of one criterion within a single file
@@ -3,6 +3,6 @@
3
3
  module Simplecov
4
4
  class Rspec
5
5
  # This gem's version
6
- VERSION = '1.0.0'
6
+ VERSION = '1.1.0'
7
7
  end
8
8
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'simplecov'
4
+ require_relative 'simplecov-rspec/described_source_files'
5
+ require_relative 'simplecov-rspec/list_uncovered_files_option'
4
6
  require_relative 'simplecov-rspec/list_uncovered_option'
5
7
  require_relative 'simplecov-rspec/uncovered_report'
6
8
 
@@ -11,11 +13,12 @@ module SimpleCov
11
13
  #
12
14
  # SimpleCov (>= 1.0) can already enforce `minimum_coverage` for line, branch, and
13
15
  # method coverage, and will exit with a non-zero status when a threshold is missed.
14
- # This gem layers three things SimpleCov doesn't do on its own:
16
+ # This gem layers four things SimpleCov doesn't do on its own:
15
17
  #
16
18
  # 1. Suppresses coverage failures when RSpec is run in dry-run mode (e.g. from an IDE).
17
19
  # 2. Lists (or summarizes) the individual uncovered lines, branches, and methods.
18
- # 3. Lets all of the above be overridden from the environment, for CI.
20
+ # 3. Scopes that listing to the files you name, or to the code the run described.
21
+ # 4. Lets all of the above be overridden from the environment, for CI.
19
22
  #
20
23
  # Simply add the line `SimpleCov::RSpec.start` in place of `SimpleCov.start` in
21
24
  # the project's `spec_helper.rb`. This line must appear before the project is
@@ -33,6 +36,9 @@ module SimpleCov
33
36
  # @example Report only counts, with a hint on how to see the details
34
37
  # SimpleCov::RSpec.start(list_uncovered: :all, list_uncovered_detail: false)
35
38
  #
39
+ # @example Scope the listing to the code the run described
40
+ # SimpleCov::RSpec.start(list_uncovered: :all, list_uncovered_files: :described)
41
+ #
36
42
  # @example Pass a configuration block to SimpleCov.start
37
43
  # SimpleCov::RSpec.start { formatter SimpleCov::Formatter::LcovFormatter }
38
44
  #
@@ -100,6 +106,27 @@ module SimpleCov
100
106
  # Read from the `LIST_UNCOVERED_DETAIL` environment variable if set: `true`,
101
107
  # `yes`, `on`, or `1` (case-insensitive) shows details; anything else summarizes.
102
108
  #
109
+ # @param list_uncovered_files [nil, Symbol, String, Array<String>, #call] which files to
110
+ # list uncovered items for (default: nil)
111
+ #
112
+ # `nil` reports on every file in the result. A String or Array of Strings names
113
+ # the files to report on, as `Dir.glob` patterns resolved against `SimpleCov.root`.
114
+ # `:described` reports on the files defining the classes the run described, which
115
+ # is the scope a focused run usually wants. A callable returning a String or an
116
+ # Array of Strings is resolved after the run rather than at `start`, which is what
117
+ # any scope derived from the run needs: `start` runs before any example is defined.
118
+ #
119
+ # This narrows only the uncovered listing. Coverage is still measured, enforced,
120
+ # and formatted for the whole project, so the percentage and the HTML report mean
121
+ # the same thing whether or not this is set.
122
+ #
123
+ # A scoped report names how many of the result's files it covered, and says so
124
+ # explicitly when they are fully covered or when nothing matched.
125
+ #
126
+ # Read from the `LIST_UNCOVERED_FILES` environment variable if set: a
127
+ # comma-separated list of patterns, `described`, or `all` (or `false`, `no`,
128
+ # `off`, `0`, or empty) for every file.
129
+ #
103
130
  # @param start_config_block [Proc] a configuration block to pass to `SimpleCov.start` (default: nil)
104
131
  #
105
132
  # @param rspec_dry_run [Boolean] whether the rspec run is a dry run
@@ -132,8 +159,45 @@ module SimpleCov
132
159
  # # OR use an environment variable to override the default
133
160
  # LIST_UNCOVERED=all rspec
134
161
  #
162
+ # @example List uncovered items for one file only
163
+ # SimpleCov::RSpec.start(list_uncovered: :all, list_uncovered_files: 'lib/example_project/parser.rb')
164
+ #
165
+ # # OR use an environment variable to override the default
166
+ # LIST_UNCOVERED_FILES=lib/example_project/parser.rb rspec
167
+ #
168
+ # @example Scope the listing to the classes the run actually described
169
+ # SimpleCov::RSpec.start(list_uncovered: :all, list_uncovered_files: :described)
170
+ #
171
+ # # OR use an environment variable to override the default
172
+ # LIST_UNCOVERED_FILES=described rspec
173
+ #
135
174
  def self.start(...) = new(...).send(:start)
136
175
 
176
+ # The source files defining the classes this RSpec run described
177
+ #
178
+ # What `list_uncovered_files: :described` scopes to. Call it directly to build a
179
+ # scope of your own — to add files the run touches but does not describe, say:
180
+ #
181
+ # list_uncovered_files: -> { SimpleCov::RSpec.described_source_files + ['lib/support.rb'] }
182
+ #
183
+ # Only meaningful once the run has defined its examples, which is why it is passed
184
+ # as a callable rather than called at `start`.
185
+ #
186
+ # A described class contributes nothing when it is anonymous, defined in C, or no
187
+ # longer reachable by name, since there is no source file to report on.
188
+ #
189
+ # @example Scope the listing to the code under test, plus one more file
190
+ # SimpleCov::RSpec.start(
191
+ # list_uncovered: :all,
192
+ # list_uncovered_files: -> { SimpleCov::RSpec.described_source_files + ['lib/support.rb'] }
193
+ # )
194
+ #
195
+ # @return [Array<String>] absolute paths, without duplicates
196
+ #
197
+ # @api public
198
+ #
199
+ def self.described_source_files = DescribedSourceFiles.call
200
+
137
201
  # Environment variable to override minimum_coverage[:line]
138
202
  # @api private
139
203
  # @private
@@ -164,6 +228,11 @@ module SimpleCov
164
228
  # @private
165
229
  LIST_UNCOVERED_DETAIL = 'LIST_UNCOVERED_DETAIL'
166
230
 
231
+ # Environment variable to override list_uncovered_files
232
+ # @api private
233
+ # @private
234
+ LIST_UNCOVERED_FILES = 'LIST_UNCOVERED_FILES'
235
+
167
236
  # Maps a coverage criterion to the environment variable that overrides its threshold
168
237
  # @api private
169
238
  # @private
@@ -245,6 +314,23 @@ module SimpleCov
245
314
  @list_uncovered_criteria ||= ListUncoveredOption.resolve(@list_uncovered, env: env, env_var: LIST_UNCOVERED)
246
315
  end
247
316
 
317
+ # The files to list uncovered items for, or nil for every file in the result
318
+ #
319
+ # Resolved from the `at_exit` hook rather than at `start`: `start` runs before any
320
+ # example is defined, so a caller scoping the report to the code under test cannot
321
+ # know which files those are until the run is over.
322
+ #
323
+ # @return [Array<String>, nil]
324
+ #
325
+ # @api private
326
+ # @private
327
+ #
328
+ def list_uncovered_files
329
+ ListUncoveredFilesOption.resolve(
330
+ @list_uncovered_files, env: env, env_var: LIST_UNCOVERED_FILES, root: simplecov_module.root
331
+ )
332
+ end
333
+
248
334
  # Whether to list individual uncovered items, or just a count per criterion
249
335
  #
250
336
  # @return [Boolean]
@@ -281,6 +367,7 @@ module SimpleCov
281
367
  fail_on_low_coverage: nil,
282
368
  list_uncovered: nil,
283
369
  list_uncovered_detail: nil,
370
+ list_uncovered_files: nil,
284
371
  rspec_dry_run: ::RSpec.configuration.dry_run?,
285
372
  env: ENV,
286
373
  simplecov_module: ::SimpleCov,
@@ -290,6 +377,7 @@ module SimpleCov
290
377
  @fail_on_low_coverage = fail_on_low_coverage
291
378
  @list_uncovered = list_uncovered
292
379
  @list_uncovered_detail = list_uncovered_detail
380
+ @list_uncovered_files = list_uncovered_files
293
381
  @start_config_block = start_config_block
294
382
  @rspec_dry_run = rspec_dry_run
295
383
  @env = env
@@ -340,10 +428,8 @@ module SimpleCov
340
428
  return if list_uncovered_criteria.empty?
341
429
 
342
430
  report = UncoveredReport.new(
343
- result: simplecov_module.result,
344
- criteria: list_uncovered_criteria,
345
- detail: list_uncovered_detail?,
346
- detail_env_var: LIST_UNCOVERED_DETAIL
431
+ result: simplecov_module.result, criteria: list_uncovered_criteria, detail: list_uncovered_detail?,
432
+ detail_env_var: LIST_UNCOVERED_DETAIL, files: list_uncovered_files
347
433
  ).to_s
348
434
  return if report.empty?
349
435
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simplecov-rspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Couball
@@ -214,7 +214,8 @@ dependencies:
214
214
  description: |
215
215
  Configures SimpleCov's line, branch, and method coverage thresholds for RSpec,
216
216
  suppresses failures during RSpec dry runs, and (optionally) lists or summarizes
217
- the lines, branches, and methods not covered by tests.
217
+ the lines, branches, and methods not covered by tests, either for the whole
218
+ project or scoped to the code the run described.
218
219
  email:
219
220
  - jcouball@yahoo.com
220
221
  executables: []
@@ -234,6 +235,8 @@ files:
234
235
  - README.md
235
236
  - Rakefile
236
237
  - lib/simplecov-rspec.rb
238
+ - lib/simplecov-rspec/described_source_files.rb
239
+ - lib/simplecov-rspec/list_uncovered_files_option.rb
237
240
  - lib/simplecov-rspec/list_uncovered_option.rb
238
241
  - lib/simplecov-rspec/uncovered_report.rb
239
242
  - lib/simplecov-rspec/version.rb
@@ -246,8 +249,8 @@ metadata:
246
249
  allowed_push_host: https://rubygems.org
247
250
  homepage_uri: https://github.com/main-branch/simplecov-rspec
248
251
  source_code_uri: https://github.com/main-branch/simplecov-rspec
249
- documentation_uri: https://rubydoc.info/gems/simplecov-rspec/1.0.0
250
- changelog_uri: https://rubydoc.info/gems/simplecov-rspec/1.0.0/file/CHANGELOG.md
252
+ documentation_uri: https://rubydoc.info/gems/simplecov-rspec/1.1.0
253
+ changelog_uri: https://rubydoc.info/gems/simplecov-rspec/1.1.0/file/CHANGELOG.md
251
254
  rubygems_mfa_required: 'true'
252
255
  rdoc_options: []
253
256
  require_paths: