How Go’s Code Checkers Actually Work

go/analysis looks at one package at a time, and Facts are how a conclusion gets downstream

分享
cover-medium-go-analysis-lint

Golang

How Go’s Code Checkers Actually Work

I once spent half an afternoon chasing a connection-count alert back to 3 call sites in an internal SDK, all missing the same line:

resp, err := http.Get(url) 
if err != nil { 
    return err 
} 
defer resp.Body.Close()

Drop that defer resp.Body.Close() and nothing errors, nothing panics. The connection pool just drains slowly under load, file descriptors creep up, and by the time an alert fires the code has usually been in production for days.

go vet doesn't catch this. Its built-in checks cover unreachable code, printf mismatches, shadowed variables. Nothing about resource cleanup. The tools that do catch it are community-built, like timakin's bodyclose. How does a linter "understand" a *http.Response needs closing? I built a simplified version to find out, and along the way ended up reading the framework all of these tools share: golang.org/x/tools/go/analysis.

Getting it to run on a single function

go/analysis's skeleton is thin. The core is an Analyzer struct and a Run function:

package bodyclose 
  
import ( 
    "go/ast" 
    "go/types" 
  
    "golang.org/x/tools/go/analysis" 
    "golang.org/x/tools/go/analysis/passes/inspect" 
    "golang.org/x/tools/go/ast/inspector" 
) 
  
var Analyzer = &analysis.Analyzer{ 
    Name:     "bodyclose", 
    Doc:      "check that *http.Response.Body is closed", 
    Run:      run, 
    Requires: []*analysis.Analyzer{inspect.Analyzer}, 
} 
  
func run(pass *analysis.Pass) (interface{}, error) { 
    insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) 
  
    insp.Preorder([]ast.Node{(*ast.FuncDecl)(nil)}, func(n ast.Node) { 
        fn := n.(*ast.FuncDecl) 
        if fn.Body != nil { 
            checkFunc(pass, fn.Body) 
        } 
    }) 
    return nil, nil 
}

Requires: []*analysis.Analyzer{inspect.Analyzer} says: run inspect.Analyzer before me. Its output is an *inspector.Inspector, an AST walker with the index already built. The payoff is direct — if ten analyzers all need to walk the same syntax tree, inspect.Analyzer builds that index once and everyone reuses it. This is a horizontal dependency: analyzers lending each other work inside the same package.

checkFunc does one plain thing: find anything that looks like a response from an HTTP call, then check whether .Body.Close() was called on it:

func checkFunc(pass *analysis.Pass, body *ast.BlockStmt) { 
    var pending []*ast.Ident 
    closed := map[string]bool{} 
  
    ast.Inspect(body, func(n ast.Node) bool { 
        switch stmt := n.(type) { 
        case *ast.AssignStmt: 
            if len(stmt.Rhs) == 1 { 
                if call, ok := stmt.Rhs[0].(*ast.CallExpr); ok && returnsHTTPResponse(pass, call) { 
                    if id, ok := stmt.Lhs[0].(*ast.Ident); ok && id.Name != "_" { 
                        pending = append(pending, id) 
                    } 
                } 
            } 
        case *ast.CallExpr: 
            // matches the shape resp.Body.Close() 
            if sel, ok := stmt.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Close" { 
                if inner, ok := sel.X.(*ast.SelectorExpr); ok && inner.Sel.Name == "Body" { 
                    if id, ok := inner.X.(*ast.Ident); ok { 
                        closed[id.Name] = true 
                    } 
                } 
            } 
        } 
        return true 
    }) 
  
    for _, id := range pending { 
        if !closed[id.Name] { 
            pass.Reportf(id.Pos(), "response %q is never closed", id.Name) 
        } 
    } 
} 
  
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 the key piece here. It's a table go/types has already built, resolving every identifier reference in the source to a concrete type object. No need to guess what sel.Sel actually calls or what it returns. go/types worked that out during type-checking and left the answer sitting there.

Wrap it with singlechecker.Main(Analyzer), point it at a function with a real missing Close, and the diagnostic lands exactly on that line. A few dozen lines, and it covers the single-package case.

