├── .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 | 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 | ![Screenshot](media/example.png) 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 | [![Development Status](https://github.com/socketry/covered/workflows/Test/badge.svg)](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 | --------------------------------------------------------------------------------