hcovguard
A coverage ratchet tool for Haskell projects
hcovguard reads HPC-generated coverage data and checks it against configurable thresholds defined in a TOML file.
Quick Start
# 1. Run your tests with coverage enabled
stack test --coverage
# or: cabal test --enable-coverage
# 2. Create a minimal config file
cat > hcovguard.toml << 'EOF'
[forAnyModule]
[forAnyModule.expression]
minimumCovered = 1
EOF
# 3. Run hcovguard with auto-discovery
hcovguard --tix $(stack path --local-hpc-root)/combined/all/all.tix --auto-discover
# 4. Adjust thresholds based on your current coverage and goals
Features
- Configurable Thresholds: Set minimum covered counts or maximum uncovered counts for expressions, top-level bindings, alternatives, and local bindings
- Per-Module Overrides: Override thresholds for specific modules by exact name or glob patterns
- Pattern Matching: Use
* (single level) and ** (recursive) glob patterns to match multiple modules
- Ignore Modules: Mark test helpers or generated code to be excluded from checks
- TOML Configuration: Simple, readable configuration format
- CI-Friendly: Returns non-zero exit code when thresholds aren't met
- Configuration Validation: Warns when patterns don't match any modules, helping catch typos and stale config
Installation
From Source
cabal install hcovguard
Or with Stack:
stack install hcovguard
Installing the Manual Page
The man page is included in the package and can be installed manually:
# Install the man page system-wide (typically requires root access)
mkdir -p /usr/local/share/man/man1
cp man/hcovguard.1 /usr/local/share/man/man1/
mandb # Update man database (on some systems)
# Then view it with:
man hcovguard
To view the man page without installing, run from the repository:
man man/hcovguard.1
Usage
First, generate coverage data by running your tests with coverage enabled:
# With Stack
stack test --coverage
# With Cabal
cabal test --enable-coverage
Then run hcovguard against the generated .tix file:
hcovguard \
--tix $(stack path --local-hpc-root)/combined/all/all.tix \
--auto-discover \
--config hcovguard.toml
Auto-Discovery of Mix Directories
The --auto-discover flag automatically searches for .mix files in common build output directories:
.stack-work/ (Stack)
dist-newstyle/ (Cabal)
.hpc/ (direct HPC output)
This is the recommended approach for most projects, as it eliminates the need to manually specify --mix-dir paths that change between platforms and GHC versions.
If you need to specify mix directories manually (e.g., for non-standard build configurations), use --mix-dir:
hcovguard \
--tix path/to/coverage.tix \
--mix-dir /custom/path/to/mix \
--mix-dir /another/mix/directory
Command Line Options
| Option |
Description |
-c, --config FILE |
Path to the TOML configuration file (default: ./hcovguard.toml) |
-t, --tix FILE |
Path to the .tix file generated by HPC (required) |
-m, --mix-dir DIR |
Directory containing .mix files (can be specified multiple times) |
-a, --auto-discover |
Auto-discover .mix directories in .stack-work, dist-newstyle, and .hpc |
-v, --verbosity LEVEL |
Verbosity level: 0=quiet, 1=normal, 2=verbose (default: 1) |
-b, --baseline |
Generate baseline config from current coverage (outputs TOML to stdout) |
-n, --dry-run |
Show which modules match which patterns without running checks |
-h, --help |
Show help text |
--version |
Show version information |
--render-man-page |
Generate a man page and output to stdout |
Note: You must specify either --auto-discover or at least one --mix-dir. If neither is provided, hcovguard will exit with an error.
Generating a Baseline Config
Use --baseline to generate a configuration file from your current coverage:
hcovguard --tix coverage.tix --auto-discover --baseline > hcovguard.toml
This outputs TOML with thresholds set to your current coverage counts—useful for:
- Initial setup when adopting hcovguard
- Resetting thresholds after a major refactor
- Establishing a new baseline after significant changes
Dry Run Mode
Use --dry-run to see which patterns match which modules without running threshold checks:
hcovguard --tix coverage.tix --auto-discover --dry-run
Example output:
MyProject.Core: matched rule #1 (module = "MyProject.Core")
MyProject.Utils: matched rule #2 (pattern = "MyProject.*")
MyProject.Internal.Helpers: matched rule #3 (pattern = "**.Internal.**") (ignored)
Test.MyProject.CoreSpec: using [forAnyModule] defaults
This helps debug pattern matching issues and verify your configuration.
Configuration
Create a hcovguard.toml file in your project root, for example:
# Default thresholds for all modules
[forAnyModule]
[forAnyModule.expression]
minimumCovered = 50
maximumUncovered = 10
[forAnyModule.topLevel]
minimumCovered = 30
[forAnyModule.alternative]
maximumUncovered = 5
# Override thresholds for specific modules (exact match)
[[forSpecifiedModules]]
module = "MyProject.Core"
[forSpecifiedModules.expression]
minimumCovered = 100
[forSpecifiedModules.topLevel]
minimumCovered = 50
# Override thresholds using pattern matching
[[forSpecifiedModules]]
pattern = "MyProject.Internal.**"
[forSpecifiedModules.expression]
minimumCovered = 20
# Ignore specific modules
[[forSpecifiedModules]]
module = "Test.Helpers"
ignore = true
# Ignore modules matching a pattern
[[forSpecifiedModules]]
pattern = "**.Test.**"
ignore = true
Threshold Options
Thresholds control the minimum coverage requirements. Both options are optional and can be used independently or together.
| Field |
Type |
Required |
Description |
minimumCovered |
Int |
No |
Minimum number of items that must be covered |
maximumUncovered |
Int |
No |
Maximum number of items that can remain uncovered |
Coverage Categories
Thresholds can be set for four coverage categories, each as a nested table:
| Category |
Description |
expression |
Expression coverage (most granular) |
topLevel |
Top-level function/binding coverage |
alternative |
Case branches and guards coverage |
local |
Local binding (let/where) coverage |
[forAnyModule]
Default thresholds applied to all modules unless overridden by [[forSpecifiedModules]].
| Field |
Type |
Required |
Description |
expression |
Table |
No |
Expression threshold options |
topLevel |
Table |
No |
Top-level threshold options |
alternative |
Table |
No |
Alternative threshold options |
local |
Table |
No |
Local binding threshold options |
Example:
[forAnyModule]
[forAnyModule.expression]
minimumCovered = 50
[forAnyModule.topLevel]
maximumUncovered = 5
[[forSpecifiedModules]]
Per-module overrides. Each entry uses one of two forms:
Match a specific module by its exact name.
| Field |
Type |
Required |
Description |
module |
String |
Yes |
Exact module name to match |
ignore |
Bool |
No |
If true, skip this module entirely (default: false) |
expression |
Table |
No |
Expression threshold options |
topLevel |
Table |
No |
Top-level threshold options |
alternative |
Table |
No |
Alternative threshold options |
local |
Table |
No |
Local binding threshold options |
Example:
[[forSpecifiedModules]]
module = "MyProject.Core"
[forSpecifiedModules.expression]
minimumCovered = 100
Match multiple modules using glob patterns.
| Field |
Type |
Required |
Description |
pattern |
String |
Yes |
Glob pattern to match module names |
ignore |
Bool |
No |
If true, skip matching modules entirely (default: false) |
expression |
Table |
No |
Expression threshold options |
topLevel |
Table |
No |
Top-level threshold options |
alternative |
Table |
No |
Alternative threshold options |
local |
Table |
No |
Local binding threshold options |
Example:
[[forSpecifiedModules]]
pattern = "MyProject.Internal.**"
[forSpecifiedModules.expression]
minimumCovered = 20
Note: Each [[forSpecifiedModules]] entry must use exactly one form. Specifying both module and pattern is an error.
Pattern Syntax
* matches any characters except . (single module level)
** matches any characters including . (multiple module levels)
Examples:
MyProject.* matches MyProject.Foo but not MyProject.Foo.Bar
MyProject.** matches MyProject.Foo, MyProject.Foo.Bar, etc.
**.Internal matches Foo.Internal, Foo.Bar.Internal, etc.
Pattern Match Order
When multiple [[forSpecifiedModules]] entries could match a module, the first matching entry wins. Entries are checked in the order they appear in the configuration file.
This means you should order your patterns from most specific to least specific:
# CORRECT: Specific patterns first
[[forSpecifiedModules]]
module = "MyProject.Core.Critical" # Exact match checked first
[forSpecifiedModules.expression]
minimumCovered = 200
[[forSpecifiedModules]]
pattern = "MyProject.Core.*" # Then specific pattern
[forSpecifiedModules.expression]
minimumCovered = 100
[[forSpecifiedModules]]
pattern = "MyProject.**" # Then broad pattern
[forSpecifiedModules.expression]
minimumCovered = 50
If no [[forSpecifiedModules]] entry matches, the [forAnyModule] defaults apply.
Why hcovguard?
-
Count-Based Thresholds: Set minimum covered counts or maximum uncovered counts rather than percentages. This makes coverage ratcheting straightforward - incrementally require more coverage as you improve your test suite, without worrying about percentage fluctuations as code is added or removed.
-
Per-Module Configuration: Different parts of your codebase deserve different standards. Require higher coverage for core business logic, lower thresholds for internal utilities, and ignore generated code entirely.
-
Glob Pattern Matching: Configure thresholds for entire module hierarchies with patterns like MyProject.Internal.** instead of listing every module individually.
-
Pure Haskell: No C library dependencies means straightforward builds across platforms and GHC versions.
Troubleshooting
"Mix file not found" error
HPC generates two types of files:
.tix files contain coverage counts (which code paths were executed)
.mix files contain coverage metadata (mapping counts to source locations)
If you see this error, hcovguard found the .tix file but cannot locate the corresponding .mix files. Solutions:
-
Use --auto-discover: This searches common build directories automatically.
-
Check your build output: Mix files are typically located in:
- Stack:
.stack-work/install/<platform>/<snapshot>/hpc/
- Cabal:
dist-newstyle/build/<platform>/<compiler>/<package>/hpc/
-
Verify mix files exist: Run find . -name "*.mix" to locate them, then use --mix-dir with the containing directory.
-
Rebuild with coverage: Mix files are only generated when building with coverage enabled. Re-run stack test --coverage or cabal test --enable-coverage.
No modules checked / empty output
This typically means:
-
Wrong tix file path: The .tix file might be empty or point to a different test run. Verify the file exists and has recent modification time.
-
Module name mismatch: The modules in your .tix file don't match any patterns in your config. Run with --verbosity 2 to see which modules are being processed.
-
All modules ignored: Check that your ignore = true patterns aren't too broad.
Pattern not matching expected modules
Remember:
* matches within a single module level (stops at .)
** matches across multiple levels (includes .)
Common mistakes:
- Using
MyProject.* when you meant MyProject.** (the former won't match MyProject.Foo.Bar)
- Using
*.Test when you meant **.Test (the former only matches Foo.Test, not Foo.Bar.Test)
Run with --verbosity 2 to see exactly which modules match which patterns.
Coverage counts seem wrong
HPC counts can be surprising because:
- Expressions are very granular (each subexpression counts separately)
- Alternatives include both case branches AND guard clauses
- Local bindings include let and where bindings
Use --verbosity 2 to see the actual counts per category for each module, then adjust your thresholds accordingly.
"No .mix directories specified" error
This error occurs when you don't specify how hcovguard should find .mix files. You must either:
- Use
--auto-discover to search common build directories automatically, or
- Specify at least one
--mix-dir path explicitly
"Pattern matched no modules" warning
If you see a warning like:
Warning: [Hcovguard-8451] Pattern matched no modules
pattern = "MyProject.Internal.**"
This means a [[forSpecifiedModules]] entry in your config didn't match any modules in the .tix file. This often indicates:
- Typo in the pattern: Double-check the module name or pattern syntax.
- Modules not included in coverage: The modules might not have been compiled with coverage enabled.
- Stale configuration: The modules may have been renamed or removed.
This is a warning, not an error — hcovguard will continue checking other modules. However, you should review your configuration to ensure patterns are intentional.
License
MIT License - see LICENSE for details.
Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.