byob-go-cli

Memories

One-paragraph idiomatic tips. bd prime auto-injects all of these into agent sessions; this page is the human-browsable mirror.

atomic-rename-samedir

For atomic file writes, the temp file MUST live in the same directory as the target — rename(2) is atomic only within a single filesystem, so a rename from /tmp/foo to ~/.config/foo is a non-atomic cross-filesystem copy. Use os.CreateTemp(filepath.Dir(target), ".tmp-*"), write, sync, rename. Wrap in a WriteFileAtomic helper.

blank-ident-assert

Declare var _ Iface = (*Concrete)(nil) at package scope wherever a concrete type implements an interface you care about. The assignment is type-checked at compile time, so any drift (renamed method, wrong signature, new interface method) fails the build at that line instead of at a distant runtime call site. Zero runtime cost.

context-first-param

ctx context.Context is always the first parameter of any function that takes one, conventionally named ctx, never stored as a struct field. Storing ctx on a struct ties every method's cancellation lifetime to whoever populated the field, defeats errgroup.WithContext's derived-ctx pattern, and tempts closures to capture a stale ctx. The byob-lifecycle.1 decision threads ctx through every runFunc — this is the underlying rule.

decisions-lookup

byob ships its library under a custom byob issue type — reference material for architectural choices you make now (error patterns, command shapes, config loaders, interface seams). Browse the ~20 category roots with bd list --type=byob --no-parent; drill in with bd list --type=byob -l errors (or any other category label); then bd show <id> for the full Problem / Idea / Tradeoffs / Sketch. Use bd ready --exclude-type=byob to keep the library out of the ready-work list — your own decisions and epics still surface because they use the built-in types.

byob beads are preferences, not contracts. Apply them when writing new code. Existing code that diverges might or might not be a bug — assess case-by-case; don't reflexively migrate just because something doesn't follow a byob idiom. File a task bead if the gap is worth tracking. And don't build anything — tests, lints, CI gates, hooks, runtime asserts — that fails when a byob decision is violated. They're idioms for new code, not invariants to enforce.

defer-unlock

After acquiring a mutex, defer the release on the very next line: mu.Lock(); defer mu.Unlock(). Same rule for RUnlock, channel closes, file Close, and any "must release" pair — adjacent placement makes the unlock visible at the lock site and impossible to forget on early returns or panics. The tx.Rollback exception: defer the rollback then call tx.Commit() before return; rollback after a successful commit is a documented no-op in database/sql, so the defer is safe.

doc-comment-shape

Doc comments on exported declarations start with the identifier name and form a complete sentence: // Store persists items to a backing SQLite file. not // This struct holds the store. godoc and IDE hover tooltips render the comment verbatim, so a comment that doesn't name what it documents loses its anchor when read out of context. Same shape for package comments: // Package store persists items to disk.

errors-is-as

Compare wrapped errors with errors.Is(err, sentinel) for sentinel checks and errors.As(err, &target) to extract a typed error — never ==, never raw type assertion. Once any layer wraps with %w (per errors-wrap-w), err == ErrFoo returns false even when errors.Is(err, ErrFoo) returns true; the type-assertion form e, ok := err.(*MyErr) has the same failure mode. errors.As walks the chain — pair with errors.Is once %w-wrapping is in the codebase.

errors-message-style

Error messages start with a lowercase letter, have no trailing punctuation, and contain no newlines or tabs. Errors get wrapped into larger sentences — fmt.Errorf("loading config: %w", err) composes into "loading config: reading foo.toml: no such file or directory", clean without mid-sentence capitals or stray periods. Multi-line remediation text belongs in an ErrHint wrapper or on ErrOut, not inside the error value.

errors-wrap-w

Always wrap errors with %w, never %v or %s: fmt.Errorf("reading %s: %w", path, err). %w preserves the chain so errors.Is/errors.As can find the underlying error; %v stringifies and silently severs it. Printed output is identical — the only difference is machine-readable introspection. Use %v only in log messages you're building but not returning.

goroutine-exit-path

Every go func() has a documented exit path: ctx cancellation via select { case <-ctx.Done(): return }, a sync.WaitGroup the parent calls Wait on, a channel close that breaks the inner loop, or natural completion of a bounded loop. No fire-and-forget. A goroutine blocked on an unbuffered send or receive is invisible to the GC and lives forever. byob's default fanout is errgroup.Group (byob-lifecycle.3); the rule applies to any goroutine.

got-want-order

Test failure messages put got before want: t.Errorf("Foo(%v) = %v, want %v", in, got, want). The testing package's diff output, IDEs, and reader habit all assume that order; reversing to "expected vs actual" works locally but breaks tooling. Mnemonic: "got is what you got, want is what you wanted." Note that go-cmp inverts at the call site — cmp.Diff(want, got) — because the diff renders as -want +got; the wrapping message still reads got-then-want.

