undercover 0.8.4 → 0.9.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: f86959c16f0b74f14a45569ca4d746650e764eef3b94cc8f10ff341b2900f099
4
- data.tar.gz: ef70c14d604fa88d59cca1cec6155382eed5360795a6c9c38c3b3d72366e2080
3
+ metadata.gz: 45405b87566ffc4a838da25a9154b47f48b0578c469497eda044ea665005cfed
4
+ data.tar.gz: f90beafad3d6d90215c2326c7c702e725a25bfefbaf5b82ed2ea749133042b67
5
5
  SHA512:
6
- metadata.gz: 703e9ea6340101fd7d49368eb07bfd70986f31f7306219f5b3f791383eecf63483db59360716005e528ecdf6aae91e7ce4bb1d3a7d3c5b62dceccb5dde1e85b0
7
- data.tar.gz: 704e695944e5135f41408bc9fa9f8b27c3213574a884d04a5f8723c7f985d0f309d32c49977d74ced2d21da4b580129419d2971a26fe91c495b4e0246ce4c1ff
6
+ metadata.gz: 588486852ac15ef12aad5442b86567ed9e32ec36cb91ba4c45b164972f62cb1e1e1a253d5f9f2ee831bdc26074ba8446e8dfd99f6b0ae80405eac521f648a8f8
7
+ data.tar.gz: 5b63e08d782e36bae4513828a5f5b40bcf2c777775248285224faa19f482032ee962a02ae4e4859cf8c6d81e4a0816106d29e5a5b21bdb115646797ccb7b0f64
@@ -0,0 +1,15 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: grodowski
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
@@ -5,9 +5,9 @@ jobs:
5
5
  runs-on: ubuntu-latest
6
6
  strategy:
7
7
  matrix:
8
- ruby: ['4.0', '3.4']
8
+ ruby: ['4.0', '3.4', '3.2']
9
9
  steps:
10
- - uses: actions/checkout@v6
10
+ - uses: actions/checkout@v7
11
11
  with:
12
12
  fetch-depth: 0 # fetch all since test fixtures depend on history
13
13
  - name: Set up Ruby ${{ matrix.ruby }}
@@ -23,7 +23,7 @@ jobs:
23
23
  run: |
24
24
  git fetch --update-head-ok origin master:master
25
25
  undercover --simplecov coverage/undercover_coverage.json --compare master
26
- - uses: actions/upload-artifact@v6
26
+ - uses: actions/upload-artifact@v7
27
27
  with:
28
28
  name: undercover-${{ matrix.ruby }}-coverage
29
29
  path: coverage/undercover_coverage.json
@@ -31,7 +31,7 @@ jobs:
31
31
  runs-on: ubuntu-latest
32
32
  needs: build
33
33
  steps:
34
- - uses: actions/download-artifact@v7
34
+ - uses: actions/download-artifact@v8
35
35
  with:
36
36
  name: undercover-4.0-coverage
37
37
  - name: Upload coverage
data/.rubocop.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  AllCops:
2
2
  NewCops: enable
3
- TargetRubyVersion: 3.0
3
+ TargetRubyVersion: 3.2
4
4
  Exclude:
5
5
  - spec/fixtures/**/*
6
6
  SuggestExtensions: false
