Catching bugs before they ship is what separates professional Go codebases from experimental prototypes. The official static analysis framework built by the Go team gives you a standardized, composable way to write, share, and chain custom linters without reinventing the wheel. In this tutorial, you'll learn how to leverage golang.org/x/tools/go/analysis to build a modular analysis pipeline that integrates seamlessly with go vet, CI systems, and modern editor tooling.
What Is the Go Analysis Framework?
When you run go vet ./..., you're already using the output of this framework. Under the hood, the Go team built golang.org/x/tools/go/analysis as a language-agnostic specification for writing static analyzers. Instead of treating linters as monolithic scripts, the framework breaks the problem into small, reusable units called Analyzer structs. Each analyzer focuses on a single concern, and the framework handles parsing, type-checking, parallel execution, and result aggregation for you.
This modular design means you can compose multiple checks into a single pipeline, share them across teams via go get, and plug them into existing workflows without maintaining custom AST traversal logic. Whether you're building internal policy enforcement, catching deprecated API usage, or enforcing formatting rules, the framework gives you a consistent contract to work against.
Core Concepts: Analyzers, Passes, and Reports
Before writing code, it helps to understand the three components that drive every analysis run:
-
Analyzer: A configuration struct that declares the tool's name, documentation, required dependencies, and the
Runcallback. -
Pass: The execution context passed to your
Runfunction. It contains the parsed AST (pass.Files), file set (pass.Fset), type-checked packages (pass.TypesInfo), and a reporting mechanism (pass.Report). -
Report: The mechanism for emitting findings. You create an
analysis.Diagnosticwith a position and message, then hand it topass.Report.
The framework enforces a strict separation between analysis logic and output rendering. This means the same analyzer can produce JSON, human-readable diffs, or IDE quick-fix suggestions depending on how you run it.
Building Your First Modular Static Analyzer
Let's build a practical example: an analyzer that flags direct usage of fmt.Println in production code and suggests log.Println instead. This is a common policy enforcement scenario for teams that want consistent logging across services.
Step 1: Define the Analyzer
Create a new module and add the framework as a dependency:
mkdir go-modular-analyzer && cd go-modular-analyzer
go mod init github.com/yourusername/go-modular-analyzer
go get golang.org/x/tools/go/analysis
go get golang.org/x/tools/go/analysis/singlechecker
Now, define the analyzer in cmd/myanalyzer/main.go:
package main
import (
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/singlechecker"
)
var Analyzer = &analysis.Analyzer{
Name: "fmtprintnolint",
Doc: "flags fmt.Println usage and suggests log.Println",
Run: run,
}
func main() {
singlechecker.Main(Analyzer)
}
Step 2: Inspect the AST
Implement the run function. You'll walk the AST, look for function calls, and verify the selector matches fmt.Println:
import (
"go/ast"
"go/token"
"golang.org/x/tools/go/analysis"
)
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
}
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != "fmt" || sel.Sel.Name != "Println" {
return true
}
pass.Report(analysis.Diagnostic{
Pos: call.Pos(),
End: call.End(),
Message: "fmt.Println detected. Use log.Println for production logging.",
})
return true
})
}
return nil, nil
}
Notice how pass.Files gives you access to the parsed packages without you managing go/parser or go/ast lifecycle. The framework also handles incremental builds, so re-running the analyzer on unchanged files is nearly free.
Step 3: Wire It Up for CLI Execution
Because you're using singlechecker, you can compile and run the analyzer like any standard Go tool:
go build -o myanalyzer ./cmd/myanalyzer
myanalyzer ./...
If you target a file containing fmt.Println("hello"), you'll see:
path/to/main.go:10:5: fmt.Println detected. Use log.Println for production logging.
Composing Analyzers for Pipeline Execution
The real power of this framework emerges when you chain multiple analyzers together. Because each Analyzer is just a struct with a Run function, you can pass them as dependencies to one another or run them sequentially in a single analysis.Run call.
import (
"golang.org/x/tools/go/analysis"
)
var AllAnalyzers = []*analysis.Analyzer{
Analyzer,
// Add more analyzers here as they grow
}
func main() {
// For pipeline execution:
analysis.Run(AllAnalyzers, nil, false)
}
When you use analysis.Run, the framework automatically detects dependency edges via the Requires field. If analyzer B needs the type-checked information produced by analyzer A, it will wait for A to complete before invoking its Run function. This eliminates race conditions and manual orchestration.
For teams managing dozens of internal linters, this dependency graph becomes your analysis pipeline documentation. You can visualize it, enforce execution order, and even skip analyzers that don't apply to specific packages using the RunOnlyWhen hook.
Integrating with go vet and CI Pipelines
One of the most practical benefits of writing against the standard framework is native go vet compatibility. If you name your analyzer fmtprintnolint and compile it as a Go vet plugin, it will automatically execute when someone runs:
go vet -vettool=./myanalyzer ./...
In CI environments, this translates to a single, reliable gate:
- name: Run static analysis
run: |
go vet -vettool=$(go build -o /tmp/myanalyzer ./cmd/myanalyzer) ./...
Because the framework standardizes exit codes and diagnostic formatting, your CI runner can parse results consistently whether they come from built
Enjoyed this? I build simple, powerful AI tools — try the free Text Summarizer or browse the full toolkit at Solomon Tools. No signup, no subscription.
Top comments (0)