DEV Community

Solomon
Solomon

Posted on

Go Analysis Framework: modular static analysis by go team

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 Run callback.
  • Pass: The execution context passed to your Run function. 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.Diagnostic with a position and message, then hand it to pass.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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

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) ./...
Enter fullscreen mode Exit fullscreen mode

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)