One wrapper function and the checker goes blind

Nobody calls http.Get bare all over a real codebase. It usually gets wrapped:

// pkg/upstream/client.go 
package upstream 
  
func FetchUser(id string) (*http.Response, error) { 
    resp, err := http.Get("https://api.internal/users/" + id) 
    if err != nil { 
        return nil, err 
    } 
    return resp, nil 
} 
// main.go 
resp, err := upstream.FetchUser("42") 
if err != nil { 
    return err 
} 
// forgot resp.Body.Close()

Miss that line, and it might not blow up in production for months.

returnsHTTPResponse only looks at the return type, and FetchUser's signature happens to be (*http.Response, error) too, so this check passes right through. go/types resolves the full signature of every imported function regardless. Type information is visible across package boundaries. This version of the checker happens to catch this particular example.

What actually stalls it is a different question: did FetchUser handle the response internally before returning it? The type signature can't answer it. Someone has to walk the actual function body to know.

And the function body is only reachable while upstream itself is being analyzed. Pass.Files holds the syntax tree of whichever package is currently under analysis. By the time main is being analyzed, upstream is down to its type signatures, and the body isn't in reach anymore.

This is deliberate in go/analysis: one package at a time, like a compiler's separate compilation, in exchange for parallelism and incrementality. The cost shows up right here — a conclusion like "what did this function's body actually do" can only be computed once, while analyzing the package that owns it, and then has to be carried forward somehow to whoever analyzes the callers later.

Pass.Files only holds the package currently being analyzed — the function body is out of reach the moment analysis crosses a package boundary.

Fact: carrying a conclusion across a package boundary

The mechanism for carrying a conclusion across packages is called Fact. The interface has exactly one method:

type Fact interface { 
    AFact() // empty method, used purely as a type marker 
}

The official printf checker does exactly this: deciding whether a function is a wrapper around fmt.Printf (like log.Fatalf) uses this same mechanism. Its Fact type looks like this:

// isWrapper is a fact indicating that a function is a 
// print or printf wrapper. 
type isWrapper struct{ Kind Kind } 
  
func (f *isWrapper) AFact() {}

Where it’s exported:

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

Where it’s imported:

var fact isWrapper 
if pass.ImportObjectFact(obj, &fact) { 
    kind = fact.Kind 
}

While analyzing the log package, the printf checker walks Fatalf's body, notices it forwards its arguments straight to fmt.Sprintf, and stamps a Fact: this function is a printf wrapper. When it later analyzes code that calls log.Fatalf, it doesn't re-read log's source — it just asks whether this function is carrying an isWrapper Fact. If yes, its arguments get checked under printf's rules.

(golang/tools · printf.go)

Making the checker work across packages for real

Back to bodyclose. Give it a Fact type:

// needsClose marks that the *http.Response returned by this 
// function is the caller's responsibility to close. 
type needsClose struct{} 
  
func (*needsClose) AFact() {} 
  
var Analyzer = &analysis.Analyzer{ 
    Name:      "bodyclose", 
    Doc:       "check that *http.Response.Body is closed", 
    Run:       run, 
    Requires:  []*analysis.Analyzer{inspect.Analyzer}, 
    FactTypes: []analysis.Fact{new(needsClose)}, 
}

While analyzing upstream, if FetchUser returns an unclosed response straight to its caller, that isn't a bug in upstream itself, so no diagnostic gets reported there. Instead, stamp a Fact and hand the conclusion off:

if obj, ok := pass.TypesInfo.Defs[fn.Name].(*types.Func); ok { 
    pass.ExportObjectFact(obj, &needsClose{}) 
}

While analyzing main, at the call to upstream.FetchUser(...), ask the reverse question:

if callee, ok := pass.TypesInfo.Uses[sel.Sel].(*types.Func); ok { 
    var fact needsClose 
    if pass.ImportObjectFact(callee, &fact) { 
        // this call's response also needs Close — feed it into 
        // the same pending/closed bookkeeping as before 
    } 
}
The needsClose Fact travels from the upstream package all the way to main — it's still the same missing Close that trips the check.
creator created

