How Go Code Analysis Works: Building a Cross-Package Linter with Facts

This article explains Go's go/analysis framework by building a linter that detects unclosed http.Response.Body, demonstrating AST inspection, type-driven detection, cross-package fact propagation, and automatic fix suggestions, while comparing Requires vs FactTypes and noting real-world limitations.

Radish, Keep Going!
Radish, Keep Going!
Radish, Keep Going!
How Go Code Analysis Works: Building a Cross-Package Linter with Facts

The article begins with a real-world bug: a missing defer resp.Body.Close() after an http.Get call. The code compiles and runs without panic, but over hours or days the connection pool exhausts and file descriptors leak. Go's built-in go vet cannot catch this; community tools like bodyclose (by timakin) can. The author builds a simplified checker to understand the underlying framework: golang.org/x/tools/go/analysis.

Getting a Single-Package Checker Running

The core of go/analysis is an Analyzer struct with a Run function. The example registers inspect.Analyzer as a requirement, which provides a pre-built AST inspector ( *inspector.Inspector) shared across analyzers.

var Analyzer = &analysis.Analyzer{
	Name: "bodyclose",
	Doc: "check that *http.Response.Body is closed",
	Run: run,
	Requires: []*analysis.Analyzer{inspect.Analyzer},
}

The run function uses the inspector to preorder-traverse function declarations, then calls checkFunc on each function body. checkFunc collects identifiers assigned from calls that return *http.Response (via returnsHTTPResponse) and tracks whether .Body.Close() is called on them.

func returnsHTTPResponse(pass *analysis.Pass, call *ast.CallExpr) bool {
	sel, ok := call.Fun.(*ast.SelectorExpr)
	if !ok { return false }
	fn, ok := pass.TypesInfo.Uses[sel.Sel].(*types.Func)
	if !ok { return false }
	sig := fn.Type().(*types.Signature)
	return sig.Results().Len() > 0 &&
		sig.Results().At(0).Type().String() == "*net/http.Response"
}
pass.TypesInfo.Uses

is a go/types -computed table mapping every identifier reference to its type object, eliminating guesswork about which function is called and its return type.

Wrapping with singlechecker.Main(Analyzer) produces a runnable command-line tool that correctly flags a missing Close in a single function.

Wrapper Functions Break the Checker

Real projects wrap http.Get in helper functions, e.g., upstream.FetchUser returning (*http.Response, error). The type signature matches, so returnsHTTPResponse still passes. However, the checker cannot know whether FetchUser itself closed the response before returning it — that requires analyzing FetchUser 's body, which resides in another package. go/analysis intentionally analyzes one package at a time (like separate compilation) to enable parallelism and incrementality. Pass.Files only contains the current package's syntax trees; when analyzing main, the upstream package body is unavailable.

Fact: Carrying Conclusions Across Packages

The mechanism for cross-package conclusion passing is Fact, an interface with a single marker method AFact(). The official printf checker uses this to mark functions like log.Fatalf as printf wrappers.

type isWrapper struct { Kind Kind }
func (f *isWrapper) AFact() {}

When analyzing the log package, the checker exports a fact on log.Fatalf:

pass.ExportObjectFact(origin(w.obj), &isWrapper{Kind: kind})

. Later, when analyzing a caller of log.Fatalf, it imports the fact: pass.ImportObjectFact(obj, &fact).

Making the Checker Truly Cross-Package

For bodyclose, define a needsClose fact:

type needsClose struct{}
func (*needsClose) AFact() {}

var Analyzer = &analysis.Analyzer{
	... 
	FactTypes: []analysis.Fact{new(needsClose)},
}

When analyzing upstream, if FetchUser returns an unclosed response, export the fact on the function object: pass.ExportObjectFact(obj, &needsClose{}). When analyzing main and encountering a call to upstream.FetchUser, import the fact: if present, treat the returned response as needing a close and run the same pending/closed bookkeeping.

This enables detection of the missing Close in main.go even though the response originated in another package. The article notes this version still lacks true control-flow analysis (branches, loops); production bodyclose uses go/ssa for CFG-based tracking.

Requires vs FactTypes: Two Different Dimensions

Requires — Horizontal, same package. Reuses results from other analyzers in the same package (e.g., inspect.Analyzer 's AST index).

FactTypes — Vertical, cross-package. Passes conclusions from analyzing this package to downstream package analyzers. Requires avoids duplicate computation; FactTypes solves the problem of source code not being available across package boundaries.

Beyond Reporting: Automatic Fixes

pass.Reportf

only prints text. Diagnostic has a SuggestedFixes field containing TextEdit instructions (position range + replacement bytes). The article shows converting Reportf to a full pass.Report with a fix that inserts defer resp.Body.Close() after the error-check block.

pass.Report(analysis.Diagnostic{
	Pos: c.ident.Pos(),
	End: c.ident.End(),
	Message: fmt.Sprintf("response %q is never closed", name),
	SuggestedFixes: []analysis.SuggestedFix{{
		Message: fmt.Sprintf("insert defer %s.Body.Close()", name),
		TextEdits: []analysis.TextEdit{{
			Pos: c.insertPos,
			End: c.insertPos,
			NewText: []byte(fmt.Sprintf("
defer %s.Body.Close()", name)),
		}},
	}},
})

The insert position must be after the if err != nil guard to avoid a nil-pointer panic. Running with -fix applies the edit and the driver re-formats the code.

A Practical Limitation of Facts

"Some driver implementations (such as those based on Bazel and Blaze) do not currently apply analyzers to packages of the standard library. Therefore, for best results, analyzer authors should not rely on analysis facts being available for standard packages."

Certain build systems skip running analyzers on the standard library. Consequently, facts for standard-library functions (e.g., log.Printf being a printf wrapper) are never generated. The printf checker works around this by hardcoding such facts as built-in fallbacks. Real-world analyzers often combine fact derivation with static fallbacks.

One Interface Powers the Entire Toolchain

The same analyzer binary can run standalone via singlechecker.Main or plug into go vet -vettool= via unitchecker (triggered by a .cfg argument). multichecker.Main runs multiple analyzers. Tools like go vet, gopls, golangci-lint, and Uber's nilaway all build on the same Analyzer, Pass, Fact foundation. The package has 6,500+ dependents on pkg.go.dev.

The author concludes by noting that adding the linter to CI prevented recurrence of the original missing- Close bug.

References

Package docs: https://pkg.go.dev/golang.org/x/tools/go/analysis

printf checker source: https://github.com/golang/tools/blob/master/go/analysis/passes/printf/printf.go

inspector package: https://pkg.go.dev/golang.org/x/tools/go/ast/inspector

bodyclose: https://github.com/timakin/bodyclose

nilaway: https://github.com/uber-go/nilaway

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

static analysislinterAST inspectioncross-package analysisFact mechanismgo/analysisgo/typessuggested fixes
Radish, Keep Going!
Written by

Radish, Keep Going!

Personal sharing

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.