A composable quality gate library for Go projects. Compose quality gate stages with dependency safety — the compiler prevents invalid wiring.
go get github.com/hotchkj/mage-gate/gateConstruct dependencies individually: a display runner (wraps output mode), an artifact store (shared by stages that exchange tokens), file ops, and a root path. Each stage function takes exactly the dependencies it needs.
Optional: pass package patterns and QualityScope options from your own configuration (flags, environment variables, TOML in your entrypoint, or literals):
qs, err := qg.NewQualityScope("./...",
qg.Exclude("vendor", "testdata"),
qg.TestFilePatterns("*_test.go"),
)
if err != nil {
return err
}This repository's magefiles/ package reads gate.toml as a local convenience; the gate library does not include a TOML loader. The [quality_scope] table holds the same package pattern and options you would pass to NewQualityScope in code.
Coverage %: the number compared to your threshold (and the one you should act on) is the total from go tool cover on the quality-scoped profile: CoveredTest passes -coverpkg built from the scope with excludes removed, and Coverage may filter the same profile. A plain go test ./... -coverprofile=… without that scoped list reports a different aggregate, often lower when optional packages (BDD, harness) appear as 0% in the merge.
//go:build mage
package main
import (
"context"
"os"
qg "github.com/hotchkj/mage-gate/gate"
)
func Gate() error {
ctx := context.Background()
runner, err := qg.NewDisplayRunner(qg.NewProductionRunner(), qg.OutputModeAgent, os.Stdout, os.Stderr)
if err != nil {
return err
}
store := qg.NewArtifactStore()
fileOps := qg.NewProductionFileOps()
root := "."
qs, err := qg.NewQualityScope("./...", qg.Exclude("features"), qg.TestFilePatterns("*_test.go"))
if err != nil {
return err
}
pkgScope, err := qg.NewPackageScope(qs.Packages())
if err != nil {
return err
}
resolver := qg.NewProductionToolResolver()
lt, err := qg.NewLintToolchain(
qg.LintConfig(".golangci.yml"),
qg.LintToolSpec("github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1"),
)
if err != nil {
return err
}
if err := qg.Lint(ctx, runner, resolver, fileOps, root, pkgScope, lt); err != nil {
return err
}
if err := qg.Deadcode(
ctx, runner, resolver, fileOps, root, pkgScope,
qg.DeadcodeToolSpec("golang.org/x/tools/cmd/deadcode@v0.31.0"),
qg.DeadcodeArgs("-test", "-tags=yourtag"),
); err != nil {
return err
}
if err := qg.Vet(ctx, runner, fileOps, root, pkgScope); err != nil {
return err
}
if err := qg.Compile(ctx, runner, fileOps, root, pkgScope); err != nil {
return err
}
testOut, err := qg.Test(ctx, runner, store, fileOps, root, pkgScope)
if err != nil {
return err
}
if err := qg.Duration(ctx, runner, store, fileOps, root, testOut, qg.MaxSeconds(1.0)); err != nil {
return err
}
inv, err := qg.QualityScopeInventory(ctx, runner, store, fileOps, root, qs)
if err != nil {
return err
}
coveredOut, err := qg.CoveredTest(ctx, runner, store, fileOps, root, pkgScope, qs, inv)
if err != nil {
return err
}
covOut, err := qg.Coverage(ctx, runner, store, fileOps, root, coveredOut, qg.MinPercent(90))
if err != nil {
return err
}
if err := qg.Crap(ctx, runner, resolver, store, fileOps, root, covOut, inv, qg.MaxScore(8),
qg.GocycloToolSpec("github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0"),
); err != nil {
return err
}
mr, err := qg.NewMutationRunner(runner, resolver, store, fileOps)
if err != nil {
return err
}
scanOut, err := mr.Scan(ctx, root, qs, inv,
qg.GremlinsToolSpec("github.com/hotchkj/gremlins/cmd/gremlins@v0.6.1-pre.1"),
)
if err != nil {
return err
}
if err := qg.MutationSites(scanOut, qg.MaxSites(50)); err != nil {
return err
}
return nil
}The library uses output tokens to enforce dependency chains at compile time.
// This won't compile — Crap requires CoverageOutput, not TestOutput
_ = qg.Crap(ctx, runner, resolver, store, fileOps, root, testOut, inv, qg.MaxScore(8),
qg.GocycloToolSpec("github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0"))Run separate test suites with different QualityScope values. Each Test returns its own TestOutput; use the matching token for each Duration check (duration validates all events in that run, not quality-scope excludes). For coverage and Crap, pick the suite whose CoveredTestOutput reflects the packages you want to measure (illustrative consumer layout: production code under ./cmd/... — this module has no cmd/ tree).
//go:build mage
package main
import (
"context"
"os"
qg "github.com/hotchkj/mage-gate/gate"
)
func GateMultiSuite() error {
ctx := context.Background()
runner, err := qg.NewDisplayRunner(qg.NewProductionRunner(), qg.OutputModeAgent, os.Stdout, os.Stderr)
if err != nil {
return err
}
store := qg.NewArtifactStore()
fileOps := qg.NewProductionFileOps()
root := "."
pkgScopeAll, err := qg.NewPackageScope("./...")
if err != nil {
return err
}
qs1, err := qg.NewQualityScope("./cmd/...", qg.Exclude("testdata"))
if err != nil {
return err
}
qs2, err := qg.NewQualityScope("./internal/...", qg.Exclude("fixtures"))
if err != nil {
return err
}
pkgScope1, err := qg.NewPackageScope(qs1.Packages())
if err != nil {
return err
}
pkgScope2, err := qg.NewPackageScope(qs2.Packages())
if err != nil {
return err
}
resolver := qg.NewProductionToolResolver()
lt, err := qg.NewLintToolchain(
qg.LintConfig(".golangci.yml"),
qg.LintToolSpec("github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1"),
)
if err != nil {
return err
}
if err := qg.Lint(ctx, runner, resolver, fileOps, root, pkgScopeAll, lt); err != nil {
return err
}
if err := qg.Compile(ctx, runner, fileOps, root, pkgScopeAll); err != nil {
return err
}
// Suite 1: cmd package tests
test1Out, err := qg.Test(ctx, runner, store, fileOps, root, pkgScope1)
if err != nil {
return err
}
if err := qg.Duration(ctx, runner, store, fileOps, root, test1Out, qg.MaxSeconds(2.0)); err != nil {
return err
}
// Suite 2: internal package tests
test2Out, err := qg.Test(ctx, runner, store, fileOps, root, pkgScope2)
if err != nil {
return err
}
if err := qg.Duration(ctx, runner, store, fileOps, root, test2Out, qg.MaxSeconds(1.0)); err != nil {
return err
}
// Coverage and Crap from suite 1 token (production coverage for cmd)
inv1, err := qg.QualityScopeInventory(ctx, runner, store, fileOps, root, qs1)
if err != nil {
return err
}
coveredOut, err := qg.CoveredTest(ctx, runner, store, fileOps, root, pkgScope1, qs1, inv1)
if err != nil {
return err
}
covOut, err := qg.Coverage(ctx, runner, store, fileOps, root, coveredOut, qg.MinPercent(90))
if err != nil {
return err
}
if err := qg.Crap(ctx, runner, resolver, store, fileOps, root, covOut, inv1, qg.MaxScore(8),
qg.GocycloToolSpec("github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0"),
); err != nil {
return err
}
return nil
}Package-level functions in gate. Each takes context.Context, then individual dependencies (runner, fileOps, root, and store where needed), then stage-specific inputs. Lint and Format take a LintToolchain assembled via NewLintToolchain(config, tool, opts...).
| Function | Returns | Description |
|---|---|---|
Lint(ctx, runner, resolver, fileOps, root, packages, toolchain) |
error |
Runs golangci-lint verify (run) |
Format(ctx, runner, resolver, fileOps, root, packages, toolchain) |
error |
Applies configured formatters (fmt) |
Deadcode(ctx, runner, resolver, fileOps, root, packages, toolSpec, opts...) |
error |
Detects unreachable code |
Vet(ctx, runner, fileOps, root, packages, opts...) |
error |
Runs go vet |
Compile(ctx, runner, fileOps, root, packages, opts...) |
error |
Verifies compilation with go build (no release binaries) |
Test(ctx, runner, store, fileOps, root, packages, opts...) |
(TestOutput, error) |
Runs go test once without coverage flags |
QualityScopeInventory(ctx, runner, store, fileOps, root, qualityScope) |
(QualityScopeInventoryOutput, error) |
Discovers and stores reusable quality-scope package inventory |
CoveredTest(ctx, runner, store, fileOps, root, packages, qualityScope, inventory, opts...) |
(CoveredTestOutput, error) |
Runs coverage-bearing test pass for Coverage/Crap |
Coverage(ctx, runner, store, fileOps, root, coveredOutput, minPercent) |
(CoverageOutput, error) |
Checks coverage threshold |
Crap(ctx, runner, resolver, store, fileOps, root, covOut, inventory, maxScore, gocycloToolSpec, opts...) |
error |
Checks Crap score |
Duration(ctx, runner, store, fileOps, root, testOut, qg.MaxSeconds(secs)) |
error |
Checks each test completion event from the producing test run; quality-scope excludes do not apply; qg.MaxSeconds is required |
NewMutationRunner(runner, resolver, store, fileOps) |
(MutationRunner, error) |
Shared gremlins scan/kill execution surface; nil deps rejected at construction |
MutationRunner.Scan(ctx, root, qualityScope, inventory, gremlinsToolSpec, opts...) |
(MutationScanOutput, error) |
Gremlins dry-run on a MutationRunner value; token carries store binding for downstream checks |
MutationSites(scanOut, qg.MaxSites(n)) |
error |
Site budget over MutationScanOutput from Scan |
MutationCoverage(scanOut, qg.MinMutationCoverage(percent)) |
error |
Optional mutation coverage share over the same scan token |
MutationKills(ctx, runner, resolver, store, fileOps, root, qualityScope, inventory, qg.MinKillRate(percent), gremlinsToolSpec, opts...) |
(MutationKillsOutput, error) |
On-demand mutation kill rate check (not part of Gate) |
Threshold arguments are option values (qg.MinPercent, qg.MaxScore, qg.MaxSeconds, qg.MaxSites, qg.MinMutationCoverage), not untyped literals passed positionally.
Mutation tooling is five separable concepts:
- Inventory producer —
QualityScopeInventoryruns package discovery once and storesquality-scope-package-rows.jsonwith a scope fingerprint; coverage, CRAP, mutation scan, and mutation kill consumers receive the typed inventory token and validate fingerprint match. - Dry-run producer —
MutationRunner.Scanruns gremlins with--dry-runonce and writes the mutation artifact. QualityScope and its inventory define that run (package seed, build tags, excludes, test-file patterns); it is the measurement boundary for mutation metrics, not the same thing asPackageScoperun targets for lint orgo test. - Site budget —
MutationSites(scanOut, qg.MaxSites(n))enforces the per-file mutation-site cap from that scan’s artifact. - Mutation coverage —
MutationCoverage(scanOut, qg.MinMutationCoverage(p))enforces the covered-mutant share from the same dry-run artifact. Optional; when enabled, it still does not require a second gremlins dry-run. - Kill rate — A full gremlins run (
MutationRunner.Kill+MutationKillRateon the kill token, or the bundledMutationKillsentrypoint) measures whether tests kill mutants. That path is not a substitute for the dry-run metrics unless callers explicitly useMutationSitesFromKills/MutationCoverageFromKillsover full-run output.
Composition: One Scan can supply both the site-budget check and, when you configure it, the mutation-coverage check—one dry-run, two quality checks. MutationKills (full run) remains separate: optional, on-demand, and typically a dedicated magefile target (e.g. merge or deeper quality), not the same cost profile as pre-commit dry-run checks.
// Coverage minimum threshold (required)
qg.MinPercent(95)
// Maximum CRAP score (required)
qg.MaxScore(5)
// Maximum test duration (required)
qg.MaxSeconds(2.0)
// Maximum mutation sites per file (required)
qg.MaxSites(100)
// QualityScope excludes and test file patterns (options mutate private qualityScopeConfig;
// package pattern is required identity set only by NewQualityScope)
// qg.NewQualityScope("./...", qg.Exclude("internal/test", "tools"), qg.TestFilePatterns("*_test.go"))
// CompileArgs for go build flags on Compile (e.g. -trimpath); artifact builds are consumer-owned.
qg.CompileArgs("-trimpath")
// Vet and test passthrough args
qg.VetArgs("-json")
qg.TestArgs("-tags=integration", "-short")
// Lint config path (required)
qg.LintConfig(".golangci.yml")
// Custom golangci-lint definition and explicit builder pin
qg.CustomGCL(".custom-gcl.yml")
qg.CustomLintToolSpec("github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1")
// Extra golangci-lint argv (after subcommand prefix: run … for Lint, fmt … for Format)
qg.LintArgs("--verbose")
// Deadcode: optional args for the deadcode tool. Include "-test" for test-inclusive
// reachability (required for library repos with no main packages).
qg.DeadcodeArgs("-test", "-tags=integration")
// Extra gocyclo argv for Crap
qg.CrapArgs("-top", "10")
// Gremlins unleash extras (MutationSites / MutationKills); sites still inject --dry-run first.
qg.MutationArgs("--timeout=10m")Skip coverage and Crap; run a smaller set of stages (lint, compile, test, mutation site budget via gremlins --dry-run):
//go:build mage
package main
import (
"context"
"os"
qg "github.com/hotchkj/mage-gate/gate"
)
func PreCommit() error {
ctx := context.Background()
runner, err := qg.NewDisplayRunner(qg.NewProductionRunner(), qg.OutputModeAgent, os.Stdout, os.Stderr)
if err != nil {
return err
}
store := qg.NewArtifactStore()
fileOps := qg.NewProductionFileOps()
root := "."
qs, err := qg.NewQualityScope("./...", qg.Exclude("features", "testdata"))
if err != nil {
return err
}
pkgScope, err := qg.NewPackageScope(qs.Packages())
if err != nil {
return err
}
resolver := qg.NewProductionToolResolver()
lt, err := qg.NewLintToolchain(
qg.LintConfig(".golangci.yml"),
qg.LintToolSpec("github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1"),
)
if err != nil {
return err
}
if err := qg.Lint(ctx, runner, resolver, fileOps, root, pkgScope, lt); err != nil {
return err
}
if err := qg.Compile(ctx, runner, fileOps, root, pkgScope); err != nil {
return err
}
if _, err := qg.Test(ctx, runner, store, fileOps, root, pkgScope); err != nil {
return err
}
inv, err := qg.QualityScopeInventory(ctx, runner, store, fileOps, root, qs)
if err != nil {
return err
}
mr, err := qg.NewMutationRunner(runner, resolver, store, fileOps)
if err != nil {
return err
}
scanOut, err := mr.Scan(ctx, root, qs, inv,
qg.GremlinsToolSpec("github.com/hotchkj/gremlins/cmd/gremlins@v0.6.1-pre.1"),
)
if err != nil {
return err
}
if err := qg.MutationSites(scanOut, qg.MaxSites(50)); err != nil {
return err
}
return nil
}Output mode is a property of the runner, not the stage. Construct the runner with the desired mode via NewDisplayRunner.
Silent on success; failures show ERROR/Fix/Hint format:
runner, err := qg.NewDisplayRunner(qg.NewProductionRunner(), qg.OutputModeAgent, os.Stdout, os.Stderr)
if err != nil {
return err
}ERROR: [coverage] stage coverage failed
Fix: check coverage stage configuration
Hint: see coverage stage artifacts for details
Full subprocess output on the display writers (e.g. CI logs):
runner, err := qg.NewDisplayRunner(qg.NewProductionRunner(), qg.OutputModeVerbose, os.Stdout, os.Stderr)
if err != nil {
return err
}If coverage is below the threshold:
ERROR: [coverage] stage coverage failed
Fix: check coverage stage configuration
Hint: see coverage stage artifacts for details
Action: Write tests for uncovered code paths. Use go tool cover -html=coverage.out to visualize coverage.
If any function exceeds the Crap threshold:
ERROR: [crap] stage crap failed
Fix: check crap stage configuration
Hint: see crap stage artifacts for details
Action: Either reduce cyclomatic complexity or add tests to improve coverage for complex functions.
If any test runs longer than the configured maximum:
ERROR: [duration] stage duration failed
Fix: check duration stage configuration
Hint: see duration stage artifacts for details
Action: Profile slow tests and optimize.
If the code has too many viable mutation sites:
ERROR: [mutationsites] mutationsites failed
Fix: reduce mutation site count by splitting large files
Hint: rule of thumb: split by theme, move at least 30% of code out
Action: Split large files by theme to reduce mutation site density.
- Add a dependency on the module root (not only the
gatesubfolder):go get github.com/hotchkj/mage-gate@vX.Y.Z(or a pseudo-version frommainuntil the first tag exists). - Add a
magefiles/package with//go:build mageand aGate()function that wiresgatethe way you want (this repo’smagefiles/magefile.go+gate.tomlare the reference implementation). - Run
go run github.com/magefile/mage@v1.17.0 gatefrom the repo root (Mage discoversmagefiles/automatically).
For local development against a clone of this repo, use a temporary replace in the consumer’s go.mod:
replace github.com/hotchkj/mage-gate => ../mage-gateAdjust the relative path if your clone is not a sibling directory. Remove replace before tagging the consumer for release.
This module is a library (no go get-able CLI in cmd/). To compile packages locally:
go build ./gate/... ./cmdrunner/... ./internal/...Release binaries, cross-compilation, and packaging are consumer-owned — the gate verifies quality only.
Run the full quality gate before submitting changes:
go run github.com/magefile/mage@v1.17.0 gateApply formatters separately (same [lint] toolchain as mage lint):
go run github.com/magefile/mage@v1.17.0 formatThe repo pre-commit hook (.githooks/pre-commit) runs mage format then mage gate: apply first, then verify. gate itself stays non-mutating (no Format step in runGateSteps).