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: [] (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
list_result = model.list
files = list_result['files']
# 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: [])¶
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: Hash - See list return type
Example:
list_result = model.list
files = list_result['files']
# => [ { 'file' => '/abs/path/lib/foo.rb', 'covered' => 12, 'total' => 14, 'percentage' => 85.71, 'stale' => "ok" }, ... ]
# Get worst coverage first
worst_files = model.list(sort_order: :ascending)['files'].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 or relative to root)
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} }
uncovered_for(path)¶
Returns list of uncovered line numbers for a specific file.
Parameters: - path (String): File path (absolute or relative to root)
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' => { ... } }
detailed_for(path)¶
Returns per-line coverage details with hit counts.
Parameters: - path (String): File path (absolute or relative to root)
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' => { ... } }
raw_for(path)¶
Returns raw SimpleCov lines array for a specific file.
Parameters: - path (String): File path (absolute or relative to root)
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, ...] }
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: [], 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, 'percent_covered' => 81.3 },
# 'tracking' => { 'enabled' => true, 'globs' => ['lib/**/*.rb'] },
# 'files' => { 'total' => 4, 'with_coverage' => { 'total' => 4, 'ok' => 4, 'stale' => { ... } } }
# }
# Filter to specific directory
lib_totals = model.project_totals(tracked_globs: 'lib/**/*.rb')
When raise_on_stale: true is set, the method raises on stale coverage instead of returning totals. Otherwise, totals exclude stale files (M, T, L, E) from line counts and report stale breakdowns under files['with_coverage']['stale'].
Note: The without_coverage hash will only be present if tracked_globs were specified.
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
list_result = model.list
files = list_result['files']
relative_files = model.relativize(files)
Return Types¶
list¶
Returns Hash with file data and staleness metadata:
{
'files' => [
{
'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' => String # Staleness indicator: "ok", "error", "missing", "newer", or "length_mismatch"
}
],
'skipped_files' => Array<String>, # Files skipped due to coverage errors
'missing_tracked_files' => Array<String>,# Tracked files missing from coverage
'newer_files' => Array<String>, # Files newer than coverage
'deleted_files' => Array<String>, # Coverage entries for deleted files
'length_mismatch_files' => Array<String>,# Files whose line counts differ from coverage
'unreadable_files' => Array<String> # Files that could not be read
}
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
'percent_covered' => Float # Overall percent covered
},
'tracking' => {
'enabled' => Boolean, # Whether tracked_globs are active
'globs' => Array<String> # Active tracked globs (empty when disabled)
},
'files' => {
'total' => Integer, # Total number of files (with + without coverage)
'with_coverage' => {
'total' => Integer, # Files with coverage entries
'ok' => Integer, # Fresh coverage entries
'stale' => {
'total' => Integer, # Stale coverage entries
'by_type' => {
'missing_from_disk' => Integer,
'newer' => Integer,
'length_mismatch' => Integer,
'unreadable' => Integer
}
}
},
'without_coverage' => {
'total' => Integer, # Tracked files missing coverage entries
'by_type' => {
'missing_from_coverage' => Integer,
'unreadable' => Integer,
'skipped' => Integer
}
}
}
}
Note: The without_coverage hash will only be present if tracked_globs were specified.
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['files']
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['files']
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['files']
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['lines']['percent_covered'].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['files']
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['files']
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['files']
totals = @model.project_totals
# Overall stats
overall_percentage = totals['lines']['percent_covered']
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")
Per-Model Context (Advanced)¶
By default, all CoverageModel instances share the global context for error handling and logging. For advanced scenarios where you need different models with different logging or error handling configurations in the same process, you can pass a custom context to each model.
require "cov_loupe"
# Scenario: Analyzing coverage for multiple projects in one script
# Project A: Detailed logging for debugging
context_a = CovLoupe.create_context(
error_handler: CovLoupe::ErrorHandlerFactory.for_library,
log_target: 'project_a_coverage.log'
)
model_a = CovLoupe::CoverageModel.new(
root: '/path/to/project_a',
resultset: '/path/to/project_a/coverage/.resultset.json',
context: context_a
)
# Project B: Different log file
context_b = context_a.with(log_target: 'project_b_coverage.log')
model_b = CovLoupe::CoverageModel.new(
root: '/path/to/project_b',
resultset: '/path/to/project_b/coverage/.resultset.json',
context: context_b
)
# Each model logs to its own file
summary_a = model_a.summary_for('lib/foo.rb') # Logs to project_a_coverage.log
summary_b = model_b.summary_for('lib/bar.rb') # Logs to project_b_coverage.log
# You can also change a model's context at runtime
model_a.context = CovLoupe.context # Switch to global context
When to use per-model contexts: - Managing coverage for multiple projects in one script - Different error handling strategies per model - Separate log files for different data sources - Testing scenarios requiring isolated configurations
Simple use case (most common):
# For most use cases, just configure the global context once
CovLoupe.error_handler = CovLoupe::ErrorHandlerFactory.for_library
CovLoupe.default_log_file = 'coverage_analysis.log'
# All models automatically use the global context
model = CovLoupe::CoverageModel.new
Staleness Detection¶
The list method returns a 'stale' field for each file with one of these values:
"ok"- Coverage data is current"missing"- Missing: File no longer exists on disk"newer"- Timestamp: File modified more recently than coverage data"length_mismatch"- Length: Source file line count differs from coverage data"error"- Error: Staleness check failed
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