V4.0 Breaking Changes Guide¶
This document describes the breaking changes introduced in version 4.0.0. These changes affect the CLI flags for mode selection and staleness checks, as well as a method rename in the Ruby API.
Table of Contents¶
- CLI Changes
- MCP Mode Now Requires Explicit
-m/--mode mcpFlag - Unified Stale Coverage Enforcement
--raise-on-stale/-S- Explicit Value Required--color/-C- Explicit Value Required--tracked-globsDefault Changed to Empty Array- Ruby API Changes
- CoverageLineResolver Now Requires
root:andvolume_case_sensitive: - Method Renamed
- Return Type Changed:
listNow Returns a Hash - Return Type Changed:
project_totalsSchema Updated - Logger Initialization Changed
- Deleted Files Now Raise
FileNotFoundError - Staleness Indicators Changed from Strings to Symbols
- Removed Branch-Only Coverage Support
- Getting Help
CLI Changes¶
⚠️ MCP Mode Now Requires Explicit -m/--mode mcp Flag¶
BREAKING: Automatic mode detection has been removed. The -m/--mode mcp flag is now required to run cov-loupe as an MCP server.
Previous Behavior (v3.x)¶
- cov-loupe automatically detected MCP mode based on TTY/stdin status
--force-modecould override detection (values:cli,mcp,auto)
New Behavior (v4.x)¶
- No automatic detection - mode defaults to
cli -m mcpor--mode mcpis required for MCP server mode- Accepted values:
cli(default) ormcp
Migration for MCP Users¶
If you use cov-loupe as an MCP server, you MUST update your configuration:
- Remove the old entry (see MCP Integration Guide - Setup by Client for removal commands with proper
--scopeoptions) - Add the new entry with
-m mcpflag:
# Claude Code
claude mcp add cov-loupe cov-loupe -- -m mcp
# Codex
codex mcp add cov-loupe cov-loupe -m mcp
# Gemini
gemini mcp add cov-loupe cov-loupe -- -m mcp
Without -m mcp or --mode mcp, the server will run in CLI mode and hang waiting for subcommands.
Migration for CLI Users¶
CLI users are unaffected. The default mode is cli, so no changes are needed. However: - --force-cli removed → use -m cli or --mode cli if you need to be explicit (rare) - --force-mode removed → use -m/--mode instead
Unified Stale Coverage Enforcement¶
The staleness checking logic has been unified into a single flag that raises an error if any staleness is detected.
- Old:
--staleness/check_stale(inconsistent behavior) - New:
--raise-on-stale(boolean)
Behavior¶
--raise-on-stale true(orraise_on_stale: true): The command will exit with an error code if any file in the result set is stale or if the project totals are stale.- Default (false): Staleness is reported in the output (e.g., status
M,T,L), but the command returns success (unless other errors occur).
Migration¶
- If you relied on previous flags to enforce staleness checks, switch to
--raise-on-stale trueor-S true.
IMPORTANT: As of v4.0.0, boolean flags now require explicit values for consistency.
--raise-on-stale / -S - Explicit Value Required¶
- Old (no longer works):
--raise-on-stale,-S - New (required):
--raise-on-stale true,-S true,--raise-on-stale=yes, etc.
--color / -C - Explicit Value Required¶
- Old (no longer works):
--color,-C - New (required):
--color true,-C true,--color=on, etc.
These changes improve consistency between short and long flag forms and eliminate ambiguous behavior where long-form bare flags would fail but short-form bare flags would succeed.
⚠️ --tracked-globs Default Changed to Empty Array¶
BREAKING: The --tracked-globs CLI option now defaults to [] (empty) instead of lib/**/*.rb,app/**/*.rb,src/**/*.rb. The Ruby API (CoverageModel) now also defaults tracked_globs: to [] (previously nil, which behaved the same).
This affects: - CLI: cov-loupe list (without --tracked-globs) - Ruby API: CoverageModel.new (for consistency, though behavior is unchanged)
Previous Behavior (v4.x early versions)¶
--tracked-globsCLI option defaulted tolib/**/*.rb,app/**/*.rb,src/**/*.rb- Files outside these patterns were silently excluded from CLI output
missing_tracked_files(inlist) included any tracked files not in coverage
New Behavior (v4.x current)¶
--tracked-globsdefaults to[](empty)- Shows all files in the resultset without filtering
- No files are flagged as missing unless you explicitly set globs
Rationale¶
The previous default caused three problems: 1. Silent exclusions: Coverage results for files not matching the default patterns (e.g., config/, custom directories) were hidden 2. False positives: Files like migrations, bin scripts, etc. were incorrectly flagged as "missing" 3. Wrong assumptions: Not all projects use lib/ and app/ - some use src/, others have custom structures
The new default shows all coverage data transparently without making assumptions about your project structure.
Migration Steps¶
For CLI usage (if you want the old filtering behavior with lib/**/*.rb,app/**/*.rb,src/**/*.rb):
Set COV_LOUPE_OPTS to match your SimpleCov track_files configuration:
# In spec_helper.rb or similar
SimpleCov.start do
add_filter '/spec/'
track_files 'lib/**/*.rb'
track_files 'app/**/*.rb'
end
# In your shell config (.bashrc, .zshrc, etc.)
export COV_LOUPE_OPTS="--tracked-globs lib/**/*.rb,app/**/*.rb"
For Ruby API usage:
No functional changes needed, but the default signature has changed for consistency. The Ruby API now defaults tracked_globs: [] (previously nil). Both behave identically, so existing code works unchanged:
# Default behavior (behavior unchanged, signature updated for consistency)
model = CovLoupe::CoverageModel.new(root: '.')
result = model.list # tracked_globs: [] → no filtering
# Explicit globs for filtering and tracking
model = CovLoupe::CoverageModel.new(
root: '.',
tracked_globs: ['lib/**/*.rb', 'app/**/*.rb']
)
result = model.list # Uses explicit globs
If you're fine with seeing all files in the resultset (and only files in the resultset) (no action needed for CLI or Ruby API): - The new default shows all files that have coverage data - No filtering applied, but also no detection of files lacking coverage data
Important Note¶
Files lacking any coverage at all (not loaded during tests) will not appear in the resultset and therefore won't be visible with the default empty array. To detect such files, you must set --tracked-globs to match the files you expect to have coverage.
Ruby API Changes¶
CoverageLineResolver Now Requires root: and volume_case_sensitive:¶
Breaking Change: CovLoupe::Resolvers::CoverageLineResolver now requires root: and volume_case_sensitive: keyword arguments, and CovLoupe::Resolvers::ResolverHelpers.lookup_lines / create_coverage_resolver now require these parameters as well.
Migration¶
# Old
resolver = CovLoupe::Resolvers::CoverageLineResolver.new(cov_data)
lines = CovLoupe::Resolvers::ResolverHelpers.lookup_lines(cov_data, abs_path)
# New
root = '/path/to/project'
volume_case_sensitive = CovLoupe::PathUtils.volume_case_sensitive?(root)
resolver = CovLoupe::Resolvers::CoverageLineResolver.new(cov_data, root: root, volume_case_sensitive: volume_case_sensitive)
lines = CovLoupe::Resolvers::ResolverHelpers.lookup_lines(cov_data, abs_path, root: root, volume_case_sensitive: volume_case_sensitive)
Note: If you're using CoverageModel (recommended), this is handled automatically - the model detects volume case-sensitivity during initialization based on the project root and passes it to resolvers internally.
Method Renamed¶
- Old:
CoverageModel#all_files_coverage - New:
CoverageModel#list
Return Type Changed: list Now Returns a Hash¶
Breaking Change: CoverageModel#list now returns a hash containing comprehensive staleness information instead of just an array of file data.
Old Behavior (v3.x)¶
model = CovLoupe::CoverageModel.new(root: '.')
files = model.list # Returns array directly
# Filter and use the array
low_coverage = files.select { |f| f['percentage'] < 80 }
model.format_table(files)
New Behavior (v4.x)¶
model = CovLoupe::CoverageModel.new(root: '.')
result = model.list # Returns hash with multiple keys
# Access the files array
files = result['files']
# Filter and use the array
low_coverage = files.select { |f| f['percentage'] < 80 }
model.format_table(files)
# Access new staleness information
result['skipped_files'] # Files that raised errors during processing
result['missing_tracked_files'] # Files from tracked_globs not in coverage
result['newer_files'] # Files modified after coverage was generated
result['deleted_files'] # Files in coverage that no longer exist
Migration Steps¶
Option 1: Quick Fix (Extract files array)
Option 2: Leverage New Staleness Data
result = model.list
# Use the files array as before
files = result['files']
low_coverage = files.select { |f| f['percentage'] < 80 }
# Now you can also:
if result['skipped_files'].any?
warn "Warning: #{result['skipped_files'].size} files were skipped due to errors"
result['skipped_files'].each do |skip|
warn " #{skip['file']}: #{skip['error']}"
end
end
if result['newer_files'].any?
warn "Warning: #{result['newer_files'].size} files are newer than coverage data"
end
Impact on format_table¶
The format_table method still accepts an array of file hashes (not the full hash from list):
# Correct
files = model.list['files']
table = model.format_table(files)
# Also correct (passing nil gets all files)
table = model.format_table(nil)
# Incorrect - do not pass the full hash
result = model.list
table = model.format_table(result) # This will fail
Return Type Changed: project_totals Schema Updated¶
Breaking Change: CoverageModel#project_totals now returns a structured hash with explicit lines, tracking, and files sections. The top-level percentage and excluded_files fields were removed.
Old Behavior (v3.x)¶
totals = model.project_totals
# => {
# "lines" => { "total" => 123, "covered" => 100, "uncovered" => 23 },
# "percentage" => 81.3,
# "files" => { "total" => 4, "ok" => 4, "stale" => 0 },
# "excluded_files" => { ... }
# }
New Behavior (v4.x)¶
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" => { "total" => 0, "by_type" => { ... } } }
# }
# }
Migration Steps¶
- Replace
totals['percentage']withtotals['lines']['percent_covered']. - Replace
totals['files']['ok']andtotals['files']['stale']withtotals['files']['with_coverage']['ok']andtotals['files']['with_coverage']['stale']['total']. - If you relied on
excluded_files, usefiles.with_coverage.stale.by_typeandfiles.without_coverage.by_type(present only when tracking is enabled).
Logger Initialization Changed¶
The CovLoupe::Logger class has updated its initialize signature.
- Old:
initialize(target:, mcp_mode: false) - New:
initialize(target:, mode: :library)# or :cli or :mcp
Migration¶
If you are manually instantiating CovLoupe::Logger:
# Old
logger = CovLoupe::Logger.new(target: 'cov_loupe.log', mcp_mode: true)
logger = CovLoupe::Logger.new(target: 'cov_loupe.log', mcp_mode: false)
# New
logger = CovLoupe::Logger.new(target: 'cov_loupe.log', mode: :mcp)
logger = CovLoupe::Logger.new(target: 'cov_loupe.log', mode: :cli) # or :library
Deleted Files Now Raise FileNotFoundError¶
Breaking Change: Querying a file that has been deleted (but still exists in the coverage resultset) now raises FileNotFoundError instead of returning stale coverage data.
Previous Behavior (v3.x)¶
# File lib/foo.rb was deleted after running tests
model = CovLoupe::CoverageModel.new(root: '.')
result = model.summary_for('lib/foo.rb')
# => { 'file' => '/path/to/lib/foo.rb', 'summary' => { 'covered' => 4, 'total' => 6, 'percentage' => 66.67 } }
# Returns stale coverage data with no error
# CLI would return coverage percentage and exit 0
$ cov-loupe summary lib/foo.rb
lib/foo.rb: 66.67% (4/6)
$ echo $?
0
New Behavior (v4.x)¶
# File lib/foo.rb was deleted after running tests
model = CovLoupe::CoverageModel.new(root: '.')
result = model.summary_for('lib/foo.rb')
# => raises CovLoupe::FileNotFoundError: "File not found: lib/foo.rb"
# CLI raises error and exits 1
$ cov-loupe summary lib/foo.rb
Error: File not found: lib/foo.rb
$ echo $?
1
Rationale¶
Deleted files represent stale data that: 1. Misleads coverage metrics and statistics 2. Violates the API contract (docstring already promised FileNotFoundError) 3. Should be treated the same as other staleness issues
If a file no longer exists, its coverage data is no longer meaningful. The new behavior ensures you don't accidentally include deleted file coverage in your metrics.
Impact¶
This affects: - model.summary_for(path) - All single-file query methods - model.raw_for(path) - model.uncovered_for(path) - model.detailed_for(path) - CLI commands: summary, raw, uncovered, detailed - MCP tools: coverage_summary_tool, coverage_raw_tool, etc.
Migration¶
If you expect deleted files to raise errors (recommended): - No action needed. This is the correct behavior.
If you relied on getting coverage for deleted files: - This was incorrect behavior. Update your workflow to: 1. Re-run tests after file deletions to get fresh coverage, OR 2. Use the list command to see deleted files in the deleted_files array without querying them directly
Example: Checking for deleted files
model = CovLoupe::CoverageModel.new(root: '.')
result = model.list
if result['deleted_files'].any?
puts "Warning: Coverage data exists for deleted files:"
result['deleted_files'].each { |f| puts " - #{f}" }
end
Staleness Indicators Changed from Strings to Symbols¶
Breaking Change: Staleness indicators in the stale field now use Ruby symbols instead of single-character strings.
Previous Behavior (v3.x)¶
result = model.list
# => { 'files' => [{ 'file' => 'lib/foo.rb', 'stale' => 'M', ... }], ... }
# Staleness was indicated by strings:
# 'M' - Missing file
# 'T' - Timestamp mismatch
# 'L' - Line count mismatch
# 'E' - Error during staleness check
# false - Fresh coverage data
New Behavior (v4.x)¶
result = model.list
# => { 'files' => [{ 'file' => 'lib/foo.rb', 'stale' => "missing", ... }], ... }
# Staleness is now indicated by symbols:
# "missing" - Missing file
# "newer" - Timestamp mismatch
# "length_mismatch" - Line count mismatch
# "error" - Error during staleness check
# "ok" - Fresh coverage data
Rationale¶
Symbols are more idiomatic in Ruby for enumerated values and provide: - Better performance: Symbols are interned, so comparisons are faster - Clearer semantics: Symbols represent categories/concepts, not text - Consistency: Aligns with Ruby conventions for status indicators - Type safety: Symbol vs String distinction catches bugs
Impact¶
This affects code that: - Checks equality with string literals: stale == 'M' will no longer match - Uses string pattern matching: Case statements with string patterns need updating - Serializes to JSON: Symbols are converted to strings in JSON output - Type checks: stale.is_a?(Symbol) instead of stale.is_a?(String)
Frequency: High - affects any code that checks staleness status.
Migration¶
If you check equality with string literals:
# Old
if file['stale'] == 'M'
puts "File is missing"
end
# New - use symbols
if file['stale'] == 'missing'
puts "File is missing"
end
# Or use string comparison (less efficient but works with both versions)
if file['stale'].to_s == 'missing'
puts "File is missing"
end
If you use case statements with string patterns:
# Old
case file['stale']
when 'M' then handle_missing
when 'T' then handle_timestamp
when 'L' then handle_length
when 'E' then handle_error
when false then handle_fresh
end
# New - use symbols
case file['stale']
when 'missing' then handle_missing
when 'newer' then handle_timestamp
when 'length_mismatch' then handle_length
when 'error' then handle_error
when 'ok' then handle_fresh
end
# Or use to_s for backward compatibility
case file['stale'].to_s
when 'missing' then handle_missing
when 'newer' then handle_timestamp
when 'length_mismatch' then handle_length
when 'error' then handle_error
when 'ok' then handle_fresh
end
If you check for any staleness:
# Old (works for both versions)
if file['stale']
puts "Stale file (#{file['stale']})"
end
# New - explicit type check
if file['stale'].is_a?(Symbol)
puts "Stale file (#{file['stale']})"
end
# Or use the same approach (works for both versions)
if file['stale']
puts "Stale file (#{file['stale']})"
end
JSON serialization note: When serializing to JSON (CLI, MCP, etc.), symbols are automatically converted to strings:
# In Ruby
file['stale'] # => "missing"
# In JSON output
{ "file": "lib/foo.rb", "stale": "missing" }
Table output legend updated:
Staleness: missing = Missing file, newer = Timestamp mismatch, length_mismatch = Line count mismatch, error = Check failed
Complete Staleness Value Reference¶
| Status | v3.x (String) | v4.x (Symbol) | Description |
|---|---|---|---|
| Fresh | false | "ok" | Coverage data is current |
| Missing file | 'M' | "missing" | File no longer exists on disk |
| Timestamp mismatch | 'T' | "newer" | File modified after coverage was generated |
| Line count mismatch | 'L' | "length_mismatch" | Source file line count differs from coverage data |
| Check error | 'E' | "error" | Staleness check failed (permissions, I/O errors, etc.) |
Removed Branch-Only Coverage Support¶
Breaking Change: The automatic synthesis of line coverage data from SimpleCov branch-only coverage results has been removed.
Rationale¶
The logic required to maintain this feature was complex and prone to edge cases, particularly regarding staleness detection and line-count mismatches. Additionally, branch-only coverage is a rarely used configuration in the SimpleCov ecosystem.
Impact¶
If your project is configured to track only branch coverage in SimpleCov (e.g., enable_coverage :branch without also tracking lines), cov-loupe will no longer be able to process your coverage data and will raise a CorruptCoverageDataError.
How to Migrate¶
Most users do not need to take any action. Line coverage is enabled by default in SimpleCov.
If you have enable_coverage :branch in your configuration, your .resultset.json contains both lines and branches data. This is fully supported. cov-loupe will read and report the lines coverage as usual.
The change in v4.0 is simply that cov-loupe no longer looks at the branches data at all. Previously, if lines data was missing (a rare edge case), cov-loupe would attempt to calculate line coverage by summing up branch hits. This fallback logic has been removed.
Getting Help¶
If you encounter issues migrating to v4.0:
- Check the TROUBLESHOOTING.md guide.
- Review the CLI_USAGE.md for complete CLI reference.
- Open an issue at https://github.com/keithrbennett/cov-loupe/issues.