Library API Guide¶
Use this gem programmatically to inspect coverage without running the CLI or MCP server. The primary entry point is CovLoupe::CoverageModel.
Table of Contents¶
Quick Start¶
require "cov_loupe"
# Defaults (omit args; shown here with comments):
# - root: "."
# - resultset: resolved from common paths under root
# - raise_on_stale: false (don't raise on stale data)
# - tracked_globs: nil (no project-level file-set checks)
model = CovLoupe::CoverageModel.new
# Custom configuration (non-default values):
model = CovLoupe::CoverageModel.new(
root: File.join(Dir.home, 'project'), # non-default project root
resultset: "build/coverage", # file or directory containing .resultset.json
raise_on_stale: true, # enable strict staleness checks (raise on stale)
tracked_globs: ["lib/cov_loupe/tools/**/*.rb"] # for 'list' staleness: flag new/missing files
)
# List all files with coverage summary
files = model.list
# Per-file queries
target = 'lib/cov_loupe/base_tool.rb'
summary = model.summary_for(target)
uncovered = model.uncovered_for(target)
detailed = model.detailed_for(target)
raw = model.raw_for(target)
Method Reference¶
list(sort_order: :descending, raise_on_stale: nil, tracked_globs: nil)¶
Returns coverage summary for all files in the resultset.
Parameters: - sort_order (Symbol, optional): :descending (default) or :ascending by coverage percentage - raise_on_stale (Boolean, optional): Whether to raise error if project is stale. Defaults to model setting. - tracked_globs (Array
Returns: Array<Hash> - See list return type
Example:
files = model.list
# => [ { 'file' => '/abs/path/lib/foo.rb', 'covered' => 12, 'total' => 14, 'percentage' => 85.71, 'stale' => false }, ... ]
# Get worst coverage first
worst_files = model.list(sort_order: :ascending).first(10)
# Force staleness check
model.list(raise_on_stale: true)
summary_for(path)¶
Returns coverage summary for a specific file.
Parameters: - path (String): File path (absolute, relative to root, or basename)
Returns: Hash - See summary_for return type
Raises: CovLoupe::FileError if file not in coverage data
Example:
summary = model.summary_for(target)
# => { 'file' => '/abs/.../lib/foo.rb', 'summary' => {'covered'=>12, 'total'=>14, 'percentage'=>85.71}, 'stale' => false }
uncovered_for(path)¶
Returns list of uncovered line numbers for a specific file.
Parameters: - path (String): File path (absolute, relative to root, or basename)
Returns: Hash - See uncovered_for return type
Raises: CovLoupe::FileError if file not in coverage data
Example:
uncovered = model.uncovered_for("lib/foo.rb")
# => { 'file' => '/abs/.../lib/foo.rb', 'uncovered' => [5, 9, 12], 'summary' => { ... }, 'stale' => false }
detailed_for(path)¶
Returns per-line coverage details with hit counts.
Parameters: - path (String): File path (absolute, relative to root, or basename)
Returns: Hash - See detailed_for return type
Raises: CovLoupe::FileError if file not in coverage data
Example:
detailed = model.detailed_for("lib/foo.rb")
# => { 'file' => '/abs/.../lib/foo.rb', 'lines' => [{'line' => 1, 'hits' => 1, 'covered' => true}, ...], 'summary' => { ... }, 'stale' => false }
raw_for(path)¶
Returns raw SimpleCov lines array for a specific file.
Parameters: - path (String): File path (absolute, relative to root, or basename)
Returns: Hash - See raw_for return type
Raises: CovLoupe::FileError if file not in coverage data
Example:
raw = model.raw_for("lib/foo.rb")
# => { 'file' => '/abs/.../lib/foo.rb', 'lines' => [nil, 1, 0, 3, ...], 'stale' => false }
format_table(rows = nil, sort_order: :descending, raise_on_stale: nil, tracked_globs: nil)¶
Generates formatted ASCII table string.
Parameters: - rows (Arraylist - sort_order (Symbol, optional): :descending (default) or :ascending - raise_on_stale (Boolean, optional): Whether to raise error if project is stale. Defaults to model setting. - tracked_globs (Array
Returns: String - Formatted table with Unicode borders
Example:
# Default: all files
table = model.format_table
puts table
# Custom rows
lib_files = model.list['files'].select { |f| f['file'].include?('/lib/') }
lib_table = model.format_table(lib_files, sort_order: :descending)
puts lib_table
project_totals(tracked_globs: nil, raise_on_stale: nil)¶
Returns aggregated coverage totals across all files.
Parameters: - tracked_globs (Arrayraise_on_stale (Boolean, optional): Whether to raise error if project is stale. Defaults to model setting.
Returns: Hash - See project_totals return type
Example:
totals = model.project_totals
# => { 'lines' => { 'total' => 123, 'covered' => 100, 'uncovered' => 23 }, 'percentage' => 81.3, 'files' => { 'total' => 5, 'ok' => 4, 'stale' => 1 } }
# Filter to specific directory
lib_totals = model.project_totals(tracked_globs: 'lib/**/*.rb')
relativize(data)¶
Converts absolute file paths in coverage data to relative paths from project root.
Parameters: - data (Hash or Array
Returns: Hash or Array<Hash> - Same structure with relative paths
Example:
summary = model.summary_for('lib/cov_loupe/model.rb')
# => { 'file' => '/path/to/project/lib/cov_loupe/model.rb', ... }
relative_summary = model.relativize(summary)
# => { 'file' => 'lib/cov_loupe/model.rb', ... }
# Works with arrays too
files = model.list
relative_files = model.relativize(files)
Return Types¶
list¶
Returns Array<Hash> where each hash contains:
{
'file' => String, # Absolute file path
'covered' => Integer, # Number of covered lines
'total' => Integer, # Total relevant lines
'percentage' => Float, # Coverage percentage (0.00-100.00)
'stale' => false | String # Staleness indicator: false, 'M', 'T', or 'L'
}
summary_for¶
Returns Hash:
{
'file' => String, # Absolute file path
'summary' => {
'covered' => Integer, # Number of covered lines
'total' => Integer, # Total relevant lines
'percentage' => Float # Coverage percentage (0.00-100.00)
}
}
uncovered_for¶
Returns Hash:
{
'file' => String, # Absolute file path
'uncovered' => Array<Integer>, # Line numbers that are not covered
'summary' => {
'covered' => Integer,
'total' => Integer,
'percentage' => Float
}
}
detailed_for¶
Returns Hash:
{
'file' => String, # Absolute file path
'lines' => Array<Hash>, # Per-line coverage details
'summary' => {
'covered' => Integer,
'total' => Integer,
'percentage' => Float
}
}
Each element in lines array:
{
'line' => Integer, # Line number (1-indexed)
'hits' => Integer, # Execution count (0 means not covered)
'covered' => Boolean # true if hits > 0
}
raw_for¶
Returns Hash:
{
'file' => String, # Absolute file path
'lines' => Array<Integer | nil> # SimpleCov lines array (nil = irrelevant, 0 = uncovered, >0 = hit count)
}
project_totals¶
Returns Hash:
{
'lines' => {
'total' => Integer, # Total relevant lines across all files
'covered' => Integer, # Total covered lines
'uncovered' => Integer # Total uncovered lines
},
'percentage' => Float, # Overall coverage percentage
'files' => {
'total' => Integer, # Total number of files
'ok' => Integer, # Files with fresh coverage
'stale' => Integer # Files with stale coverage
}
}
Error Handling¶
Exception Types¶
The library raises these custom exceptions:
CovLoupe::ResultsetNotFoundError- Coverage data file not foundCovLoupe::FileError- Requested file not in coverage dataCovLoupe::CoverageDataStaleError- Coverage data is stale (only whenraise_on_stale: true)CovLoupe::CoverageDataError- Invalid coverage data format or structure
All exceptions inherit from CovLoupe::Error.
Basic Error Handling¶
require "cov_loupe"
begin
model = CovLoupe::CoverageModel.new
summary = model.summary_for("lib/foo.rb")
puts "Coverage: #{summary['summary']['percentage']}%"
rescue CovLoupe::FileError => e
puts "File not in coverage data: #{e.message}"
rescue CovLoupe::ResultsetNotFoundError => e
puts "Coverage data not found: #{e.message}"
puts "Run your tests first: bundle exec rspec"
rescue CovLoupe::Error => e
puts "Coverage error: #{e.message}"
end
Handling Stale Coverage¶
# Option 1: Check staleness without raising
model = CovLoupe::CoverageModel.new(raise_on_stale: false)
files = model.list
stale_files = files.select { |f| f['stale'] }
if stale_files.any?
puts "Warning: #{stale_files.length} files have stale coverage"
stale_files.each do |f|
puts " #{f['file']}: #{f['stale']}"
end
end
# Option 2: Raise on staleness
begin
model = CovLoupe::CoverageModel.new(raise_on_stale: true)
files = model.list
rescue CovLoupe::CoverageDataStaleError => e
puts "Stale coverage detected: #{e.message}"
puts "Re-run tests: bundle exec rspec"
exit 1
end
Graceful Degradation¶
# Try multiple file paths
def find_coverage(model, possible_paths)
possible_paths.each do |path|
begin
return model.summary_for(path)
rescue CovLoupe::FileError
next
end
end
nil
end
summary = find_coverage(model, [
"lib/services/auth_service.rb",
"app/services/auth_service.rb",
"services/auth_service.rb"
])
if summary
puts "Coverage: #{summary['summary']['percentage']}%"
else
puts "File not found in coverage data"
end
Advanced Recipes¶
Batch File Analysis¶
require "cov_loupe"
model = CovLoupe::CoverageModel.new
# Analyze multiple files efficiently
files_to_check = [
"lib/auth_service.rb",
"lib/payment_processor.rb",
"lib/user_manager.rb"
]
results = files_to_check.map do |path|
begin
summary = model.summary_for(path)
{
file: path,
coverage: summary['summary']['percentage'],
status: summary['summary']['percentage'] >= 80 ? :ok : :low
}
rescue CovLoupe::FileError
{
file: path,
coverage: nil,
status: :missing
}
end
end
# Report
results.each do |r|
status_icon = { ok: '✓', low: '⚠', missing: '✗' }[r[:status]]
puts "#{status_icon} #{r[:file]}: #{r[:coverage] || 'N/A'}%"
end
Coverage Threshold Validation¶
require "cov_loupe"
class CoverageValidator
THRESHOLDS = {
'lib/api/' => 90.0, # API layer needs 90%+
'app/models/' => 85.0, # Models need 85%+
'app/controllers/' => 75.0, # Controllers need 75%+
}
def initialize(model)
@model = model
end
def validate!
files = @model.list
failures = []
files.each do |file|
threshold = threshold_for(file['file'])
next unless threshold
if file['percentage'] < threshold
failures << {
file: file['file'],
actual: file['percentage'],
required: threshold,
gap: threshold - file['percentage']
}
end
end
if failures.any?
puts "❌ #{failures.length} files below coverage threshold:"
failures.sort_by { |f| -f[:gap] }.each do |f|
puts " #{f[:file]}: #{f[:actual]}% (need #{f[:required]}%)"
end
exit 1
else
puts "✓ All files meet coverage thresholds"
end
end
private
def threshold_for(path)
THRESHOLDS.each do |prefix, threshold|
return threshold if path.include?(prefix)
end
nil
end
end
model = CovLoupe::CoverageModel.new
validator = CoverageValidator.new(model)
validator.validate!
Directory-Level Aggregation¶
require "cov_loupe"
model = CovLoupe::CoverageModel.new
# Calculate coverage by directory using the totals API
patterns = %w[lib/cov_loupe/tools/**/*.rb lib/cov_loupe/commands/**/*.rb lib/cov_loupe/presenters/**/*.rb]
directory_stats = patterns.map do |pattern|
totals = model.project_totals(tracked_globs: pattern)
{
directory: pattern,
files: totals['files']['total'],
coverage: totals['percentage'].round(2),
covered: totals['lines']['covered'],
total: totals['lines']['total']
}
end
# Display sorted by coverage
directory_stats.sort_by { |s| s[:coverage] }.each do |stat|
puts "#{stat[:directory]}: #{stat[:coverage]}% (#{stat[:files]} files)"
end
Coverage Delta Tracking¶
require "cov_loupe"
require "json"
class CoverageDeltaTracker
def initialize(baseline_path: "coverage_baseline.json")
@baseline_path = baseline_path
@model = CovLoupe::CoverageModel.new
end
def save_baseline
current = @model.list
File.write(@baseline_path, JSON.pretty_generate(current))
puts "Saved coverage baseline (#{current.length} files)"
end
def compare
unless File.exist?(@baseline_path)
puts "No baseline found. Run save_baseline first."
return
end
baseline = JSON.parse(File.read(@baseline_path))
current = @model.list
improved = []
regressed = []
current.each do |file|
baseline_file = baseline.find { |f| f['file'] == file['file'] }
next unless baseline_file
delta = file['percentage'] - baseline_file['percentage']
if delta > 0.1
improved << {
file: file['file'],
before: baseline_file['percentage'],
after: file['percentage'],
delta: delta
}
elsif delta < -0.1
regressed << {
file: file['file'],
before: baseline_file['percentage'],
after: file['percentage'],
delta: delta
}
end
end
if improved.any?
puts "\n✓ Coverage Improvements:"
improved.sort_by { |f| -f[:delta] }.each do |f|
puts " #{f[:file]}: #{f[:before]}% → #{f[:after]}% (+#{f[:delta].round(2)}%)"
end
end
if regressed.any?
puts "\n⚠ Coverage Regressions:"
regressed.sort_by { |f| f[:delta] }.each do |f|
puts " #{f[:file]}: #{f[:before]}% → #{f[:after]}% (#{f[:delta].round(2)}%)"
end
end
if improved.empty? && regressed.empty?
puts "No significant coverage changes"
end
end
end
# Usage
tracker = CoverageDeltaTracker.new
tracker.save_baseline # Run before making changes
# ... make code changes and re-run tests ...
tracker.compare # See what changed
Custom Reporting¶
require "cov_loupe"
class CoverageReporter
def initialize(model)
@model = model
end
def generate_markdown_report(output_path)
files = @model.list
totals = @model.project_totals
# Overall stats
overall_percentage = totals['percentage']
total_lines = totals['lines']['total']
covered_lines = totals['lines']['covered']
total_files = totals['files']['total']
# Files below threshold
threshold = 80.0
low_coverage = files.select { |file| file['percentage'] < threshold }
# Build low coverage table
low_coverage_section = if low_coverage.any?
rows = low_coverage.sort_by { |file| file['percentage'] }.map do |file|
uncovered = @model.uncovered_for(file['file'])
missing_count = uncovered['uncovered'].length
"| #{file['file']} | #{file['percentage']}% | #{missing_count} |"
end.join("\n")
<<~LOW_COVERAGE_TABLE
## Files Below #{threshold}% Coverage
| File | Coverage | Missing Lines |
|------|----------|---------------|
#{rows}
LOW_COVERAGE_TABLE
else
""
end
# Build top performers table
top_rows = files.sort_by { |file| -file['percentage'] }.take(10).map do |file|
"| #{file['file']} | #{file['percentage']}% |"
end.join("\n")
# Generate report
report = <<~COVERAGE_REPORT
# Coverage Report
Generated: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}
## Overall Coverage: #{overall_percentage}%
- Total Files: #{total_files}
- Total Lines: #{total_lines}
- Covered Lines: #{covered_lines}
#{low_coverage_section}
## Top 10 Best Covered Files
| File | Coverage |
|------|----------|
#{top_rows}
COVERAGE_REPORT
File.write(output_path, report)
puts "Report saved to #{output_path}"
end
end
model = CovLoupe::CoverageModel.new
reporter = CoverageReporter.new(model)
reporter.generate_markdown_report("coverage_report.md")
Staleness Detection¶
The list method returns a 'stale' field for each file with one of these values:
false- Coverage data is current'M'- Missing: File no longer exists on disk'T'- Timestamp: File modified more recently than coverage data'L'- Length: Source file line count differs from coverage data
Note: Per-file methods (summary_for, uncovered_for, detailed_for, raw_for) do not include staleness information in their return values. To check staleness for individual files, use list and filter the results.
When raise_on_stale: true is enabled in CoverageModel.new, the model will raise CovLoupe::CoverageDataStaleError exceptions when stale files are detected during method calls.
Related Documentation¶
- Examples - Practical cookbook-style examples
- CLI Usage - Command-line interface reference
- Error Handling - Detailed error handling documentation
- MCP Integration - AI assistant integration