├── .git-blame-ignore-revs
├── .mailmap
├── guides
├── links.yaml
├── getting-started
│ └── readme.md
└── configuration
│ └── readme.md
├── media
└── example.png
├── test
└── covered
│ ├── source
│ ├── template.xrb
│ ├── multiline.rb
│ ├── rescue.rb
│ ├── erb.rb
│ └── xrb.rb
│ ├── wrapper.rb
│ ├── minitest.rb
│ ├── persist.rb
│ ├── rspec.rb
│ ├── summary.rb
│ ├── brief_summary.rb
│ ├── policy
│ └── autoload.rb
│ ├── statistics.rb
│ ├── forks.rb
│ ├── policy.rb
│ ├── partial_summary.rb
│ └── files.rb
├── examples
├── erb
│ ├── template.erb
│ └── test.rb
├── coverage
│ ├── erb
│ │ ├── template.erb
│ │ ├── readme.md
│ │ └── coverage.rb
│ ├── simplecov.rb
│ ├── covered.rb
│ ├── tracepoint.rb
│ ├── test.rb
│ └── parser.rb
├── begin.rb
└── struct
│ ├── coverage.rb
│ ├── iseq.rb
│ └── struct.rb
├── .editorconfig
├── config
└── sus.rb
├── lib
├── covered
│ ├── version.rb
│ ├── minitest.rb
│ ├── autostart.rb
│ ├── rspec.rb
│ ├── sus.rb
│ ├── brief_summary.rb
│ ├── capture.rb
│ ├── forks.rb
│ ├── source.rb
│ ├── markdown_summary.rb
│ ├── wrapper.rb
│ ├── partial_summary.rb
│ ├── policy.rb
│ ├── persist.rb
│ ├── files.rb
│ ├── statistics.rb
│ ├── config.rb
│ ├── summary.rb
│ └── coverage.rb
└── covered.rb
├── fixtures
├── rspec
│ ├── spec_helper.rb
│ └── dummy_spec.rb
├── minitest_tests.rb
├── rspec_tests.rb
├── minitest
│ └── dummy_test.rb
└── wrapper_examples.rb
├── .gitignore
├── releases.md
├── bake.rb
├── .github
├── workflows
│ ├── rubocop.yaml
│ ├── documentation-coverage.yaml
│ ├── test.yaml
│ └── documentation.yaml
└── copilot-instructions.md
├── gems.rb
├── context
├── index.yaml
├── getting-started.md
├── usage.md
└── configuration.md
├── bake
└── covered
│ ├── debug.rb
│ ├── validate.rb
│ └── policy.rb
├── covered.gemspec
├── license.md
├── .rubocop.yml
├── release.cert
└── readme.md
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | 027357c3431a1aaa201664460f387f13010fdd70
2 |
--------------------------------------------------------------------------------
/.mailmap:
--------------------------------------------------------------------------------
1 | Aron Latis <44033315+latisaron@users.noreply.github.com>
2 |
--------------------------------------------------------------------------------
/guides/links.yaml:
--------------------------------------------------------------------------------
1 | getting-started:
2 | order: 1
3 | configuration:
4 | order: 2
5 |
--------------------------------------------------------------------------------
/media/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/socketry/covered/HEAD/media/example.png
--------------------------------------------------------------------------------
/test/covered/source/template.xrb:
--------------------------------------------------------------------------------
1 | 0.5 ?>
2 | Hello World
3 |
4 | Goodbye World
5 |
--------------------------------------------------------------------------------
/examples/erb/template.erb:
--------------------------------------------------------------------------------
1 | <% if value %>
2 | Hello <%= value %>
3 | <% else %>
4 | Hello <%= default %>
5 | <% end %>
6 |
--------------------------------------------------------------------------------
/examples/coverage/erb/template.erb:
--------------------------------------------------------------------------------
1 | <% for @item in @items %>
2 | <%= @item %>
3 | <% end %>
4 |
5 | <% if 1 == 2 %>
6 | Math is broken.
7 | <% end %>
8 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | indent_size = 2
6 |
7 | [*.{yml,yaml}]
8 | indent_style = space
9 | indent_size = 2
10 |
--------------------------------------------------------------------------------
/config/sus.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | require "covered/sus"
7 | include Covered::Sus
8 |
--------------------------------------------------------------------------------
/lib/covered/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | module Covered
7 | VERSION = "0.28.1"
8 | end
9 |
--------------------------------------------------------------------------------
/fixtures/rspec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | require_relative "../../lib/covered/rspec"
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /agent.md
2 | /.context
3 | /.bundle
4 | /pkg
5 | /gems.locked
6 | /.covered.db
7 | /external
8 |
9 | .covered.db
10 | /.github/workflows/coverage.yaml
11 | /.github/workflows/test-external.yaml
12 |
--------------------------------------------------------------------------------
/releases.md:
--------------------------------------------------------------------------------
1 | # Releases
2 |
3 | ## v0.28.0
4 |
5 | - List files that have 100% coverage in `PartialSummary` output.
6 |
7 | ## v0.27.0
8 |
9 | - Drop development dependeny on `trenni` and add dependeny on `xrb`.
10 |
--------------------------------------------------------------------------------
/test/covered/wrapper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "wrapper_examples"
7 |
8 | describe Covered::Wrapper do
9 | it_behaves_like WrapperExamples
10 | end
11 |
--------------------------------------------------------------------------------
/fixtures/minitest_tests.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022, by Samuel Williams.
5 |
6 | module MinitestTests
7 | def test_path
8 | File.expand_path("minitest/dummy_test.rb", __dir__)
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/examples/coverage/simplecov.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 |
6 | require "simplecov"
7 |
8 | SimpleCov.command_name "Example"
9 | SimpleCov.start
10 |
11 | require_relative "test"
12 |
--------------------------------------------------------------------------------
/fixtures/rspec/dummy_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022, by Samuel Williams.
5 |
6 | RSpec.describe "Hello World Test" do
7 | it "can hello world" do
8 | expect("Hello World").to eq "Hello World"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/examples/begin.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2023, by Samuel Williams.
5 |
6 | # COVERAGE=PartialSummary bundle exec ruby "-rcovered/autostart" begin.rb
7 |
8 | begin
9 | x = [1]
10 | [2]
11 | [3]
12 | rescue
13 | end
14 |
--------------------------------------------------------------------------------
/examples/struct/coverage.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Released under the MIT License.
5 | # Copyright, 2022-2025, by Samuel Williams.
6 |
7 | require "coverage"
8 |
9 | Coverage.start
10 |
11 | require_relative "struct"
12 |
13 | p Coverage.result
14 |
--------------------------------------------------------------------------------
/examples/struct/iseq.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Released under the MIT License.
5 | # Copyright, 2022, by Samuel Williams.
6 |
7 | path = File.expand_path("struct.rb", __dir__)
8 | iseq = RubyVM::InstructionSequence.compile_file(path)
9 | puts iseq.disassemble
10 |
--------------------------------------------------------------------------------
/examples/struct/struct.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Released under the MIT License.
5 | # Copyright, 2022, by Samuel Williams.
6 |
7 | Thing = Struct.new(:name, :shape)
8 | thing = Thing.new(:cat, :rectangle)
9 |
10 | [
11 | thing.name,
12 | thing.shape,
13 | ]
14 |
--------------------------------------------------------------------------------
/lib/covered.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "covered/version"
7 | require_relative "covered/policy"
8 |
9 | require_relative "covered/brief_summary"
10 | require_relative "covered/partial_summary"
11 |
--------------------------------------------------------------------------------
/examples/coverage/covered.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 |
6 | ENV["COVERAGE"] ||= "PartialSummary"
7 | require "covered/policy/default"
8 |
9 | $covered.start
10 |
11 | require_relative "test"
12 |
13 | $covered.finish
14 |
15 | $covered.call($stdout)
16 |
--------------------------------------------------------------------------------
/fixtures/rspec_tests.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022, by Samuel Williams.
5 |
6 | module RSpecTests
7 | def test_path
8 | File.expand_path("rspec/dummy_spec.rb", __dir__)
9 | end
10 |
11 | def spec_helper_path
12 | File.expand_path("rspec/spec_helper.rb", __dir__)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/examples/coverage/tracepoint.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 |
6 | trace_point = TracePoint.new(:call, :return, :line, :c_call, :c_return, :b_call, :b_return) do |trace|
7 | puts [trace.path, trace.lineno].join(":")
8 | end
9 |
10 | trace_point.start
11 |
12 | require_relative "test"
13 |
--------------------------------------------------------------------------------
/fixtures/minitest/dummy_test.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Released under the MIT License.
5 | # Copyright, 2019-2025, by Samuel Williams.
6 |
7 | require_relative "../../lib/covered/minitest"
8 | require "minitest/autorun"
9 |
10 | class DummyTest < Minitest::Test
11 | def test_hello_world
12 | assert_equal "Hello World", "Hello World"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/bake.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2025, by Samuel Williams.
5 |
6 | # Update the project documentation with the new version number.
7 | #
8 | # @parameter version [String] The new version number.
9 | def after_gem_release_version_increment(version)
10 | context["releases:update"].call(version)
11 | context["utopia:project:update"].call
12 | end
13 |
--------------------------------------------------------------------------------
/.github/workflows/rubocop.yaml:
--------------------------------------------------------------------------------
1 | name: RuboCop
2 |
3 | on: [push, pull_request]
4 |
5 | permissions:
6 | contents: read
7 |
8 | jobs:
9 | check:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: ruby/setup-ruby@v1
15 | with:
16 | ruby-version: ruby
17 | bundler-cache: true
18 |
19 | - name: Run RuboCop
20 | timeout-minutes: 10
21 | run: bundle exec rubocop
22 |
--------------------------------------------------------------------------------
/examples/coverage/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2023, by Samuel Williams.
5 |
6 | trace_point = TracePoint.new(:call, :return, :line, :c_call, :c_return, :b_call, :b_return) do |trace|
7 | puts [trace.event, trace.path, trace.lineno, trace.method_id].join(":")
8 | end
9 |
10 | class String
11 | def freezer
12 | self.freeze
13 | end
14 | end
15 |
16 | output = String.new
17 |
18 | trace_point.start
19 |
20 | begin
21 | output << "
22 | ".freezer;
23 | end
24 |
--------------------------------------------------------------------------------
/.github/workflows/documentation-coverage.yaml:
--------------------------------------------------------------------------------
1 | name: Documentation Coverage
2 |
3 | on: [push, pull_request]
4 |
5 | permissions:
6 | contents: read
7 |
8 | env:
9 | COVERAGE: PartialSummary
10 |
11 | jobs:
12 | validate:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: ruby/setup-ruby@v1
18 | with:
19 | ruby-version: ruby
20 | bundler-cache: true
21 |
22 | - name: Validate coverage
23 | timeout-minutes: 5
24 | run: bundle exec bake decode:index:coverage lib
25 |
--------------------------------------------------------------------------------
/examples/erb/test.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Released under the MIT License.
5 | # Copyright, 2023-2025, by Samuel Williams.
6 |
7 | require "coverage"
8 | require "erb"
9 |
10 | Coverage.start(lines: true, eval: true)
11 |
12 | def test(value = nil, default: "World", path: "template.erb")
13 | template = ERB.new(File.read(path))
14 | template.location = path
15 |
16 | template.result(binding)
17 | end
18 |
19 | # The order changes coverage, previous results are discarded.
20 | test("Ruby")
21 | test
22 |
23 | puts Coverage.result
24 |
--------------------------------------------------------------------------------
/gems.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | source "https://rubygems.org"
7 |
8 | gemspec
9 |
10 | group :maintenance do
11 | gem "bake-modernize"
12 | gem "bake-gem"
13 | gem "bake-releases"
14 |
15 | gem "agent-context"
16 |
17 | gem "utopia-project"
18 | end
19 |
20 | group :test do
21 | gem "sus"
22 | gem "decode"
23 |
24 | gem "rubocop"
25 | gem "rubocop-socketry"
26 |
27 | gem "xrb"
28 |
29 | gem "bake-test"
30 | gem "bake-test-external"
31 |
32 | gem "minitest"
33 | gem "rspec"
34 | end
35 |
--------------------------------------------------------------------------------
/test/covered/minitest.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | require "covered"
7 | require "minitest_tests"
8 |
9 | describe "Covered::Minitest" do
10 | include MinitestTests
11 |
12 | it "can run minitest test suite with coverage" do
13 | input, output = IO.pipe
14 |
15 | system({"COVERAGE" => "PartialSummary"}, test_path, out: output, err: output)
16 | output.close
17 |
18 | buffer = input.read
19 | expect(buffer).to be =~ /(.*?) files checked; (.*?) lines executed; (.*?)% covered/
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/test/covered/persist.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 |
6 | require "covered/coverage"
7 | require "covered/persist"
8 | require "wrapper_examples"
9 |
10 | describe Covered::Persist do
11 | it_behaves_like WrapperExamples
12 |
13 | let(:coverage) {Covered::Coverage.for(__FILE__)}
14 | let(:output) {Covered::Base.new}
15 | let(:persist) {subject.new(output)}
16 | let(:record) {persist.serialize(coverage)}
17 |
18 | it "can serialize coverage" do
19 | expect(record[:path]).to be == __FILE__
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/covered/minitest.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 | # Copyright, 2022, by Adam Daniels.
6 |
7 | require_relative "config"
8 |
9 | require "minitest"
10 |
11 | $covered = Covered::Config.load
12 |
13 | module Covered
14 | module Minitest
15 | def run(*)
16 | $covered.start
17 |
18 | super
19 | end
20 | end
21 | end
22 |
23 | if $covered.record?
24 | Minitest.singleton_class.prepend(Covered::Minitest)
25 |
26 | Minitest.after_run do
27 | $covered.finish
28 | $covered.call($stderr)
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/covered/rspec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | require "covered"
7 | require "rspec_tests"
8 |
9 | describe "Covered::RSpec" do
10 | include RSpecTests
11 |
12 | it "can run rspec test suite with coverage" do
13 | input, output = IO.pipe
14 |
15 | system({"COVERAGE" => "PartialSummary"}, "rspec", "--require", spec_helper_path, test_path, out: output, err: output)
16 | output.close
17 |
18 | buffer = input.read
19 | expect(buffer).to be =~ /(.*?) files checked; (.*?) lines executed; (.*?)% covered/
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/examples/coverage/parser.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Released under the MIT License.
5 | # Copyright, 2019-2025, by Samuel Williams.
6 |
7 | require "pry"
8 | require "parser/current"
9 |
10 | ast = Parser::CurrentRuby.parse_file("test.rb")
11 | # ast.location.expression.source
12 |
13 | def print_methods(ast)
14 | if ast.is_a? Parser::AST::Node
15 | if ast.type == :send
16 | puts "Calling #{ast.children[1]} on #{ast.location.line}"
17 | end
18 |
19 | ast.children.each do |child|
20 | print_methods(child)
21 | end
22 | end
23 | end
24 |
25 | print_methods(ast)
26 |
27 | binding.pry
28 |
--------------------------------------------------------------------------------
/test/covered/source/multiline.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | require "covered/files"
7 | require "covered/capture"
8 |
9 | let(:code) {<<~RUBY}
10 | output = String.new
11 | begin
12 | output << "
13 | ".freeze; end
14 | RUBY
15 |
16 | it "can parse multi-line methods" do
17 | files = Covered::Files.new
18 |
19 | source = Covered::Source.for(__FILE__, code: code, line_offset: 10)
20 |
21 | capture = Covered::Capture.new(files)
22 | capture.execute(source)
23 |
24 | coverage = files[__FILE__]
25 | expect(coverage.counts).not.to be(:include?, 0)
26 | end
27 |
--------------------------------------------------------------------------------
/examples/coverage/erb/readme.md:
--------------------------------------------------------------------------------
1 | # ERB Example
2 |
3 | This example shows the coverage computation on ERB templates:
4 |
5 | ## Usage
6 |
7 | Simply run the `coverage.rb` script:
8 |
9 | ```
10 | > ./coverage.rb
11 |
12 | template.erb
13 | Line| Hits|
14 | 1| 16|<% for @item in @items %>
15 | 2| 12| <%= @item %>
16 | 3| 4|<% end %>
17 | 4| |
18 | 5| 4|<% if 1 == 2 %>
19 | 6| | Math is broken.
20 | 7| 4|<% end %>
21 | ** 6/6 lines executed; 100.0% covered.
22 |
23 | * 1 files checked; 6/6 lines executed; 100.0% covered.
24 | ```
25 |
26 | You will see the coverage for the `template.erb` file.
27 |
--------------------------------------------------------------------------------
/test/covered/summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "covered/summary"
7 | require "covered/files"
8 |
9 | describe Covered::Summary do
10 | let(:files) {Covered::Files.new}
11 | let(:summary) {Covered::Summary.new}
12 |
13 | let(:first_line) {File.readlines(__FILE__).first}
14 | let(:io) {StringIO.new}
15 |
16 | it "can generate source code listing" do
17 | files.mark(__FILE__, 24, 1)
18 | files.mark(__FILE__, 25, 0)
19 |
20 | summary.call(files, io)
21 |
22 | expect(io.string).to be(:include?, "RSpec.describe Covered::Summary do")
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/covered/brief_summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "covered/brief_summary"
7 | require "covered/files"
8 |
9 | describe Covered::BriefSummary do
10 | let(:files) {Covered::Files.new}
11 | let(:summary) {subject.new}
12 |
13 | let(:first_line) {File.readlines(__FILE__).first}
14 | let(:io) {StringIO.new}
15 |
16 | it "can generate partial summary" do
17 | files.mark(__FILE__, 37, 1)
18 | files.mark(__FILE__, 38, 0)
19 |
20 | summary.call(files, io)
21 |
22 | expect(io.string).not.to be =~ /#{first_line}/
23 | expect(io.string).to be =~ /#{__FILE__}/
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/covered/policy/autoload.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 |
6 | require "covered/policy"
7 |
8 | describe Covered::Policy::Autoload do
9 | with "PartialSummary class" do
10 | let(:autoload) {subject.new("PartialSummary")}
11 |
12 | it "should autoload and instantiate class" do
13 | expect(autoload.new).to be_a Covered::PartialSummary
14 | end
15 | end
16 |
17 | with "unknown class" do
18 | let(:autoload) {subject.new("Unknown")}
19 |
20 | it "fails to autoload unknown class" do
21 | expect do
22 | autoload.new
23 | end.to raise_exception(LoadError)
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/covered/autostart.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2023-2025, by Samuel Williams.
5 |
6 | require_relative "config"
7 |
8 | module Coverage
9 | module Autostart
10 | # Start recording coverage information.
11 | # Usage: RUBYOPT=-rcovered/autostart ruby my_script.rb
12 | def self.autostart!
13 | config = Covered::Config.load
14 | config.start
15 |
16 | pid = Process.pid
17 |
18 | at_exit do
19 | # Don't break forked children:
20 | if Process.pid == pid
21 | config.finish
22 |
23 | if config.report?
24 | config.call($stderr)
25 | end
26 | end
27 | end
28 | end
29 | end
30 | end
31 |
32 | Coverage::Autostart.autostart!
33 |
--------------------------------------------------------------------------------
/lib/covered/rspec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "config"
7 | require "rspec/core/formatters"
8 |
9 | $covered = Covered::Config.load
10 |
11 | module Covered
12 | module RSpec
13 | module Policy
14 | def load_spec_files
15 | $covered.start
16 |
17 | super
18 | end
19 |
20 | def covered
21 | $covered
22 | end
23 |
24 | def covered= policy
25 | $covered = policy
26 | end
27 | end
28 | end
29 | end
30 |
31 | if $covered.record?
32 | RSpec::Core::Configuration.prepend(Covered::RSpec::Policy)
33 |
34 | RSpec.configure do |config|
35 | config.after(:suite) do
36 | $covered.finish
37 | $covered.call(config.output_stream)
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/context/index.yaml:
--------------------------------------------------------------------------------
1 | # Automatically generated context index for Utopia::Project guides.
2 | # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`.
3 | ---
4 | description: A modern approach to code coverage.
5 | metadata:
6 | documentation_uri: https://socketry.github.io/covered/
7 | funding_uri: https://github.com/sponsors/ioquatix/
8 | source_code_uri: https://github.com/socketry/covered.git
9 | files:
10 | - path: getting-started.md
11 | title: Getting Started
12 | description: This guide explains how to get started with `covered` and integrate
13 | it with your test suite.
14 | - path: configuration.md
15 | title: Configuration
16 | description: This guide will help you to configure covered for your project's specific
17 | requirements.
18 |
--------------------------------------------------------------------------------
/test/covered/source/rescue.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | require "covered/files"
7 | require "covered/capture"
8 | require "covered/summary"
9 |
10 | let(:code) {<<~RUBY}
11 | begin
12 | raise "Hello"
13 | rescue ArgumentError => error
14 | rescue RuntimeError => error
15 | x = 20
16 | y = 30
17 | end
18 | RUBY
19 |
20 | it "can parse multi-line methods" do
21 | files = Covered::Files.new
22 |
23 | source = Covered::Source.for(__FILE__, code: code, line_offset: 11)
24 |
25 | capture = Covered::Capture.new(files)
26 | capture.execute(source)
27 |
28 | coverage = files[__FILE__]
29 | expect(coverage.counts).not.to be(:include?, 0)
30 |
31 | # Show the actual coverage:
32 | # Covered::Summary.new(threshold: nil).call(files, $stderr)
33 | end
34 |
--------------------------------------------------------------------------------
/lib/covered/sus.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 |
6 | module Covered
7 | module Sus
8 | def initialize(...)
9 | super
10 |
11 | # Defer loading the coverage configuration unless we are actually running with coverage startd to avoid performance cost/overhead.
12 | if ENV["COVERAGE"]
13 | require_relative "config"
14 |
15 | @covered = Covered::Config.load(root: self.root)
16 | if @covered.record?
17 | @covered.start
18 | end
19 | else
20 | @covered = nil
21 | end
22 | end
23 |
24 | def after_tests(assertions)
25 | super(assertions)
26 |
27 | if @covered&.record?
28 | @covered.finish
29 | @covered.call(self.output.io)
30 | end
31 | end
32 |
33 | def covered
34 | @covered
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/fixtures/wrapper_examples.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 | # Copyright, 2022, by Felix Yan.
6 |
7 | require "covered/wrapper"
8 |
9 | WrapperExamples = Sus::Shared("a wrapper") do
10 | let(:output) {Covered::Base.new}
11 | let(:wrapper) {subject.new(output)}
12 |
13 | it "passes #mark through" do
14 | expect(output).to receive(:mark).with("fleeb.rb", 5, 1)
15 |
16 | wrapper.mark("fleeb.rb", 5, 1)
17 | end
18 |
19 | it "passes #start through" do
20 | expect(output).to receive(:start)
21 |
22 | wrapper.start
23 | end
24 |
25 | it "passes #finish through" do
26 | expect(output).to receive(:finish)
27 |
28 | wrapper.finish
29 | end
30 |
31 | it "passes #each through" do
32 | expect(output).to receive(:each)
33 |
34 | wrapper.each do
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/bake/covered/debug.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | def initialize(context)
7 | super
8 |
9 | require_relative "../../lib/covered"
10 | end
11 |
12 | # Debug the coverage of a file. Show which lines should be executable.
13 | #
14 | # @parameter paths [Array(String)] The paths to parse.
15 | # @parameter execute [Boolean] Whether to execute the code.
16 | def parse(paths: [], execute: false)
17 | files = output = Covered::Files.new
18 |
19 | paths.each do |path|
20 | output.mark(path, 0, 0)
21 | end
22 |
23 | if execute
24 | capture = Covered::Capture.new(output)
25 | capture.start
26 | paths.each do |path|
27 | load path
28 | end
29 | capture.finish
30 |
31 | files.paths = files.paths.slice(*paths)
32 | end
33 |
34 | Covered::Summary.new.call(output, $stderr)
35 | end
36 |
--------------------------------------------------------------------------------
/examples/coverage/erb/coverage.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Released under the MIT License.
5 | # Copyright, 2019-2025, by Samuel Williams.
6 |
7 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__)
8 |
9 | require "erb"
10 | require "covered/config"
11 |
12 | template_path = File.expand_path("template.erb", __dir__)
13 |
14 | covered = Covered::Config.load(coverage: "FullSummary")
15 | covered.start
16 |
17 | template = ERB.new(File.read(template_path)).tap do |template|
18 | template.filename = template_path
19 | end
20 |
21 | @items = ["Cats", "Dogs", "Chickens"]
22 |
23 | template.result(binding)
24 |
25 | covered.finish
26 | covered.call($stdout)
27 |
28 | covered.each do |coverage|
29 | puts "Coverage counts (values): #{coverage.counts.inspect}"
30 | puts "Coverage counts (size): #{coverage.counts.size}"
31 | puts "File lines (size): #{coverage.read.lines.size}"
32 | end
33 |
--------------------------------------------------------------------------------
/lib/covered/brief_summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2025, by Samuel Williams.
5 |
6 | require_relative "summary"
7 |
8 | module Covered
9 | class BriefSummary < Summary
10 | def call(wrapper, output = $stdout, before: 4, after: 4)
11 | terminal = self.terminal(output)
12 |
13 | ordered = []
14 |
15 | statistics = self.each(wrapper) do |coverage|
16 | ordered << coverage unless coverage.complete?
17 | end
18 |
19 | terminal.puts
20 | statistics.print(output)
21 |
22 | if ordered.any?
23 | terminal.puts "", "Least Coverage:"
24 | ordered.sort_by!(&:missing_count).reverse!
25 |
26 | ordered.first(5).each do |coverage|
27 | path = wrapper.relative_path(coverage.path)
28 |
29 | terminal.write path, style: :brief_path
30 | terminal.puts ": #{coverage.missing_count} lines not executed!"
31 | end
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/covered/source/erb.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | require "erb"
7 | require "covered/files"
8 | require "covered/capture"
9 | require "covered/summary"
10 |
11 | let(:code) {<<~ERB}
12 |
13 | <% items.each do |item| %>
14 | -
15 | The item:
16 | <%= item %>
17 |
18 | <% end %>
19 |
20 | ERB
21 |
22 | it "can parse multi-line methods" do
23 | skip "Unsupported Ruby Version" unless RUBY_VERSION >= "3.2"
24 |
25 | files = Covered::Files.new
26 |
27 | template = ERB.new(code)
28 | template.location = [__FILE__, 12]
29 |
30 | capture = Covered::Capture.new(files)
31 | capture.start
32 | template.result_with_hash(items: [1, 2, 3])
33 | capture.finish
34 |
35 | expect(files.paths[__FILE__].counts).not.to be(:include?, 0)
36 |
37 | # Show the actual coverage:
38 | # Covered::Summary.new(threshold: nil).call(files, $stderr)
39 | end
40 |
--------------------------------------------------------------------------------
/bake/covered/validate.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2022-2025, by Samuel Williams.
5 |
6 | def initialize(context)
7 | super
8 |
9 | require_relative "../../lib/covered/config"
10 | end
11 |
12 | # Validate the coverage of multiple test runs.
13 | # @parameter paths [Array(String)] The coverage database paths.
14 | # @parameter minimum [Float] The minimum required coverage in order to pass.
15 | # @parameter input [Covered::Policy] The input policy to validate.
16 | def validate(paths: nil, minimum: 1.0, input:)
17 | policy ||= context.lookup("covered:policy:current").call(paths: paths)
18 |
19 | # Calculate statistics:
20 | statistics = Covered::Statistics.new
21 |
22 | policy.each do |coverage|
23 | statistics << coverage
24 | end
25 |
26 | # Print statistics:
27 | statistics.print($stderr)
28 |
29 | policy.call(STDOUT)
30 |
31 | # Validate statistics and raise an error if they are not met:
32 | statistics.validate!(minimum)
33 | end
34 |
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | # GitHub Copilot Instructions
2 |
3 | ## Use Agent Context
4 |
5 | When working on this project, consult the `agent.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards.
6 |
7 | If the file does not exist, you will need to install it, by running the following command:
8 |
9 | ```bash
10 | $ bundle install
11 | $ bundle exec bake agent:context:install
12 | ```
13 |
14 | This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions.
15 |
16 | ## Ignoring Files
17 |
18 | The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled.
19 |
20 | While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request.
21 |
--------------------------------------------------------------------------------
/test/covered/source/xrb.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "covered/files"
7 | require "covered/summary"
8 | require "covered/capture"
9 |
10 | require "xrb/template"
11 |
12 | let(:template_path) {File.expand_path("template.xrb", __dir__)}
13 | let(:template) {XRB::Template.load_file(template_path)}
14 |
15 | let(:files) {Covered::Files.new}
16 | let(:only) {Covered::Only.new(template_path, files)}
17 | let(:capture) {Covered::Capture.new(files)}
18 |
19 | let(:summary) {Covered::Summary.new}
20 |
21 | it "correctly generates coverage for template" do
22 | skip "Unsupported Ruby Version" unless RUBY_VERSION >= "3.2"
23 |
24 | capture.start
25 | template.to_string
26 | capture.finish
27 |
28 | expect(files.paths).to be(:include?, template_path)
29 |
30 | io = StringIO.new
31 | summary.call(files, io)
32 |
33 | # Show the actual coverage:
34 | # Covered::Summary.new(threshold: nil).call(files, $stderr)
35 |
36 | expect(io.string).to be(:include?, "2/3 lines executed; 66.67% covered")
37 | end
38 |
--------------------------------------------------------------------------------
/covered.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/covered/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "covered"
7 | spec.version = Covered::VERSION
8 |
9 | spec.summary = "A modern approach to code coverage."
10 | spec.authors = ["Samuel Williams", "Adam Daniels", "Aron Latis", "Cyril Roelandt", "Felix Yan", "Michael Adams", "Shannon Skipper", "Stephen Ierodiaconou"]
11 | spec.license = "MIT"
12 |
13 | spec.cert_chain = ["release.cert"]
14 | spec.signing_key = File.expand_path("~/.gem/release.pem")
15 |
16 | spec.homepage = "https://github.com/socketry/covered"
17 |
18 | spec.metadata = {
19 | "documentation_uri" => "https://socketry.github.io/covered/",
20 | "funding_uri" => "https://github.com/sponsors/ioquatix/",
21 | "source_code_uri" => "https://github.com/socketry/covered.git",
22 | }
23 |
24 | spec.files = Dir.glob(["{bake,context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__)
25 |
26 | spec.required_ruby_version = ">= 3.2"
27 |
28 | spec.add_dependency "console", "~> 1.0"
29 | spec.add_dependency "msgpack", "~> 1.0"
30 | end
31 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on: [push, pull_request]
4 |
5 | permissions:
6 | contents: read
7 |
8 | jobs:
9 | test:
10 | name: ${{matrix.ruby}} on ${{matrix.os}}
11 | runs-on: ${{matrix.os}}-latest
12 | continue-on-error: ${{matrix.experimental}}
13 |
14 | strategy:
15 | matrix:
16 | os:
17 | - ubuntu
18 | - macos
19 |
20 | ruby:
21 | - "3.2"
22 | - "3.3"
23 | - "3.4"
24 |
25 | experimental: [false]
26 |
27 | include:
28 | - os: ubuntu
29 | ruby: truffleruby
30 | experimental: true
31 | - os: ubuntu
32 | ruby: jruby
33 | experimental: true
34 | - os: ubuntu
35 | ruby: head
36 | experimental: true
37 |
38 | steps:
39 | - uses: actions/checkout@v4
40 | - uses: ruby/setup-ruby@v1
41 | with:
42 | ruby-version: ${{matrix.ruby}}
43 | bundler-cache: true
44 |
45 | - name: Run tests
46 | timeout-minutes: 10
47 | run: bundle exec bake test
48 |
--------------------------------------------------------------------------------
/lib/covered/capture.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "wrapper"
7 |
8 | require "coverage"
9 |
10 | module Covered
11 | class Capture < Wrapper
12 | def start
13 | super
14 |
15 | ::Coverage.start(lines: true, eval: true)
16 | end
17 |
18 | def clear
19 | super
20 |
21 | ::Coverage.result(stop: false, clear: true)
22 | end
23 |
24 | EVAL_PATHS = {
25 | "(eval)" => true,
26 | "(irb)" => true,
27 | "eval" => true
28 | }
29 |
30 | def finish
31 | results = ::Coverage.result
32 |
33 | results.each do |path, result|
34 | next if EVAL_PATHS.include?(path)
35 |
36 | path = self.expand_path(path)
37 |
38 | # Skip files which don't exist. This can happen if `eval` is used with an invalid/incorrect path.
39 | if File.exist?(path)
40 | @output.mark(path, 1, result[:lines])
41 | else
42 | # warn "Skipping coverage for #{path.inspect} because it doesn't exist!"
43 | # Ignore.
44 | end
45 | end
46 |
47 | super
48 | end
49 |
50 | def execute(source, binding: TOPLEVEL_BINDING)
51 | start
52 |
53 | eval(source.code!, binding, source.path, source.line_offset)
54 | ensure
55 | finish
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/.github/workflows/documentation.yaml:
--------------------------------------------------------------------------------
1 | name: Documentation
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages:
9 | permissions:
10 | contents: read
11 | pages: write
12 | id-token: write
13 |
14 | # Allow one concurrent deployment:
15 | concurrency:
16 | group: "pages"
17 | cancel-in-progress: true
18 |
19 | env:
20 | BUNDLE_WITH: maintenance
21 |
22 | jobs:
23 | generate:
24 | runs-on: ubuntu-latest
25 |
26 | steps:
27 | - uses: actions/checkout@v4
28 |
29 | - uses: ruby/setup-ruby@v1
30 | with:
31 | ruby-version: ruby
32 | bundler-cache: true
33 |
34 | - name: Installing packages
35 | run: sudo apt-get install wget
36 |
37 | - name: Generate documentation
38 | timeout-minutes: 5
39 | run: bundle exec bake utopia:project:static --force no
40 |
41 | - name: Upload documentation artifact
42 | uses: actions/upload-pages-artifact@v3
43 | with:
44 | path: docs
45 |
46 | deploy:
47 | runs-on: ubuntu-latest
48 |
49 | environment:
50 | name: github-pages
51 | url: ${{steps.deployment.outputs.page_url}}
52 |
53 | needs: generate
54 | steps:
55 | - name: Deploy to GitHub Pages
56 | id: deployment
57 | uses: actions/deploy-pages@v4
58 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | Copyright, 2018-2025, by Samuel Williams.
4 | Copyright, 2018, by Shannon Skipper.
5 | Copyright, 2019, by Cyril Roelandt.
6 | Copyright, 2022, by Adam Daniels.
7 | Copyright, 2022, by Felix Yan.
8 | Copyright, 2023, by Stephen Ierodiaconou.
9 | Copyright, 2023, by Michael Adams.
10 | Copyright, 2025, by Aron Latis.
11 |
12 | Permission is hereby granted, free of charge, to any person obtaining a copy
13 | of this software and associated documentation files (the "Software"), to deal
14 | in the Software without restriction, including without limitation the rights
15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16 | copies of the Software, and to permit persons to whom the Software is
17 | furnished to do so, subject to the following conditions:
18 |
19 | The above copyright notice and this permission notice shall be included in all
20 | copies or substantial portions of the Software.
21 |
22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28 | SOFTWARE.
29 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | plugins:
2 | - rubocop-socketry
3 |
4 | AllCops:
5 | DisabledByDefault: true
6 |
7 | Layout/ConsistentBlankLineIndentation:
8 | Enabled: true
9 |
10 | Layout/IndentationStyle:
11 | Enabled: true
12 | EnforcedStyle: tabs
13 |
14 | Layout/InitialIndentation:
15 | Enabled: true
16 |
17 | Layout/IndentationWidth:
18 | Enabled: true
19 | Width: 1
20 |
21 | Layout/IndentationConsistency:
22 | Enabled: true
23 | EnforcedStyle: normal
24 |
25 | Layout/BlockAlignment:
26 | Enabled: true
27 |
28 | Layout/EndAlignment:
29 | Enabled: true
30 | EnforcedStyleAlignWith: start_of_line
31 |
32 | Layout/BeginEndAlignment:
33 | Enabled: true
34 | EnforcedStyleAlignWith: start_of_line
35 |
36 | Layout/ElseAlignment:
37 | Enabled: true
38 |
39 | Layout/DefEndAlignment:
40 | Enabled: true
41 |
42 | Layout/CaseIndentation:
43 | Enabled: true
44 |
45 | Layout/CommentIndentation:
46 | Enabled: true
47 |
48 | Layout/EmptyLinesAroundClassBody:
49 | Enabled: true
50 |
51 | Layout/EmptyLinesAroundModuleBody:
52 | Enabled: true
53 |
54 | Layout/EmptyLineAfterMagicComment:
55 | Enabled: true
56 |
57 | Layout/SpaceInsideBlockBraces:
58 | Enabled: true
59 | EnforcedStyle: no_space
60 | SpaceBeforeBlockParameters: false
61 |
62 | Layout/SpaceAroundBlockParameters:
63 | Enabled: true
64 | EnforcedStyleInsidePipes: no_space
65 |
66 | Style/FrozenStringLiteralComment:
67 | Enabled: true
68 |
69 | Style/StringLiterals:
70 | Enabled: true
71 | EnforcedStyle: double_quotes
72 |
--------------------------------------------------------------------------------
/lib/covered/forks.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2023-2025, by Samuel Williams.
5 |
6 | require_relative "wrapper"
7 |
8 | module Covered
9 | class Forks < Wrapper
10 | def start
11 | super
12 |
13 | Handler.start(self)
14 | end
15 |
16 | def finish
17 | Handler.finish
18 |
19 | super
20 | end
21 |
22 | module Handler
23 | LOCK = Mutex.new
24 |
25 | class << self
26 | attr :coverage
27 |
28 | def start(coverage)
29 | LOCK.synchronize do
30 | if @coverage
31 | raise ArgumentError, "Coverage is already being tracked!"
32 | end
33 |
34 | @coverage = coverage
35 | end
36 | end
37 |
38 | def finish
39 | LOCK.synchronize do
40 | @coverage = nil
41 | end
42 | end
43 |
44 | def after_fork
45 | return unless coverage = Handler.coverage
46 | pid = Process.pid
47 |
48 | # Any pre-existing coverage is being tracked by the parent process, so discard it.
49 | coverage.clear
50 |
51 | at_exit do
52 | # Don't break forked children:
53 | if Process.pid == pid
54 | coverage.finish
55 | end
56 | end
57 | end
58 | end
59 |
60 | def _fork
61 | pid = super
62 |
63 | if pid.zero?
64 | Handler.after_fork
65 | end
66 |
67 | return pid
68 | end
69 |
70 | ::Process.singleton_class.prepend(self)
71 | end
72 |
73 | private_constant :Handler
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/test/covered/statistics.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "covered/statistics"
7 |
8 | describe Covered::Statistics do
9 | let(:statistics) {subject.new}
10 | let(:source) {Covered::Source.new("foo.rb")}
11 |
12 | with "initial state" do
13 | it "is zero" do
14 | expect(statistics.count).to be == 0
15 | expect(statistics.executable_count).to be == 0
16 | expect(statistics.executed_count).to be == 0
17 | end
18 |
19 | it "is complete" do
20 | expect(statistics).to be(:complete?)
21 | end
22 | end
23 |
24 | with "after adding full coverage" do
25 | let(:coverage) {Covered::Coverage.new(source, [nil, 1])}
26 |
27 | def before
28 | statistics << coverage
29 | super
30 | end
31 |
32 | it "has one entry" do
33 | expect(statistics.count).to be == 1
34 | expect(statistics.executable_count).to be == 1
35 | expect(statistics.executed_count).to be == 1
36 | end
37 |
38 | it "is complete" do
39 | expect(statistics).to be(:complete?)
40 | end
41 | end
42 |
43 | with "after adding partial coverage" do
44 | let(:coverage) {Covered::Coverage.new(source, [nil, 1, 0])}
45 |
46 | def before
47 | statistics << coverage
48 | super
49 | end
50 |
51 | it "has one entry" do
52 | expect(statistics.count).to be == 1
53 | expect(statistics.executable_count).to be == 2
54 | expect(statistics.executed_count).to be == 1
55 | end
56 |
57 | it "is not complete" do
58 | expect(statistics).not.to be(:complete?)
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/context/getting-started.md:
--------------------------------------------------------------------------------
1 | # Getting Started
2 |
3 | This guide explains how to get started with `covered` and integrate it with your test suite.
4 |
5 | ## Installation
6 |
7 | Add this line to your application's `Gemfile`:
8 |
9 | ``` ruby
10 | gem 'covered'
11 | ```
12 |
13 | ### Sus Integration
14 |
15 | In your `config/sus.rb` add the following:
16 |
17 | ``` ruby
18 | require 'covered/sus'
19 | include Covered::Sus
20 | ```
21 |
22 | ### RSpec Integration
23 |
24 | In your `spec/spec_helper.rb` add the following before loading any other code:
25 |
26 | ``` ruby
27 | require 'covered/rspec'
28 | ```
29 |
30 | Ensure that you have a `.rspec` file with `--require spec_helper`:
31 |
32 | --require spec_helper
33 | --format documentation
34 | --warnings
35 |
36 | ### Minitest Integration
37 |
38 | In your `test/test_helper.rb` add the following before loading any other code:
39 |
40 | ``` ruby
41 | require 'covered/minitest'
42 | require 'minitest/autorun'
43 | ```
44 |
45 | In your test files, e.g. `test/dummy_test.rb` add the following at the top:
46 |
47 | ``` ruby
48 | require_relative 'test_helper'
49 | ```
50 |
51 | ### Template Coverage
52 |
53 | Covered supports coverage of templates which are compiled into Ruby code. This is only supported on Ruby 3.2+ due to
54 | enhancements in the coverage interface.
55 |
56 | ### Partial Summary
57 |
58 | COVERAGE=PartialSummary rspec
59 |
60 | This report only shows snippets of source code with incomplete coverage.
61 |
62 | ### Brief Summary
63 |
64 | COVERAGE=BriefSummary rspec
65 |
66 | This report lists several files in order of least coverage.l
67 |
--------------------------------------------------------------------------------
/guides/getting-started/readme.md:
--------------------------------------------------------------------------------
1 | # Getting Started
2 |
3 | This guide explains how to get started with `covered` and integrate it with your test suite.
4 |
5 | ## Installation
6 |
7 | Add this line to your application's `Gemfile`:
8 |
9 | ``` ruby
10 | gem 'covered'
11 | ```
12 |
13 | ### Sus Integration
14 |
15 | In your `config/sus.rb` add the following:
16 |
17 | ``` ruby
18 | require 'covered/sus'
19 | include Covered::Sus
20 | ```
21 |
22 | ### RSpec Integration
23 |
24 | In your `spec/spec_helper.rb` add the following before loading any other code:
25 |
26 | ``` ruby
27 | require 'covered/rspec'
28 | ```
29 |
30 | Ensure that you have a `.rspec` file with `--require spec_helper`:
31 |
32 | --require spec_helper
33 | --format documentation
34 | --warnings
35 |
36 | ### Minitest Integration
37 |
38 | In your `test/test_helper.rb` add the following before loading any other code:
39 |
40 | ``` ruby
41 | require 'covered/minitest'
42 | require 'minitest/autorun'
43 | ```
44 |
45 | In your test files, e.g. `test/dummy_test.rb` add the following at the top:
46 |
47 | ``` ruby
48 | require_relative 'test_helper'
49 | ```
50 |
51 | ### Template Coverage
52 |
53 | Covered supports coverage of templates which are compiled into Ruby code. This is only supported on Ruby 3.2+ due to
54 | enhancements in the coverage interface.
55 |
56 | ### Partial Summary
57 |
58 | COVERAGE=PartialSummary rspec
59 |
60 | This report only shows snippets of source code with incomplete coverage.
61 |
62 | ### Brief Summary
63 |
64 | COVERAGE=BriefSummary rspec
65 |
66 | This report lists several files in order of least coverage.l
67 |
--------------------------------------------------------------------------------
/test/covered/forks.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2023-2025, by Samuel Williams.
5 |
6 | require "covered/config"
7 | require "tmpdir"
8 |
9 | describe Covered::Forks do
10 | def measure_coverage(code)
11 | Dir.mktmpdir do |root|
12 | config = Covered::Config.load(root: root)
13 | test_path = config.policy.expand_path("test.rb")
14 |
15 | config.start
16 | begin
17 | File.write(test_path, code)
18 |
19 | if block_given?
20 | yield test_path
21 | else
22 | eval(code, TOPLEVEL_BINDING.dup, test_path, 1)
23 | end
24 | ensure
25 | config.finish
26 | end
27 |
28 | return config.output.to_h[test_path]
29 | end
30 | end
31 |
32 | it "tracks persistent coverage across forks" do
33 | skip "Unsupported Ruby Version" unless RUBY_VERSION >= "3.2.1"
34 |
35 | coverage = measure_coverage(<<~RUBY)
36 | 3.times do
37 | Object.new
38 | end
39 |
40 | pid = fork do
41 | 3.times do
42 | Object.new
43 | end
44 | end
45 |
46 | Process.wait(pid)
47 | RUBY
48 |
49 | expect(coverage.counts).to be == [
50 | nil, 1, 3, nil, nil, 1, 1, 3, nil, nil, nil, 1
51 | ]
52 | end
53 |
54 | it "tracks persistent coverage across processes" do
55 | skip "Unsupported Ruby Version" unless RUBY_VERSION >= "3.2.1"
56 |
57 | code = <<~RUBY
58 | 3.times do
59 | Object.new
60 | end
61 | RUBY
62 |
63 | coverage = measure_coverage(code) do |path|
64 | pid = spawn("ruby", path)
65 |
66 | Process.wait(pid)
67 | end
68 |
69 | expect(coverage.counts).to be == [
70 | nil, 1, 3
71 | ]
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/covered/source.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2023, by Samuel Williams.
5 |
6 | module Covered
7 | class Source
8 | def self.for(path, **options)
9 | if File.exist?(path)
10 | # options[:code] ||= File.read(path)
11 | options[:modified_time] ||= File.mtime(path)
12 | end
13 |
14 | self.new(path, **options)
15 | end
16 |
17 | def initialize(path, code: nil, line_offset: 1, modified_time: nil)
18 | @path = path
19 | @code = code
20 | @line_offset = line_offset
21 | @modified_time = modified_time
22 | end
23 |
24 | attr_accessor :path
25 | attr :code
26 | attr :line_offset
27 | attr :modified_time
28 |
29 | def to_s
30 | "\#<#{self.class} path=#{path}>"
31 | end
32 |
33 | def read(&block)
34 | if block_given?
35 | File.open(self.path, "r", &block)
36 | else
37 | File.read(self.path)
38 | end
39 | end
40 |
41 | # The actual code which is being covered. If a template generates the source, this is the generated code, while the path refers to the template itself.
42 | def code!
43 | self.code || self.read
44 | end
45 |
46 | def code?
47 | !!self.code
48 | end
49 |
50 | def serialize(packer)
51 | packer.write(self.path)
52 | packer.write(self.code)
53 | packer.write(self.line_offset)
54 | packer.write(self.modified_time)
55 | end
56 |
57 | def self.deserialize(unpacker)
58 | path = unpacker.read
59 | code = unpacker.read
60 | line_offset = unpacker.read
61 | modified_time = unpacker.read
62 |
63 | self.new(path, code: code, line_offset: line_offset, modified_time: modified_time)
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/bake/covered/policy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2023-2025, by Samuel Williams.
5 | # Copyright, 2023, by Michael Adams.
6 |
7 | def initialize(context)
8 | super
9 |
10 | require_relative "../../lib/covered/config"
11 | end
12 |
13 | # Load the current coverage policy.
14 | # Defaults to the default coverage path if no paths are specified.
15 | # @parameter paths [Array(String)] The coverage database paths.
16 | def current(paths: nil, reports: Covered::Config.reports)
17 | policy = Covered::Policy.new
18 |
19 | # Load the default path if no paths are specified:
20 | paths ||= Dir.glob(Covered::Persist::DEFAULT_PATH, base: context.root)
21 |
22 | # If no paths are specified, raise an error:
23 | if paths.empty?
24 | raise ArgumentError, "No coverage paths specified!"
25 | end
26 |
27 | # Load all coverage information:
28 | paths.each do |path|
29 | # It would be nice to have a better algorithm here than just ignoring mtime - perhaps using checksums?
30 | Covered::Persist.new(policy.output, path).load!(ignore_mtime: true)
31 | end
32 |
33 | if reports
34 | policy.reports!(reports)
35 | end
36 |
37 | return policy
38 | end
39 |
40 | # Validate the coverage of multiple test runs.
41 | # @parameter paths [Array(String)] The coverage database paths.
42 | # @parameter minimum [Float] The minimum required coverage in order to pass.
43 | # @parameter input [Covered::Policy] The input policy to validate.
44 | def statistics(paths: nil, minimum: 1.0, input:)
45 | input ||= context.lookup("covered:policy:current").call(paths: paths)
46 |
47 | # Calculate statistics:
48 | statistics = Covered::Statistics.new
49 |
50 | input.each do |coverage|
51 | statistics << coverage
52 | end
53 |
54 | return statistics
55 | end
56 |
--------------------------------------------------------------------------------
/test/covered/policy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "covered/policy"
7 |
8 | describe Covered::Policy do
9 | let(:pattern) {"**/*.rb"}
10 | let(:policy) {subject.new}
11 |
12 | it "can start capture via policy" do
13 | expect do
14 | policy.start
15 | policy.finish
16 | end.not.to raise_exception
17 | end
18 |
19 | it "can #include a pattern" do
20 | policy.include(pattern)
21 |
22 | expect(policy.output.pattern).to be == pattern
23 | expect(policy.output).to be_a(Covered::Include)
24 | end
25 |
26 | it "can #skip a pattern" do
27 | policy.skip(pattern)
28 |
29 | expect(policy.output.pattern).to be == pattern
30 | expect(policy.output).to be_a(Covered::Skip)
31 | end
32 |
33 | it "can #only a pattern" do
34 | policy.only(pattern)
35 |
36 | expect(policy.output.pattern).to be == pattern
37 | expect(policy.output).to be_a(Covered::Only)
38 | end
39 |
40 | it "can specify #root" do
41 | policy.root(__dir__)
42 |
43 | expect(policy.output.path).to be == __dir__
44 | expect(policy.output).to be_a(Covered::Root)
45 | end
46 |
47 | it "can select default reports" do
48 | policy.reports!(true)
49 |
50 | expect(policy.reports.count).to be == 1
51 | expect(policy.reports.first).to be_a Covered::BriefSummary
52 | end
53 |
54 | it "can select specified reports" do
55 | policy.reports!("BriefSummary,PartialSummary")
56 |
57 | expect(policy.reports.count).to be == 2
58 | end
59 |
60 | it "can #call" do
61 | io = StringIO.new
62 |
63 | policy.reports << Covered::BriefSummary.new
64 | policy.call(io)
65 |
66 | expect(io.string).to be(:include?, "0 files checked; 0/0 lines executed; 100.0% covered.")
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/release.cert:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11
3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK
4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz
5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd
6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj
7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB
8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2
9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW
10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE
11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN
12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss
13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn
14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM
15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW
16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O
17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs
18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs
19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE
20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl
21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/
22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp
23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws
24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP
25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt
26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
28 | -----END CERTIFICATE-----
29 |
--------------------------------------------------------------------------------
/test/covered/partial_summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "covered/partial_summary"
7 | require "covered/files"
8 |
9 | describe Covered::PartialSummary do
10 | let(:files) {Covered::Files.new}
11 | let(:summary) {subject.new}
12 |
13 | let(:first_line) {File.readlines(__FILE__).first}
14 | let(:io) {StringIO.new}
15 |
16 | it "can generate partial summary" do
17 | files.mark(__FILE__, 22, 1)
18 | files.mark(__FILE__, 23, 0)
19 |
20 | summary.call(files, io)
21 |
22 | expect(io.string).not.to be =~ /#{first_line}/
23 | expect(io.string).to be(:include?, "What are some of the best recursion jokes?")
24 | end
25 |
26 | it "should break segments with elipsis" do
27 | files.mark(__FILE__, 1, 0)
28 | files.mark(__FILE__, 2, 1)
29 |
30 | files.mark(__FILE__, 30, 0)
31 |
32 | summary.call(files, io)
33 |
34 | expect(io.string).to be(:include?, " :\n")
35 | end
36 |
37 | it "shows 100% coverage files when there are partial files" do
38 | # Create a scenario with mixed coverage
39 | partial_file = __FILE__
40 | complete_file = File.join(__dir__, "../covered/summary.rb")
41 |
42 | # Mark partial coverage for this file
43 | files.mark(partial_file, 1, 1)
44 | files.mark(partial_file, 2, 0) # uncovered line
45 |
46 | # Mark complete coverage for summary.rb
47 | files.mark(complete_file, 1, 1)
48 | files.mark(complete_file, 2, 1)
49 |
50 | summary.call(files, io)
51 |
52 | # Should show the message about 100% coverage files
53 | expect(io.string).to be(:include?, "100% coverage and is not shown above:")
54 | expect(io.string).to be(:include?, "summary.rb")
55 | end
56 |
57 | it "does not show 100% coverage files when all files are 100%" do
58 | # Create a scenario where all files have 100% coverage
59 | file1 = __FILE__
60 | file2 = File.join(__dir__, "../covered/summary.rb")
61 |
62 | # Mark complete coverage for both files
63 | files.mark(file1, 1, 1)
64 | files.mark(file1, 2, 1)
65 | files.mark(file2, 1, 1)
66 | files.mark(file2, 2, 1)
67 |
68 | summary.call(files, io)
69 |
70 | # Should NOT show the message about 100% coverage files (would be redundant)
71 | expect(io.string).not.to be(:include?, "100% coverage and is not shown above:")
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/covered/markdown_summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2021-2025, by Samuel Williams.
5 |
6 | require_relative "statistics"
7 | require_relative "wrapper"
8 |
9 | require "console/output"
10 |
11 | module Covered
12 | class MarkdownSummary
13 | def initialize(threshold: 1.0)
14 | @threshold = threshold
15 | end
16 |
17 | def each(wrapper)
18 | statistics = Statistics.new
19 |
20 | wrapper.each do |coverage|
21 | statistics << coverage
22 |
23 | if @threshold.nil? or coverage.ratio < @threshold
24 | yield coverage
25 | end
26 | end
27 |
28 | return statistics
29 | end
30 |
31 | def print_annotations(output, coverage, line, line_offset)
32 | if annotations = coverage.annotations[line_offset]
33 | prefix = "#{line_offset}|".rjust(8) + "*|".rjust(8)
34 | output.write prefix
35 |
36 | output.write line.match(/^\s+/)
37 | output.puts "\# #{annotations.join(", ")}"
38 | end
39 | end
40 |
41 | def print_line_header(output)
42 | output.puts "Line|".rjust(8) + "Hits|".rjust(8)
43 | end
44 |
45 | def print_line(output, line, line_offset, count)
46 | prefix = "#{line_offset}|".rjust(8) + "#{count}|".rjust(8)
47 |
48 | output.write prefix
49 | output.write line
50 |
51 | # If there was no newline at end of file, we add one:
52 | unless line.end_with?($/)
53 | output.puts
54 | end
55 | end
56 |
57 | # A coverage array gives, for each line, the number of line execution by the interpreter. A nil value means coverage is finishd for this line (lines like else and end).
58 | def call(wrapper, output = $stdout)
59 | output.puts "# Coverage Report"
60 | output.puts
61 |
62 | ordered = []
63 | buffer = StringIO.new
64 |
65 | statistics = self.each(wrapper) do |coverage|
66 | ordered << coverage unless coverage.complete?
67 | end
68 |
69 | statistics.print(output)
70 |
71 | if ordered.any?
72 | output.puts "", "\#\# Least Coverage:", ""
73 | ordered.sort_by!(&:missing_count).reverse!
74 |
75 | ordered.first(5).each do |coverage|
76 | path = wrapper.relative_path(coverage.path)
77 |
78 | output.puts "- `#{path}`: #{coverage.missing_count} lines not executed!"
79 | end
80 | end
81 |
82 | output.print(buffer.string)
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/lib/covered/wrapper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2023, by Samuel Williams.
5 |
6 | module Covered
7 | class Base
8 | # Start tracking coverage.
9 | def start
10 | end
11 |
12 | # Discard any coverage data and restart tracking.
13 | def clear
14 | end
15 |
16 | # Stop tracking coverage.
17 | def finish
18 | end
19 |
20 | def accept?(path)
21 | true
22 | end
23 |
24 | def mark(path, lineno, value)
25 | end
26 |
27 | def add(coverage)
28 | end
29 |
30 | # Enumerate the coverage data.
31 | # @yields {|coverage| ...}
32 | # @parameter coverage [Coverage] The coverage data, including the source file and execution counts.
33 | def each
34 | end
35 |
36 | def relative_path(path)
37 | path
38 | end
39 |
40 | def expand_path(path)
41 | path
42 | end
43 | end
44 |
45 | class Wrapper < Base
46 | def initialize(output = Base.new)
47 | @output = output
48 | end
49 |
50 | attr :output
51 |
52 | def start
53 | @output.start
54 | end
55 |
56 | def clear
57 | @output.clear
58 | end
59 |
60 | def finish
61 | @output.finish
62 | end
63 |
64 | def accept?(path)
65 | @output.accept?(path)
66 | end
67 |
68 | def mark(path, lineno, value)
69 | @output.mark(path, lineno, value)
70 | end
71 |
72 | def add(coverage)
73 | @output.add(coverage)
74 | end
75 |
76 | # @yield [Coverage] the path to the file, and the execution counts.
77 | def each(&block)
78 | @output.each(&block)
79 | end
80 |
81 | def relative_path(path)
82 | @output.relative_path(path)
83 | end
84 |
85 | def expand_path(path)
86 | @output.expand_path(path)
87 | end
88 |
89 | def to_h
90 | to_enum(:each).collect{|coverage| [coverage.path, coverage]}.to_h
91 | end
92 | end
93 |
94 | class Filter < Wrapper
95 | def mark(path, lineno, value)
96 | @output.mark(path, lineno, value) if accept?(path)
97 | end
98 |
99 | # @yield [Coverage] the path to the file, and the execution counts.
100 | def each(&block)
101 | @output.each do |coverage|
102 | yield coverage if accept?(coverage.path)
103 | end
104 | end
105 |
106 | def accept?(path)
107 | match?(path) and super
108 | end
109 |
110 | def match?(path)
111 | true
112 | end
113 | end
114 | end
115 |
--------------------------------------------------------------------------------
/lib/covered/partial_summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2025, by Samuel Williams.
5 |
6 | require_relative "summary"
7 |
8 | module Covered
9 | class PartialSummary < Summary
10 | def print_coverage(terminal, coverage, before: 4, after: 4)
11 | return if coverage.zero?
12 |
13 | line_offset = 1
14 | counts = coverage.counts
15 | last_line = nil
16 |
17 | coverage.read do |file|
18 | print_line_header(terminal)
19 |
20 | file.each_line do |line|
21 | range = Range.new([line_offset - before, 0].max, line_offset+after)
22 |
23 | if counts[range]&.include?(0)
24 | count = counts[line_offset]
25 |
26 | if last_line and last_line != line_offset-1
27 | terminal.puts ":".rjust(16)
28 | end
29 |
30 | print_annotations(terminal, coverage, line, line_offset)
31 | print_line(terminal, line, line_offset, count)
32 |
33 | last_line = line_offset
34 | end
35 |
36 | line_offset += 1
37 | end
38 | end
39 | end
40 |
41 | def call(wrapper, output = $stdout, **options)
42 | terminal = self.terminal(output)
43 | complete_files = []
44 | partial_files_count = 0
45 |
46 | statistics = self.each(wrapper) do |coverage|
47 | partial_files_count += 1
48 |
49 | path = wrapper.relative_path(coverage.path)
50 | terminal.puts ""
51 | terminal.puts path, style: :path
52 |
53 | begin
54 | print_coverage(terminal, coverage, **options)
55 | rescue => error
56 | print_error(terminal, error)
57 | end
58 |
59 | coverage.print(output)
60 | end
61 |
62 | # Collect files with 100% coverage that were not shown
63 | wrapper.each do |coverage|
64 | if coverage.ratio >= 1.0
65 | complete_files << wrapper.relative_path(coverage.path)
66 | end
67 | end
68 |
69 | terminal.puts
70 | statistics.print(output)
71 |
72 | # Only show information about files with 100% coverage if there were files with partial coverage shown above
73 | if complete_files.any? && partial_files_count > 0
74 | terminal.puts ""
75 | if complete_files.size == 1
76 | terminal.puts "1 file has 100% coverage and is not shown above:"
77 | else
78 | terminal.puts "#{complete_files.size} files have 100% coverage and are not shown above:"
79 | end
80 |
81 | complete_files.sort.each do |path|
82 | terminal.write " - ", style: :covered_prefix
83 | terminal.puts path, style: :brief_path
84 | end
85 | end
86 | end
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/lib/covered/policy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "summary"
7 | require_relative "files"
8 | require_relative "capture"
9 | require_relative "persist"
10 | require_relative "forks"
11 |
12 | module Covered
13 | class Policy < Wrapper
14 | def initialize
15 | super(Files.new)
16 |
17 | @reports = []
18 | @capture = nil
19 | end
20 |
21 | attr :output
22 |
23 | def freeze
24 | return self if frozen?
25 |
26 | capture
27 | @reports.freeze
28 |
29 | super
30 | end
31 |
32 | def include(...)
33 | @output = Include.new(@output, ...)
34 | end
35 |
36 | def skip(...)
37 | @output = Skip.new(@output, ...)
38 | end
39 |
40 | def only(...)
41 | @output = Only.new(@output, ...)
42 | end
43 |
44 | def root(...)
45 | @output = Root.new(@output, ...)
46 | end
47 |
48 | def persist!(...)
49 | @output = Persist.new(@output, ...)
50 | end
51 |
52 | def capture
53 | @capture ||= Forks.new(
54 | Capture.new(@output)
55 | )
56 | end
57 |
58 | def start
59 | capture.start
60 | end
61 |
62 | def finish
63 | capture.finish
64 | end
65 |
66 | attr :reports
67 |
68 | class Autoload
69 | def initialize(name)
70 | @name = name
71 | end
72 |
73 | def new
74 | begin
75 | klass = Covered.const_get(@name)
76 | rescue NameError
77 | require_relative(snake_case(@name))
78 | end
79 |
80 | klass = Covered.const_get(@name)
81 |
82 | return klass.new
83 | end
84 |
85 | def call(...)
86 | self.new.call(...)
87 | end
88 |
89 | def to_s
90 | "\#<#{self.class} loading #{@name}>"
91 | end
92 |
93 | private
94 |
95 | def snake_case(string)
96 | return string.gsub(/::/, "/").
97 | gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
98 | gsub(/([a-z\d])([A-Z])/,'\1_\2').
99 | tr("-", "_").
100 | downcase
101 | end
102 | end
103 |
104 | def reports!(reports)
105 | if reports.is_a?(String)
106 | names = reports.split(",")
107 |
108 | names.each do |name|
109 | begin
110 | klass = Covered.const_get(name)
111 | @reports << klass.new
112 | rescue NameError
113 | @reports << Autoload.new(name)
114 | end
115 | end
116 | elsif reports == true
117 | @reports << Covered::BriefSummary.new
118 | elsif reports == false
119 | @reports.clear
120 | elsif reports.is_a?(Array)
121 | @reports.concat(reports)
122 | else
123 | @reports << reports
124 | end
125 | end
126 |
127 | def call(...)
128 | @reports.each do |report|
129 | report.call(self, ...)
130 | end
131 | end
132 | end
133 | end
134 |
--------------------------------------------------------------------------------
/test/covered/files.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require "covered/files"
7 |
8 | describe Covered::Files do
9 | let(:files) {subject.new}
10 |
11 | with "#mark" do
12 | it "can mark lines" do
13 | files.mark("program.rb", 2, 1)
14 |
15 | expect(files.paths["program.rb"][2]).to be == 1
16 | end
17 |
18 | it "can mark the same line twice" do
19 | 2.times do
20 | files.mark("program.rb", 2, 1)
21 | end
22 |
23 | expect(files.paths["program.rb"][2]).to be == 2
24 | end
25 | end
26 | end
27 |
28 | describe Covered::Filter do
29 | let(:filter) {subject.new}
30 |
31 | it "accepts everything" do
32 | expect(filter.accept?("foo")).to be == true
33 | end
34 | end
35 |
36 | describe Covered::Include do
37 | let(:files) {Covered::Files.new}
38 | let(:pattern) {File.join(__dir__, "**", "*.rb")}
39 | let(:include) {subject.new(files, pattern)}
40 |
41 | it "should match some files" do
42 | expect(include.glob).not.to be(:empty?)
43 | end
44 |
45 | let(:path) {include.glob.first}
46 |
47 | it "should defer to existing files" do
48 | include.mark(path, 5, 1)
49 |
50 | paths = include.to_h
51 |
52 | expect(paths).to have_keys(path)
53 | expect(paths[path].counts).to be == [nil, nil, nil, nil, nil, 1]
54 | end
55 |
56 | it "should enumerate paths" do
57 | enumerator = include.to_enum(:each)
58 |
59 | expect(enumerator.next).to be_a(Covered::Coverage)
60 | end
61 | end
62 |
63 | describe Covered::Skip do
64 | let(:files) {Covered::Files.new}
65 | let(:skip) {subject.new(files, /file.rb/)}
66 |
67 | it "should ignore files which match given pattern" do
68 | skip.mark("file.rb", 1, 1)
69 |
70 | expect(files).to be(:empty?)
71 | end
72 |
73 | it "should include files which don't match given pattern" do
74 | skip.mark("foo.rb", 1, 1)
75 |
76 | expect(files).not.to be(:empty?)
77 | expect(skip.to_h).to have_keys("foo.rb")
78 | end
79 | end
80 |
81 | describe Covered::Only do
82 | let(:files) {Covered::Files.new}
83 | let(:only) {subject.new(files, "file.rb")}
84 |
85 | it "should ignore files which don't match given pattern" do
86 | only.mark("foo.rb", 1, 1)
87 |
88 | expect(files).to be(:empty?)
89 | end
90 |
91 | it "should include files which match given pattern" do
92 | only.mark("file.rb", 1, 1)
93 |
94 | expect(files).not.to be(:empty?)
95 | end
96 | end
97 |
98 | describe Covered::Root do
99 | let(:files) {Covered::Files.new}
100 | let(:root) {subject.new(files, "lib/")}
101 |
102 | it "should ignore files which don't match root" do
103 | root.mark("foo.rb", 1, 1)
104 |
105 | expect(files).to be(:empty?)
106 | end
107 |
108 | it "should include files which match root" do
109 | root.mark("lib/foo.rb", 1, 1)
110 |
111 | expect(files).not.to be(:empty?)
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/lib/covered/persist.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 | # Copyright, 2023, by Stephen Ierodiaconou.
6 |
7 | require_relative "wrapper"
8 |
9 | require "msgpack"
10 | require "time"
11 |
12 | module Covered
13 | class Persist < Wrapper
14 | DEFAULT_PATH = ".covered.db"
15 |
16 | def initialize(output, path = DEFAULT_PATH)
17 | super(output)
18 |
19 | @path = self.expand_path(path)
20 | end
21 |
22 | def apply(record, ignore_mtime: false)
23 | if coverage = record[:coverage]
24 | if path = record[:path]
25 | path = self.expand_path(path)
26 | coverage.path = path
27 | end
28 |
29 | if ignore_mtime || coverage.fresh?
30 | add(coverage)
31 | return true
32 | end
33 | end
34 |
35 | return false
36 | end
37 |
38 | def serialize(coverage)
39 | {
40 | # We want to use relative paths so that moving the repo won't break everything:
41 | pid: Process.pid,
42 | path: relative_path(coverage.path),
43 | # relative_path: relative_path(coverage.path),
44 | coverage: coverage,
45 | }
46 | end
47 |
48 | def load!(**options)
49 | return unless File.exist?(@path)
50 |
51 | # Load existing coverage information and mark all files:
52 | File.open(@path, "rb") do |file|
53 | file.flock(File::LOCK_SH)
54 |
55 | make_unpacker(file).each do |record|
56 | # pp load: record
57 | self.apply(record, **options)
58 | end
59 | end
60 | rescue
61 | raise LoadError, "Failed to load coverage from #{@path}, maybe old format or corrupt!"
62 | end
63 |
64 | def save!
65 | # Dump all coverage:
66 | File.open(@path, "ab") do |file|
67 | file.flock(File::LOCK_EX)
68 |
69 | packer = make_packer(file)
70 |
71 | @output.each do |coverage|
72 | # pp save: coverage
73 | packer.write(serialize(coverage))
74 | end
75 |
76 | packer.flush
77 | end
78 | end
79 |
80 | def finish
81 | super
82 |
83 | self.save!
84 | end
85 |
86 | def each(&block)
87 | return to_enum unless block_given?
88 |
89 | @output.clear
90 | self.load!
91 |
92 | super
93 | end
94 |
95 | def make_factory
96 | factory = MessagePack::Factory.new
97 |
98 | factory.register_type(0x00, Symbol)
99 |
100 | factory.register_type(0x01, Time,
101 | packer: MessagePack::Time::Packer,
102 | unpacker: MessagePack::Time::Unpacker
103 | )
104 |
105 | factory.register_type(0x20, Source,
106 | recursive: true,
107 | packer: :serialize,
108 | unpacker: :deserialize,
109 | )
110 |
111 | factory.register_type(0x21, Coverage,
112 | recursive: true,
113 | packer: :serialize,
114 | unpacker: :deserialize,
115 | )
116 |
117 | return factory
118 | end
119 |
120 | def make_packer(io)
121 | return make_factory.packer(io)
122 | end
123 |
124 | def make_unpacker(io)
125 | return make_factory.unpacker(io)
126 | end
127 | end
128 | end
129 |
--------------------------------------------------------------------------------
/lib/covered/files.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "coverage"
7 | require_relative "wrapper"
8 |
9 | require "set"
10 |
11 | module Covered
12 | class Files < Base
13 | def initialize(*)
14 | super
15 |
16 | @paths = {}
17 | end
18 |
19 | attr_accessor :paths
20 |
21 | def [](path)
22 | @paths[path] ||= Coverage.for(path)
23 | end
24 |
25 | def empty?
26 | @paths.empty?
27 | end
28 |
29 | def mark(path, line_number, value)
30 | self[path].mark(line_number, value)
31 | end
32 |
33 | def annotate(path, line_number, value)
34 | self[path].annotate(line_number, value)
35 | end
36 |
37 | def add(coverage)
38 | self[coverage.path].merge!(coverage)
39 | end
40 |
41 | def each
42 | return to_enum unless block_given?
43 |
44 | @paths.each_value do |coverage|
45 | yield coverage
46 | end
47 | end
48 |
49 | def clear
50 | @paths.clear
51 | end
52 | end
53 |
54 | class Include < Wrapper
55 | def initialize(output, pattern, base = "")
56 | super(output)
57 |
58 | @pattern = pattern
59 | @base = base
60 | end
61 |
62 | attr :pattern
63 |
64 | def glob
65 | paths = Set.new
66 | root = self.expand_path(@base)
67 | pattern = File.expand_path(@pattern, root)
68 |
69 | Dir.glob(pattern) do |path|
70 | unless File.directory?(path)
71 | paths << File.realpath(path)
72 | end
73 | end
74 |
75 | return paths
76 | end
77 |
78 | def each(&block)
79 | paths = glob
80 |
81 | super do |coverage|
82 | paths.delete(coverage.path)
83 |
84 | yield coverage
85 | end
86 |
87 | paths.each do |path|
88 | yield Coverage.for(path)
89 | end
90 | end
91 | end
92 |
93 | class Skip < Filter
94 | def initialize(output, pattern)
95 | super(output)
96 |
97 | @pattern = pattern
98 | end
99 |
100 | attr :pattern
101 |
102 | if Regexp.instance_methods.include? :match?
103 | # This is better as it doesn't allocate a MatchData instance which is essentially useless.
104 | def match? path
105 | !@pattern.match?(path)
106 | end
107 | else
108 | def match? path
109 | !(@pattern =~ path)
110 | end
111 | end
112 | end
113 |
114 | class Only < Filter
115 | def initialize(output, pattern)
116 | super(output)
117 |
118 | @pattern = pattern
119 | end
120 |
121 | attr :pattern
122 |
123 | def match?(path)
124 | @pattern === path
125 | end
126 | end
127 |
128 | class Root < Filter
129 | def initialize(output, path)
130 | super(output)
131 |
132 | @path = path
133 | end
134 |
135 | attr :path
136 |
137 | def expand_path(path)
138 | File.expand_path(super, @path)
139 | end
140 |
141 | def relative_path(path)
142 | if path.start_with?(@path)
143 | path.slice(@path.size+1, path.size)
144 | else
145 | super
146 | end
147 | end
148 |
149 | def match?(path)
150 | path.start_with?(@path)
151 | end
152 | end
153 | end
154 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Covered
2 |
3 | 
4 |
5 | Covered uses modern Ruby features to generate comprehensive coverage, including support for templates which are compiled
6 | into Ruby.
7 |
8 | - Incremental coverage - if you run your full test suite, and the run a subset, it will still report the correct
9 | coverage - so you can incrementally work on improving coverage.
10 | - Integration with Sus, Git, RSpec and Minitest- no need to configure anything - out of the box support for these
11 | platforms.
12 | - Supports coverage of views - templates compiled to Ruby code can be tracked for coverage reporting.
13 |
14 | [](https://github.com/socketry/covered/actions?workflow=Test)
15 |
16 | ## Motivation
17 |
18 | Originally, Ruby coverage tools were unable to handle `eval`ed code. This is because the `coverage` module built into
19 | Ruby doesn't expose the necessary hooks to capture it. Using the [parser](https://github.com/whitequark/parser) gem and
20 | trace points allows us to do our own source code analysis to compute executable lines, thus making it possible to
21 | compute coverage for "templates".
22 |
23 | After this concept prooved useful, [it was integrated directly into Ruby](https://bugs.ruby-lang.org/issues/19008).
24 |
25 | ## Usage
26 |
27 | Please see the [project documentation](https://socketry.github.io/covered/) for more details.
28 |
29 | - [Getting Started](https://socketry.github.io/covered/guides/getting-started/index) - This guide explains how to get started with `covered` and integrate it with your test suite.
30 |
31 | - [Configuration](https://socketry.github.io/covered/guides/configuration/index) - This guide will help you to configure covered for your project's specific requirements.
32 |
33 | ## Releases
34 |
35 | Please see the [project releases](https://socketry.github.io/covered/releases/index) for all releases.
36 |
37 | ### v0.28.0
38 |
39 | - List files that have 100% coverage in `PartialSummary` output.
40 |
41 | ### v0.27.0
42 |
43 | - Drop development dependeny on `trenni` and add dependeny on `xrb`.
44 |
45 | ## See Also
46 |
47 | - [simplecov](https://github.com/colszowka/simplecov) – one of the original coverage implementations for Ruby, uses
48 | the built-in `coverage` library.
49 | - [sus](https://github.com/socketry/sus) - a test framework which uses `covered` to generate coverage reports.
50 |
51 | ## Contributing
52 |
53 | We welcome contributions to this project.
54 |
55 | 1. Fork it.
56 | 2. Create your feature branch (`git checkout -b my-new-feature`).
57 | 3. Commit your changes (`git commit -am 'Add some feature'`).
58 | 4. Push to the branch (`git push origin my-new-feature`).
59 | 5. Create new Pull Request.
60 |
61 | ### Developer Certificate of Origin
62 |
63 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
64 |
65 | ### Community Guidelines
66 |
67 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
68 |
--------------------------------------------------------------------------------
/lib/covered/statistics.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "wrapper"
7 | require_relative "coverage"
8 |
9 | module Covered
10 | class CoverageError < StandardError
11 | end
12 |
13 | class Statistics
14 | include Ratio
15 |
16 | def self.for(coverage)
17 | self.new.tap do |statistics|
18 | statistics << coverage
19 | end
20 | end
21 |
22 | class Aggregate
23 | include Ratio
24 |
25 | def initialize
26 | @count = 0
27 | @executable_count = 0
28 | @executed_count = 0
29 | end
30 |
31 | # Total number of files added.
32 | attr :count
33 |
34 | # The number of lines which could have been executed.
35 | attr :executable_count
36 |
37 | # The number of lines that were executed.
38 | attr :executed_count
39 |
40 | def as_json
41 | {
42 | count: count,
43 | executable_count: executable_count,
44 | executed_count: executed_count,
45 | percentage: percentage.to_f.round(2),
46 | }
47 | end
48 |
49 | def to_json(options)
50 | as_json.to_json(options)
51 | end
52 |
53 | def << coverage
54 | @count += 1
55 |
56 | @executable_count += coverage.executable_count
57 | @executed_count += coverage.executed_count
58 | end
59 | end
60 |
61 | def initialize
62 | @total = Aggregate.new
63 | @paths = Hash.new
64 | end
65 |
66 | attr :total
67 | attr :paths
68 |
69 | def count
70 | @paths.size
71 | end
72 |
73 | def executable_count
74 | @total.executable_count
75 | end
76 |
77 | def executed_count
78 | @total.executed_count
79 | end
80 |
81 | def << coverage
82 | @total << coverage
83 | (@paths[coverage.path] ||= coverage.empty).merge!(coverage)
84 | end
85 |
86 | def [](path)
87 | @paths[path]
88 | end
89 |
90 | def as_json
91 | {
92 | total: total.as_json,
93 | paths: @paths.map{|path, coverage| [path, coverage.as_json]}.to_h,
94 | }
95 | end
96 |
97 | def to_json(options)
98 | as_json.to_json(options)
99 | end
100 |
101 | COMPLETE = [
102 | "Enter the code dojo: 100% coverage attained, bugs defeated with one swift strike.",
103 | "Nirvana reached: 100% code coverage, where bugs meditate and vanish like a passing cloud.",
104 | "With 100% coverage, your code has unlocked the path to enlightenment – bugs have no place to hide.",
105 | "In the realm of code serenity, 100% coverage is your ticket to coding enlightenment.",
106 | "100% coverage, where code and bugs coexist in perfect harmony, like Yin and Yang.",
107 | "Achieving the Zen of code coverage, your code is a peaceful garden where bugs find no shelter.",
108 | "Congratulations on coding enlightenment! 100% coverage means your code is one with the universe.",
109 | "With 100% coverage, your code is a tranquil pond where bugs cause no ripples.",
110 | "At the peak of code mastery: 100% coverage, where bugs bow down before the wisdom of your code.",
111 | "100% code coverage: Zen achieved! Bugs in harmony, code at peace.",
112 | ]
113 |
114 | def print(output)
115 | output.puts "#{count} files checked; #{@total.executed_count}/#{@total.executable_count} lines executed; #{@total.percentage.to_f.round(2)}% covered."
116 |
117 | if self.complete?
118 | output.puts "🧘 #{COMPLETE.sample}"
119 | end
120 | end
121 |
122 | def validate!(minimum = 1.0)
123 | if total.ratio < minimum
124 | raise CoverageError, "Coverage of #{self.percentage.to_f.round(2)}% is less than required minimum of #{(minimum * 100.0).round(2)}%!"
125 | end
126 | end
127 | end
128 | end
129 |
--------------------------------------------------------------------------------
/lib/covered/config.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2019-2025, by Samuel Williams.
5 |
6 | require_relative "policy"
7 |
8 | module Covered
9 | class Config
10 | PATH = "config/covered.rb"
11 |
12 | def self.root
13 | ENV["COVERED_ROOT"] || Dir.pwd
14 | end
15 |
16 | def self.path(root)
17 | path = ::File.expand_path(PATH, root)
18 |
19 | if ::File.exist?(path)
20 | return path
21 | end
22 | end
23 |
24 | def self.reports
25 | ENV["COVERAGE"]
26 | end
27 |
28 | def self.load(root: self.root, reports: self.reports)
29 | derived = Class.new(self)
30 |
31 | if path = self.path(root)
32 | config = Module.new
33 | config.module_eval(::File.read(path), path)
34 | derived.prepend(config)
35 | end
36 |
37 | return derived.new(root, reports)
38 | end
39 |
40 | def initialize(root, reports)
41 | @root = root
42 | @reports = reports
43 | @policy = nil
44 |
45 | @environment = nil
46 | end
47 |
48 | def report?
49 | !!@reports
50 | end
51 |
52 | alias :record? :report?
53 |
54 | attr :coverage
55 |
56 | def policy
57 | @policy ||= Policy.new.tap{|policy| make_policy(policy)}.freeze
58 | end
59 |
60 | def output
61 | policy.output
62 | end
63 |
64 | # Start coverage tracking.
65 | def start
66 | # Save and setup the environment:
67 | @environment = ENV.to_h
68 | autostart!
69 |
70 | # Start coverage tracking:
71 | policy.start
72 | end
73 |
74 | # Finish coverage tracking.
75 | def finish
76 | # Finish coverage tracking:
77 | policy.finish
78 |
79 | # Restore the environment:
80 | ENV.replace(@environment)
81 | @environment = nil
82 | end
83 |
84 | # Generate coverage reports to the given output.
85 | # @param output [IO] The output stream to write the coverage report to.
86 | def call(output)
87 | policy.call(output)
88 | end
89 |
90 | def each(&block)
91 | policy.each(&block)
92 | end
93 |
94 | # Which paths to ignore when computing coverage for a given project.
95 | # @returns [Array(String)] An array of relative paths to ignore.
96 | def ignore_paths
97 | ["test/", "fixtures/", "spec/", "vendor/", "config/"]
98 | end
99 |
100 | # Which paths to include when computing coverage for a given project.
101 | # @returns [Array(String)] An array of relative patterns to include, e.g. `"lib/**/*.rb"`.
102 | def include_patterns
103 | ["lib/**/*.rb"]
104 | end
105 |
106 | # Override this method to implement your own policy.
107 | def make_policy(policy)
108 | # Only files in the root would be tracked:
109 | policy.root(@root)
110 |
111 | patterns = ignore_paths.map do |path|
112 | File.join(@root, path)
113 | end
114 |
115 | # We will ignore any files in the test or spec directory:
116 | policy.skip(Regexp.union(patterns))
117 |
118 | # We will include all files under lib, even if they aren't loaded:
119 | include_patterns.each do |pattern|
120 | policy.include(pattern)
121 | end
122 |
123 | policy.persist!
124 |
125 | policy.reports!(@reports)
126 | end
127 |
128 | protected
129 |
130 | REQUIRE_COVERED_AUTOSTART = "-rcovered/autostart"
131 |
132 | def autostart!
133 | if rubyopt = ENV["RUBYOPT"] and !rubyopt.empty?
134 | rubyopt = [rubyopt.strip, REQUIRE_COVERED_AUTOSTART].join(" ")
135 | else
136 | rubyopt = REQUIRE_COVERED_AUTOSTART
137 | end
138 |
139 | ENV["RUBYOPT"] = rubyopt
140 |
141 | unless ENV["COVERED_ROOT"]
142 | ENV["COVERED_ROOT"] = @root
143 | end
144 |
145 | # Don't report coverage in child processes:
146 | ENV.delete("COVERAGE")
147 | end
148 | end
149 | end
150 |
--------------------------------------------------------------------------------
/lib/covered/summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "statistics"
7 | require_relative "wrapper"
8 |
9 | module Covered
10 | class Summary
11 | def initialize(threshold: 1.0)
12 | @threshold = threshold
13 | end
14 |
15 | def terminal(output)
16 | require "console/terminal"
17 |
18 | Console::Terminal.for(output).tap do |terminal|
19 | terminal[:path] ||= terminal.style(nil, nil, :bold, :underline)
20 | terminal[:brief_path] ||= terminal.style(:yellow)
21 |
22 | terminal[:uncovered_prefix] ||= terminal.style(:red)
23 | terminal[:covered_prefix] ||= terminal.style(:green)
24 | terminal[:ignored_prefix] ||= terminal.style(nil, nil, :faint)
25 | terminal[:header_prefix] ||= terminal.style(nil, nil, :faint)
26 |
27 | terminal[:uncovered_code] ||= terminal.style(:red)
28 | terminal[:covered_code] ||= terminal.style(:green)
29 | terminal[:ignored_code] ||= terminal.style(nil, nil, :faint)
30 |
31 | terminal[:annotations] ||= terminal.style(:blue)
32 | terminal[:error] ||= terminal.style(:red)
33 | end
34 | end
35 |
36 | def each(wrapper)
37 | statistics = Statistics.new
38 |
39 | wrapper.each do |coverage|
40 | statistics << coverage
41 |
42 | if @threshold.nil? or coverage.ratio < @threshold
43 | yield coverage
44 | end
45 | end
46 |
47 | return statistics
48 | end
49 |
50 | def print_annotations(terminal, coverage, line, line_offset)
51 | if annotations = coverage.annotations[line_offset]
52 | prefix = "#{line_offset}|".rjust(8) + "*|".rjust(8)
53 | terminal.write prefix, style: :ignored_prefix
54 |
55 | terminal.write line.match(/^\s+/)
56 | terminal.puts "\# #{annotations.join(", ")}", style: :annotations
57 | end
58 | end
59 |
60 | def print_line_header(terminal)
61 | prefix = "Line|".rjust(8) + "Hits|".rjust(8)
62 |
63 | terminal.puts prefix, style: :header_prefix
64 | end
65 |
66 | def print_line(terminal, line, line_offset, count)
67 | prefix = "#{line_offset}|".rjust(8) + "#{count}|".rjust(8)
68 |
69 | if count == nil
70 | terminal.write prefix, style: :ignored_prefix
71 | terminal.write line, style: :ignored_code
72 | elsif count == 0
73 | terminal.write prefix, style: :uncovered_prefix
74 | terminal.write line, style: :uncovered_code
75 | else
76 | terminal.write prefix, style: :covered_prefix
77 | terminal.write line, style: :covered_code
78 | end
79 |
80 | # If there was no newline at end of file, we add one:
81 | unless line.end_with? $/
82 | terminal.puts
83 | end
84 | end
85 |
86 | def print_coverage(terminal, coverage)
87 | line_offset = 1
88 | counts = coverage.counts
89 |
90 | coverage.read do |file|
91 | print_line_header(terminal)
92 |
93 | file.each_line do |line|
94 | count = counts[line_offset]
95 |
96 | print_annotations(terminal, coverage, line, line_offset)
97 |
98 | print_line(terminal, line, line_offset, count)
99 |
100 | line_offset += 1
101 | end
102 | end
103 | end
104 |
105 | def print_error(terminal, error)
106 | terminal.puts "Error: #{error.message}", style: :error
107 | terminal.puts error.backtrace
108 | end
109 |
110 | # A coverage array gives, for each line, the number of line execution by the interpreter. A nil value means coverage is finishd for this line (lines like else and end).
111 | def call(wrapper, output = $stdout, **options)
112 | terminal = self.terminal(output)
113 |
114 | statistics = self.each(wrapper) do |coverage|
115 | path = wrapper.relative_path(coverage.path)
116 | terminal.puts ""
117 | terminal.puts path, style: :path
118 |
119 | begin
120 | print_coverage(terminal, coverage, **options)
121 | rescue => error
122 | print_error(terminal, error)
123 | end
124 |
125 | coverage.print(output)
126 | end
127 |
128 | terminal.puts
129 | statistics.print(output)
130 | end
131 | end
132 |
133 | class FullSummary < Summary
134 | def initialize
135 | super(threshold: nil)
136 | end
137 | end
138 |
139 | class Quiet
140 | def call(wrapper, output = $stdout)
141 | # Silent.
142 | end
143 | end
144 | end
145 |
--------------------------------------------------------------------------------
/context/usage.md:
--------------------------------------------------------------------------------
1 | ## General Information
2 |
3 | As per the official documentation, `covered` is a modern code-coverage library for Ruby.
4 |
5 | Instead of relying only on Ruby’s built-in `Coverage` API, it combines **tracepoints** with
6 | static analysis from the `parser` gem so it can track _any_ Ruby that eventually gets executed
7 | (including code produced by `eval`, or templates such as ERB/ HAML that are compiled to Ruby).
8 | Because it knows which lines are _actually_ executable, the reported percentages are usually
9 | more accurate than classic line-count tools like SimpleCov.
10 |
11 | ## Installation
12 |
13 | Add this line to your application's `Gemfile`:
14 |
15 | ```ruby
16 | gem 'covered'
17 | ```
18 |
19 | ## Configuration
20 |
21 | ### Configure what you see
22 |
23 | Under the hood, `covered` uses a single environment variable `COVERAGE` to:
24 | 1. turn the coverage tracking on/ off
25 | 2. allow the user to select how much detail to be printed
26 |
27 | By default, when the `COVERAGE` is not specifically set anywhere, you will not see anything, and nothing will be stored during the runs.
28 |
29 | You can modify this behavior either by defining the environment variable or specifying it when running the tests command. Your choices of values are:
30 | 1. `BriefSummary` - you see a brief summary showcasing the overall percentage of line coverage.
31 | - Ideally you would use this for quick feedback locally
32 | - You can also use this to set a threshold through Github Actions around merging rules in Pull Requests.
33 | 2. `PartialSummary` - you see contextual snippets around missing lines
34 | - Ideally you would use this for quickly investigating missing coverage in specific files
35 | - You can also use this to set a threshold through Github Actions around merging rules in Pull Requests, and also deliver information about which lines are not tested to the developer.
36 | 3. `FullSummary` - you see every line, both covered and uncovered, which may be overwhelming
37 | - Ideally you would use this when doing a deep dive that requires verbosity.
38 | 4. `Quiet` - you do not see anything in the console but the coverage is saved internally for later usage
39 | - Ideally used in CI pipelines.
40 |
41 | ### Configure file choices for coverage
42 |
43 | You can configure covered by creating a `config/covered.rb` file in your project.
44 |
45 | ```ruby
46 | def ignore_paths
47 | super + ["engines/"]
48 | end
49 |
50 | def include_patterns
51 | super + ["bake/**/*.rb"]
52 | end
53 | ```
54 |
55 | 1. `ignore_paths` specifies which paths to ignore when computing coverage for a given project.
56 | 2. `include_patterns` specifies which paths to include when computing coverage for a given project.
57 |
58 | More information around the Configuration possibilities can be found here: https://socketry.github.io/covered/source/Covered/Config/index.html.
59 |
60 | One possibly helpful functionality to take note of is that you can override the `make_policy` method in order to implement your own policy.
61 | ## Integration
62 |
63 | ### Sus Integration
64 |
65 | In your `config/sus.rb` add the following:
66 |
67 | ```ruby
68 | require 'covered/sus'
69 | include Covered::Sus
70 | ```
71 | ### RSpec Integration
72 |
73 | In your `spec/spec_helper.rb` add the following before loading any other code:
74 |
75 | ```ruby
76 | require 'covered/rspec'
77 | ```
78 |
79 | Ensure that you have a `.rspec` file with `--require spec_helper`:
80 |
81 | ```plain
82 | --require spec_helper
83 | --format documentation
84 | --warnings
85 | ```
86 |
87 | ### Minitest Integration
88 |
89 | In your `test/test_helper.rb` add the following before loading any other code:
90 |
91 | ```ruby
92 | require 'covered/minitest'
93 | require 'minitest/autorun'
94 | ```
95 |
96 | In your test files, e.g. `test/dummy_test.rb` add the following at the top:
97 |
98 | ```ruby
99 | require_relative 'test_helper'
100 | ```
101 |
102 |
103 | ## Coverage Improvement
104 |
105 | The taxonomy of tests isn't really relevant for the purpose of improving the coverage and the safety of your codebase.
106 |
107 | You are going to think about tests by referring to the level of software processes that they test. When trying to improve coverage, you must first understand what the purpose of the on-going process is, with the scope of coverage in mind:
108 | 1. Verifying individual components - you are going to be writing Unit Tests.
109 | 2. Verifying interaction between multiple units - you are going to be writing Integration Tests.
110 | 3. Verifying end-to-end-workflows - you are going to be writing System Tests.
111 |
--------------------------------------------------------------------------------
/lib/covered/coverage.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Released under the MIT License.
4 | # Copyright, 2018-2025, by Samuel Williams.
5 |
6 | require_relative "source"
7 |
8 | module Covered
9 | module Ratio
10 | def ratio
11 | return 1.0 if executable_count.zero?
12 |
13 | Rational(executed_count, executable_count)
14 | end
15 |
16 | def complete?
17 | executed_count == executable_count
18 | end
19 |
20 | def percentage
21 | ratio * 100
22 | end
23 | end
24 |
25 | class Coverage
26 | include Ratio
27 |
28 | def self.for(path, **options)
29 | self.new(Source.for(path, **options))
30 | end
31 |
32 | def initialize(source, counts = [], annotations = {})
33 | @source = source
34 | @counts = counts
35 | @annotations = annotations
36 | end
37 |
38 | attr_accessor :source
39 | attr :counts
40 | attr :annotations
41 |
42 | def total
43 | counts.sum{|count| count || 0}
44 | end
45 |
46 | # Create an empty coverage with the same source.
47 | def empty
48 | self.class.new(@source, [nil] * @counts.size)
49 | end
50 |
51 | def annotate(line_number, annotation)
52 | @annotations[line_number] ||= []
53 | @annotations[line_number] << annotation
54 | end
55 |
56 | def mark(line_number, value = 1)
57 | # As currently implemented, @counts is base-zero rather than base-one.
58 | # Line numbers generally start at line 1, so the first line, line 1, is at index 1. This means that index[0] is usually nil.
59 | Array(value).each_with_index do |value, index|
60 | offset = line_number + index
61 | if @counts[offset]
62 | @counts[offset] += value
63 | else
64 | @counts[offset] = value
65 | end
66 | end
67 | end
68 |
69 | def merge!(other)
70 | # If the counts are non-zero and don't match, that can indicate a problem.
71 |
72 | other.counts.each_with_index do |count, index|
73 | if count
74 | @counts[index] ||= 0
75 | @counts[index] += count
76 | end
77 | end
78 |
79 | @annotations.merge!(other.annotations) do |line_number, a, b|
80 | Array(a) + Array(b)
81 | end
82 | end
83 |
84 | # Construct a new coverage object for the given line numbers. Only the given line numbers will be considered for the purposes of computing coverage.
85 | # @parameter line_numbers [Array(Integer)] The line numbers to include in the new coverage object.
86 | def for_lines(line_numbers)
87 | counts = [nil] * @counts.size
88 | line_numbers.each do |line_number|
89 | counts[line_number] = @counts[line_number]
90 | end
91 |
92 | self.class.new(@source, counts, @annotations)
93 | end
94 |
95 | def path
96 | @source.path
97 | end
98 |
99 | def path= value
100 | @source.path = value
101 | end
102 |
103 | def fresh?
104 | if @source.modified_time.nil?
105 | # We don't know when the file was last modified, so we assume it is stale:
106 | return false
107 | end
108 |
109 | unless File.exist?(@source.path)
110 | # The file no longer exists, so we assume it is stale:
111 | return false
112 | end
113 |
114 | if @source.modified_time >= File.mtime(@source.path)
115 | # The file has not been modified since we last processed it, so we assume it is fresh:
116 | return true
117 | end
118 |
119 | return false
120 | end
121 |
122 | def read(&block)
123 | @source.read(&block)
124 | end
125 |
126 | def freeze
127 | return self if frozen?
128 |
129 | @counts.freeze
130 | @annotations.freeze
131 |
132 | super
133 | end
134 |
135 | def to_a
136 | @counts
137 | end
138 |
139 | def zero?
140 | total.zero?
141 | end
142 |
143 | def [] line_number
144 | @counts[line_number]
145 | end
146 |
147 | def executable_lines
148 | @counts.compact
149 | end
150 |
151 | def executable_count
152 | executable_lines.count
153 | end
154 |
155 | def executed_lines
156 | executable_lines.reject(&:zero?)
157 | end
158 |
159 | def executed_count
160 | executed_lines.count
161 | end
162 |
163 | def missing_count
164 | executable_count - executed_count
165 | end
166 |
167 | def print(output)
168 | output.puts "** #{executed_count}/#{executable_count} lines executed; #{percentage.to_f.round(2)}% covered."
169 | end
170 |
171 | def to_s
172 | "\#<#{self.class} path=#{self.path} #{self.percentage.to_f.round(2)}% covered>"
173 | end
174 |
175 | def as_json
176 | {
177 | counts: counts,
178 | executable_count: executable_count,
179 | executed_count: executed_count,
180 | percentage: percentage.to_f.round(2),
181 | }
182 | end
183 |
184 | def serialize(packer)
185 | packer.write(@source)
186 | packer.write(@counts)
187 | packer.write(@annotations)
188 | end
189 |
190 | def self.deserialize(unpacker)
191 | source = unpacker.read
192 | counts = unpacker.read
193 | annotations = unpacker.read
194 |
195 | self.new(source, counts, annotations)
196 | end
197 | end
198 | end
199 |
--------------------------------------------------------------------------------
/context/configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | This guide will help you to configure covered for your project's specific requirements.
4 |
5 | ## Quick Start
6 |
7 | The simplest way to configure covered is through environment variables:
8 |
9 | ``` bash
10 | # Basic coverage with default report
11 | COVERAGE=true rspec
12 |
13 | # Specific report types
14 | COVERAGE=PartialSummary rspec
15 | COVERAGE=BriefSummary rspec
16 | COVERAGE=MarkdownSummary rspec
17 |
18 | # Multiple reports (comma-separated)
19 | COVERAGE=PartialSummary,BriefSummary rspec
20 | ```
21 |
22 | ## Configuration File
23 |
24 | For advanced configuration, create a `config/covered.rb` file in your project:
25 |
26 | ~~~ ruby
27 | # config/covered.rb
28 |
29 | def ignore_paths
30 | super + ["engines/", "app/assets/", "db/migrate/"]
31 | end
32 |
33 | def include_patterns
34 | super + ["bake/**/*.rb", "engines/**/*.rb"]
35 | end
36 |
37 | def make_policy(policy)
38 | super
39 |
40 | # Custom policy configuration
41 | policy.skip(/\/generated\//)
42 | policy.include("lib/templates/**/*.erb")
43 | end
44 | ~~~
45 |
46 | ## Environment Variables
47 |
48 | Covered uses several environment variables for configuration:
49 |
50 | | Variable | Description | Default |
51 | |----------------|-----------------------------------|--------------------|
52 | | `COVERAGE` | Report types to generate | `nil` (no reports) |
53 | | `COVERED_ROOT` | Project root directory | `Dir.pwd` |
54 | | `RUBYOPT` | Modified internally for autostart | Current value |
55 |
56 | ### Examples
57 |
58 | ``` bash
59 | # Disable coverage entirely
60 | unset COVERAGE
61 |
62 | # Enable default report (BriefSummary)
63 | COVERAGE=true
64 |
65 | # Custom project root
66 | COVERED_ROOT=/path/to/project COVERAGE=PartialSummary rspec
67 | ```
68 |
69 | ## Report Types
70 |
71 | Covered provides several built-in report types:
72 |
73 | ### Summary Reports
74 |
75 | - **`Summary`** - Full detailed coverage report with line-by-line analysis
76 | - **`FullSummary`** - Complete coverage without threshold filtering
77 | - **`BriefSummary`** - Shows overall statistics + top 5 files with least coverage
78 | - **`PartialSummary`** - Shows only code segments with incomplete coverage + lists 100% covered files
79 | - **`MarkdownSummary`** - Coverage report formatted as Markdown
80 | - **`Quiet`** - Suppresses all output
81 |
82 | ### Usage Examples
83 |
84 | ``` ruby
85 | # In config/covered.rb
86 | def make_policy(policy)
87 | super
88 |
89 | # Add multiple reports
90 | policy.reports << Covered::PartialSummary.new
91 | policy.reports << Covered::MarkdownSummary.new(threshold: 0.8)
92 | end
93 | ```
94 |
95 | ## Path Configuration
96 |
97 | ### Ignoring Paths
98 |
99 | Default ignored paths include `test/`, `spec/`, `fixtures/`, `vendor/`, and `config/`. Customize with:
100 |
101 | ``` ruby
102 | def ignore_paths
103 | super + [
104 | "engines/", # Engine directories
105 | "app/assets/", # Asset files
106 | "db/migrate/", # Database migrations
107 | "tmp/", # Temporary files
108 | "log/", # Log files
109 | "coverage/", # Coverage output
110 | "public/packs/" # Webpack outputs
111 | ]
112 | end
113 | ```
114 |
115 | ### Including Patterns
116 |
117 | Default includes `lib/**/*.rb`. Extend for additional patterns:
118 |
119 | ``` ruby
120 | def include_patterns
121 | super + [
122 | "app/**/*.rb", # Application code
123 | "bake/**/*.rb", # Bake tasks
124 | "engines/**/*.rb", # Engine code
125 | "lib/templates/**/*.erb" # Template files (Ruby 3.2+)
126 | ]
127 | end
128 | ```
129 |
130 | ## Advanced Policy Configuration
131 |
132 | The `make_policy` method provides fine-grained control:
133 |
134 | ``` ruby
135 | def make_policy(policy)
136 | super
137 |
138 | # Filter by regex patterns
139 | policy.skip(/\/generated\//) # Skip generated files
140 | policy.skip(/\.generated\.rb$/) # Skip files ending in .generated.rb
141 |
142 | # Include specific files
143 | policy.include("config/application.rb")
144 |
145 | # Only track specific patterns
146 | policy.only(/^app\//)
147 |
148 | # Set custom root
149 | policy.root(File.expand_path('..', __dir__))
150 |
151 | # Enable persistent coverage across runs
152 | policy.persist!
153 |
154 | # Configure reports programmatically
155 | if ENV['CI']
156 | policy.reports << Covered::MarkdownSummary.new
157 | else
158 | policy.reports << Covered::PartialSummary.new
159 | end
160 | end
161 | ```
162 |
163 | ## Common Configuration Patterns
164 |
165 | ### Rails Applications
166 |
167 | ``` ruby
168 | def ignore_paths
169 | super + [
170 | "app/assets/",
171 | "db/migrate/",
172 | "db/seeds.rb",
173 | "config/environments/",
174 | "config/initializers/",
175 | "tmp/",
176 | "log/",
177 | "public/",
178 | "storage/"
179 | ]
180 | end
181 |
182 | def include_patterns
183 | super + [
184 | "app/**/*.rb",
185 | "lib/**/*.rb",
186 | "config/application.rb",
187 | "config/routes.rb"
188 | ]
189 | end
190 | ```
191 |
192 | ### Gem Development
193 |
194 | ``` ruby
195 | def ignore_paths
196 | super + ["examples/", "benchmark/"]
197 | end
198 |
199 | def include_patterns
200 | super + ["bin/**/*.rb"]
201 | end
202 |
203 | def make_policy(policy)
204 | super
205 |
206 | # Only track the gem's main code
207 | policy.only(/^lib\//)
208 | end
209 | ```
210 |
211 | ### Monorepo/Multi-Engine
212 |
213 | ``` ruby
214 | def ignore_paths
215 | super + [
216 | "engines/*/spec/",
217 | "engines/*/test/",
218 | "shared/fixtures/"
219 | ]
220 | end
221 |
222 | def include_patterns
223 | super + [
224 | "engines/*/lib/**/*.rb",
225 | "engines/*/app/**/*.rb",
226 | "shared/lib/**/*.rb"
227 | ]
228 | end
229 | ```
230 |
231 | ## Template Coverage (Ruby 3.2+)
232 |
233 | For projects using ERB, Haml, or other template engines:
234 |
235 | ``` ruby
236 | def include_patterns
237 | super + [
238 | "app/views/**/*.erb",
239 | "lib/templates/**/*.erb",
240 | "app/views/**/*.haml"
241 | ]
242 | end
243 |
244 | def make_policy(policy)
245 | super
246 |
247 | # Ensure template coverage is enabled
248 | require 'covered/erb' if defined?(ERB)
249 | end
250 | ```
251 |
252 | ## CI/CD Integration
253 |
254 | ### GitHub Actions
255 |
256 | ``` ruby
257 | def make_policy(policy)
258 | super
259 |
260 | if ENV['GITHUB_ACTIONS']
261 | # Use markdown format for GitHub
262 | policy.reports << Covered::MarkdownSummary.new
263 |
264 | # Fail build on low coverage
265 | policy.reports << Class.new do
266 | def call(wrapper, output = $stdout)
267 | statistics = wrapper.each.inject(Statistics.new) { |s, c| s << c }
268 | if statistics.ratio < 0.90
269 | exit 1
270 | end
271 | end
272 | end.new
273 | end
274 | end
275 | ```
276 |
277 | ### Custom Thresholds
278 |
279 | ``` ruby
280 | def make_policy(policy)
281 | super
282 |
283 | # Different thresholds for different environments
284 | threshold = case ENV['RAILS_ENV']
285 | when 'production' then 0.95
286 | when 'staging' then 0.90
287 | else 0.80
288 | end
289 |
290 | policy.reports << Covered::Summary.new(threshold: threshold)
291 | end
292 | ```
293 |
294 | ## Performance Optimization
295 |
296 | For large codebases:
297 |
298 | ``` ruby
299 | def make_policy(policy)
300 | super
301 |
302 | # Skip large generated directories
303 | policy.skip(/\/node_modules\//)
304 | policy.skip(/\/vendor\/bundle\//)
305 | policy.skip(/\/coverage\//)
306 |
307 | # Use more efficient reports for large projects
308 | if Dir['**/*.rb'].length > 1000
309 | policy.reports << Covered::BriefSummary.new
310 | else
311 | policy.reports << Covered::PartialSummary.new
312 | end
313 | end
314 | ```
315 |
316 | ## Troubleshooting
317 |
318 | ### Common Issues
319 |
320 | **Coverage not working:**
321 | - Ensure `require 'covered/rspec'` (or similar) is at the top of your test helper
322 | - Check that `COVERAGE` environment variable is set
323 | - Verify the configuration file path is correct: `config/covered.rb`
324 |
325 | **Missing files in reports:**
326 | - Files must be required/loaded during test execution to be tracked
327 | - Use `include_patterns` to track files not loaded by tests
328 | - Check `ignore_paths` isn't excluding desired files
329 |
330 | **Performance issues:**
331 | - Use `BriefSummary` instead of `Summary` for large codebases
332 | - Add more specific patterns to `ignore_paths`
333 | - Use `policy.only()` to limit scope
334 |
335 | **Template coverage not working:**
336 | - Requires Ruby 3.2+ for full template support
337 | - Ensure template engines are loaded before coverage starts
338 | - Check that template files match `include_patterns`
339 |
340 | ### Debug Configuration
341 |
342 | ``` ruby
343 | def make_policy(policy)
344 | super
345 |
346 | # Debug: print current configuration
347 | if ENV['DEBUG_COVERAGE']
348 | puts "Ignore paths: #{ignore_paths}"
349 | puts "Include patterns: #{include_patterns}"
350 | puts "Root: #{@root}"
351 | end
352 | end
353 | ```
354 |
355 | See the {ruby Covered::Config} class for complete API documentation.
356 |
--------------------------------------------------------------------------------
/guides/configuration/readme.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | This guide will help you to configure covered for your project's specific requirements.
4 |
5 | ## Quick Start
6 |
7 | The simplest way to configure covered is through environment variables:
8 |
9 | ``` bash
10 | # Basic coverage with default report
11 | COVERAGE=true rspec
12 |
13 | # Specific report types
14 | COVERAGE=PartialSummary rspec
15 | COVERAGE=BriefSummary rspec
16 | COVERAGE=MarkdownSummary rspec
17 |
18 | # Multiple reports (comma-separated)
19 | COVERAGE=PartialSummary,BriefSummary rspec
20 | ```
21 |
22 | ## Configuration File
23 |
24 | For advanced configuration, create a `config/covered.rb` file in your project:
25 |
26 | ~~~ ruby
27 | # config/covered.rb
28 |
29 | def ignore_paths
30 | super + ["engines/", "app/assets/", "db/migrate/"]
31 | end
32 |
33 | def include_patterns
34 | super + ["bake/**/*.rb", "engines/**/*.rb"]
35 | end
36 |
37 | def make_policy(policy)
38 | super
39 |
40 | # Custom policy configuration
41 | policy.skip(/\/generated\//)
42 | policy.include("lib/templates/**/*.erb")
43 | end
44 | ~~~
45 |
46 | ## Environment Variables
47 |
48 | Covered uses several environment variables for configuration:
49 |
50 | | Variable | Description | Default |
51 | |----------------|-----------------------------------|--------------------|
52 | | `COVERAGE` | Report types to generate | `nil` (no reports) |
53 | | `COVERED_ROOT` | Project root directory | `Dir.pwd` |
54 | | `RUBYOPT` | Modified internally for autostart | Current value |
55 |
56 | ### Examples
57 |
58 | ``` bash
59 | # Disable coverage entirely
60 | unset COVERAGE
61 |
62 | # Enable default report (BriefSummary)
63 | COVERAGE=true
64 |
65 | # Custom project root
66 | COVERED_ROOT=/path/to/project COVERAGE=PartialSummary rspec
67 | ```
68 |
69 | ## Report Types
70 |
71 | Covered provides several built-in report types:
72 |
73 | ### Summary Reports
74 |
75 | - **`Summary`** - Full detailed coverage report with line-by-line analysis
76 | - **`FullSummary`** - Complete coverage without threshold filtering
77 | - **`BriefSummary`** - Shows overall statistics + top 5 files with least coverage
78 | - **`PartialSummary`** - Shows only code segments with incomplete coverage + lists 100% covered files
79 | - **`MarkdownSummary`** - Coverage report formatted as Markdown
80 | - **`Quiet`** - Suppresses all output
81 |
82 | ### Usage Examples
83 |
84 | ``` ruby
85 | # In config/covered.rb
86 | def make_policy(policy)
87 | super
88 |
89 | # Add multiple reports
90 | policy.reports << Covered::PartialSummary.new
91 | policy.reports << Covered::MarkdownSummary.new(threshold: 0.8)
92 | end
93 | ```
94 |
95 | ## Path Configuration
96 |
97 | ### Ignoring Paths
98 |
99 | Default ignored paths include `test/`, `spec/`, `fixtures/`, `vendor/`, and `config/`. Customize with:
100 |
101 | ``` ruby
102 | def ignore_paths
103 | super + [
104 | "engines/", # Engine directories
105 | "app/assets/", # Asset files
106 | "db/migrate/", # Database migrations
107 | "tmp/", # Temporary files
108 | "log/", # Log files
109 | "coverage/", # Coverage output
110 | "public/packs/" # Webpack outputs
111 | ]
112 | end
113 | ```
114 |
115 | ### Including Patterns
116 |
117 | Default includes `lib/**/*.rb`. Extend for additional patterns:
118 |
119 | ``` ruby
120 | def include_patterns
121 | super + [
122 | "app/**/*.rb", # Application code
123 | "bake/**/*.rb", # Bake tasks
124 | "engines/**/*.rb", # Engine code
125 | "lib/templates/**/*.erb" # Template files (Ruby 3.2+)
126 | ]
127 | end
128 | ```
129 |
130 | ## Advanced Policy Configuration
131 |
132 | The `make_policy` method provides fine-grained control:
133 |
134 | ``` ruby
135 | def make_policy(policy)
136 | super
137 |
138 | # Filter by regex patterns
139 | policy.skip(/\/generated\//) # Skip generated files
140 | policy.skip(/\.generated\.rb$/) # Skip files ending in .generated.rb
141 |
142 | # Include specific files
143 | policy.include("config/application.rb")
144 |
145 | # Only track specific patterns
146 | policy.only(/^app\//)
147 |
148 | # Set custom root
149 | policy.root(File.expand_path('..', __dir__))
150 |
151 | # Enable persistent coverage across runs
152 | policy.persist!
153 |
154 | # Configure reports programmatically
155 | if ENV['CI']
156 | policy.reports << Covered::MarkdownSummary.new
157 | else
158 | policy.reports << Covered::PartialSummary.new
159 | end
160 | end
161 | ```
162 |
163 | ## Common Configuration Patterns
164 |
165 | ### Rails Applications
166 |
167 | ``` ruby
168 | def ignore_paths
169 | super + [
170 | "app/assets/",
171 | "db/migrate/",
172 | "db/seeds.rb",
173 | "config/environments/",
174 | "config/initializers/",
175 | "tmp/",
176 | "log/",
177 | "public/",
178 | "storage/"
179 | ]
180 | end
181 |
182 | def include_patterns
183 | super + [
184 | "app/**/*.rb",
185 | "lib/**/*.rb",
186 | "config/application.rb",
187 | "config/routes.rb"
188 | ]
189 | end
190 | ```
191 |
192 | ### Gem Development
193 |
194 | ``` ruby
195 | def ignore_paths
196 | super + ["examples/", "benchmark/"]
197 | end
198 |
199 | def include_patterns
200 | super + ["bin/**/*.rb"]
201 | end
202 |
203 | def make_policy(policy)
204 | super
205 |
206 | # Only track the gem's main code
207 | policy.only(/^lib\//)
208 | end
209 | ```
210 |
211 | ### Monorepo/Multi-Engine
212 |
213 | ``` ruby
214 | def ignore_paths
215 | super + [
216 | "engines/*/spec/",
217 | "engines/*/test/",
218 | "shared/fixtures/"
219 | ]
220 | end
221 |
222 | def include_patterns
223 | super + [
224 | "engines/*/lib/**/*.rb",
225 | "engines/*/app/**/*.rb",
226 | "shared/lib/**/*.rb"
227 | ]
228 | end
229 | ```
230 |
231 | ## Template Coverage (Ruby 3.2+)
232 |
233 | For projects using ERB, Haml, or other template engines:
234 |
235 | ``` ruby
236 | def include_patterns
237 | super + [
238 | "app/views/**/*.erb",
239 | "lib/templates/**/*.erb",
240 | "app/views/**/*.haml"
241 | ]
242 | end
243 |
244 | def make_policy(policy)
245 | super
246 |
247 | # Ensure template coverage is enabled
248 | require 'covered/erb' if defined?(ERB)
249 | end
250 | ```
251 |
252 | ## CI/CD Integration
253 |
254 | ### GitHub Actions
255 |
256 | ``` ruby
257 | def make_policy(policy)
258 | super
259 |
260 | if ENV['GITHUB_ACTIONS']
261 | # Use markdown format for GitHub
262 | policy.reports << Covered::MarkdownSummary.new
263 |
264 | # Fail build on low coverage
265 | policy.reports << Class.new do
266 | def call(wrapper, output = $stdout)
267 | statistics = wrapper.each.inject(Statistics.new) { |s, c| s << c }
268 | if statistics.ratio < 0.90
269 | exit 1
270 | end
271 | end
272 | end.new
273 | end
274 | end
275 | ```
276 |
277 | ### Custom Thresholds
278 |
279 | ``` ruby
280 | def make_policy(policy)
281 | super
282 |
283 | # Different thresholds for different environments
284 | threshold = case ENV['RAILS_ENV']
285 | when 'production' then 0.95
286 | when 'staging' then 0.90
287 | else 0.80
288 | end
289 |
290 | policy.reports << Covered::Summary.new(threshold: threshold)
291 | end
292 | ```
293 |
294 | ## Performance Optimization
295 |
296 | For large codebases:
297 |
298 | ``` ruby
299 | def make_policy(policy)
300 | super
301 |
302 | # Skip large generated directories
303 | policy.skip(/\/node_modules\//)
304 | policy.skip(/\/vendor\/bundle\//)
305 | policy.skip(/\/coverage\//)
306 |
307 | # Use more efficient reports for large projects
308 | if Dir['**/*.rb'].length > 1000
309 | policy.reports << Covered::BriefSummary.new
310 | else
311 | policy.reports << Covered::PartialSummary.new
312 | end
313 | end
314 | ```
315 |
316 | ## Troubleshooting
317 |
318 | ### Common Issues
319 |
320 | **Coverage not working:**
321 | - Ensure `require 'covered/rspec'` (or similar) is at the top of your test helper
322 | - Check that `COVERAGE` environment variable is set
323 | - Verify the configuration file path is correct: `config/covered.rb`
324 |
325 | **Missing files in reports:**
326 | - Files must be required/loaded during test execution to be tracked
327 | - Use `include_patterns` to track files not loaded by tests
328 | - Check `ignore_paths` isn't excluding desired files
329 |
330 | **Performance issues:**
331 | - Use `BriefSummary` instead of `Summary` for large codebases
332 | - Add more specific patterns to `ignore_paths`
333 | - Use `policy.only()` to limit scope
334 |
335 | **Template coverage not working:**
336 | - Requires Ruby 3.2+ for full template support
337 | - Ensure template engines are loaded before coverage starts
338 | - Check that template files match `include_patterns`
339 |
340 | ### Debug Configuration
341 |
342 | ``` ruby
343 | def make_policy(policy)
344 | super
345 |
346 | # Debug: print current configuration
347 | if ENV['DEBUG_COVERAGE']
348 | puts "Ignore paths: #{ignore_paths}"
349 | puts "Include patterns: #{include_patterns}"
350 | puts "Root: #{@root}"
351 | end
352 | end
353 | ```
354 |
355 | See the {ruby Covered::Config} class for complete API documentation.
356 |
--------------------------------------------------------------------------------