CLI commands
Who this is for: anyone running servo from a terminal, a go:generate directive, or a CI
job, who needs to know exactly what a command does and what it will print.
Every command below was run against
examples/basic to produce the output
shown. Positions are printed as absolute paths, shortened on this page to fit the column — with one
exception: graph prints them relative to the module root, because it shares its output struct with
the generated App.Graph() and the two have to agree byte for byte.
Installing
go install github.com/okian/servo/v3/cmd/servo@latest
That puts a servo on your PATH pinned to nothing in particular. For a project, add the generator
to the module instead, so its version is recorded in go.mod beside every other dependency and
every developer and every CI runner runs the same one:
go get -tool github.com/okian/servo/v3/cmd/servo
It is then invoked through the go command:
go tool servo generate
Not go run github.com/okian/servo/v3/cmd/servo. A consumer module requires servo for the
marker package alone, so the generator’s own dependencies are not in that module’s build list and
the command fails before it starts:
missing go.sum entry for module providing package golang.org/x/tools/go/packages
(imported by github.com/okian/servo/v3/cmd/servo)
go get -tool adds a tool directive to go.mod and pulls those dependencies in, which is what
makes go tool servo resolve. It is a one-time step per module, and the one
servo init prints.
Invocation
servo [<command>] [flags] [arguments]
The command is optional. If the first argument doesn’t start with -, it is taken as the
command name. Otherwise the command defaults to generate, so servo, servo generate, and
servo --dir=cmd/api are all generate invocations. That default is the contract, not an accident:
a bare servo generates.
The default does not swallow -h. A leading dash otherwise means “no command”, which for a
while made servo -h parse as generate --help and print generate’s four flags — the one place
a new user looks for the other eleven commands. A help request is now recognised before the command
is extracted, so -h, --help, -help and help all print the same list and exit 0.
Flags come before positional arguments. Flag parsing stops at the first non-flag argument, so
servo explain --json api.Server works and servo explain api.Server --json does not — the
second form treats --json as a second positional argument and fails with a usage error. Both
single and double dashes are accepted (-dir, --dir), with either a space or an equals sign
(--dir cmd/api, --dir=cmd/api).
Exit status is 0 or 1. Success is 0, including a help request. Every failure — a resolution
diagnostic, a stale generated file, an unknown command, a bad flag — is 1. There are no other exit codes, so a CI job needs no
special-casing. (The separate servo-vet binary exits 2 when it refuses a flag, which
is go vet’s own convention for “the tool would not run”, not a servo diagnostic.)
Diagnostics go to stderr, results to stdout. generate and check print nothing at all on
success: silence means the work is done.
--dir and injector scope
--dir (default .) is the directory the module scan starts from. Servo loads ./... relative to
it, which means --dir is how you narrow the scan, not just where you point it.
An injector is a package containing a servo.Build(...) call. A module can hold several — a
monorepo’s cmd/api, cmd/worker and cmd/migrator each wiring their own graph — and commands
split into two groups on how they handle that:
| Behaviour | Commands |
|---|---|
| Processes every injector in scope, reporting all of them | generate, check, doctor |
| Answers a question about one graph, so it asks you to disambiguate | graph, explain, why, list |
| Doesn’t scan a module at all | init, migrate, new, version, help |
The second group errors out when more than one injector is in scope, listing the positions it found:
servo: multiple injectors found in this scope — pass --dir to pick one:
examples/basic/cmd/basic/spec.go:16:2
examples/basic/cmd/migrator/spec.go:11:2
Pointing --dir at one injector’s own directory narrows the scan to exactly that graph, because a
package main can never be imported by another package main — sibling injectors are structurally
unreachable from it.
Build flags
Servo resolves your graph by loading your module the same way the go command does, so it takes the go build flags that decide which files and packages exist:
--tags tag,list additional build tags to consider satisfied during the load
--mod mode module download mode: readonly, vendor or mod
--modfile file read an alternate go.mod instead of the one in the module root
--overlay file read a JSON config file providing an overlay for build operations
Same names, same syntax, same meaning as go build. --tags takes a comma-separated list (the
deprecated space-separated form the go command still accepts works here too), and repeating the flag
takes the last one, exactly as the go command does.
They are accepted by the seven commands that load packages — generate, check, graph,
explain, why, list, doctor — and by no others. init and new write files without loading
anything, and version and help read nothing at all. (init does take --tags, but only to
choose the constraint it scaffolds; it never loads a package with them.)
migrate walks the tree with go/parser and never evaluates a build constraint at all,
so a --tags there would be a lie: it reads v1 Register calls whether or not a tag would have
excluded the file.
The flags that only change what the compiler or linker emits are deliberately absent —
-race, -cover, -trimpath, -pgo. Servo runs neither tool. (-race does activate the race
build tag, so --tags=race expresses that case explicitly.)
GOOS and GOARCH have no flag because the go command has none either; set them in the
environment, as you would for go build:
GOOS=linux servo generate
Note what that does not do: GOOS is not a variant axis. Generating under a different GOOS
rewrites the same servo_gen.go in place with the cross-compiled graph, under the same constraint —
so if your providers differ by platform, that overwrites the graph for your host. See
limitations.
--modfile and --overlay take paths relative to your working directory, not to --dir, so
they behave the way the same flags behave on go build.
GOFLAGS
Servo does not take its tags from GOFLAGS, and this is deliberate. It is the one place where
“same as the go command” has to yield: go build produces a binary you throw away, while
servo generate produces a file you commit. A GOFLAGS=-tags=prod in a shell profile or a CI image
would resolve a different graph and write it into the file named for the default configuration —
prod-only providers under a //go:build !servoinject constraint claiming to compile everywhere,
with nothing in the diff to explain it. What servo commits has to be a function of your repository
and the flags you actually typed, so pass --tags explicitly.
Every other GOFLAGS entry still reaches the go command untouched — -mod, -modfile and the rest
behave exactly as they would for go list, because servo never overrides them.
Tags servo rejects
A tag that can’t distinguish one build from another can’t gate a variant, so servo rejects it up front rather than letting it fail somewhere unhelpful:
| Rejected | Why |
|---|---|
A GOOS or GOARCH name (linux, arm64, js, …) |
Passing one through -tags doesn’t select a platform, it adds a second one, and the build then fails inside the standard library with GOOS redeclared in this block and nothing pointing back at servo. Set the environment variable instead |
unix, cgo, gc, gccgo, boringcrypto |
The toolchain already sets these for the build they describe, so they’re true without being passed and can’t distinguish one variant from another. (race, msan and asan are not in this group: they’re set only when -race/-msan/-asan is passed, so they can gate a variant like any other tag) |
go1.21 and friends |
The toolchain sets a tag for its own release and every earlier one |
ignore |
The ecosystem’s universal “never build this file” tag, used by the standard library’s own generator sources. Passing it doesn’t select anything — it compiles every deliberately-excluded file in your module and the standard library, and the failure lands somewhere in $GOROOT |
Anything with a character outside letters, digits, _ and . |
No //go:build line could name it. The go command accepts such a tag silently, which is exactly why servo has to be the one to say so |
| An uppercase tag | Servo’s own rule, not Go’s. Variant file names are derived from the tag set, and prod and Prod name the same file on a case-insensitive filesystem |
Build variants
Passing --tags changes the graph servo resolves — providers behind //go:build prod become
visible, and providers behind //go:build !prod disappear. A generated file describing that graph
references types that only exist in that configuration, so it must not compile in any other. Servo
handles this by giving each configuration its own file.
The generated file’s constraint is your spec file’s constraint, mirrored. Servo negates the
servoinject term and leaves everything else exactly as you wrote it, then conjoins the tags the
graph was resolved under:
| Spec file’s constraint | Flags | Generated file | Its constraint |
|---|---|---|---|
servoinject |
(none) | servo_gen.go |
!servoinject |
servoinject |
--tags=prod |
servo.prod_gen.go |
!servoinject && prod |
servoinject && !prod |
(none) | servo_gen.go |
!servoinject && !prod |
servoinject && prod |
--tags=prod |
servo.prod_gen.go |
!servoinject && prod |
servoinject && !prod |
--tags=dev |
servo.dev_gen.go |
!servoinject && !prod && dev |
Servo never invents a negation. If two variants have to exclude each other — a default build and a
prod build, say — you write that in the spec files themselves, in Go’s own constraint language:
// cmd/app/spec.go
//go:build servoinject && !prod
// cmd/app/spec_prod.go
//go:build servoinject && prod
Now servo generate sees only the first spec and writes servo_gen.go gated !servoinject &&
!prod; servo generate --tags=prod sees only the second and writes servo.prod_gen.go gated
!servoinject && prod. The two coexist, go build picks the first, go build -tags=prod picks the
second, and neither run touches the other’s file.
This is also why a variant needs its own spec file rather than a flag on one shared spec: a
servo.Bind[store.Store, *postgres.PG]() naming a type that only exists under prod cannot
type-check in the default configuration, so no single spec file could describe both.
Servo refuses to write two variants that don’t exclude each other. Deriving the constraint from
your spec file is what frees servo from having to track the variant set, but nothing stops you from
generating two variants that overlap — the plain //go:build servoinject that servo init
scaffolds, generated a second time with --tags=prod, gives !servoinject beside
!servoinject && prod, and go build -tags=prod would compile both. generate and check both
detect that and stop, naming the two files and the fix:
servo: servo.prod_gen.go and servo_gen.go would both compile in the same build
servo_gen.go: //go:build !servoinject
servo.prod_gen.go: //go:build !servoinject && prod
Some build satisfies both constraints at once, and the package would then
declare App and New twice. Servo mirrors each spec file's own constraint and
never invents a negation, so the exclusion has to come from the spec files.
Either gate them so no configuration matches two — `//go:build servoinject && !prod`
on the default spec, `//go:build servoinject && prod` on the other — or, if this
injector does not vary with these tags at all, leave it alone and scope the
run to the one that does with --dir
Detecting it is servo’s job; resolving it is not. Rewriting the sibling file to insert the
&& !prod that would fix it would make generation depend on which files happen to be in your
working tree, so servo reports and stops.
A configuration you never generated has no generated file. Constraints are ordinary Go, so this
behaves exactly as hand-written conditional compilation does. Spec files gated servoinject && prod
and servoinject && dev with no default leave a plain go build with no New at all —
undefined: New, the same error a missing //go:build case would give you anywhere else. Writing
the default variant’s spec as the catch-all (servoinject && !prod && !dev) is what makes an
unrecognised tag fall back to it. Asking for two variants at once (go build -tags=prod,dev) is a different matter: prod and dev
gated only against the default still overlap each other, so servo refuses to generate the second
one until each spec excludes all the others — servoinject && prod && !dev and
servoinject && dev && !prod. With more than two configurations that is O(n²) terms by hand, which
is the honest cost of the model; servo will not let you skip it silently.
Most tag usage needs no variant at all. If //go:build prod and //go:build !prod both define
func New() *DB, the resolved graph is identical either way, so one servo_gen.go already works.
You only need a second variant when the graph genuinely differs.
Variant file names
servo_gen.go and servo_gen_test.go when there are no tags — unchanged, so nothing moves in an
existing project. With tags, the canonical (sorted, deduplicated) tag set becomes a dot-separated
segment: servo.prod_gen.go, servo.integration-prod_gen.go, and the servo.prod_gen_test.go
override variant alongside.
Both separators are load-bearing, and both are the opposite of the obvious choice:
- A dot before the tags, not an underscore. Go derives an implicit
GOOS/GOARCHconstraint from a file’s underscore-separated suffix, so aservo_gen_linux.gowould be generated, committed, and then silently ignored on every non-Linux machine.go/buildcuts the name at the first dot before looking for underscores, which makes everything afterservo.invisible to that rule for any tag, with no list of reserved names to keep current. - A dash between tags, not an underscore.
_is legal inside a build tag, so joining with it would map the tag sets{a_b}and{a, b}to the same file and lose one of them.-cannot appear in a tag at all, so the name stays unambiguous.
Checking variants
check and doctor inspect the variant matching the flags you give them, so a module with two
variants gets one run each:
servo check
servo check --tags=prod
In a multi-injector module, an injector whose spec is gated out of the current configuration gets no
generated file, so its main.go calls a New that nothing supplies. generate and check stay
quiet about that — excluding an injector from a configuration is a legitimate thing to do — but
doctor reports it, because the alternative is an undefined: New from the compiler much later:
$ servo doctor --tags=prod
[FAIL] example.com/app/cmd/worker holds a spec file this configuration cannot see, so nothing
generates its New — either give it a variant for these flags, or gate the package itself
out of this build
generate
servo generate [--dir <path>] [--tags tag,list] [--mod mode] [--modfile file] [--overlay file]
Resolves every injector found under --dir and writes each one’s generated file. The default
command.
For each injector it emits its generated file next to that injector’s spec file — servo_gen.go,
or a variant name when --tags is given — and additionally the
servo_gen_test.go override variant when the spec declares at least one servo.Override (see
Generated API). Both files are written atomically — via a
temp file in the same directory and a rename — so a concurrent reader never sees a half-written
file, and a process killed mid-write leaves the previous complete version in place.
Prints nothing on success. On failure it reports every injector that failed, not just the first, each prefixed with its package path:
example.com/servodiagnostics/ambiguous: servo: 1 diagnostic(s):
ambiguous/store.go:23:6: servo: no provider for ambiguous.Store
needed by *ambiguous.Server ambiguous/store.go:23:6
root ambiguous/spec.go:9:3
2 types implement ambiguous.Store — add one of:
servo.Bind[ambiguous.Store, *ambiguous.Postgres]() ambiguous/store.go:11:6
servo.Bind[ambiguous.Store, *ambiguous.Redis]() ambiguous/store.go:17:6
Resolution has exactly two outcomes: a complete plan, or a set of diagnostics. A failed generation never writes a partial file. See Diagnostics for every message it can produce.
check
servo check [--dir <path>] [--tags tag,list] [--mod mode] [--modfile file] [--overlay file]
Verifies that every injector’s committed generated file is byte-identical to what a fresh
generation would produce. Never writes anything. With --tags it checks that configuration’s
variant, so a module with several needs one run per variant.
This is the CI command. A constructor signature change without a matching re-run of
servo generate fails here instead of shipping a stale generated file. It reports every stale
injector in one run, with a unified diff for each:
servo check: cmd/migrator/servo_gen.go is stale — run `servo generate`
--- cmd/migrator/servo_gen.go (committed)
+++ cmd/migrator/servo_gen.go (fresh)
-func (a *App) Report() servo.StartupReport { // hand-edited
+func (a *App) Report() servo.StartupReport {
note: this is servo v3.2.1. If regenerating does not settle it, the machine that
committed the file was running a different version — compare `servo version`, and
pin one for everybody with `go get -tool github.com/okian/servo/v3/cmd/servo`.
The diff is +/- lines only — no hunk headers, no unchanged context — which for a generated file
is usually the whole story in two lines.
The trailing note is on every stale report, because “stale” has exactly one other cause and the
diff cannot tell them apart. A change to generated code’s internal shape is deliberately not a
breaking change — consumers regenerate — so two machines on two servo versions produce a real
difference in a file neither of them edited, and it reads exactly like a forgotten regenerate.
Without the note the loop is: CI says stale, the developer runs the command it names with their own
binary, pushes, CI says stale again. The version it prints is servo version’s, so the
two can be compared directly.
A missing generated file is reported distinctly from a stale one:
servo check: cmd/basic/servo_gen.go does not exist — run `servo generate`
Two things check does not do. It does not compare servo_gen_test.go, so an override variant
that has drifted is not reported here (it will still be compiled and run by go test). And it
does not verify that the generated file is committed to VCS — that is doctor’s job.
graph
servo graph [--dir <path>] [--format=text|json|dot|mermaid]
Exports one injector’s resolved graph. text is the default; edges always point from a dependent
to its dependency.
text — grouped by level, which is the unit of Init concurrency:
── Level 1 ──
*servobasic/logger.Logger
deps: none
capabilities: Finalizer
binding: sole candidate
pos: logger/logger.go:10:6
── Level 2 ──
*servobasic/postgres.DB
deps: *servobasic/logger.Logger
capabilities: Initializer, Finalizer, Healther
binding: explicit bind
pos: postgres/postgres.go:13:6
── Level 3 ──
*servobasic/api.Server
deps: *servobasic/postgres.DB
capabilities: Runner, Drainer, Finalizer, Readier
binding: sole candidate
pos: api/api.go:15:6
json — the stable machine format, and byte-for-byte the same schema the generated
App.Graph() serialises to. Both paths populate the identical servo.Graph struct, documented
field by field in servo package:
{
"nodes": [
{
"type": "*example.com/servobasic/logger.Logger",
"level": 1,
"deps": null,
"capabilities": ["Finalizer"],
"binding": "sole candidate",
"pos": "logger/logger.go:10:6"
}
]
}
Two details there are load-bearing, and both exist so a consumer written against one producer works
against the other. An empty deps is null, not [] — the generated App.Graph() emits a Go
literal, where the empty slice is nil, and a CLI that wrote [] beside null for an equally
empty capabilities was not even self-consistent. And pos is relative to the module root, the
same rewriting emission does, rather than the absolute path explain and list print: an absolute
path would embed the machine’s filesystem layout in output the generated file has to match.
dot — Graphviz, rankdir=BT, nodes filled by level and labelled with their capabilities:
servo graph --format=dot --dir cmd/api | dot -Tsvg > graph.svg
mermaid — a graph BT flowchart with a classDef per level, for pasting into a README or a
docs page:
graph BT
n0["*example.com/servobasic/logger.Logger"]:::level1
n1["*example.com/servobasic/postgres.DB"]:::level2
n2["*example.com/servobasic/api.Server"]:::level3
n1 --> n0
n2 --> n1
classDef level1 fill:#bfdbfe;
classDef level2 fill:#93c5fd;
classDef level3 fill:#60a5fa;
Scopes in the graph output
Every format separates the app’s singletons from each scope’s members, because a scoped node’s level counts from its own scope’s floor rather than the app’s.
text prints a block per scope, with its policy and what it borrows:
══ example.com/servoscoped/chat.RoomKey ══
linger: 30s max: 10000
accessors: example.com/servoscoped/chat.Rooms
borrows: *example.com/servoscoped/logger.Logger
── Scope level 1 ──
*example.com/servoscoped/chat.RoomLog
...
json adds a scope field to each scoped node and a top-level scopes array. Both are
omitempty, so an app with nothing scoped emits exactly the JSON it always did:
{
"nodes": [
{ "type": "*chat.Room", "level": 2, "scope": "chat.RoomKey", "…": "…" }
],
"scopes": [
{
"key": "chat.RoomKey",
"linger": "30s",
"max": 10000,
"accessors": ["chat.Rooms"],
"members": ["*chat.RoomLog", "*chat.Room"],
"borrows": ["*logger.Logger"]
}
]
}
dot puts each scope in a dashed cluster_scope<n> subgraph, and mermaid in a labelled
subgraph, both with the key type drawn as its own node. A consumer’s edge to an accessor
interface is routed to that key: an accessor is generated code, not a resolved node, so the edge
would otherwise dangle.
An unrecognised format is an error: servo graph: unknown --format "svg" (want text|json|dot|mermaid).
explain
servo explain [--dir <path>] [--json] <type>
Answers, for one node: which provider was selected and why, where that provider is declared, what
it depends on, what depends on it, its lifetime, its level, and its detected capabilities. Scoped
nodes can be asked about too, and the lifetime: line is where the difference shows:
$ servo explain chat.Room
*example.com/servoscoped/chat.Room
provider: chat.NewRoom (chat/chat.go:74:6)
binding: sole candidate
lifetime: scoped — one per example.com/servoscoped/chat.RoomKey, linger 30s, max 10000
level: 2
depends on: example.com/servoscoped/chat.RoomKey, *example.com/servoscoped/chat.RoomLog
depended on: (acquired via example.com/servoscoped/chat.Rooms)
capabilities: Initializer, Runner, Drainer, Finalizer
$ servo explain api.Server
*example.com/servobasic/api.Server
provider: api.New (api/api.go:15:6)
binding: sole candidate
lifetime: singleton — one per process, built by New
level: 3
depends on: *example.com/servobasic/postgres.DB
depended on: none
capabilities: Runner, Drainer, Finalizer, Readier
binding is one of three values, and it is the answer to “why this provider”:
| Value | Meaning |
|---|---|
explicit bind |
A servo.Bind (or Override) named this concrete type |
sole candidate |
Exactly one function in the module returns this exact type |
sole implementation |
The parameter is an interface, and exactly one candidate in the main module implements it |
How <type> is matched. An exact match against the node’s full type string wins. Failing
that, servo looks for a node whose type string ends with the argument — so api.Server finds
*example.com/servobasic/api.Server without you typing the import path. Two consequences worth
knowing:
- A leading
*never matches a suffix.servo explain '*api.Server'fails withservo: no node matches "*api.Server"; writeapi.Server, or the full*example.com/servobasic/api.Server. - An ambiguous suffix is an error rather than a guess:
servo: "Server" matches multiple nodes, be more specific: ....
--json prints the same information as an object with type, provider, pos, binding,
lifetime, level, depends_on, depended_on and capabilities, plus scope when the node is
scoped (omitted when it isn’t).
why
servo why [--dir <path>] [--json] <type>
Answers “why is this in my binary at all”: the shortest path from a root down to the named node.
$ servo why logger.Logger
root *example.com/servobasic/worker.Consumer
-> *example.com/servobasic/logger.Logger
The search runs breadth-first from every root at once, so with several roots you get a shortest
path — not necessarily one through the root you had in mind. In the example above logger.Logger
is reachable from both api.Server (via postgres.DB, two hops) and worker.Consumer (one hop),
and the shorter one is reported.
A path into a scope is reported through the accessor edge without naming it. Asking why a scoped node is present prints its consumer directly:
$ servo why --dir examples/scoped example.com/servoscoped/chat.Room
root *example.com/servoscoped/api.Server
-> *example.com/servoscoped/chat.Room
api.Server takes chat.Rooms, not *chat.Room. The accessor is generated code rather than a
resolved node, so — as in graph’s dot and mermaid output — the edge is collapsed
onto the scoped type it hands out, and the answer is about reachability rather than about the
parameter list.
A node that resolved but isn’t reachable from any root is reported as such:
servo why: <type> is not reachable from any root. Type matching works exactly as in
explain. --json prints the path as an array of type strings.
list
servo list [--dir <path>] [--rejected] [--all] [--json]
Dumps the candidate index — every function servo accepted as a possible provider, with its position:
$ servo list
api.New api/api.go:15:6
logger.New logger/logger.go:10:6
mockstore.New mockstore/mockstore.go:29:6
postgres.New postgres/postgres.go:13:6
queue.NewOrdersAccount queue/queue.go:24:6
queue.NewAuditAccount queue/queue.go:28:6
relay.New relay/relay.go:24:6
worker.New worker/worker.go:13:6
--rejected is the higher-value mode, and the first thing to reach for when you wrote a
constructor and servo doesn’t see it. It lists every function that looked like it might be a
provider and the rule that excluded it:
$ servo list --rejected
api.(*Server).Ready api/api.go:19:18 method, not a function
api.(*Server).Run api/api.go:29:18 method, not a function
postgres.(*DB).Init postgres/postgres.go:21:14 method, not a function
Every reason string is enumerated in Resolution rules. Functions with no results at all are neither accepted nor rejected — they aren’t trying to construct anything, and listing them would bury the real answers under every helper in the module.
--all includes stdlib and third-party packages. Both modes default to the main module only,
because someone asking why their constructor wasn’t picked up is never asking about
unicode.ToLower. --json prints {name, pos} objects, or {name, pos, reason} with
--rejected.
init
servo init [--dir <path>] [--tags tag,list]
Scaffolds two files in --dir, and prints the one-time module setup they need:
$ servo init --dir cmd/app
servo init: wrote cmd/app/servo_spec.go
servo init: wrote cmd/app/servo_generate.go
Next, once per module, so that go generate can run the generator and so
that every machine runs the same one:
go get -tool github.com/okian/servo/v3/cmd/servo
Then declare your roots in cmd/app/servo_spec.go and run:
go generate ./...
servo_spec.go — the spec file, with the build tag and the package clause already correct, and
no go:generate directive in it:
//go:build servoinject
package main
import "github.com/okian/servo/v3/servo"
func wire() {
servo.Build(
// servo.Root[*yourpkg.YourType](),
)
}
servo_generate.go — untagged, and holding nothing but the directive:
package main
//go:generate go tool servo generate
The directive cannot live in the spec file. go generate honours build constraints, so a
directive inside a //go:build servoinject file is invisible to go generate ./... — which then
exits 0, prints nothing, and generates nothing. Silence is the worst failure mode available to a
tool whose whole claim is build-time checking, so the directive gets its own untagged file. There is
one such file per directory rather than one per variant, because go generate has no build tags to
select between them and a second copy would just run the same generation twice.
And it is go tool servo, not go run <module path>, for the reason under
Installing: the generator’s dependencies are not in a consumer module’s build list.
That is what the go get -tool line is for, and why init prints it.
The package name is taken from any existing .go file in the directory, falling back to main —
the usual case of a spec file landing next to a cmd/*/main.go. The directory is created if it
doesn’t exist.
It refuses to overwrite the spec: servo init: <path> already exists. servo_generate.go is
written only when absent, and left untouched otherwise.
With --tags, it scaffolds a variant instead: servo init --tags=prod writes
servo_spec_prod.go gated //go:build servoinject && prod. When it also has to write the directive
file, that carries the matching flags — go tool servo generate --tags=prod. When one is already
there from an earlier init, it is left alone, so a second variant means adding its
//go:generate line to that file yourself: each configuration needs its own generation, and the
file is untagged, so both lines live in it side by side. init then names any sibling spec still
visible under those tags, since leaving the default spec ungated is what makes two variants collide:
servo init: wrote cmd/app/servo_spec_prod.go
servo init: servo_spec.go is also visible with --tags=prod, so both would generate a file and the two would compile together.
Narrow it to `//go:build servoinject && !prod` (or otherwise exclude the new tags) before running servo generate.
doctor
servo doctor [--dir <path>] [--tags tag,list] [--mod mode] [--modfile file] [--overlay file]
Diagnoses setup problems before go generate is ever run, across every injector in scope. Every
line is [OK ], [FAIL], [WARN] or [INFO]. Only [FAIL] affects the exit status — one is
enough to make the command exit 1 with servo doctor: problems found. [WARN] is a check that
could not reach a verdict, and [INFO] is the variant inventory, which reports rather than judges;
neither ever fails the run.
$ servo doctor --dir examples/basic
servo doctor:
[OK ] no build errors outside the injector(s)
-- example.com/servobasic/cmd/basic --
[OK ] spec file found at cmd/basic/spec.go:16:2, correctly gated by the servoinject build tag
[OK ] generated file present: cmd/basic/servo_gen.go
[OK ] generated file matches a fresh generation
[OK ] generated file is tracked by git
-- example.com/servobasic/cmd/migrator --
[OK ] spec file found at cmd/migrator/spec.go:11:2, correctly gated by the servoinject build tag
[OK ] generated file present: cmd/migrator/servo_gen.go
[OK ] generated file matches a fresh generation
[OK ] generated file is tracked by git
What each check means:
| Check | Fails when |
|---|---|
| Module loads | go/packages can’t load the module at all |
| Spec file found | No servo.Build(...) call, or one in a file without the servoinject constraint |
| No build errors outside the injector(s) | Some other package doesn’t type-check. Errors inside an injector’s own package are deliberately ignored: before the first generation, main.go legitimately references a New that doesn’t exist yet |
| Injector visible in this configuration | A package holds a spec file these flags exclude, so nothing generates its New. Reported here and nowhere else — see Checking variants |
| Generated file present | The generated file — servo_gen.go, or the variant matching --tags — is missing next to the spec |
| Generated file fresh | Same comparison check makes |
| Tracked by git | A [WARN], never a FAIL — best-effort, so no git, no repo, or a different VCS just means “can’t tell” |
| No orphaned generated file | A servo.<tags>_gen.go beside the spec that no spec file in that directory could produce any more |
| Variant inventory | Never fails — an [INFO] line naming the sibling variants these flags did not check, and the command that would |
The generated file should be committed, which is what the git check is nudging: a checkout should
build without anyone having to run servo generate first.
The last two exist because everything above them concerns only the one variant the current flags
select, which leaves a project’s other variants invisible to every servo command. An orphan — a
generated file whose spec was deleted while it stayed behind — is the sharper of the two: it keeps
compiling into whichever build satisfies its constraint, nothing will ever regenerate it, and so it
drifts silently from the moment its spec went away. That is a FAIL:
$ servo doctor
[FAIL] servo.prod_gen.go is generated from a spec that no longer exists — delete it, or
restore the spec file it came from
The inventory is the softer half — a stale prod variant nobody ran --tags=prod against draws
three green servo commands next to a red go build, so doctor names it and the command that
would check it:
$ servo doctor --dir examples/variants
[INFO] not checked by this run, being other variants: servo.prod_gen.go (`servo generate --tags=prod`)
migrate
servo migrate [--dir <path>]
Reads v1-style Register(X{}, N) calls and prints a report plus a v3 spec skeleton to stdout.
Nothing is written to disk and no function bodies are rewritten.
The report exists to surface information, not to claim a migration is automatic. v1 components
took no constructor parameters — they found each other through package-level globals — so there is
no dependency graph to derive a real order from. What the report gives you is the old order
values for review, with duplicates flagged as likely latent ordering bugs:
servo migrate report:
v1 has no constructor parameters, so there is no real dependency graph to derive
an order from — this only surfaces the OLD order values for review.
order=1 Logger legacy.go:21:2
order=2 DB legacy.go:22:2 <- shares this order with another service: a likely latent ordering bug
order=2 Cache legacy.go:23:2 <- shares this order with another service: a likely latent ordering bug
order=3 Server legacy.go:24:2
Then a spec skeleton with one servo.Root per registration, each annotated with the order it used
to carry:
//go:build servoinject
package main
import "github.com/okian/servo/v3/servo"
func wire() {
servo.Build(
servo.Root[*Logger](), // was order=1
servo.Root[*DB](), // was order=2
servo.Root[*Cache](), // was order=2
servo.Root[*Server](), // was order=3
)
}
Turning globals into constructor parameters is the part that needs human
judgement, and it is the part servo deliberately doesn’t guess at. With no registrations found it
says so and exits 0.
examples/migrate is a worked
example.
new
servo new component <Name>
servo new adapter <pkgname>
servo new mock-adapter <moq|mockery|gomock> <GeneratedTypeName>
Prints a scaffold to stdout — never writes a file, never takes --dir, and never imports servo.
Redirect it where you want it.
component prints a type, a constructor, and all seven capability methods commented out,
ready to uncomment whichever the component actually needs.
adapter prints the shape of a third-party wrapper: a Config, a Client, a
func New(cfg Config) (*Client, func(), error) constructor returning a cleanup func, and Stop
and Health methods.
mock-adapter prints the small hand-written file a generated mock needs before it can be a
provider. Each of the three tools needs a different fix, for a different reason — moq generates
no constructor at all, while mockery’s and gomock’s constructors take a per-test value the graph
has no way to supply — and this subcommand knows which is which. The full explanation, with
runnable examples, is in the README’s
Mocking section.
An unknown kind or tool is an error naming the valid set.
version
servo version
Prints one line: the servo version, the Go toolchain that built the binary, and its target platform.
$ servo version
servo v3.2.1 go1.27.0 darwin/arm64
This matters more than it does for most tools. Servo writes files you commit and gates them with
check, so two machines on two servo versions produce a diff in a file neither of them
edited — which is indistinguishable, from the diff alone, from a forgotten regenerate. check’s
stale report names this command for exactly that reason.
A binary built straight from a working tree has no module version — debug.ReadBuildInfo reports
(devel) — so the VCS stamp the go command embeds is printed instead, which is the only thing that
distinguishes two such builds from each other: (devel, 71f44864ed6c), or
(devel, 71f44864ed6c, dirty) when the tree had uncommitted changes.
help
servo help [command]
servo -h | --help | -help
Prints the command list, the shared build flags, and a link to this reference, to stdout, exiting 0. With a command name it prints that command’s usage line and summary instead:
$ servo help check
usage: servo check [--dir]
verify every generated file matches a fresh generation; writes nothing
An unknown command prints the same list rather than only saying the name was wrong — being told
unknown command "geneate" and nothing else is a dead end when the nearest list of the twelve names
is a website away:
$ servo geneate
servo: unknown command "geneate"
Commands:
generate resolve every injector found under --dir and write its generated file
…
Run `servo help` for flags and usage.
That path exits 1. An unknown topic (servo help geneate) is the same error, again with the list.
Write it without dashes. servo -h and servo --help are not this command — they start with a
dash, so they are generate --help, which prints generate’s four flags and exits 1.
servo-vet
Build tags and
servo-vet. Run standalone, it analyses only the default configuration, and-tagsis refused, with exit code 2.go/analysisregisters that flag on everysinglecheckerbinary and documents it as “no effect (deprecated)”:checker.Runbuilds its ownpackages.Configwith no build flags, soservo-vet -tags=prod ./...would exit 0 having analysed the default configuration while anyone who typed it believesprodwas covered. There is no hook to make it work — the config is internal tox/tools— so the honest move is to refuse and name the invocation that does:$ servo-vet -tags=prod ./... servo-vet: -tags does not work here — it is go/analysis's own no-op flag, so this run would silently analyse only the default configuration. To check a tagged configuration, drive servo-vet through the go command, which does understand build flags: go vet -tags=prod -vettool=$(which servo-vet) ./...The refusal is by inspection of
os.Args, so it catches-tags,--tags,-tags=prodand-tags prod, wherever they sit in the argument list.-tagswith no value is left togo/analysis’s own parser.
go get -tool github.com/okian/servo/v3/cmd/servo-vet
go tool servo-vet ./...
A standalone go/analysis analyzer (named
servovet) for the two servo mistakes the compiler cannot catch. go install
github.com/okian/servo/v3/cmd/servo-vet@latest works too; go run against the module path does
not, for the same go.sum reason it doesn’t for cmd/servo.
A marker call without the build tag. Calls to servo.Build, Root, Bind, Override,
Scoped, Value, Include, Linger or Max in any file that doesn’t carry a build constraint
requiring servoinject. The markers panic when actually executed, so such a call compiles straight
into your real binary and panics at runtime. This catches it in the editor instead:
spec.go:9:2: servo: servo.Build called in a file without a `//go:build servoinject` constraint —
it will compile into the real binary and panic at runtime; run `servo init` or add the tag
A ScopeKey method with a reachable receiver. servo calls that method on a typed nil, because
the key has to be known before an instance can be chosen, and no signature can say “never
dereferences the receiver”:
chat/chat.go:91:6: servo: ScopeKey must not name its receiver — servo calls it on a typed nil,
so a receiver the body can reach is a nil dereference in production; write
`func (*T) ScopeKey(...)`
The check is narrowed to methods that really are key extractors — context.Context first, (K,
error) out, K a defined non-interface type — so an unrelated method that happens to share the
name is left alone. servo generate makes both checks too; the analyzer runs them everywhere,
including in packages no injector has reached yet.
Include is the marker most worth having the analyzer for. A shared marker set lives in its own
package, away from any spec file, which is where the servoinject constraint is easiest to forget —
and servo generate refuses an untagged included file only once some injector
reaches it.
Using the analyzer from another tool
The analyzer is exported, as Analyzer in
github.com/okian/servo/v3/servovet — a
package with exactly that one identifier in it. cmd/servo-vet is a thin singlechecker wrapper
around the same variable, so anything that speaks go vet’s protocol — go vet -vettool=, most
editor integrations — works against the binary, and anything that wants an *analysis.Analyzer
imports the package. singlechecker, multichecker, unitchecker and analysistest all take it
directly.
golangci-lint’s module plugin system is the second kind. It needs a Go module that registers the
analyzer at init, which is why the variable had to leave package main:
package servolint
import (
"github.com/golangci/plugin-module-register/register"
"github.com/okian/servo/v3/servovet"
"golang.org/x/tools/go/analysis"
)
func init() {
register.Plugin("servovet", func(any) (register.LinterPlugin, error) { return plugin{}, nil })
}
type plugin struct{}
func (plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
return []*analysis.Analyzer{servovet.Analyzer}, nil
}
func (plugin) GetLoadMode() string { return register.LoadModeTypesInfo }
LoadModeTypesInfo, not LoadModeSyntax: both checks resolve identifiers through
pass.TypesInfo — a marker call is recognised by the *types.Func it resolves to, never by the
text servo.Build — so syntax alone is not enough. Name that module in a .custom-gcl.yml and
golangci-lint custom builds a linter binary with it compiled in.
Wiring it into a project
go:generate — what servo init scaffolds, in its own untagged servo_generate.go, pinned to
your module’s own servo version by the tool directive go get -tool wrote into go.mod:
//go:generate go tool servo generate
Both halves are load-bearing, and both are covered under init: the directive cannot sit
in the tagged spec file, because go generate honours build constraints; and it cannot be go run
github.com/okian/servo/v3/cmd/servo, because the generator’s dependencies are not in a consumer
module’s build list.
If the injector is a variant, servo init --tags=prod scaffolds it already
gated — //go:build servoinject && prod, with --tags=prod on the directive when it writes the
directive file — and names any existing spec that does not yet exclude the new tags, which is the
step everyone forgets.
CI — run check, not generate, so a stale file fails the build instead of being quietly
fixed on the runner. A project with variants needs one check per configuration, since each
variant has its own generated file and check only inspects the one its flags select:
servo check
servo check --tags=prod
examples/variants is a working
two-variant project, checked both ways in this repository’s own CI.
.github/workflows/go.yml
is a reference workflow doing exactly that.
Pre-commit — githooks/pre-commit
runs go tool servo check locally, in that form and for the same reason the go:generate line
uses it. It is not enabled by default; turn it on per clone with:
git config core.hooksPath githooks