initialism-casing

Initialisms (URL, HTTP, ID, JSON, API) keep consistent case in identifiers: appID not appId, serveHTTP not serveHttp, URLParser not UrlParser. Matches the stdlib (http.ServeHTTP, url.URL). The trap is copy-pasting from JS/Java/C# corpora where userId and urlParser read as natural — Go's stdlib has trained readers to expect the all-or-nothing form, and inconsistency stands out.

no-blank-error-discard

Don't write _ = err or _, _ = io.Copy(dst, src) to silence an error. Either handle it (return, log, fall back), or annotate the discard with a comment explaining why the error is provably safe to ignore: _ = f.Close() // best-effort close on read-only file. Silent discards are how production bugs hide in plain sight — every one is either a missed return path or worth one line of justification.

no-get-prefix

Field accessors omit the Get prefix: func (u *User) Name() string, not GetName(). Setters keep Set because func SetName(s string) has no idiomatic alternative. Action verbs are not accessors and keep their natural name: s3.GetObject is correct because it performs an RPC, not a field read. Mental model: Counts() is a noun (the count); GetCounts() is a Java-flavored method call.

pass-by-value-default

Don't pass pointers to function arguments just to save a copy. Small types — int, string, time.Time, structs of a few words, slices, maps — go by value. Passing *string adds an indirection, can force the value to escape to the heap, and signals "this function might mutate me" that you don't mean. Reach for a pointer when the function genuinely mutates the receiver, when the struct is large enough that copying matters (proto messages, big configs), or when nil encodes "absent."

principles-dry-kiss-solid

Duplicate twice before abstracting. Three similar lines is better than a premature interface or helper — the right abstraction is usually visible only after the second or third instance lands. The byob decisions already encode the abstractions worth pulling forward (Factory DI, narrow Options, small interfaces, semantic error types), so reach for them when the shape matches, not because a named principle (KISS, YAGNI, DRY, SOLID) demands it. Treat those names as tiebreakers during refactor, not constraints during design — invoking them up front tends to push toward more structure rather than less.

prompter-tty-check

Every Prompter method must check IO.IsStdinTTY() first. If stdin is not a terminal (CI, piped input, nohup'd background process), return the sentinel ErrNotTTY instead of prompting. Callers surface a clear error ("pass --yes to run non-interactively") or branch on errors.Is(err, prompt.ErrNotTTY). Failure mode to avoid: a prompt library reads EOF, interprets it as "no," and silently skips a destructive action. Loud failure beats silent wrong answer.

quote-strings-in-errors

Format user-supplied strings in errors and log messages with %q not %s: fmt.Errorf("unknown subcommand %q", name). %q wraps the value in Go-syntax quotes and escapes non-printables — empty strings render as "" instead of vanishing into mid-sentence whitespace, trailing newlines surface as \n, and a quoted "--help" doesn't get visually merged with surrounding text.

receiver-name

Method receiver names are a 1–2 letter abbreviation of the type, used consistently across every method on that type — func (s *Store) ... everywhere, never mixing in func (st *Store) or generic this/self/me. Short receivers read like math; consistent receivers let you scan a type's methods without re-parsing the binding name.

sync-oncevalue

Use sync.OnceValue[T] (Go 1.21+) to wrap an expensive lazy factory instead of hand-rolling sync.Once + captured vars. First call runs the underlying function and caches the return; subsequent calls return the cache. sync.OnceValues[A, B] handles the two-return form, which is what factory closures usually want: func() (Store, error). Errors are sticky — a failed open won't magically succeed on retry, almost always what you want for a factory.

test-cleanup

Use t.Cleanup(func()) instead of bare defer for test teardown. It runs LIFO even on panic, composes cleanly with t.Run subtests, and lives next to the resource it's tearing down rather than at the top of the test. Cleanups registered inside a helper stay attached to the test even after the helper returns — defer can't do that. Subtests inherit t and can register their own cleanups independently.

test-helper

Call t.Helper() as the first line of every test helper function. The testing framework skips helper frames when reporting failure locations, so a failed assertion points at the test that called the helper instead of at the helper's internals. Skip it only when debugging the helper itself.

test-tempdir

Use t.TempDir() for any test that touches the filesystem: it creates a per-test isolated directory and auto-cleans after the test ends. Write fixtures inline with os.WriteFile rather than maintaining a committed testdata/ tree — tests stay hermetic, parallel-safe, and the fixture and assertion read on the same screen. Reserve testdata/ for truly static reference files (golden outputs, sample binary inputs).

validate-at-boundaries

Validate user input once, at the boundary — Options.Validate() per byob-input-validation.5, or the config-parse step per byob-input-validation.2. Internal callers can then trust what they receive; don't re-check args in every helper. Defensive validation deep in internal code rots into noise, hides real bugs by making the validation graph hard to read, and quietly assumes the rest of the codebase is untrusted when it isn't.