Contributing
Build, test and lint Hive, and the conventions its code follows.
Thanks for helping. This page covers how to build, test and lint Hive, and the conventions the code follows. The architecture overview is in the README, and design decisions are recorded in architecture decisions.
Setup
Go 1.26+ on macOS, Linux or Windows (the Go toolchain fetches the patch
release go.mod names). Tools are pinned and installed into ./bin on
first use, so there is nothing else to install.
make build # ./hive with version informationmake check # what CI runs: vet, lint, race tests (with goleak), go.mod tidinessmake vuln # known vulnerabilities the code can reach (govulncheck)make upgrade # upgrade from every earlier 1.x release and backmake reproducible # build the release archives twice and compare themmake help # every targetTo run a daemon you can throw away, point HIVE_HOME at a scratch directory.
Keep the path short: macOS limits socket paths to 104 bytes.
export HIVE_HOME=$(mktemp -d /tmp/hv.XXXX) HIVE_CONFIG=/dev/null HIVE_LOG=debug./hive demo && ./hive./hive daemon logs -f # in another terminalPull requests
- Keep changes small and focused. Use Conventional Commits
(
feat(process): …,fix(tui): …). - Every bug fix comes with a test that fails without it. For a release-blocking (P0 or P1) defect this is a release requirement (GOVERNANCE.md).
make checkpasses. CI also runs the fuzz targets,govulncheckand a snapshot release build.- A substantial change, such as a new API area, a plugin or observer API change, a state schema change or anything breaking, starts as an RFC.
- A new dependency needs an ADR in the architecture decisions section explaining why writing the code ourselves is worse.
Code layout
One package per domain, one file per role. The role files a package may have:
| File | Holds |
|---|---|
pkg.module.go |
Package doc comment, Config, NewModule (the package’s wiring) |
pkg.model.go |
Data types |
pkg.service.go |
The service contract (interface) and its implementation |
pkg.store.go / pkg.filesystem_store.go |
Persistence port and its adapter |
pkg.protocol.go |
Register(router, svc): wire handlers, and wireError mapping domain errors to wire codes |
pkg.error.go |
Sentinel errors |
pkg.config.go, pkg.<topic>.go |
Anything else, named by topic |
Go only reads _darwin / _linux filename suffixes before the first dot, so
pkg.thing_linux.go is not OS-specific by name. OS-specific files need an
explicit //go:build line.
Dependencies point down. cmd → runtime / client / tui → domain
packages → leaves. Leaf packages (protocol, jsonfile, pgroup, platform,
logging, buildinfo) import nothing from Hive. Clients (client, tui)
reach the daemon over the wire only. depguard in .golangci.yml enforces
this, so a wrong import fails lint.
Coding standards
Contracts and wiring
- The consumer owns the interface. Declare the small interface you need
(
Environments{ Get }) next to the code that uses it; don’t import a whole service. Keep interfaces to five methods or fewer. - Constructors accept interfaces and return concrete types. Wiring happens in
NewModuleand at the two composition roots (runtime.NewModule,cmd/hive’snewApp/newRootCmd) and nowhere else. - No package-level mutable state, no
init(), no singletons. Loggers, time and OS access are injected (seeprocess.WithOrphanControl), so tests stay deterministic. - Optional settings use functional options (
process.WithLogger) or aConfigstruct whose zero values mean defaults. Existing call sites must keep compiling.
Concurrency
- Every goroutine has an owner, a way to stop, and a join. If you write
go, also write how it ends.goleakruns in every package’sTestMainand fails the build on a leak. - No I/O while holding a mutex. Copy what you need, unlock, then do the
I/O. (A slow disk used to freeze terminal attach through
publish.) - Channels between components are bounded, and overflow has an explicit
policy. For example, a terminal subscriber that falls behind is cut off and
told why (
Subscription.Lagged). It does not silently lose bytes. - When order matters, give it a single writer. Fanning work out to one
goroutine per event reorders it (see the TUI’s
inputQueue). - Prefer state over queues for things a slow consumer can skip. A screen
viewer gets a frame computed from the current screen when it is ready,
not a queue of every change (
vt.View), so it can never fall behind into corruption.
Errors
context.Contextis the first parameter of anything that blocks or does I/O.- Wrap with
fmt.Errorf("doing X: %w", err). Compare witherrors.Is/errors.AsType, never==. Sentinel errors live inpkg.error.go. - Each package maps its errors to wire codes in one place, its
wireError. Clients branch onprotocol.Error.Code, never on message text. - No panics outside
mainand startup wiring (MustRegister). A failure that a later step cannot handle is logged, not dropped:_ = store.Update(…)is a bug in daemon code.
Logging
log/slogonly, with a logger passed in. Neverslog.Default()(lint enforces this).- Key/value pairs in snake_case from a shared vocabulary:
env,process,pid,path,method,status,err. - Levels:
Debugis for developers;Infois for lifecycle events (started, stopped, recovered);Warnmeans something was degraded but handled;Errormeans the daemon could not do what it promised. - Never log terminal content or command output. It can contain secrets.
Compatibility
- Wire changes are additive: new methods, new optional fields. A breaking
change bumps
protocol.Version, and peers on another version getunsupported_version. - On-disk JSON files must keep loading after an upgrade. When a format
changes, raise its
schema, migrate the old one on read, and refuse a newer one withjsonfile.CheckSchema(an older hive after a rollback must not misread or overwrite it). Record it in docs/compatibility.md. - Config keys are never renamed silently. Deprecate with a warning first.
Tests
- Black-box tests (
package foo_test) by default. Larger integration suites go in a package’stests/directory.export_test.gois a last resort. - Table-driven where cases share a shape. Name subtests by behaviour, not by input.
- Use fakes for ports (
Store,Runner,Factory,client.Client) so most tests need no processes. When the OS is the point (PTYs, process groups,flock), test the real thing. - No sleeps as synchronisation. Poll a condition with a deadline, or wait on a channel.
- Fuzz anything that parses untrusted bytes (
FuzzStreamReceive,FuzzParse) and register new targets in theMakefile’sFUZZ_TARGETS. - The CLI’s end-to-end tests re-run the test binary as
hive(HIVE_CLI_TEST_EXEC=1), and shim tests re-run it as a shim (HIVE_SHIM_TEST_EXEC=1). Never let an in-process test autostart a daemon:os.Executable()is the test binary there. - Children of a race-enabled test binary are race-enabled too. Give them
GORACE=atexit_sleep_ms=0, or each one lingers a second after exiting and trips the leak check. Check their stderr forDATA RACE, because a race in a child does not fail the parent test by itself. - A test that starts processes waits for them to exit (see
releaseininternal/shim/shim_test.go). Sleeping and hoping is not enough under-raceon a loaded CI machine.