uncov 0.3.0 → 0.4.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: 71c9c6024e7115e88987532fd3fdb4e3667dd1d60701f558bc1c34a23e38c171
4
- data.tar.gz: 9b89570ceac5692a3521e72e4faa35446c555c6c494b318d502e1c1c3fff98c5
3
+ metadata.gz: 397cffc8dced395da502cb60cc2136da4cb7f886ab0dc53636d8d3dd886b263b
4
+ data.tar.gz: 9d73dc0cbf0467f131d470f4ee486013daf64f2e73980749ffcad09ccb8fced7
5
5
  SHA512:
6
- metadata.gz: 474941a337634f028565480b36b362a561cdb7b8c9a7105e998fadb89d23bf6717d070252dae19c991acb09b19d44357797b7964c20dfb0f78316a1f2d0c1ce5
7
- data.tar.gz: e3cfbd78c00926fdc14c84c3e35d46d8141595eef8f07fa442172db7215c55502eda0e3ccf6e371cde39ccbc9c9e2e769ec8c0739924d2d7bfe9a0ad2a32a42d
6
+ metadata.gz: 5407ea15f650d927ec5887b5d1d0530caea5cefa92d3f9bbde1e0895de820cae5cf415d61a223af834d2265d717ac1ba4984b8e18ed547915ad55e1b86d04406
7
+ data.tar.gz: 21c9b8dc0af1213f24167b0a0442550444cb58fb3af667d8612951818a3301cd33c981d6151a3c69579a726a30057b62102c2f3fc207ffa56da3078495172d3f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2025-05-05: [v0.4.0](https://github.com/mpapis/uncov/releases/tag/v0.4.0)
4
+
5
+ ### Minor
6
+
7
+ - [#38](https://github.com/mpapis/uncov/pull/38): Read configuration from file, by [@mpapis](https://github.com/mpapis)
8
+ - [#39](https://github.com/mpapis/uncov/pull/39): Add output context lines, by [@mpapis](https://github.com/mpapis)
9
+
10
+ ### Patch
11
+
12
+ - [#37](https://github.com/mpapis/uncov/pull/37): Fix exception raising without namespace, by [@mpapis](https://github.com/mpapis)
13
+
14
+
15
+
3
16
  ## 2025-04-30: [v0.3.0](https://github.com/mpapis/uncov/releases/tag/v0.3.0)
4
17
 
5
18
  ### Minor
data/CONTRIBUTING.md CHANGED
@@ -1,26 +1,25 @@
1
1
  # Contributing
2
- Bug reports and pull requests are welcome on GitHub at https://github.com/mpapis/uncov.
3
2
 
4
- Do not know where to start? Help triaging issues, make sure they can be reproduced.
3
+ ## Want to help?
4
+ - Bug reports and pull requests are welcome on GitHub at https://github.com/mpapis/uncov.
5
+ - Do not know where to start? Help triaging issues, make sure they can be reproduced.
5
6
 
6
7
 
7
8
  ## Reporting a Vulnerability
8
-
9
9
  If you discover a security vulnerability within uncov, please email [mpapis@gmail.com](mailto:mpapis@gmail.com).
10
10
  All security vulnerabilities will be promptly addressed.
11
11
 
12
+
12
13
  ## Development
13
14
  - `docker-compose build uncov` to (re-)build dev container,
14
15
  - `docker-compose run uncov` to enter dev container,
15
- - `bundle` to install dependencies,
16
- - `rake` to run test and lint,
17
- - `rake install` to install the gem,
18
- - `uncov` to see uncovered changes (check itself)
16
+ - `bundle` to install dependencies and uncov,
17
+ - `rake` to run lint, test and uncov - **precheck** before opening PR,
18
+ - `uncov` to test manually
19
19
 
20
20
 
21
21
  ## Pull requests
22
-
23
- Open pull requests against the `develop` branch
22
+ Open pull requests against the `develop` or `release-v*` branches.
24
23
 
25
24
  Branch prefix influences auto labeling next release:
26
25
  - `ignore/` - will not trigger next release (excluded from changelog),
@@ -28,13 +27,21 @@ Branch prefix influences auto labeling next release:
28
27
  - `feature/` - will bump minor version,
29
28
  - `breaking/` - will bump major version.
30
29
 
31
- If unsure it's not important, the release label's can be changed by maintainers.
30
+ If unsure it's not important, the release label's (`release:*`) can be changed by maintainers.
32
31
 
33
32
 
34
33
  ## Release Process
34
+ This gem uses GitHub Actions for continuous integration and automated releases.
35
35
 
36
- This gem uses GitHub Actions for continuous integration and automated releases. Here's how the release process works:
37
36
 
37
+ ### Check future changelog
38
+ 1. Go to the "Actions" tab in the GitHub repository
39
+ 2. Select the "Changelog" workflow
40
+ 3. Click "Run workflow"
41
+ 4. Go to "Summary" and see generated version and changelog
42
+
43
+
44
+ ### Release
38
45
  1. Go to the "Actions" tab in the GitHub repository
39
46
  2. Select the "Release" workflow
40
47
  3. Click "Run workflow"
@@ -46,9 +53,6 @@ This will:
46
53
 
47
54
 
48
55
  ## Security Practices
49
-
50
- This project follows these security practices:
51
-
52
56
  1. **Code Review**:
53
57
  - All pull requests must be reviewed by a project maintainer
54
58
  - Workflow files cannot be modified without explicit review from core maintainers
data/PHILOSOPHY.md ADDED
@@ -0,0 +1,55 @@
1
+ # What is uncov?
2
+ `uncov` stands for uncovered code.
3
+
4
+
5
+ ## Why uncov
6
+ I was irritated by manually checking which files were changed in git
7
+ and then looking at each file coverage report for missing coverage.
8
+
9
+
10
+ ## Why code coverage
11
+ This was tiresome but something that I thought was required to validate
12
+ if I thought about important behaviors of my code in tests.
13
+
14
+
15
+ ## Code coverage is worthless
16
+ Code coverage does not indicate quality of the tests,
17
+ but lack of coverage indicates lack of testing specific behavior at all.
18
+
19
+ Despite its limitations as a quality metric, we can use coverage insights
20
+ as a starting point to develop better testing practices. Here's how:
21
+
22
+ ## How to improve quality of tests
23
+ Think about testing behaviors, do not test implementation.
24
+ This implies more work preparing test data / the state of the system,
25
+ in a way that shows how the system behaves in specific conditions.
26
+
27
+ There has been a lot written about how to test, learn how others approach testing/coverage:
28
+ - https://testdesiderata.com/
29
+ - https://martinfowler.com/bliki/TestCoverage.html
30
+ - https://martinfowler.com/articles/practical-test-pyramid.html
31
+ - https://www.betterspecs.org/
32
+ - https://github.com/ch1ago/bdd
33
+
34
+ There is a lot of gold in there, but I do not agree with everything there,
35
+ you need to find your own way that works for your specific project needs.
36
+
37
+
38
+ ## How uncov helps
39
+ `uncov` focuses your attention where it matters most - on recently changed code that lacks test coverage.
40
+ By analyzing git diffs and connecting them to your test coverage results, `uncov`:
41
+ - Identifies only the files you've modified, saving you time
42
+ - Shows precisely which changed lines remain untested
43
+ - Integrates with your existing test workflow through SimpleCov
44
+ - Provides context around uncovered code to help you understand what needs testing
45
+
46
+ This targeted approach helps you maintain high-quality tests for new and modified code
47
+ without getting overwhelmed by legacy coverage issues.
48
+
49
+
50
+ ## Take action
51
+ Ready to improve your testing workflow?
52
+
53
+ Install uncov with `gem install uncov` and run it after making changes to immediately see which behaviors need tests.
54
+ Check the [README](README.md) for more details and
55
+ `uncov -h` for configuration options to customize uncov for your project's needs.
data/README.md CHANGED
@@ -1,50 +1,86 @@
1
1
  # Uncov
2
+ Uncov analyzes test coverage for changed files in your Git repository,
3
+ helping you ensure that all your recent changes are properly tested.
2
4
 
3
- Uncov analyzes test coverage for changed files in your Git repository, helping you ensure that all your recent changes are properly tested.
5
+ Uncov uses `git diff` to detect changes and `simplecov` reports to detect uncovered code.
4
6
 
5
- ## Features
7
+ [The uncov Manifesto](PHILOSOPHY.md)
8
+
9
+ ![report diff_lines to terminal output](diff_lines_terminal.png)
6
10
 
11
+ ## Features
7
12
  - Compare your working tree to a target branch
8
13
  - Identify changed Ruby files
9
- - Check test coverage for those changes using SimpleCov
10
- - Run tests automatically for files without coverage data
11
- - Generate reports of uncovered lines in changed files
14
+ - Run tests automatically for (changed) relevant files
15
+ - Print report of uncovered lines in (changed) files
12
16
 
13
- ## Installation
14
17
 
18
+ ## Installation
15
19
  ```bash
16
20
  gem install uncov
17
21
  ```
18
-
19
22
  Or add to your Gemfile (only for convenience):
20
23
  ```ruby
21
24
  gem 'uncov', require: false
22
25
  ```
23
26
 
27
+
24
28
  ## Usage
25
29
  Basic usage:
26
30
  ```bash
27
31
  uncov
28
32
  ```
29
- With options:
33
+
34
+ Display configuration options:
30
35
  ```bash
31
- uncov --target develop --path custom/coverage/path
36
+ $ uncov -h
37
+ Usage: uncov [options]
38
+ -t, --target TARGET Target branch for comparison, default: "HEAD"
39
+ -r, --report TYPE Report type to generate, one_of: "diff_lines"(default)
40
+ -o, --output-format FORMAT Output format, one_of: "terminal"(default)
41
+ -C, --context LINES_NUMBER Additional lines context in output, default: 1
42
+ --test-command COMMAND Test command that generates SimpleCov, default: "COVERAGE=true bundle exec rake test"
43
+ --simplecov-file PATH SimpleCov results file, default: "autodetect"
44
+ --relevant-files Relevant files shell filename globing: https://ruby-doc.org/core-3.1.1/File.html#method-c-fnmatch, default: "{{bin,exe,exec}/*,{app,lib}/**/*.{rake,rb},Rakefile}"
45
+ --debug Get some insights, default: false
46
+ -v, --version Show version
47
+ -h, --help Print this help
48
+ uncov 0.3.0 by Michal Papis <mpapis@gmail.com>
32
49
  ```
33
- Options
34
- - `-t`, `--target BRANCH`: Target branch for comparison (default: main)
35
- - `-c`, `--command COMMAND`: Test command to run (default: bundle exec rake test)
36
- - `-p`, `--path PATH`: Path to SimpleCov results (default: coverage/.resultset.json)
37
- - `-h`, `--help`: Display help
38
- - `-v`, `--version`: Display version
39
50
 
40
- ## Requirements
41
51
 
52
+ ## Configuration file
53
+ `.uncov` file in the directory where it's ran stores default options,
54
+ specify one argument per line - this eliminates the need for special parsing of the file.
55
+
56
+ Example:
57
+ ```text
58
+ --target
59
+ develop
60
+ --test-command
61
+ COVERAGE=1 rspec
62
+ ```
63
+
64
+
65
+ ## Using in CI
66
+ `uncov` uses itself to check new missing code coverage [.github/workflows/ci.yml](.github/workflows/ci.yml),
67
+ no need to set minimal, always get better.
68
+
69
+ Ideas for CI:
70
+ - run `uncov` after running your tests with coverage enabled,
71
+ - be less restrictive - provide custom `--relevant-files` pattern
72
+ that excludes some paths you do not think should be always tested.
73
+
74
+
75
+ ## Requirements
42
76
  - Ruby 3.2+
43
77
  - A Git repository
44
78
  - SimpleCov for test coverage
45
79
 
80
+
46
81
  ## Contributing
47
82
  Contributing, developing, pull requests, releasing, security -> [CONTRIBUTING.md](CONTRIBUTING.md).
48
83
 
84
+
49
85
  ## License
50
86
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/uncov CHANGED
@@ -3,4 +3,12 @@
3
3
 
4
4
  require 'uncov'
5
5
 
6
- Uncov::CLI.start(ARGV)
6
+ status =
7
+ catch(:exit) do
8
+ case Uncov::CLI.start(ARGV)
9
+ when true then 0
10
+ when false then 1
11
+ else 128
12
+ end
13
+ end
14
+ exit status
data/lib/uncov/cli.rb CHANGED
@@ -5,49 +5,14 @@ require 'optparse'
5
5
  # provide terminal interface for uncov
6
6
  class Uncov::CLI
7
7
  def self.start(args)
8
- new.start(args)
9
- end
10
-
11
- def start(args)
12
- parse_options(args)
13
- report = Uncov::Report.new
8
+ Uncov.configure(args)
9
+ report = Uncov::Report.build
14
10
  Uncov::Formatter.output(report)
15
- exit(report.covered? ? 0 : 1)
16
- end
17
-
18
- private
11
+ !report.uncov?
12
+ rescue StandardError => e
13
+ raise if Uncov.configuration.debug
19
14
 
20
- def parse_options(args) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
21
- parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
22
- opts.banner = 'Usage: uncov [options]'
23
- opts.on('-r', '--report TYPE', 'Report type to generate (git_diff)') do |type|
24
- Uncov.configuration.report = type.to_sym
25
- end
26
- opts.on('-t', '--target BRANCH', 'Target branch for comparison') do |branch|
27
- Uncov.configuration.git_diff_target = branch
28
- end
29
- opts.on('-c', '--command COMMAND', 'Test command to run SimpleCov') do |command|
30
- Uncov.configuration.test_command = command
31
- end
32
- opts.on('-p', '--path PATH', 'Target filesystem path') do |path|
33
- Uncov.configuration.path = path
34
- end
35
- opts.on('-f', '--formater FORMATTER', 'Formatter for output') do |formatter|
36
- Uncov.configuration.output_format = formatter.to_sym
37
- end
38
- opts.on('--simplecov-path PATH', 'Path to SimpleCov results') do |path|
39
- Uncov.configuration.simplecov_output_path = path
40
- end
41
- opts.on('-h', '--help', 'Print this help') do
42
- puts opts
43
- puts "uncov #{Uncov::VERSION} by Michal Papis <mpapis@gmail.com>"
44
- exit
45
- end
46
- opts.on('-v', '--version', 'Show version') do
47
- puts "uncov #{Uncov::VERSION} by Michal Papis <mpapis@gmail.com>"
48
- exit
49
- end
50
- end
51
- parser.parse!(args)
15
+ warn e.message
16
+ nil
52
17
  end
53
18
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # configuration option
4
+ class Uncov::Configuration::Option
5
+ attr_reader :name, :description, :options, :default, :allowed_values, :value_parse, :value
6
+
7
+ def initialize(name, description, options, default, allowed_values, value_parse)
8
+ @name = name
9
+ @description = description
10
+ @options = Array(options)
11
+ @default = default.freeze
12
+ @allowed_values = allowed_values
13
+ @value_parse = value_parse
14
+ self.value = default
15
+ end
16
+
17
+ def value=(value)
18
+ if allowed_values&.none?(value)
19
+ raise \
20
+ Uncov::OptionValueNotAllowed,
21
+ "Configuration option(#{name.inspect}) tried to set: #{value.inspect}, only: #{allowed_values.inspect} allowed"
22
+ else
23
+ @value = value
24
+ end
25
+ end
26
+
27
+ def on_parser(parser) = parser.on(*options, options_description) { |value| self.value = value_parse.call(value) }
28
+
29
+ private
30
+
31
+ def options_description
32
+ if allowed_values
33
+ "#{description}, one_of: #{options_one_of.join(', ')}"
34
+ else
35
+ "#{description}, default: #{default.inspect}"
36
+ end
37
+ end
38
+
39
+ def options_one_of
40
+ allowed_values.map do |value|
41
+ if value == default
42
+ "#{value.inspect}(default)"
43
+ else
44
+ # :nocov: for now as there is no case yet in Configuration
45
+ value.inspect
46
+ # :nocov:
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,17 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'formatter'
4
+ require_relative 'report'
5
+
3
6
  # handle configuration for uncov
4
7
  class Uncov::Configuration
8
+ CONFIG_FILE = '.uncov'
5
9
  FILE_MATCH_FLAGS = File::FNM_EXTGLOB | File::FNM_PATHNAME | File::FNM_DOTMATCH
6
- attr_accessor :git_diff_target, :report, :output_format, :path, :relevant_files, :simplecov_output_path, :test_command
10
+
11
+ class << self
12
+ def option(name, description, options:, default:, allowed_values: nil, value_parse: ->(value) { value })
13
+ self.options << [name, description, options, default, allowed_values, value_parse]
14
+ define_method(name) { self.options[name].value }
15
+ define_method("#{name}=") { |value| self.options[name].value = value }
16
+ end
17
+
18
+ def options = @options ||= []
19
+ end
20
+
21
+ option 'target', 'Target branch for comparison', options: ['-t', '--target TARGET'], default: 'HEAD'
22
+ option 'report', 'Report type to generate', options: ['-r', '--report TYPE'], default: 'diff_lines', allowed_values: Uncov::Report.types
23
+ option 'output_format', 'Output format',
24
+ options: ['-o', '--output-format FORMAT'], default: 'terminal', allowed_values: Uncov::Formatter.formats
25
+ option 'context', 'Additional lines context in output',
26
+ options: ['-C', '--context LINES_NUMBER'], default: 1, value_parse: lambda(&:to_i)
27
+ option 'test_command', 'Test command that generates SimpleCov',
28
+ options: '--test-command COMMAND', default: 'COVERAGE=true bundle exec rake test'
29
+ option 'simplecov_file', 'SimpleCov results file', options: '--simplecov-file PATH', default: 'autodetect'
30
+ option 'relevant_files', 'Relevant files shell filename globing: https://ruby-doc.org/core-3.1.1/File.html#method-c-fnmatch',
31
+ options: '--relevant-files', default: '{{bin,exe,exec}/*,{app,lib}/**/*.{rake,rb},Rakefile}'
32
+ option 'debug', 'Get some insights', options: '--debug', default: false, value_parse: ->(_value) { true }
7
33
 
8
34
  def initialize
9
- @git_diff_target = 'HEAD'
10
- @test_command = 'COVERAGE=true bundle exec rake test'
11
- @simplecov_output_path = 'autodetect'
12
- @path = '.'
13
- @relevant_files = ['{bin,exe,exec}/*', '{app,lib}/**/*.{rake,rb}']
14
- @report = :diff_lines
15
- @output_format = :terminal
35
+ define_options
36
+ parse_config
37
+ end
38
+
39
+ def parse_cli(args) = parser.parse!(args)
40
+ def options_values = options.to_h { |name, option| [name.to_sym, option.value] }
41
+
42
+ private
43
+
44
+ def define_options
45
+ self.class.options.each do |name, description, options, default, allowed_values, value_parse|
46
+ self.options[name] = Option.new(name, description, options, default, allowed_values, value_parse)
47
+ end
16
48
  end
49
+
50
+ def parse_config
51
+ return unless File.exist?(CONFIG_FILE)
52
+
53
+ args = File.readlines(CONFIG_FILE).map(&:strip)
54
+ parse_cli(args)
55
+ end
56
+
57
+ def parser
58
+ @parser ||=
59
+ OptionParser.new do |parser|
60
+ parser_header(parser)
61
+ options.each_value { |option| option.on_parser(parser) }
62
+ parser_footer(parser)
63
+ end
64
+ end
65
+
66
+ def parser_header(parser) = parser.banner = 'Usage: uncov [options]'
67
+
68
+ def parser_footer(parser)
69
+ parser.on('-v', '--version', 'Show version') do
70
+ puts "uncov #{Uncov::VERSION} by Michal Papis <mpapis@gmail.com>"
71
+ throw :exit, 0
72
+ end
73
+ parser.on('-h', '--help', 'Print this help') do
74
+ puts parser
75
+ puts "uncov #{Uncov::VERSION} by Michal Papis <mpapis@gmail.com>"
76
+ throw :exit, 0
77
+ end
78
+ end
79
+
80
+ def options = @options ||= {}
17
81
  end
@@ -9,11 +9,7 @@ class Uncov::Finder::FileSystem
9
9
  private
10
10
 
11
11
  def all_files
12
- Uncov.configuration.relevant_files.flat_map do |expresion|
13
- Dir
14
- .glob(expresion, Uncov::Configuration::FILE_MATCH_FLAGS, base: Uncov.configuration.path)
15
- .select { |f| File.file?(f) }
16
- end
12
+ Dir.glob(Uncov.configuration.relevant_files, Uncov::Configuration::FILE_MATCH_FLAGS).select { |f| File.file?(f) }
17
13
  end
18
14
 
19
15
  def lines_proc(file_name) = -> { cache(file_name) { read_lines(file_name) } }
@@ -7,13 +7,11 @@ module Uncov::Finder::GitBase
7
7
  protected
8
8
 
9
9
  def relevant_file?(path)
10
- Uncov.configuration.relevant_files.any? do |pattern|
11
- File.fnmatch?(pattern, path, Uncov::Configuration::FILE_MATCH_FLAGS)
12
- end
10
+ File.fnmatch?(Uncov.configuration.relevant_files, path, Uncov::Configuration::FILE_MATCH_FLAGS)
13
11
  end
14
12
 
15
13
  def open_repo
16
- ::Git.open(Uncov.configuration.path)
14
+ ::Git.open('.')
17
15
  rescue ArgumentError => e
18
16
  raise Uncov::NotGitRepoError, Uncov.configuration.path if e.message.end_with?(' is not in a git working tree')
19
17
 
@@ -19,24 +19,26 @@ module Uncov::Finder::GitDiff
19
19
  def changed_lines(file_diff)
20
20
  GitDiffParser.parse(file_diff.patch).flat_map do |patch|
21
21
  patch.changed_lines.map do |changed_line|
22
- [changed_line.number, nil] if changed_line.content[0] == '+'
22
+ next unless changed_line.content[0] == '+'
23
+
24
+ [changed_line.number, nil]
23
25
  end
24
26
  end.compact.to_h
25
27
  end
26
28
 
27
29
  def git_diff
28
30
  repo = open_repo
29
- target =
30
- case git_diff_target
31
+ git_target =
32
+ case target
31
33
  when 'HEAD'
32
- git_diff_target
34
+ target
33
35
  else
34
- repo.branches[git_diff_target] or raise NotGitBranchError, git_diff_target
36
+ repo.branches[target] or raise Uncov::NotGitBranchError, target
35
37
  end
36
38
 
37
- repo.diff(target)
39
+ repo.diff(git_target)
38
40
  end
39
41
 
40
- def git_diff_target = Uncov.configuration.git_diff_target
42
+ def target = Uncov.configuration.target
41
43
  end
42
44
  end
@@ -5,7 +5,7 @@ require 'json'
5
5
  # collect coverage information, regenerates report if any trigger_files are newer then the report
6
6
  module Uncov::Finder::SimpleCov
7
7
  class << self
8
- def files(trigger_files = [])
8
+ def files(trigger_files)
9
9
  regenerate_report if requires_regeneration?(trigger_files)
10
10
  raise_on_missing_coverage_path!
11
11
  coverage.transform_values { |file_coverage| covered_lines(file_coverage) }
@@ -14,6 +14,7 @@ module Uncov::Finder::SimpleCov
14
14
  private
15
15
 
16
16
  def requires_regeneration?(trigger_files)
17
+ return true unless coverage_path
17
18
  return true unless File.exist?(coverage_path)
18
19
  return false if trigger_files.empty?
19
20
 
@@ -21,10 +22,14 @@ module Uncov::Finder::SimpleCov
21
22
  trigger_files.any? { |file_name| File.exist?(file_name) && File.mtime(file_name) > coverage_path_mtime }
22
23
  end
23
24
 
24
- def regenerate_report = Dir.chdir(Uncov.configuration.path) { system(Uncov.configuration.test_command) }
25
+ def regenerate_report
26
+ system(Uncov.configuration.test_command, exception: true)
27
+ rescue RuntimeError
28
+ raise Uncov::FailedToGenerateReport
29
+ end
25
30
 
26
31
  def coverage
27
- root_path = "#{File.absolute_path(Uncov.configuration.path)}/"
32
+ root_path = "#{File.absolute_path('.')}/"
28
33
  parsed = JSON.parse(File.read(coverage_path))
29
34
  coverage = parsed['coverage'] || parsed.values.max_by { |suite| suite['timestamp'] }['coverage']
30
35
  coverage.transform_keys { |key| key.delete_prefix(root_path) }
@@ -32,24 +37,22 @@ module Uncov::Finder::SimpleCov
32
37
 
33
38
  def covered_lines(file_coverage)
34
39
  file_coverage['lines'].each_with_index.filter_map do |coverage, line_index|
35
- [line_index + 1, coverage.positive?] if coverage
40
+ [line_index + 1, coverage.positive?] if coverage.is_a?(Integer)
36
41
  end.to_h
37
42
  end
38
43
 
39
44
  def coverage_path
40
- if Uncov.configuration.simplecov_output_path == 'autodetect'
41
- %w[coverage/coverage.json coverage/.resultset.json]
42
- .map { |coverage_path| File.join(Uncov.configuration.path, coverage_path) }
43
- .find { |path| File.exist?(path) }
45
+ if Uncov.configuration.simplecov_file == 'autodetect'
46
+ %w[coverage/coverage.json coverage/.resultset.json].find { |path| File.exist?(path) }
44
47
  else
45
- File.join(Uncov.configuration.path, Uncov.configuration.simplecov_output_path)
48
+ Uncov.configuration.simplecov_file
46
49
  end
47
50
  end
48
51
 
49
52
  def raise_on_missing_coverage_path!
50
- return if File.exist?(coverage_path)
53
+ return if coverage_path && File.exist?(coverage_path)
51
54
 
52
- raise Uncov::AutodetectSimpleCovPathError if Uncov.configuration.simplecov_output_path == 'autodetect'
55
+ raise Uncov::AutodetectSimpleCovPathError if Uncov.configuration.simplecov_file == 'autodetect'
53
56
 
54
57
  raise Uncov::MissingSimpleCovReport, coverage_path
55
58
  end
data/lib/uncov/finder.rb CHANGED
@@ -8,9 +8,10 @@ class Uncov::Finder
8
8
  def git_file?(file_name) = git_files[file_name]
9
9
  def git_file_names = git_files.keys
10
10
  def git_diff_file_names = git_diff_files.keys
11
- def git_diff_file_line?(file_name, line_number) = git_diff_files.dig(file_name, line_number)
11
+ def git_diff_file_line?(file_name, line_number) = git_diff_files[file_name].key?(line_number)
12
12
  def git_diff_file_lines(file_name) = git_diff_files[file_name]
13
13
  def file_system_file_line(file_name, line_number) = file_system_files[file_name]&.call&.dig(line_number)
14
+ def file_system_file_lines(file_name) = file_system_files[file_name]&.call
14
15
  def no_cov_file_line?(file_name, line_number) = no_cov_files[file_name]&.call&.dig(line_number)
15
16
  def simple_cov_file_line?(file_name, line_number) = simple_cov_files.dig(file_name, line_number)
16
17
 
@@ -5,6 +5,8 @@ require 'forwardable'
5
5
 
6
6
  # print report to terminal with colors
7
7
  class Uncov::Formatter::Terminal
8
+ include Uncov::Cache
9
+
8
10
  attr_reader :report
9
11
 
10
12
  def initialize(report)
@@ -12,29 +14,54 @@ class Uncov::Formatter::Terminal
12
14
  end
13
15
 
14
16
  def output
15
- if report.covered?
16
- puts 'All changed files have 100% test coverage!'.green
17
- return
18
- end
19
-
20
17
  puts "Found #{report.uncovered_files.size} files with uncovered changes:".yellow
21
18
  output_files
22
- puts format("\nOverall coverage of changes: %.2f%%", report.coverage).yellow
19
+ puts
20
+ puts format('Overall coverage of changes: %.2f%%', report.coverage).yellow
23
21
  end
24
22
 
25
- def output_files = report.uncovered_files.each { |file_coverage| output_file(file_coverage) }
23
+ def output_files
24
+ report.uncovered_files.each do |file_coverage|
25
+ output_file(file_coverage)
26
+ end
27
+ end
26
28
 
27
29
  def output_file(file_coverage)
30
+ puts
31
+ output_file_header(file_coverage)
32
+ max = number_length(file_coverage)
33
+ file_coverage.display_lines.each do |line|
34
+ output_line(line, max)
35
+ end
36
+ end
37
+
38
+ def output_file_header(file_coverage)
28
39
  puts format(
29
- "\n%<name>s -> %<coverage>.2f%% changes covered, uncovered lines:",
40
+ '%<name>s -> %<coverage>.2f%% (%<covered_lines>d / %<relevant_lines>d) changes covered, uncovered lines:',
30
41
  name: file_coverage.file_name,
31
- coverage: file_coverage.coverage
42
+ coverage: file_coverage.coverage,
43
+ covered_lines: file_coverage.covered_lines.count,
44
+ relevant_lines: file_coverage.relevant_lines.count
32
45
  ).yellow
33
- nl = number_length(file_coverage)
34
- file_coverage.uncovered_lines.each do |line|
35
- puts format("%#{nl}d: %s", line.number, line.content).red
46
+ end
47
+
48
+ def output_line(line, max)
49
+ if line.uncov?
50
+ puts format_line(line, max).red
51
+ elsif line.context
52
+ puts format_line(line, max).green
53
+ else
54
+ # :nocov:
55
+ raise 'unknown display line' # unreachable code
56
+ # :nocov:
36
57
  end
37
58
  end
38
59
 
39
- def number_length(file_coverage) = file_coverage.uncovered_lines.last.number.to_s.length
60
+ def format_line(line, max)
61
+ format("%#{max}d: %s", line.number, line.content)
62
+ end
63
+
64
+ def number_length(file_coverage)
65
+ file_coverage.display_lines.last.number.to_s.length
66
+ end
40
67
  end
@@ -3,12 +3,26 @@
3
3
  # chose formater to output the report
4
4
  module Uncov::Formatter
5
5
  class << self
6
+ def formats = %w[terminal]
7
+
6
8
  def output(report)
9
+ if report.files.empty?
10
+ return puts 'No files to report.'.green
11
+ elsif !report.uncov?
12
+ return puts "All changed files(#{report.files.count}) have 100% test coverage!".green
13
+ end
14
+
15
+ output_report(report)
16
+ end
17
+
18
+ private
19
+
20
+ def output_report(report)
7
21
  case Uncov.configuration.output_format
8
- when :terminal
22
+ when 'terminal'
9
23
  Uncov::Formatter::Terminal.new(report).output
10
24
  else
11
- raise UnsupportedFormatterError, Uncov.configuration.output_format
25
+ raise Uncov::UnsupportedFormatterError, Uncov.configuration.output_format
12
26
  end
13
27
  end
14
28
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # calculate context for important lines,
4
+ # @return [Integer] only added context lines matching all_line_numbers and not in important_line_numbers
5
+ module Uncov::Report::Context
6
+ class << self
7
+ def calculate(all_line_numbers, important_line_number, context)
8
+ context_line_numbers = {}
9
+ important_line_number.each do |line_number|
10
+ (1..context).to_a.each do |offset|
11
+ context_line_numbers[line_number - offset] = true
12
+ context_line_numbers[line_number + offset] = true
13
+ end
14
+ end
15
+ (context_line_numbers.keys.sort & all_line_numbers) - important_line_number
16
+ end
17
+ end
18
+ end
@@ -3,28 +3,59 @@
3
3
  # report only files lines from the diff
4
4
  module Uncov::Report::DiffLines
5
5
  class << self
6
- def files(report)
7
- report.git_diff_file_names.map do |file_name|
6
+ def files(finder)
7
+ finder.git_diff_file_names.map do |file_name|
8
8
  Uncov::Report::File.new(
9
9
  file_name:,
10
10
  git: true,
11
- lines: lines(report, file_name)
11
+ lines: lines(finder, file_name)
12
12
  )
13
13
  end
14
14
  end
15
15
 
16
16
  private
17
17
 
18
- def lines(report, file_name)
19
- report.git_diff_file_lines(file_name).keys.map do |line_number|
20
- Uncov::Report::File::Line.new(
21
- number: line_number,
22
- content: report.file_system_file_line(file_name, line_number),
23
- no_cov: report.no_cov_file_line?(file_name, line_number),
24
- simple_cov: report.simple_cov_file_line?(file_name, line_number),
25
- git_diff: true
26
- )
18
+ def lines(finder, file_name)
19
+ lines_hash = git_diff_files_lines(finder, file_name)
20
+ add_context(finder, file_name, lines_hash)
21
+ lines_hash.sort.to_h.values
22
+ end
23
+
24
+ def git_diff_files_lines(finder, file_name)
25
+ finder.git_diff_file_lines(file_name).keys.to_h do |line_number|
26
+ [line_number, new_line(finder, file_name, line_number)]
27
+ end
28
+ end
29
+
30
+ def add_context(finder, file_name, lines_hash)
31
+ line_numbers =
32
+ lines_hash.filter_map do |line_number, line|
33
+ line_number if line.uncov?
34
+ end
35
+ all_line_numbers = finder.file_system_file_lines(file_name).keys
36
+ context_line_numbers = Uncov::Report::Context.calculate(all_line_numbers, line_numbers, Uncov.configuration.context)
37
+ context_line_numbers.each do |line_number|
38
+ context_line(finder, file_name, lines_hash, line_number)
27
39
  end
28
40
  end
41
+
42
+ def context_line(finder, file_name, lines_hash, line_number)
43
+ if lines_hash.key?(line_number)
44
+ lines_hash[line_number].context = true
45
+ else
46
+ lines_hash[line_number] = new_line(finder, file_name, line_number, context: true)
47
+ end
48
+ end
49
+
50
+ def new_line(finder, file_name, line_number, context: false)
51
+ Uncov::Report::File::Line.new(
52
+ number: line_number,
53
+ content: finder.file_system_file_line(file_name, line_number),
54
+ no_cov: finder.no_cov_file_line?(file_name, line_number),
55
+ simple_cov: finder.simple_cov_file_line?(file_name, line_number),
56
+ git_diff: finder.git_diff_file_line?(file_name, line_number),
57
+ context:
58
+ )
59
+ end
29
60
  end
30
61
  end
@@ -1,9 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # represents file line coverage in report
4
- class Uncov::Report::File
5
- Line = Struct.new('Line', :number, :content, :simple_cov, :no_cov, :git_diff) do
6
- def covered? = simple_cov != false || no_cov
7
- def relevant? = !no_cov
4
+ class Uncov::Report::File::Line < Uncov::Struct.new(:number, :content, :simple_cov, :no_cov, :context, :git_diff)
5
+ def uncov?
6
+ simple_cov == false && !no_cov
7
+ end
8
+
9
+ def display?
10
+ uncov? || context
11
+ end
12
+
13
+ def relevant?
14
+ !no_cov
8
15
  end
9
16
  end
@@ -1,17 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # represents file coverage in report
4
- class Uncov::Report::File
4
+ class Uncov::Report::File < Uncov::Struct.new(:file_name, :lines, :git)
5
5
  include Uncov::Cache
6
6
 
7
- attr_reader :file_name, :lines, :git
8
-
9
- def initialize(file_name:, lines:, git:)
10
- @file_name = file_name
11
- @lines = lines
12
- @git = git
13
- end
14
-
15
7
  def coverage
16
8
  cache(:coverage) do
17
9
  if relevant_lines.count.zero?
@@ -22,9 +14,31 @@ class Uncov::Report::File
22
14
  end
23
15
  end
24
16
 
25
- def covered? = lines.all?(&:covered?)
26
- def changed_lines = lines.select(&:git_diff)
27
- def covered_lines = lines.select(&:covered?)
28
- def uncovered_lines = lines.reject(&:covered?)
29
- def relevant_lines = lines.select(&:relevant?)
17
+ def uncov?
18
+ uncov_lines.any?
19
+ end
20
+
21
+ def uncov_lines
22
+ cache(:uncov_lines) do
23
+ lines.select(&:uncov?)
24
+ end
25
+ end
26
+
27
+ def covered_lines
28
+ cache(:covered_lines) do
29
+ lines.reject(&:uncov?)
30
+ end
31
+ end
32
+
33
+ def display_lines
34
+ cache(:display_lines) do
35
+ lines.select(&:display?)
36
+ end
37
+ end
38
+
39
+ def relevant_lines
40
+ cache(:relevant_lines) do
41
+ lines.select(&:relevant?)
42
+ end
43
+ end
30
44
  end
data/lib/uncov/report.rb CHANGED
@@ -1,22 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'cache'
4
+ require_relative 'struct'
4
5
 
5
6
  # calculated coverage report for configured report type
6
- class Uncov::Report
7
+ class Uncov::Report < Uncov::Struct.new(:files)
7
8
  include Uncov::Cache
8
9
 
9
- def files
10
- cache(:files) do
11
- case Uncov.configuration.report
12
- when :diff_lines
13
- finder = Uncov::Finder.new(:git_diff)
14
- Uncov::Report::DiffLines.files(finder)
10
+ class << self
11
+ def types
12
+ %w[diff_lines]
13
+ end
14
+
15
+ def build
16
+ files =
17
+ case Uncov.configuration.report
18
+ when 'diff_lines'
19
+ finder = Uncov::Finder.new(:git_diff)
20
+ Uncov::Report::DiffLines.files(finder)
21
+ end
22
+ new(files:)
23
+ end
24
+ end
25
+
26
+ def uncovered_files
27
+ cache(:uncovered_files) do
28
+ files.select(&:uncov?)
29
+ end
30
+ end
31
+
32
+ def coverage
33
+ cache(:coverage) do
34
+ if files.empty?
35
+ 100.0
36
+ else
37
+ files.sum(&:coverage) / files.size
15
38
  end
16
39
  end
17
40
  end
18
41
 
19
- def uncovered_files = cache(:uncovered_files) { files.reject(&:covered?) }
20
- def coverage = cache(:coverage) { files.sum(&:coverage) / files.size }
21
- def covered? = files.all?(&:covered?)
42
+ def uncov?
43
+ uncovered_files.any?
44
+ end
22
45
  end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Uncov::Struct < Struct; end
data/lib/uncov/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Uncov
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/uncov.rb CHANGED
@@ -7,22 +7,31 @@ Dir["#{File.dirname(__FILE__)}/**/*.rb"]
7
7
  # uncover missing code coverage by tests
8
8
  module Uncov
9
9
  class << self
10
- attr_accessor :configuration
11
-
12
- def configure
13
- self.configuration ||= Configuration.new
10
+ def configure(args = [])
14
11
  yield(configuration) if block_given?
12
+ configuration.parse_cli(args) if args.any?
13
+ warn({ configuration: configuration.options_values }.inspect) if configuration.debug
14
+ nil
15
+ end
16
+
17
+ def configuration
18
+ @configuration ||= Configuration.new
15
19
  end
16
20
 
17
- def reset
18
- self.configuration = Configuration.new
21
+ def configuration_reset!
22
+ @configuration = Configuration.new
19
23
  end
20
24
  end
21
25
 
22
- class Error < StandardError; end
26
+ class Error < StandardError
27
+ def inspect = "#<#{self.class}: #{message}>"
28
+ end
29
+
30
+ class ConfigurationError < Error; end
23
31
  class GitError < Error; end
24
32
  class SimpleCovError < Error; end
25
33
  class FormatterError < Error; end
34
+ class OptionValueNotAllowed < ConfigurationError; end
26
35
 
27
36
  class NotGitRepoError < GitError
28
37
  attr_reader :path
@@ -38,6 +47,10 @@ module Uncov
38
47
  def message = "Target branch #{target_branch.inspect} not found locally or in remote"
39
48
  end
40
49
 
50
+ class FailedToGenerateReport < SimpleCovError
51
+ def message = cause.message
52
+ end
53
+
41
54
  class MissingSimpleCovReport < SimpleCovError
42
55
  attr_reader :coverage_path
43
56
 
@@ -46,7 +59,7 @@ module Uncov
46
59
  end
47
60
 
48
61
  class AutodetectSimpleCovPathError < SimpleCovError
49
- def initialize = @message = 'Could not autodetect coverage report path'
62
+ def message = 'Could not autodetect coverage report path'
50
63
  end
51
64
 
52
65
  class UnsupportedFormatterError < FormatterError
@@ -56,6 +69,3 @@ module Uncov
56
69
  def message = "#{output_format.inspect} is not a supported formatter"
57
70
  end
58
71
  end
59
-
60
- # Initialize with defaults
61
- Uncov.configure
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: uncov
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michał Papis
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-30 00:00:00.000000000 Z
11
+ date: 2025-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: colorize
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '1.7'
69
+ - !ruby/object:Gem::Dependency
70
+ name: optparse
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
69
83
  description: uncov compares your current branch with a target branch, identifies changed
70
84
  files, and reports on test coverage for those changes
71
85
  email:
@@ -76,15 +90,16 @@ extensions: []
76
90
  extra_rdoc_files: []
77
91
  files:
78
92
  - CHANGELOG.md
79
- - CHANGELOG_BLA.md
80
93
  - CONTRIBUTING.md
81
94
  - LICENSE.txt
95
+ - PHILOSOPHY.md
82
96
  - README.md
83
97
  - bin/uncov
84
98
  - lib/uncov.rb
85
99
  - lib/uncov/cache.rb
86
100
  - lib/uncov/cli.rb
87
101
  - lib/uncov/configuration.rb
102
+ - lib/uncov/configuration/option.rb
88
103
  - lib/uncov/finder.rb
89
104
  - lib/uncov/finder/file_system.rb
90
105
  - lib/uncov/finder/git.rb
@@ -95,9 +110,11 @@ files:
95
110
  - lib/uncov/formatter.rb
96
111
  - lib/uncov/formatter/terminal.rb
97
112
  - lib/uncov/report.rb
113
+ - lib/uncov/report/context.rb
98
114
  - lib/uncov/report/diff_lines.rb
99
115
  - lib/uncov/report/file.rb
100
116
  - lib/uncov/report/file/line.rb
117
+ - lib/uncov/struct.rb
101
118
  - lib/uncov/version.rb
102
119
  homepage: https://github.com/mpapis/uncov
103
120
  licenses:
data/CHANGELOG_BLA.md DELETED
@@ -1,28 +0,0 @@
1
- # Changelog
2
-
3
- ## 2025-04-29: [v0.2.2](https://github.com/mpapis/uncov/releases/tag/v0.2.2)
4
-
5
- ### Patch
6
-
7
- - [#27](https://github.com/mpapis/uncov/pull/27): Fix simplecov resultset parsing bug, by [@mpapis](https://github.com/mpapis)
8
-
9
-
10
- ## 2025-04-29: [v0.2.1](https://github.com/mpapis/uncov/releases/tag/v0.2.1)
11
-
12
- ### Patch
13
-
14
- - [#26](https://github.com/mpapis/uncov/pull/26): Fix simplecov resultset parsing bug, by [@mpapis](https://github.com/mpapis)
15
-
16
-
17
- ## 2025-04-29: [v0.2.0](https://github.com/mpapis/uncov/releases/tag/v0.2.0)
18
-
19
- ### Minor
20
-
21
- - [#23](https://github.com/mpapis/uncov/pull/23): Remove need for bundler, by [@mpapis](https://github.com/mpapis)
22
-
23
-
24
- ## 2025-04-28: [v0.1.1](https://github.com/mpapis/uncov/releases/tag/v0.1.1)
25
-
26
- ### Minor
27
-
28
- `uncov -r diff_lines -f termianl`, by [@mpapis](https://github.com/mpapis)