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 +4 -4
- data/.github/FUNDING.yml +15 -0
- data/.github/workflows/ruby.yml +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +28 -1
- data/Gemfile +3 -1
- data/README.md +149 -9
- data/bin/undercover +1 -9
- data/lib/undercover/ast_branch_annotator.rb +62 -0
- data/lib/undercover/changeset.rb +29 -5
- data/lib/undercover/cli.rb +106 -28
- data/lib/undercover/coverage_index.rb +19 -0
- data/lib/undercover/coverage_root.rb +73 -0
- data/lib/undercover/erb_extractor.rb +32 -0
- data/lib/undercover/erb_node.rb +20 -0
- data/lib/undercover/erb_visitor.rb +42 -0
- data/lib/undercover/file_loader.rb +51 -0
- data/lib/undercover/filter_set.rb +34 -9
- data/lib/undercover/formatter.rb +18 -1
- data/lib/undercover/json_formatter.rb +84 -0
- data/lib/undercover/lcov_parser.rb +39 -16
- data/lib/undercover/node.rb +78 -0
- data/lib/undercover/node_visitor.rb +86 -0
- data/lib/undercover/options.rb +50 -17
- data/lib/undercover/result.rb +43 -24
- data/lib/undercover/simplecov_formatter.rb +50 -19
- data/lib/undercover/simplecov_result_adapter.rb +49 -11
- data/lib/undercover/version.rb +1 -1
- data/lib/undercover.rb +73 -35
- data/undercover.gemspec +9 -6
- metadata +43 -13
- data/lib/undercover/root_to_relative_paths.rb +0 -24
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45405b87566ffc4a838da25a9154b47f48b0578c469497eda044ea665005cfed
|
|
4
|
+
data.tar.gz: f90beafad3d6d90215c2326c7c702e725a25bfefbaf5b82ed2ea749133042b67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 588486852ac15ef12aad5442b86567ed9e32ec36cb91ba4c45b164972f62cb1e1e1a253d5f9f2ee831bdc26074ba8446e8dfd99f6b0ae80405eac521f648a8f8
|
|
7
|
+
data.tar.gz: 5b63e08d782e36bae4513828a5f5b40bcf2c777775248285224faa19f482032ee962a02ae4e4859cf8c6d81e4a0816106d29e5a5b21bdb115646797ccb7b0f64
|
data/.github/FUNDING.yml
ADDED
|
@@ -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']
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -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@
|
|
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@
|
|
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@
|
|
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
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.
|
|
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'
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
|
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
|
-
# :
|
|
302
|
+
# simplecov:disable
|
|
163
303
|
def skip_this_method
|
|
164
304
|
never_reached
|
|
165
305
|
end
|
|
166
|
-
# :
|
|
306
|
+
# simplecov:enable
|
|
167
307
|
```
|
|
168
308
|
|
|
169
|
-
|
|
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
|
-
|
|
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
|
data/lib/undercover/changeset.rb
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
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
|
data/lib/undercover/cli.rb
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
+
|
|
16
|
+
def self.run(args, timed: false)
|
|
15
17
|
opts = Undercover::Options.new.parse(args)
|
|
16
|
-
|
|
18
|
+
# Text after the JSON document would break parsing it
|
|
19
|
+
return run_report(opts) if !timed || opts.formatter == 'json'
|
|
17
20
|
|
|
18
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|