data/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-10-26
10
+ ### Added
11
+ - ERB view template coverage, opt in with `--include-files '*.rb,*.rake,*.ru,Rakefile,*.erb'`. Requires Ruby 3.2+ and `SimpleCov.enable_coverage :eval` ([#250](https://github.com/grodowski/undercover/pull/250))
12
+ - Add `--commits-only` to check only committed changes after `--compare ref`, ignoring the index and working tree, for sparse or partial clones ([#273](https://github.com/grodowski/undercover/pull/273))
13
+ - Add coverage report filtering to reduce resource usage ([#263](https://github.com/grodowski/undercover/pull/263))
14
+ - Print a one-line hint about UndercoverCI after the text report (in Claude Code sessions, about the undercover Claude Code skill instead); hide it with `--no-hint` or `UNDERCOVER_NO_HINT=1` ([#278](https://github.com/grodowski/undercover/pull/278))
15
+
16
+ ### Changed
17
+ - Parse Ruby with Prism instead of imagen and parser: Ruby 3.4/4.0 syntax is analysed instead of skipped, `_1` blocks are checked, destructured block params are named as written, and `--ruby-syntax`/`-r` is ignored with a warning ([#279](https://github.com/grodowski/undercover/pull/279))
18
+ - **Breaking:** require SimpleCov `>= 1.0` and Ruby `>= 3.2`, restoring `ignored_files` under SimpleCov 1.x. Pin `undercover ~> 0.8.0` for SimpleCov 0.x / Ruby 3.0–3.1 ([#270](https://github.com/grodowski/undercover/pull/270))
19
+ - Updated RuboCop `1.84.2` to `1.92.1`
20
+
21
+ ### Fixed
22
+ - Fix ERB view coverage: parse templates with output block helpers (`<%= form_with ... do %>`), warn about an unparseable template once and list it in `Report#erb_parse_failures`, recommend ERB globs that keep the defaults, and keep `--format json` output parseable ([#277](https://github.com/grodowski/undercover/pull/277))
23
+ - Find the coverage root of a monorepo app in sparse checkouts, by checking paths against HEAD when they are not on disk ([#275](https://github.com/grodowski/undercover/pull/275))
24
+ - Analyse large files in linear time. Matching changed lines to code blocks was quadratic in file size, so a PR touching big files could take minutes; a 6,000-line file now takes 0.2s instead of 27s ([#274](https://github.com/grodowski/undercover/pull/274))
25
+ - Respect SimpleCov's own filters and ignore changes outside the covered directory when the coverage report covers a subdirectory of the repository, such as an app in a monorepo. **Breaking:** `--include-files` and `--exclude-files` globs are now matched relative to the covered directory, so drop that directory from them — `apps/dash/**/*.rb` becomes `**/*.rb`; undercover warns rather than reporting success when globs skip every candidate file. Reports now also record the undercover version, and `--path` is deprecated and ignored because the project root comes from `--git-dir` ([#245](https://github.com/grodowski/undercover/pull/245))
26
+
27
+ # [0.8.5] - 2026-04-21
28
+ ### Added
29
+ - `--format json` CLI option for machine-readable output ([#251](https://github.com/grodowski/undercover/pull/251))
30
+
31
+ ### Fixed
32
+ - Add `benchmark` as a dependency, required for Ruby 4
33
+
9
34
  # [0.8.4] - 2026-02-06
10
35
  ### Fixed
11
36
  - Print branch coverage even on n/a lines (https://github.com/grodowski/undercover/pull/249) by [@kuahyeow](https://github.com/kuahyeow)
@@ -216,7 +241,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
216
241
  ### Added
217
242
  - First release of `undercover` 🎉
218
243
 
219
- [Unreleased]: https://github.com/grodowski/undercover/compare/v0.8.4...HEAD
244
+ [Unreleased]: https://github.com/grodowski/undercover/compare/v0.9.0...HEAD
245
+ [0.9.0]: https://github.com/grodowski/undercover/compare/v0.8.5...v0.9.0
246
+ [0.8.5]: https://github.com/grodowski/undercover/compare/v0.8.4...v0.8.5
220
247
  [0.8.4]: https://github.com/grodowski/undercover/compare/v0.8.3...v0.8.4
221
248
  [0.8.3]: https://github.com/grodowski/undercover/compare/v0.8.2...v0.8.3
222
249
  [0.8.2]: https://github.com/grodowski/undercover/compare/v0.8.1...v0.8.2
data/Gemfile CHANGED
@@ -9,9 +9,11 @@ gem 'bundler'
9
9
  gem 'pry'
10
10
  gem 'rake', '~> 13.0'
11
11
  gem 'rspec', '~> 3.0'
12
- gem 'rubocop', '~> 1.84.0'
12
+ gem 'rubocop'
13
13
  gem 'ruby-lsp-rspec', require: false
14
14
  gem 'simplecov'
15
15
  gem 'simplecov-html'
16
16
  gem 'simplecov_json_formatter'
17
17
  gem 'timecop'
18
+
19
+ gem 'erubi', '>= 1.13'
data/README.md CHANGED
@@ -37,6 +37,9 @@ Or install it yourself as:
37
37
 
38
38
  ## Setting up coverage reporting
39
39
 
40
+ > [!NOTE]
41
+ > `undercover` requires SimpleCov >= 1.0 (and Ruby >= 3.2). For older SimpleCov (0.x) or Ruby setups, pin `undercover` to `~> 0.8.0`.
42
+
40
43
  To make your specs or tests compatible with `undercover`, please add `undercover` to your gemfile to use the undercover formatter the test helper.
41
44
 
42
45
  ```ruby
@@ -54,8 +57,8 @@ require 'undercover/simplecov_formatter'
54
57
  SimpleCov.formatter = SimpleCov::Formatter::Undercover
55
58
 
56
59
  SimpleCov.start do
57
- add_filter(/^\/spec\//) # For RSpec
58
- add_filter(/^\/test\//) # For Minitest
60
+ skip(/^\/spec\//) # For RSpec (`add_filter` on SimpleCov < 1.0)
61
+ skip(/^\/test\//) # For Minitest
59
62
  enable_coverage(:branch) # Report branch coverage to trigger branch-level undercover warnings
60
63
  end
61
64
  # ...
@@ -63,6 +66,50 @@ end
63
66
 
64
67
  Then run your test suite once through to generate the initial coverage file before you can run the `undercover` command.
65
68
 
69
+ Ruby's branch coverage counts a hidden `else` for every `case` (and `if`) without one, so a `case` that handles all its values still gets flagged for a missing branch. To ignore these implicit branches:
70
+
71
+ ```ruby
72
+ SimpleCov.start do
73
+ coverage :branch, ignore: :implicit_else # SimpleCov 1.3+
74
+ # SimpleCov 1.0-1.2: enable_coverage(:branch) and SimpleCov.ignore_branches :implicit_else
75
+ end
76
+ ```
77
+
78
+ ## ERB view template coverage
79
+
80
+ Undercover supports coverage analysis for ERB view templates. This requires:
81
+
82
+ 1. **Ruby 3.2+**
83
+ 2. **SimpleCov's eval coverage**, via `enable_coverage(:eval)`
84
+
85
+ ```ruby
86
+ # spec_helper.rb
87
+ SimpleCov.start do
88
+ enable_coverage(:branch)
89
+ enable_coverage(:eval) # Required for view coverage
90
+ end
91
+ ```
92
+
93
+ 3. **Opt-in via `--include-files`** since view coverage is not enabled by default. The option replaces the default list (`*.rb,*.rake,*.ru,Rakefile`), so repeat the defaults alongside `*.erb`:
94
+
95
+ ```sh
96
+ undercover --include-files '*.rb,*.rake,*.ru,Rakefile,*.erb'
97
+ ```
98
+
99
+ Or in your `.undercover` config file:
100
+
101
+ ```sh
102
+ -f *.rb,*.rake,*.ru,Rakefile,*.erb
103
+ ```
104
+
105
+ `--exclude-files` likewise replaces its defaults (`test/*,spec/*,db/*,config/*,*_test.rb,*_spec.rb,vendor/*`). To also skip generator templates, which are never rendered by your tests, keep the defaults in the list:
106
+
107
+ ```sh
108
+ -x test/*,spec/*,db/*,config/*,*_test.rb,*_spec.rb,vendor/*,*/templates/*
109
+ ```
110
+
111
+ **Why opt-in?** View coverage requires `enable_coverage(:eval)`, which is not enabled by default in SimpleCov. Without it, view files would be flagged as having 0% coverage (false positives).
112
+
66
113
  ## Upgrading from pre-0.7.0
67
114
 
68
115
  If you're upgrading from an older version of undercover that used LCOV, you can migrate to the new SimpleCov formatter:
@@ -86,6 +133,40 @@ SimpleCov.formatter = SimpleCov::Formatter::Undercover
86
133
 
87
134
  Note: LCOV support will be deprecated in a future release, but remains fully functional for existing projects.
88
135
 
136
+ ## Monorepos
137
+
138
+ Undercover works when the coverage report covers a subdirectory of the repository rather than
139
+ the whole thing, which is the usual shape of a monorepo:
140
+
141
+ ```
142
+ monorepo/
143
+ .git
144
+ apps/
145
+ dash/ <- SimpleCov runs here
146
+ coverage/coverage.json
147
+ admin/
148
+ ```
149
+
150
+ Run it from the directory SimpleCov covered and point `--git-dir` at the repository:
151
+
152
+ ```sh
153
+ cd apps/dash
154
+ undercover --git-dir ../../.git --compare origin/main
155
+ ```
156
+
157
+ Undercover works out that coverage was recorded under `apps/dash` by matching the paths in the
158
+ report against the files in the repository, so there is nothing to configure. In particular:
159
+
160
+ - SimpleCov's own filters are honoured. `SimpleCov.start('rails')` skips `db/`, `config/` and
161
+ `vendor/`, and those files will not be reported even though git sees them as `apps/dash/db/...`
162
+ - changes in sibling directories, such as `apps/admin`, are left alone. They are outside the
163
+ coverage report and there is nothing to judge them by
164
+ - warnings are reported relative to the repository root (`apps/dash/lib/foo.rb`), so they line
165
+ up with what CI annotations expect
166
+
167
+ File globs passed to `--include-files` and `--exclude-files` are matched relative to the covered
168
+ directory, so write `lib/**/*.rb` rather than `apps/dash/lib/**/*.rb`.
169
+
89
170
  ## Usage
90
171
 
91
172
  Invoked with no arguments, Undercover will flag all untested methods and classes from the current diff:
@@ -106,6 +187,8 @@ Check out `docs/` for CI configuration examples:
106
187
  - [CircleCI - advanced](docs/circleci_advanced.yml)
107
188
  - [Semaphore](docs/semaphore.yml)
108
189
 
190
+ `undercover` prints a one-line hint about UndercoverCI to stderr after the text report (in Claude Code sessions, about the undercover Claude Code skill instead). Hide it with `--no-hint` (also in `.undercover`) or `UNDERCOVER_NO_HINT=1`.
191
+
109
192
  Merging coverage results ([sample gist](https://gist.github.com/grodowski/9744ff91034dce8df20c2a8210409fb0)) is required for parallel tests before processing with `undercover`.
110
193
 
111
194
  ## Code review integrations
@@ -127,17 +210,74 @@ Options can be passed when running the command from the command line:
127
210
  Usage: undercover [options]
128
211
  -s, --simplecov path SimpleCov JSON report file
129
212
  -l, --lcov path LCOV report file path (to be deprecated)
130
- -p, --path path Project directory
131
213
  -g, --git-dir dir Override `.git` with a custom directory
132
214
  -c, --compare ref Generate coverage warnings for all changes after `ref`
133
- -r, --ruby-syntax ver Ruby syntax version, one of: current, ruby18, ruby19, ruby20, ruby21, ruby22, ruby23, ruby24, ruby25, ruby26, ruby30, ruby31, ruby32, ruby33
215
+ --commits-only Only check committed changes after `--compare ref`, ignoring the index and working tree
134
216
  -w, --max-warnings limit Maximum number of warnings to generate before stopping analysis. Useful as a performance improvement for large diffs.
135
217
  -f, --include-files globs Include files matching specified glob patterns (comma separated). Defaults to '*.rb,*.rake,*.ru,Rakefile'
136
- -x, --exclude-files globs Skip files matching specified glob patterns (comma separated). Empty by default.
218
+ -x, --exclude-files globs Skip files matching specified glob patterns (comma separated). Defaults to 'test/*,spec/*,db/*,config/*,*_test.rb,*_spec.rb,vendor/*'
219
+ --format FORMAT Output format: text, json (default: text)
220
+ --no-hint Hide the one-line hint printed after the text report (or set UNDERCOVER_NO_HINT=1)
137
221
  -h, --help Prints this help
138
222
  --version Show version
139
223
  ```
140
224
 
225
+ ### JSON Output
226
+
227
+ Use `--format json` to get machine-readable output, useful for CI integrations and tooling:
228
+
229
+ ```sh
230
+ undercover --compare origin/main --format json
231
+ ```
232
+
233
+ Output structure:
234
+
235
+ ```json
236
+ {
237
+ "warnings": [
238
+ {
239
+ "node": "Foo#bar",
240
+ "type": "instance method",
241
+ "file": "lib/foo.rb",
242
+ "first_line": 10,
243
+ "last_line": 20,
244
+ "coverage": 0.5,
245
+ "uncovered_lines": [11],
246
+ "uncovered_branches": [
247
+ {
248
+ "line": 13,
249
+ "block": 0,
250
+ "branch": 0,
251
+ "description": "if user.admin?"
252
+ },
253
+ {
254
+ "line": 16,
255
+ "block": 0,
256
+ "branch": 1,
257
+ "description": "else"
258
+ },
259
+ {
260
+ "line": 19,
261
+ "block": 0,
262
+ "branch": 2,
263
+ "description": "? role == :admin → else"
264
+ }
265
+ ]
266
+ }
267
+ ],
268
+ "summary": {
269
+ "total_warnings": 1,
270
+ "files_affected": 1
271
+ }
272
+ }
273
+ ```
274
+
275
+ Each warning includes:
276
+ - `uncovered_lines` — line numbers with zero execution count
277
+ - `uncovered_branches` — branch entries that were never taken, each with `line`, `block`, and `branch` identifiers. The optional `description` field names the branch from the source (e.g. `"if user.admin?"`, `"else"`, `"when :active"`). For same-line branches such as ternaries, the arm type is appended after ` → ` (e.g. `"? role == :admin → else"`).
278
+
279
+ Branch-level entries require `enable_coverage(:branch)` in your SimpleCov configuration (see [Setting up coverage reporting](#setting-up-coverage-reporting)).
280
+
141
281
  ### Configuration File
142
282
 
143
283
  A configuration file named `.undercover` can be created at the top level of a project's directory containing the same set of options for the CLI.
@@ -156,17 +296,17 @@ Options assume that the program is run from the top level of the project directo
156
296
 
157
297
  Projects with low or nonexistent test coverage are likely to generate large numbers of warnings. While the default workflow would be to address them before the PR approval, your strategy might be different.
158
298
 
159
- In order to acknowledge an untested change and remove the UndercoverCI warning with the intention to improve later (or never), you can wrap the code block with the `:nocov:` syntax, e.g.
299
+ In order to acknowledge an untested change and remove the UndercoverCI warning with the intention to improve later (or never), you can wrap the code block with SimpleCov's skip markers, e.g.
160
300
 
161
301
  ```rb
162
- # :nocov:
302
+ # simplecov:disable
163
303
  def skip_this_method
164
304
  never_reached
165
305
  end
166
- # :nocov:
306
+ # simplecov:enable
167
307
  ```
168
308
 
169
- Read more about the `:nocov:` syntax in [SimpleCov's readme](https://github.com/simplecov-ruby/simplecov#ignoringskipping-code).
309
+ The legacy `# :nocov:` token still works (deprecated in SimpleCov 1.0). Read more in [SimpleCov's readme](https://github.com/simplecov-ruby/simplecov#ignoringskipping-code).
170
310
 
171
311
  ## Why?
172
312
 
data/bin/undercover CHANGED
@@ -2,15 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  $LOAD_PATH.unshift("#{__dir__}/../lib")
5
- $VERBOSE = nil
6
5
 
7
6
  require 'undercover'
8
- require 'benchmark'
9
7
 
10
- result = 0
11
- time = Benchmark.realtime do
12
- result = Undercover::CLI.run(ARGV)
13
- end
14
-
15
- puts "Undercover finished in #{time.round(4)}s"
16
- exit result
8
+ exit Undercover::CLI.run(ARGV, timed: true)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Undercover
6
+ # Labels the lines where Ruby's branch coverage starts a branch (if, unless, ternary,
7
+ # case/when) with the source of their condition
8
+ class AstBranchAnnotator < Prism::Visitor
9
+ def self.call(prism_node)
10
+ return {} unless prism_node.is_a?(Prism::Node)
11
+
12
+ new.tap { prism_node.accept(_1) }.info
13
+ end
14
+
15
+ attr_reader :info
16
+
17
+ def initialize
18
+ super
19
+ @info = {}
20
+ end
21
+
22
+ def visit_if_node(node)
23
+ if node.if_keyword_loc
24
+ label(node.if_keyword_loc, "#{node.if_keyword_loc.slice} #{node.predicate.slice}")
25
+ label_else(node.subsequent)
26
+ else
27
+ label(node.then_keyword_loc, "? #{node.predicate.slice}")
28
+ label(node.subsequent.else_keyword_loc, ':')
29
+ end
30
+ super
31
+ end
32
+
33
+ def visit_unless_node(node)
34
+ label(node.keyword_loc, "unless #{node.predicate.slice}")
35
+ label_else(node.else_clause)
36
+ super
37
+ end
38
+
39
+ def visit_when_node(node)
40
+ label(node.keyword_loc, "when #{node.conditions.first.slice}")
41
+ super
42
+ end
43
+
44
+ # A guard (`in x if y`) is not a branch of its own
45
+ def visit_in_node(node)
46
+ guard = node.pattern
47
+ return super unless guard.is_a?(Prism::IfNode) || guard.is_a?(Prism::UnlessNode)
48
+
49
+ [guard.statements, guard.predicate, node.statements].compact.each { _1.accept(self) }
50
+ end
51
+
52
+ private
53
+
54
+ def label(location, text)
55
+ info[location.start_line] ||= text
56
+ end
57
+
58
+ def label_else(branch)
59
+ label(branch.else_keyword_loc, 'else') if branch.is_a?(Prism::ElseNode)
60
+ end
61
+ end
62
+ end
@@ -8,12 +8,31 @@ module Undercover
8
8
  class Changeset
9
9
  T_ZERO = Time.strptime('0', '%s').freeze
10
10
 
11
- def initialize(dir, compare_base = nil, filter_set = nil)
11
+ def initialize(dir, compare_base = nil, filter_set = nil, commits_only: false)
12
+ raise ArgumentError, 'commits_only requires a compare_base' if commits_only && !compare_base
13
+
12
14
  @dir = dir
13
15
  @repo = Rugged::Repository.new(dir)
14
16
  @repo.workdir = Pathname.new(dir).dirname.to_s # TODO: can replace?
15
17
  @compare_base = compare_base
16
18
  @filter_set = filter_set
19
+ @commits_only = commits_only
20
+ end
21
+
22
+ # @return String absolute path to the repository working directory
23
+ def repo_workdir
24
+ repo.workdir
25
+ end
26
+
27
+ # Answers from HEAD's tree rather than the disk, so it holds for files a sparse
28
+ # checkout left out of the working tree.
29
+ # @param path [String] relative to the repository root
30
+ # @return Boolean whether HEAD has a file or directory at path
31
+ def head_path?(path)
32
+ head.tree.path(path)
33
+ true
34
+ rescue Rugged::TreeError
35
+ false
17
36
  end
18
37
 
19
38
  def last_modified
@@ -56,10 +75,15 @@ module Undercover
56
75
  private
57
76
 
58
77
  # Diffs `head` or `head` + `compare_base` (if exists),
59
- # as it makes sense to run Undercover with the most recent file versions
78
+ # as it makes sense to run Undercover with the most recent file versions.
79
+ # With `commits_only`, diffs `compare_base` against `head` and ignores the index and
80
+ # working tree, so files missing from a sparse checkout are not reported as deleted.
60
81
  def full_diff
61
- base = compare_base_obj || head
62
- @full_diff ||= base.diff(repo.index).merge!(repo.diff_workdir(head))
82
+ @full_diff ||= if commits_only
83
+ compare_base_obj.diff(head)
84
+ else
85
+ (compare_base_obj || head).diff(repo.index).merge!(repo.diff_workdir(head))
86
+ end
63
87
  end
64
88
 
65
89
  def compare_base_obj
@@ -74,6 +98,6 @@ module Undercover
74
98
  repo.head.target
75
99
  end
76
100
 
77
- attr_reader :repo, :compare_base, :filter_set
101
+ attr_reader :repo, :compare_base, :filter_set, :commits_only
78
102
  end
79
103
  end
@@ -2,43 +2,53 @@
2
2
 
3
3
  require 'undercover'
4
4
  require 'rainbow'
5
+ require 'benchmark'
5
6
 
6
7
  module Undercover
7
- module CLI
8
- WARNINGS_TO_S = {
9
- stale_coverage: Rainbow('🚨 WARNING: Coverage data is older than your ' \
10
- 'latest changes and results might be incomplete. ' \
11
- 'Re-run tests to update').yellow,
12
- no_changes: Rainbow('✅ No reportable changes').green
8
+ module CLI # rubocop:disable Metrics/ModuleLength
9
+ HINTS = {
10
+ undercover_ci: 'UndercoverCI shows these results on your pull requests: https://undercover-ci.com ' \
11
+ '(hide with --no-hint)',
12
+ claude_code: 'undercover has a Claude Code skill: https://github.com/grodowski/undercover-claude ' \
13
+ '(hide with --no-hint)'
13
14
  }.freeze
14
- def self.run(args)
15
+
16
+ def self.run(args, timed: false)
15
17
  opts = Undercover::Options.new.parse(args)
16
- syntax_version(opts.syntax_version)
18
+ # Text after the JSON document would break parsing it
19
+ return run_report(opts) if !timed || opts.formatter == 'json'
17
20
 
18
- run_report(opts)
21
+ result = nil
22
+ time = Benchmark.realtime { result = run_report(opts) }
23
+ puts "Undercover finished in #{time.round(4)}s"
24
+ result
19
25
  end
20
26
 
21
27
  def self.run_report(opts) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
22
28
  coverage_path = opts.simplecov_resultset || opts.lcov
23
29
  return handle_missing_coverage_path(opts) if coverage_path.nil?
24
30
  return handle_missing_file(coverage_path) unless File.exist?(coverage_path)
31
+ return handle_commits_only_without_compare if opts.commits_only && !opts.compare
32
+
33
+ changeset_obj = changeset(opts)
34
+ only_files = changeset_obj.file_paths.then { |f| f.empty? ? nil : f }
25
35
 
26
36
  simplecov_adapter = if opts.simplecov_resultset
27
- SimplecovResultAdapter.parse(File.open(opts.simplecov_resultset), opts)
37
+ SimplecovResultAdapter.parse(File.open(opts.simplecov_resultset), opts,
38
+ only_files: only_files)
28
39
  else
29
40
  # TODO: lcov will be deprecated end of 2025 and we'll be able to refactor harder
30
- LcovParser.parse(File.open(opts.lcov), opts)
41
+ LcovParser.parse(File.open(opts.lcov), opts, only_files: only_files)
31
42
  end
32
43
 
33
- changeset_obj = changeset(opts)
34
44
  report = Undercover::Report.new(changeset_obj, opts, simplecov_adapter).build
35
- handle_report_validation(report, coverage_path)
45
+ handle_report_validation(report, coverage_path, opts)
36
46
  end
37
47
 
38
- def self.handle_missing_coverage_path(opts)
48
+ def self.handle_missing_coverage_path(_opts = nil)
39
49
  puts Rainbow('❌ ERROR: No coverage report found. Checked default paths:').red
40
50
  puts Rainbow(' - ./coverage/coverage.json (SimpleCov)').red
41
- puts Rainbow(" - ./coverage/lcov/#{Pathname.new(File.expand_path(opts.path)).split.last}.lcov (LCOV)").red
51
+ puts Rainbow(" - ./coverage/lcov/#{Pathname.new(Dir.pwd).split.last}.lcov (LCOV)").red
42
52
  puts Rainbow('Set a custom path with --lcov or --simplecov option').red
43
53
  1
44
54
  end
@@ -48,27 +58,95 @@ module Undercover
48
58
  1
49
59
  end
50
60
 
51
- def self.handle_report_validation(report, coverage_path)
52
- error = report.validate(coverage_path)
53
- if error
54
- puts(WARNINGS_TO_S[error])
55
- return 0 if error == :no_changes
61
+ def self.handle_commits_only_without_compare
62
+ puts Rainbow('❌ ERROR: --commits-only requires --compare ref').red
63
+ 1
64
+ end
65
+
66
+ def self.handle_report_validation(report, coverage_path, opts)
67
+ validation_error = report.validate(coverage_path)
68
+ flagged = validation_error ? [] : report.flagged_results
69
+ unless validation_error
70
+ warn_if_erb_has_no_coverage(report)
71
+ warn_if_everything_was_globbed_out(report, opts)
56
72
  end
73
+ formatter = build_formatter(flagged, validation_error, opts)
74
+
75
+ puts formatter
76
+ print_hint(opts)
77
+ formatter.exit_code
78
+ end
79
+
80
+ # Flushes stdout first: when it's piped (CI logs) it is buffered, and the hint on
81
+ # stderr would otherwise land before the report
82
+ def self.print_hint(opts)
83
+ hint = hint_for(opts) or return
84
+
85
+ $stdout.flush
86
+ warn(Rainbow(hint).faint)
87
+ end
88
+
89
+ def self.hint_for(opts, env = ENV)
90
+ return if opts.formatter == 'json' || opts.no_hint || !env['UNDERCOVER_NO_HINT'].to_s.empty?
57
91
 
58
- flagged = report.flagged_results
59
- puts Undercover::Formatter.new(flagged)
60
- flagged.any? ? 1 : 0
92
+ env['CLAUDECODE'].to_s.empty? ? HINTS[:undercover_ci] : HINTS[:claude_code]
61
93
  end
62
94
 
63
- def self.syntax_version(version)
64
- return unless version
95
+ # Reporting nothing because every candidate was filtered out looks identical to a
96
+ # clean run, so say which it was. Goes to stderr to leave --format json parseable.
97
+ def self.warn_if_everything_was_globbed_out(report, opts)
98
+ return unless report.results.empty?
65
99
 
66
- Imagen.parser_version = version
100
+ skipped = report.globbed_out_files
101
+ return if skipped.empty?
102
+ return warn_erb_is_opt_in(skipped) if erb_not_opted_in?(skipped, opts)
103
+ return warn_globbed_out_besides_erb(skipped) if report.erb_files_without_coverage.any?
104
+
105
+ warn(Rainbow("⚠️ WARNING: nothing was checked - all #{skipped.size} candidate file(s) were " \
106
+ 'skipped by --include-files/--exclude-files. Globs are matched relative to the ' \
107
+ 'directory SimpleCov covered, so they should not repeat its path.').yellow)
108
+ end
109
+
110
+ # Templates skipped for lack of coverage were candidates too, so not everything was globbed out
111
+ def self.warn_globbed_out_besides_erb(skipped)
112
+ warn(Rainbow("⚠️ WARNING: besides those ERB templates, #{skipped.size} candidate file(s) were skipped " \
113
+ 'by --include-files/--exclude-files.').yellow)
114
+ end
115
+
116
+ # The default globs skip ERB templates, so blaming the glob root would mislead
117
+ def self.erb_not_opted_in?(skipped, opts)
118
+ skipped.any? { _1.end_with?(FileLoader::ERB_EXTENSION) } &&
119
+ opts.glob_allow_filters.none? { _1.include?(FileLoader::ERB_EXTENSION) }
120
+ end
121
+
122
+ def self.warn_erb_is_opt_in(skipped)
123
+ templates = skipped.count { _1.end_with?(FileLoader::ERB_EXTENSION) }
124
+ warn(Rainbow("⚠️ WARNING: nothing was checked - #{templates} changed ERB template(s) were skipped " \
125
+ 'because view coverage is opt-in. Check them with ' \
126
+ "--include-files '*.rb,*.rake,*.ru,Rakefile,*.erb' and enable_coverage(:eval) in SimpleCov.start.")
127
+ .yellow)
128
+ end
129
+
130
+ def self.warn_if_erb_has_no_coverage(report)
131
+ templates = report.erb_files_without_coverage
132
+ return if templates.empty?
133
+
134
+ warn(Rainbow("⚠️ WARNING: skipped #{templates.size} ERB template(s) - the coverage report has no " \
135
+ 'ERB coverage. Add enable_coverage(:eval) to SimpleCov.start to check them.').yellow)
136
+ end
137
+
138
+ def self.build_formatter(flagged, validation_error, opts)
139
+ if opts.formatter == 'json'
140
+ Undercover::JsonFormatter.new(flagged, validation_error)
141
+ else
142
+ Undercover::Formatter.new(flagged, validation_error)
143
+ end
67
144
  end
68
145
 
146
+ # --git-dir is resolved against the working directory, and the repository it points
147
+ # at determines the project root. Everything else is derived from there.
69
148
  def self.changeset(opts)
70
- git_dir = File.join(opts.path, opts.git_dir)
71
- Undercover::Changeset.new(git_dir, opts.compare)
149
+ Undercover::Changeset.new(opts.git_dir, opts.compare, commits_only: opts.commits_only)
72
150
  end
73
151
  end
74
152
  end