Run it, and the missing resp.Body.Close() in main.go gets caught, even though the response took a detour through another package's function first.

This version is still simplified. It doesn’t do real control-flow analysis, so if/else branches and assignments inside for loops can slip past it. Production-grade bodyclose runs control-flow graph analysis on top of go/ssa, tracking a variable's state across every execution path.

Requires vs FactTypes: two different directions

The Analyzer struct has two fields that are easy to confuse. Side by side:

Table Image
Requires lets analyzers inside the same package lend each other work. FactTypes lets a conclusion travel across a package boundary.
creator created

The first one saves a recomputation; the second gets you a conclusion when the source isn’t in front of you.

Beyond reporting: let it fix the code

So far pass.Reportf only prints a line of text. But the Diagnostic struct also has a field called SuggestedFixes, which lets an analyzer hand back a fix along with the report:

type SuggestedFix struct { 
    Message   string       // description for a human 
    TextEdits []TextEdit   // edit instructions for a machine 
} 
  
type TextEdit struct { 
    Pos     token.Pos    // from where 
    End     token.Pos    // to where 
    NewText []byte       // replace with what 
}

One sentence for a human, plus a set of “replace this span from Pos to End with NewText" instructions. For a pure insertion, Pos and End are just the same value.

Swap Reportf for the full pass.Report, and bodyclose can hand back a fix that inserts the missing defer:

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("\ndefer %s.Body.Close()", name)), 
        }}, 
    }}, 
})

The tricky part is where c.insertPos should land. Right after the assignment doesn't work: that would insert it before the if err != nil check, and resp is nil there when err isn't, so calling Close panics. Look one statement further: if the next statement after the assignment is a guard like if err != nil, insert after that guard's closing brace.

Run it with -fix and it actually rewrites the file:

$ bodyclose -fix ./example/... 
$ git diff 
+	defer resp.Body.Close()

The insertion lands right after the error check, indentation intact. The hand-written "\ndefer ..." in the TextEdit carries no tabs; the driver runs gofmt after applying the edit.

Every “quick fix” suggestion in gopls, every go fix rewrite, sits on top of this same structure. The analyzer computes Pos, End, and NewText and stops there; whether to apply the edit, and whether to ask first, is left to the editor.

Fact has a real-world catch

Fact sounds like something that keeps working everywhere it's needed. The official docs have a cold-water paragraph:

“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.”

Some build systems simply never run analyzers over the standard library. The printf checker is fully capable of deducing, while analyzing log, that log.Printf is a wrapper — but under these drivers that Fact never gets produced, because log never gets analyzed in the first place.

Its workaround is unglamorous: hard-code the conclusion into the analyzer as a built-in fact. Real-world analyzers end up as a hybrid: Fact inference, with a static fallback underneath. The docs close the paragraph with “We would like to remove this limitation in future” — a known piece of historical baggage, not the original design intent.

This interface holds up the entire toolchain

Wrap the analyzer above with singlechecker.Main and it's a runnable command-line tool. It also comes with a second mode for free: there's one check buried in the source.

if len(args) == 1 && strings.HasSuffix(args[0], ".cfg") { 
    unitchecker.Run(args[0], analyzers) 
    panic("unreachable") 
}

Pass it a single argument ending in .cfg and it switches to the unitchecker protocol — the thing running behind go vet -vettool=. Same binary, runnable standalone or plugged straight into go vet:

$ go vet ./...                              # standard vet, not a word 
$ go vet -vettool=./bodyclose ./example/... 
example/main.go:12:2: response "resp" is never closed

Want to run several analyzers at once? Swap in multichecker.Main, which takes a variadic signature.

go vet, gopls, golangci-lint, and Uber's nil-pointer hunter nilaway all stand on the same Analyzer, Pass, and Fact. On pkg.go.dev, this package is imported by 6,500+ packages as of writing. The same modular analysis design, reused 6,500 times.

Back to that SDK with the three missing Close calls. A lint rule went onto CI after that, blocking any merge that trips it, and the same mistake hasn't happened since.

References