Write Custom Linters That Plug Into go vet
The framework behind go vet lets you build project-specific static analyzers. Your team's conventions deserve automated enforcement, not just code review comments.
Every Go team has conventions that go vet and golangci-lint don't catch. "Use our injected clock, not time.Now()." "Don't import the legacy DB package." "HTTP handlers must route errors through middleware."
You enforce these in code review. Over and over. There's a better way: the same framework that powers go vet is available to you. The Go team's go/analysis package lets you write custom analyzers that plug into the existing toolchain — go vet, golangci-lint, gopls — with minimal boilerplate.
Why this matters
Generic linters catch generic bugs. They don't know your architecture. When a convention matters enough to enforce in every PR, it matters enough to automate. A custom analyzer catches violations in CI, in pre-commit hooks, and as red squiggles in your editor — before a human wastes time on it.
How it works
An analyzer is a struct with a name, docs, and a Run function. The framework hands you a *analysis.Pass containing the parsed AST, type information, and package metadata. You walk the AST, inspect nodes, and call pass.Reportf() to emit diagnostics.
Analyzers can declare dependencies on other analyzers via the Requires field. If two of your analyzers need the same AST traversal, the framework computes it once and shares the result. That composition is what makes the framework modular rather than a pile of independent scripts.
Where this helps
- Flagging direct
time.Now()calls so testable code uses an injected clock - Forbidding imports of deprecated internal packages during a migration
- Enforcing that HTTP handlers go through your error middleware, not raw
http.Error - Catching
context.Background()usage deep in request-scoped call chains
Watch out
Analyzers run inside gopls, so slow ones make your IDE sluggish. Keep each analyzer to a single AST walk. Type information isn't available when a package doesn't compile, so guard for nil before dereferencing. And integrating with golangci-lint requires publishing your analyzer as an importable Go module.
Try it yourself
package noclock
import (
"go/ast"
"golang.org/x/tools/go/analysis"
)
var Analyzer = &analysis.Analyzer{
Name: "noclock",
Doc: "flag direct time.Now() calls — use injected clock",
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
pkg, ok := sel.X.(*ast.Ident)
if ok && pkg.Name == "time" && sel.Sel.Name == "Now" {
pass.Reportf(call.Pos(),
"use injected clock instead of time.Now()")
}
return true
})
}
return nil, nil
}
// Wire it as a CLI:
// package main
// import (
// "golang.org/x/tools/go/analysis/singlechecker"
// "example.com/noclock"
// )
// func main() { singlechecker.Main(noclock.Analyzer) }
//
// Run it:
// go run ./cmd/noclock ./...
TL;DR
- What it is: The Go team's
go/analysisframework powersgo vet— and it's yours to build on - Why it matters: Custom analyzers enforce project-specific conventions automatically in CI and your editor
- Try today: Write a 30-line analyzer, wire it through
singlechecker.Main, and run it against your codebase