undercover 0.8.5 → 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: 8d93f862aad4a70d2370f9da2021a4b32c284084f4a56bc2a04da6f409d2b777
4
- data.tar.gz: 1cc3a1f5e26aa234019c3ae76df456534a7047dfbb1421175db56ab869d4150f
3
+ metadata.gz: 45405b87566ffc4a838da25a9154b47f48b0578c469497eda044ea665005cfed
4
+ data.tar.gz: f90beafad3d6d90215c2326c7c702e725a25bfefbaf5b82ed2ea749133042b67
5
5
  SHA512:
6
- metadata.gz: ae42ba519cd6bd3435fee10c67c20907690036a4bccdd425c2cde2b4276a2bbd33ffd9d8d091b73c6d760fdf7e4bca52e400d71c85f21440f2d2c4ad2fb53d86
7
- data.tar.gz: 28c65742716800a1a042b513ebe4bf14f254d46d22ef8959406d49f682132525fff4e19a21a7e7418562485cda79f9cb167bfb11877763b0b17e9645a09e116b
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 }}
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,24 @@ 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
+
9
27
  # [0.8.5] - 2026-04-21
10
28
  ### Added
11
29
  - `--format json` CLI option for machine-readable output ([#251](https://github.com/grodowski/undercover/pull/251))
@@ -223,7 +241,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
223
241
  ### Added
224
242
  - First release of `undercover` 🎉
225
243
 
226
- [Unreleased]: https://github.com/grodowski/undercover/compare/v0.8.5...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
227
246
  [0.8.5]: https://github.com/grodowski/undercover/compare/v0.8.4...v0.8.5
228
247
  [0.8.4]: https://github.com/grodowski/undercover/compare/v0.8.3...v0.8.4
229
248
  [0.8.3]: https://github.com/grodowski/undercover/compare/v0.8.2...v0.8.3
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,14 +210,14 @@ 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/*'
137
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)
138
221
  -h, --help Prints this help
139
222
  --version Show version
140
223
  ```
@@ -213,17 +296,17 @@ Options assume that the program is run from the top level of the project directo
213
296
 
214
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.
215
298
 
216
- 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.
217
300
 
218
301
  ```rb
219
- # :nocov:
302
+ # simplecov:disable
220
303
  def skip_this_method
221
304
  never_reached
222
305
  end
223
- # :nocov:
306
+ # simplecov:enable
224
307
  ```
225
308
 
226
- 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).
227
310
 
228
311
  ## Why?
229
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)
@@ -1,50 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module Undercover
4
- class AstBranchAnnotator
5
- def self.call(ast_node)
6
- new.annotate(ast_node)
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
7
13
  end
8
14
 
9
- # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
10
- def annotate(ast_node, info = {})
11
- return info unless ast_node.is_a?(::Parser::AST::Node)
15
+ attr_reader :info
12
16
 
13
- case ast_node.type
14
- when :if
15
- annotate_if_node(ast_node, info)
16
- when :case
17
- ast_node.children.drop(1).each do |child|
18
- next unless child.is_a?(::Parser::AST::Node) && child.type == :when
17
+ def initialize
18
+ super
19
+ @info = {}
20
+ end
19
21
 
20
- cond_src = expression_source(child.children.first)
21
- info[child.location.keyword.line] ||= "when #{cond_src}"
22
- end
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, ':')
23
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
24
38
 
25
- ast_node.children.each { |c| annotate(c, info) }
26
- info
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) }
27
50
  end
28
- # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
29
51
 
30
52
  private
31
53
 
32
- def annotate_if_node(ast_node, info) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
33
- loc = ast_node.location
34
- cond_src = expression_source(ast_node.children.first)
35
- case loc
36
- when Parser::Source::Map::Condition
37
- kw = loc.keyword
38
- info[kw.line] ||= "#{kw.source} #{cond_src}"
39
- info[loc.else.line] ||= 'else' if loc.else&.source == 'else'
40
- when Parser::Source::Map::Ternary
41
- info[loc.question.line] ||= "? #{cond_src}"
42
- info[loc.colon.line] ||= ':'
43
- end
54
+ def label(location, text)
55
+ info[location.start_line] ||= text
44
56
  end
45
57
 
46
- def expression_source(node)
47
- node.location.expression.source
58
+ def label_else(branch)
59
+ label(branch.else_keyword_loc, 'else') if branch.is_a?(Prism::ElseNode)
48
60
  end
49
61
  end
50
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,37 +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
- def self.run(args)
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)'
14
+ }.freeze
15
+
16
+ def self.run(args, timed: false)
9
17
  opts = Undercover::Options.new.parse(args)
10
- 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'
11
20
 
12
- 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
13
25
  end
14
26
 
15
27
  def self.run_report(opts) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
16
28
  coverage_path = opts.simplecov_resultset || opts.lcov
17
29
  return handle_missing_coverage_path(opts) if coverage_path.nil?
18
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 }
19
35
 
20
36
  simplecov_adapter = if opts.simplecov_resultset
21
- SimplecovResultAdapter.parse(File.open(opts.simplecov_resultset), opts)
37
+ SimplecovResultAdapter.parse(File.open(opts.simplecov_resultset), opts,
38
+ only_files: only_files)
22
39
  else
23
40
  # TODO: lcov will be deprecated end of 2025 and we'll be able to refactor harder
24
- LcovParser.parse(File.open(opts.lcov), opts)
41
+ LcovParser.parse(File.open(opts.lcov), opts, only_files: only_files)
25
42
  end
26
43
 
27
- changeset_obj = changeset(opts)
28
44
  report = Undercover::Report.new(changeset_obj, opts, simplecov_adapter).build
29
45
  handle_report_validation(report, coverage_path, opts)
30
46
  end
31
47
 
32
- def self.handle_missing_coverage_path(opts)
48
+ def self.handle_missing_coverage_path(_opts = nil)
33
49
  puts Rainbow('❌ ERROR: No coverage report found. Checked default paths:').red
34
50
  puts Rainbow(' - ./coverage/coverage.json (SimpleCov)').red
35
- 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
36
52
  puts Rainbow('Set a custom path with --lcov or --simplecov option').red
37
53
  1
38
54
  end
@@ -42,15 +58,83 @@ module Undercover
42
58
  1
43
59
  end
44
60
 
61
+ def self.handle_commits_only_without_compare
62
+ puts Rainbow('❌ ERROR: --commits-only requires --compare ref').red
63
+ 1
64
+ end
65
+
45
66
  def self.handle_report_validation(report, coverage_path, opts)
46
67
  validation_error = report.validate(coverage_path)
47
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)
72
+ end
48
73
  formatter = build_formatter(flagged, validation_error, opts)
49
74
 
50
75
  puts formatter
76
+ print_hint(opts)
51
77
  formatter.exit_code
52
78
  end
53
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?
91
+
92
+ env['CLAUDECODE'].to_s.empty? ? HINTS[:undercover_ci] : HINTS[:claude_code]
93
+ end
94
+
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?
99
+
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
+
54
138
  def self.build_formatter(flagged, validation_error, opts)
55
139
  if opts.formatter == 'json'
56
140
  Undercover::JsonFormatter.new(flagged, validation_error)
@@ -59,15 +143,10 @@ module Undercover
59
143
  end
60
144
  end
61
145
 
62
- def self.syntax_version(version)
63
- return unless version
64
-
65
- Imagen.parser_version = version
66
- end
67
-
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.
68
148
  def self.changeset(opts)
69
- git_dir = File.join(opts.path, opts.git_dir)
70
- Undercover::Changeset.new(git_dir, opts.compare)
149
+ Undercover::Changeset.new(opts.git_dir, opts.compare, commits_only: opts.commits_only)
71
150
  end
72
151
  end
73
152
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undercover
4
+ # A file's coverage tuples indexed by line number, so each node reads only its own
5
+ # lines instead of the whole file's coverage.
6
+ class CoverageIndex
7
+ def initialize(file_coverage)
8
+ @file_coverage = file_coverage
9
+ @positions = {}
10
+ file_coverage.each_with_index { |(line_no, *), idx| (@positions[line_no] ||= []) << idx }
11
+ end
12
+
13
+ # @param lines [Range]
14
+ # @return Array tuples on the given lines, in the order of the coverage report
15
+ def within(lines)
16
+ lines.flat_map { @positions[_1] || [] }.sort.map { @file_coverage[_1] }
17
+ end
18
+ end
19
+ end