> ## Content Index
> Fetch the complete content index at: https://huizhou92.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# How Go’s Code Checkers Actually Work
- URL: https://huizhou92.com/how-gos-code-checkers-actually-work/
- Published: 2026-08-28T03:39:22.000Z
- Updated: 2026-09-08T02:08:17.000Z
- Description: How Go’s Code Checkers Actually Work. Golang How Go’s Code Checkers Actually Work I once spent half an afternoon chasing a connection-count alert back to 3。
- Author: huizhou92
- Tags: #Migrated-1788833207488, #Import 2026-09-08 02:07

#### 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:

```go
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:

```go
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:

```go
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:

```go
// 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.](https://huizhou92.com/content/images/2026/09/0-wx7ahq-yglaflpc8.png)

#### **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:

```go
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:

```go
// 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:

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

Where it’s imported:

```go
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](https://github.com/golang/tools/blob/master/go/analysis/passes/printf/printf.go?ref=huizhou92.com))

#### **Making the checker work across packages for real**

Back to `bodyclose`. Give it a Fact type:

```go
// 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:

```go
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:

```go
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.](https://huizhou92.com/content/images/2026/09/0-xcz31ymrgktssvsm.png)

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](https://huizhou92.com/content/images/2026/09/1-3yihkuglomkokm8ki5kaqq.png)

![Requires lets analyzers inside the same package lend each other work. FactTypes lets a conclusion travel across a package boundary.](https://huizhou92.com/content/images/2026/09/0-84dcvpbpnwfp7-pe.png)

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:

```go
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.

```go
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`:

```bash
$ 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**

- Package docs: [pkg.go.dev/golang.org/x/tools/go/analysis](https://pkg.go.dev/golang.org/x/tools/go/analysis?ref=huizhou92.com)
- printf checker source: [golang/tools · printf.go](https://github.com/golang/tools/blob/master/go/analysis/passes/printf/printf.go?ref=huizhou92.com)
- inspector package: [pkg.go.dev/golang.org/x/tools/go/ast/inspector](https://pkg.go.dev/golang.org/x/tools/go/ast/inspector?ref=huizhou92.com)
- bodyclose: [github.com/timakin/bodyclose](https://github.com/timakin/bodyclose?ref=huizhou92.com)
- nilaway: [github.com/uber-go/nilaway](https://github.com/uber-go/nilaway?ref=huizhou92.com)