Verification tools
Every output below is real, captured by running each tool against patterns from the AI Bug Catalog. None of these tools overlap perfectly, and none of them is close to complete — the point of this lesson is to know exactly where each one’s coverage ends, so you know what is still on you.
go vet — built into go test, catches structural bugs for free
Section titled “go vet — built into go test, catches structural bugs for free”go vet ships with the Go toolchain and runs automatically before go test. It only reports things it can prove from the type system and a fixed set of analyzers — no style opinions, very few false positives, and no configuration required.
Catches, unconditionally, in this catalog:
copylocks: Inc passes lock by value: main.Counter contains sync.Mutexwaitgroup: WaitGroup.Add called from inside new goroutineerrorsas: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface typemain.go:10:8: using resp before checking for errorsCatches only with the go.mod directive it applies to: the loopclosure analyzer flags a goroutine capturing a loop variable — but only under a go directive older than 1.22, since the bug itself only exists there.
Misses: everything that requires proving something about program behavior rather than structure — leaks, nil-map writes that aren’t provably always-nil, missing status-code checks, missing Timeout fields, err shadowing (the shadow analyzer exists but is not run by default — enable it explicitly with go vet -vettool=$(which shadow) or golangci-lint’s govet.enable: [shadow] setting).
staticcheck — deeper static analysis, no false-negative shame
Section titled “staticcheck — deeper static analysis, no false-negative shame”staticcheck subsumes go vet’s checks and adds hundreds more, organized as SA* (bugs), ST* (style), and other prefixes. It is slower and occasionally flags something intentional, but it catches real bugs go vet structurally cannot.
Catches, in this catalog:
SA4023: this comparison is always true (typed-nil error, when provable)SA2000: should call wg.Add(1) before starting the goroutine to avoid a raceSA5000: assignment to nil map (only when provably always-nil)SA4011: ineffective break statement. Did you mean to break out of the outer loop?SA1004: sleeping for 5 nanoseconds is probably a bug; be explicit if it isn'tST1006: receiver name should be a reflection of its identity; don't use generic names such as "this" or "self"Misses: the same runtime-only categories go vet misses, plus anything requiring whole-program knowledge — most notably, whether an interface has one implementation or ten.
golangci-lint v2 — runs go vet, staticcheck, and dozens more in one pass
Section titled “golangci-lint v2 — runs go vet, staticcheck, and dozens more in one pass”golangci-lint is a meta-linter: linters.default: standard in its config runs a curated set that already includes go vet (as govet) and staticcheck, plus errcheck, unused, and others. One command replaces running each tool by hand.
version: "2"linters: default: standardCatches everything go vet and staticcheck catch above, reported under the linter that found it — golangci-lint run on the errors.As-without-& file reports:
main.go:19:5: errorsas: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type (govet)Also catches, via errcheck (part of standard) — an unchecked return value, a different bug from the ones it happens to sit next to in this catalog:
main.go:10:23: Error return value of `resp.Body.Close` is not checked (errcheck)Needs opting in, even though the analyzer exists in golangci-lint’s toolbox:
shadow(theerr-shadowing check) — enable vialinters.settings.govet.enable: [shadow].modernize(flagsinterface{}whereanyreads better, among other rewrites) — enable vialinters.enable: [modernize].
Misses: identical blind spots to go vet + staticcheck combined, since that is what it runs.
-race — the only tool on this list that watches the program run
Section titled “-race — the only tool on this list that watches the program run”go run -race and go test -race instrument every memory access and detect actual concurrent, unsynchronized access to the same memory during that specific run — not a theoretical possibility, an observed one.
Catches, in this catalog — the wg.Add-inside-goroutine pattern, run with -race:
WARNING: DATA RACEWrite at 0x00c00013e000 by goroutine 7: main.main.func1() .../main.go:18 +0xf0Previous read at 0x00c00013e000 by main goroutine: main.main() .../main.go:24 +0x23cFound 1 data race(s)Misses: any race that does not happen to occur on the specific run you tested — the race detector proves the presence of a race it observed, never the absence of one it didn’t. It also has nothing to say about goroutine leaks, resource leaks, or any single-threaded logic bug; those are not races.
govulncheck — known CVEs, filtered to code you actually call
Section titled “govulncheck — known CVEs, filtered to code you actually call”govulncheck cross-references your dependencies against the Go vulnerability database, then narrows the result to vulnerabilities your code’s call graph can actually reach — not just “a vulnerable version is somewhere in go.sum.”
Real output, a module depending on golang.org/x/[email protected] and calling language.Parse:
Vulnerability #1: GO-2021-0113 Out-of-bounds read in golang.org/x/text/language More info: https://pkg.go.dev/vuln/GO-2021-0113 Module: golang.org/x/text Found in: golang.org/x/[email protected] Fixed in: golang.org/x/[email protected] Example traces found: #1: main.go:10:26: vulndemo.main calls language.Parse
Your code is affected by 1 vulnerability from 1 module.This scan also found 1 vulnerability in packages you import and 2vulnerabilities in modules you require, but your code doesn't appear to callthese vulnerabilities.Misses: anything in this catalog. govulncheck answers one question only — “does a known, published CVE affect code I actually reach” — and has nothing to say about logic bugs, concurrency bugs, or API contract violations, whether they are yours or a dependency’s undiscovered ones.
gopls — the same analysis your editor already runs, from the command line
Section titled “gopls — the same analysis your editor already runs, from the command line”gopls check <file> runs the same diagnostics your editor shows inline, without opening one — useful in CI or over SSH. It includes go vet’s analyzers and adds a few of its own, like nilness analysis.
Real output, on the errors.As-without-& file — gopls finds both the errors.As misuse and a second, consequential bug in the same file that no other tool in this list reported:
main.go:19:5-23: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface typemain.go:20:32: nil dereference in field selectionThe second line is gopls’s own nilness analyzer: since errors.As will fail (per the first diagnostic), ve stays nil, and the next line’s ve.Field is a guaranteed nil pointer dereference — a consequence go vet and staticcheck did not connect.
Misses: the same categories as go vet, since it is built on the same analysis framework, plus anything needing whole-program or runtime information.
go test -cover — tells you what was never run, not what is broken
Section titled “go test -cover — tells you what was never run, not what is broken”go test -cover reports the percentage of statements executed during the test run. It is not a bug finder; it is a map of where your other tools — and your own reading — never even looked.
Real output, a Store.Set method whose only test exercises Get:
ok scratch/covdemo 0.474s coverage: 66.7% of statementsThe untested 33.3% is exactly Set — the method that panics on a nil map. Coverage did not find that bug; it found that nobody had looked.
Misses: correctness entirely. 100% coverage proves every line ran at least once during some test — it proves nothing about whether the assertions in that test were the right ones, or whether the input that triggered the bug was ever tried.
Running the whole set in one command
Section titled “Running the whole set in one command”golangci-lint run already covers go vet and staticcheck together. Add govulncheck for dependency CVEs and go test -race -cover for runtime races and coverage, and one line covers everything on this page except gopls (which is interactive/editor-oriented, not a CI step):
golangci-lint run && govulncheck ./... && go test -race -cover ./...Mental Model
Section titled “Mental Model”flowchart TD
subgraph Static["Static (source only)"]
Vet["go vet<br/>structural bugs (type-provable):<br/>copylocks, waitgroup, errorsas, loopclosure"]
SC["staticcheck (SA*, ST*)<br/>deeper static bugs"]
Vuln["govulncheck<br/>known CVEs reached by your call graph"]
end
subgraph Dynamic["Dynamic (must run)"]
Race["-race<br/>concurrency bugs that actually occur (only what ran)"]
Cover["go test -cover<br/>what ran vs. didn't"]
end
GCL["golangci-lint runs the whole Static box (go vet + staticcheck + more) in one command"] --> Vet
GCL --> SC