How do you package custom analyzers into a binary that go vet -vettool can run, and what does that protocol constrain?
answer
- one main, one call, one binary
- one for a check, one for a suite
- the go command asks the tool first
- a .cfg file describes one compilation unit
- anything crossing packages has to be a fact
basics
~20 sBuild a main package that calls singlechecker.Main for one analyzer or multichecker.Main for a suite; both speak the vet protocol. The go command then runs that binary once per package, giving it only that package plus its dependencies' export data.
solid answer
~50 sA driver is a `main` package: `singlechecker.Main(Analyzer)` for one check, `multichecker.Main(a, b, c)` for a suite, or `unitchecker.Main(...)` for a binary only a build tool will call. The first two are dual-mode — run directly they analyse package patterns themselves, and when the go command hands them a single `.cfg` file they delegate to `unitchecker.Run`, which is why the same binary works under `go vet -vettool`. The protocol is deliberately narrow: the go command asks for `-V=full`, which becomes part of the cache key, and `-flags`, then invokes the tool once per package with a config naming that package's files, its dependencies' export data and its fact files. One invocation therefore sees exactly one package — no whole-program view, no scanning the repository — and anything crossing package boundaries must travel as `analysis.Fact` values declared in `FactTypes`. Naming a vettool also replaces the standard vet binary, so the toolchain's own checks run only if you link them in.
code
go · 3 linesfunc main() {
singlechecker.Main(Analyzer)
}go deeper
Know that an analyzer needs a driver: a tiny main package that calls one of the checker Main functions, producing a binary you can run directly or hand to go vet.
Explain that the go command invokes the tool once per package with a config file, and that dependency information arrives as export data and facts rather than as source.
Show the operational consequences: per-package invocation, cached results keyed on tool identity, ignored files under build constraints, and the fact that a vettool replaces the standard vet binary.
Decide whether checks ship as one suite binary or several, since that choice sets who can roll a version, how staged rollout is possible, and what the marginal cost of the next check is.
## Three drivers, one framework An `*analysis.Analyzer` cannot run by itself; something has to schedule it. `golang.org/x/tools/go/analysis` ships the drivers: - **`singlechecker.Main(a *analysis.Analyzer)`** — a `main` for exactly one check. The binary is conventionally named after the check. - **`multichecker.Main(analyzers ...*analysis.Analyzer)`** — one binary containing a suite. Each check gets its own `-name` flag so users can enable or disable them individually, and checks that share a `Requires` dependency (typically `inspect.Analyzer`) walk the syntax once between them. - **`unitchecker.Main(analyzers ...*analysis.Analyzer)`** — a driver that *only* speaks the build-tool protocol, for a binary that will never be run by hand. `singlechecker` and `multichecker` are dual-mode. Invoked with package patterns they load and analyse packages themselves and print diagnostics; invoked with a single `.cfg` file argument — which is what the go command does — they delegate to `unitchecker.Run` and behave as a vet tool. That is why one binary covers both `mycheck ./...` for local experimentation and `go vet -vettool=mycheck ./...` for the build. ## The protocol When you pass `-vettool=prog`, the go command: 1. runs `prog -V=full` to obtain the tool's identity, which becomes part of the cache key for vet results; 2. runs `prog -flags` to learn, as JSON, which flags the tool accepts, so that `go vet -mycheck.strict` can be forwarded; 3. for each package in the build, writes a config file describing that *compilation unit* — the Go files, the package's import map, the export data files for its dependencies, the paths of fact files for those dependencies, and where to write its own facts — and runs `prog thatfile.cfg`. Diagnostics come back on standard error and the exit status tells the go command whether the package failed. ## What the protocol constrains **One package per invocation.** The tool is not handed the module; it is handed a unit. It must not go looking for other source on disk — under a build system the source may not even be where the tool expects, and results would stop being reproducible or cacheable. **Cross-package information must be facts.** If your check needs to know that a function in another package is, say, a logging wrapper that takes a format string, the analyzer declares a `FactTypes` entry, exports a fact about that object with `pass.ExportObjectFact` when it analyses the declaring package, and reads it back with `pass.ImportObjectFact` when it analyses a caller. The driver serialises facts to files and passes them along the dependency edges; declaring `FactTypes` is also what makes the driver run your analyzer on dependencies at all. Fact types must be serialisable and pointer-shaped, and facts must be deterministic, or caching turns into flakiness. **Only what the build gave you.** Files excluded by build constraints or `GOOS`/`GOARCH` are not in `pass.Files`; they appear in `pass.IgnoredFiles`. A check that only fires in platform-specific code will not fire on a build for the other platform. **Your tool replaces the standard one.** `-vettool` names the program the go command runs instead of the toolchain's own vet binary. Checks that normally run come along only if your driver links them in, which is exactly why suites are usually built with `multichecker` and include the upstream analyzers the team still wants alongside the local ones. **Version compatibility matters.** The tool reads export data produced by the compiler in use. A driver built against a much older toolchain can fail to decode newer export data, so the analyzer binary is something that has to be rebuilt as the toolchain moves, not built once and forgotten. **Results are cached.** Because vet results are cached against the tool identity from `-V=full`, a rebuilt tool that reports an unchanged identity can serve stale results; this is worth knowing the first time a change to a check appears to have no effect. ## Choosing between them For a platform team shipping checks to many repositories, `multichecker` is almost always right: one binary, one version to roll, per-check flags for staged rollout, and shared traversal so the marginal cost of the fifth check is small. `singlechecker` is for a check that is genuinely standalone — published for others to compose into their own driver. `unitchecker` is for the case where a build system, not a human, is the only caller. ## The other thing the protocol buys you Because the go command drives it per package, a vettool inherits the build's own view of the world: the same build tags, the same module resolution, the same package set the compiler saw. A separate linter that loads packages itself has to reconstruct all of that and will disagree with the build sooner or later. That agreement is the main reason to run a custom check through `go vet` rather than as an independent binary.
- Why does the go command run your tool with -V=full before analysing anything?It needs a stable identity for the tool to use in its cache key for vet results, and to confirm the binary really is an analysis driver. If the identity does not change when the tool changes, cached results from the previous version can be reused, which looks exactly like a check that stopped firing.
- Your check needs to know something about a function declared in a different package. How does that work under -vettool?Through facts. Declare the fact type in the analyzer's `FactTypes`, call `pass.ExportObjectFact` when analysing the package that declares the function, and `pass.ImportObjectFact` when analysing a caller. Declaring fact types makes the driver run the analyzer on dependencies too, and it serialises the facts along dependency edges. Facts must be deterministic, since they feed the cache.
- What happens to the checks the toolchain normally runs when you pass -vettool?They stop running, because `-vettool` substitutes your binary for the standard vet program rather than adding to it. A team that wants both builds one `multichecker` binary that links the upstream analyzers it cares about together with its own, which also gives every check a single version to roll out.
- Should a custom check load packages itself instead of running under go vet?Rarely. Running under the go command means inheriting the build's package set, build tags and module resolution, so the check sees exactly what the compiler saw and is cached alongside it. A self-loading driver has to reconstruct that configuration and will eventually disagree with the build, which produces findings nobody can reproduce.
saying these in an interview costs you the question
- Expecting one invocation to see the whole module
- Reading other packages' source files off disk
- Assuming -vettool adds checks to the standard ones
- Storing cross-package state in a package-level variable
- Never rebuilding the analyzer binary as the toolchain moves