From 4301f11efdfe70a6802477aadee27f25f1ff31a4 Mon Sep 17 00:00:00 2001 From: djmil Date: Wed, 3 Jun 2026 17:04:30 +0000 Subject: [PATCH] docs: update README and CLAUDE.md for v0.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: slim to landing-page essentials; add "Design forces" section explaining why pkg/result and pkg/logger exist (form over mechanism). Remove stale API docs — those live in package comments. CLAUDE.md: add "form over mechanism" design principle; update logging rule to document NewCLI two-mode model; add v0.4.0 to recent work. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 13 +++- README.md | 226 ++++++++---------------------------------------------- 2 files changed, 43 insertions(+), 196 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2db4ded..d0a1ee6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,11 @@ Key constraint: `go.mod` stays free of dev tool deps (tools are pinned in `tools ## Design philosophy +**Form over mechanism** — call sites express *what* the code is doing, not *how* it achieves it. +Mechanism belongs in constructors and infrastructure; intent belongs at the call site. +When you see `fmt.Fprintln(os.Stderr, ...)` or `if err != nil { return }` scattered through +business logic, that is mechanism leaking into intent — move it to the edges. + **[The Twelve-Factor App](https://12factor.net) is the primary reference for default implementation choices.** Follow its recommendations unless circumstances force a deviation — and document the reason when you do. @@ -63,7 +68,12 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push - bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")` - use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error - still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail` -- **Logging** — logs go to `stderr` (structured, machine-readable, per 12-factor XI); human output goes to `stdout` via `fmt.Print*`. Use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend +- **Logging** — logs go to `stderr` per 12-factor XI; human output goes to `stdout` via `fmt.Print*`. + Use `logger.NewCLI(level, debugFile)` for CLI apps: auto-detects TTY → human text on terminal, + JSON when piped. Use `logger.New(level)` for headless services that always want JSON. + Passing a non-empty `debugFile` enables debug mode: INFO/WARN/ERROR on screen plus a full + JSON DEBUG trace to file for post-run investigation. + Use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages - **Config** — all configuration parsed in `cmd/app/config.go` (flags); no hard-coded values in `internal/` or `pkg/` packages --- @@ -142,3 +152,4 @@ make clean # remove bin/ - 2026-04-01 — Replaced tools.go/go.mod pinning with tools.versions + go run tool@version; go.mod is now free of dev tool deps. - 2026-04-01 — Added make release: lists tags with no args; validates semver, runs test-race+lint+security, then tags+pushes. - 2026-04-23 — Documented result layering rule: pkg/ libraries only return Expect[T]; .Expect()/.Must() calls belong in application-layer code. +- 2026-06-03 — pkg/logger v0.4.0: replaced NewDevelopment with NewCLI(level, debugFile); two-mode model (human text on TTY / JSON when piped); debug file mode; IsInteractive() helper. Established "form over mechanism" as core design principle. diff --git a/README.md b/README.md index 3bfd6c6..ef0b5a4 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,32 @@ so packages extracted from this template can be published without module graph p --- +## Design forces + +Two libraries in this template emerged from a recurring friction in Go: +**mechanism leaking into intent**. + +**`pkg/result`** exists because `if err != nil { return nil, err }` repeated on every line +obscures what the code is actually doing. The happy path drowns in error-routing boilerplate. +`result.Expect` moves the exit logic to the edges — constructors and entry points — so the +body of a function reads as intent, not plumbing. + +**`pkg/logger`** exists because `fmt.Fprintln(os.Stderr, "error: "+msg)` leaks *how* (write +to stderr, format a string) into code that should only express *what* (something went wrong). +The same friction appeared in format selection: choosing between human text and JSON based on +the execution environment is mechanism, not business logic. `logger.NewCLI` hides that choice — +the call site just says `log.Warn(...)` and gets the right output for the context. + +The pattern: **constructors own the mechanism; call sites own the intent.** + +--- + ## Features | Area | Tool | Purpose | |---|---|---| | Language | Go 1.25 | Modules, toolchain directive | -| Logging | standard `log/slog` | Structured JSON/text logging + `WithField` extension | +| Logging | standard `log/slog` | Auto-detects terminal vs pipe: human text or JSON; optional debug file dump | | Config | standard `flag` | CLI flags with defaults, no config files | | Linting | [golangci-lint](https://golangci-lint.run) | Aggregated linters, one config file | | Security | [gosec](https://github.com/securego/gosec) + [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) | SAST + dependency vulnerability scan | @@ -92,19 +112,17 @@ Commands live in `.claude/commands/` and are available to anyone who clones the . ├── cmd/ │ └── app/ +│ ├── config.go # Flag parsing and Config struct (all flags live here) │ └── main.go # Composition root (thin — just wiring) ├── internal/ -│ ├── config/ # flag-based config (config.Load) -│ ├── logger/ # slog wrapper with WithField / WithFields │ └── greeter/ # Example domain package (replace with yours) +├── pkg/ # Publishable packages — no app-specific assumptions +│ ├── logger/ # slog wrapper: NewCLI, New, NewWriter, WithField/WithFields +│ ├── result/ # Happy-path error handling (Expect[T]) +│ └── testutil/ # Test helpers (ResultOk, Equal, …) ├── .devcontainer/ # VSCode / Codespaces container definition -├── .githooks/ # pre-push hook (installed by `make setup`) -├── .vscode/ # launch, tasks, editor settings -├── pkg/ -│ └── result/ # Example publishable package (Result/Expect types) -│ ├── doc.go # package-level godoc -│ ├── result.go # implementation -│ └── example_test.go # runnable examples (shown on pkg.go.dev) +├── .githooks/ # pre-push hook (gofmt + vet + lint + gosec) +├── .vscode/ # Launch configs, tasks, editor settings ├── tools.versions # Pinned tool versions (Makefile + hook source this) ├── .golangci.yml # Linter configuration └── Makefile # All development commands @@ -112,183 +130,6 @@ Commands live in `.claude/commands/` and are available to anyone who clones the --- -## Configuration - -All configuration is via CLI flags with sensible defaults: - -```bash -./bin/app -help - -env string environment: dev | staging | prod (default "dev") - -log-level string log level: debug | info | warn | error (default "info") - -name string greeter name (default "Gopher") - -port int listen port (default 8080) -``` - -Override at runtime: - -```bash -./bin/app -port 9090 -env prod -log-level warn -``` - ---- - -## Logging - -The logger wrapper adds `WithField` and `WithFields` for ergonomic context chaining -on top of the standard `log/slog` package: - -```go -log.WithField("request_id", rid). - WithField("user", uid). - Info("handling request") - -log.WithFields(map[string]any{ - "component": "greeter", - "name": name, -}).Debug("generating greeting") -``` - -In production (`app.env != dev`) the output is JSON (`slog.NewJSONHandler`). -In development, `logger.NewDevelopment()` uses the human-friendly text handler. - ---- - -## Testing - -Tests use the standard `testing` package. Concrete types are returned from -constructors — consumers (including tests) define their own minimal interfaces -and satisfy them with manual fakes. No code generation required. - -```go -// Interface declared in the consumer (or _test.go), not in greeter package. -type greeter interface { - Greet(name string) result.Expect[string] -} - -type fakeGreeter struct { - greetFn func(name string) result.Expect[string] -} - -func (f *fakeGreeter) Greet(name string) result.Expect[string] { - return f.greetFn(name) -} - -func TestSomething(t *testing.T) { - fake := &fakeGreeter{ - greetFn: func(name string) result.Expect[string] { - return result.Ok("Hello, " + name + "!") - }, - } - // pass fake to the system under test -} -``` - ---- - -## pkg/result — happy-path error handling - -`pkg/result` is a small publishable package that demonstrates the pattern of -keeping `go.mod` clean. It provides `Expect[T]`, a generic type that holds -either a success value or an error. - -The idea: deep call stacks write for the **happy path**, unwrapping results with -`.Expect()` or `.Must()` (which exit the goroutine on failure). A single -`defer result.Catch(&err)` at the entry point collects that exit as a normal -Go error, with the stack trace captured at the original failure site. - -### Layering rule - -`pkg/` libraries must only **return** `Expect[T]` — never call `.Expect()` or -`.Must()` themselves. Those methods exit the current goroutine via -`runtime.Goexit` and are only safe inside application-layer code (`cmd/`, -HTTP handlers) that is protected by `defer result.Catch(&err)` or -`result.Run(...)`. Calling them inside a reusable package takes control away -from the caller and makes the package non-composable. - -```go -// Wrap any (value, error) function: -data := result.Of(os.ReadFile("config.json")).Expect("read config") - -// Or construct explicitly: -func parsePort(s string) result.Expect[int] { - n, err := strconv.Atoi(s) - if err != nil { - return result.Fail[int](fmt.Errorf("parsePort: %w", err)) - } - return result.Ok(n) -} - -// Entry point catches panics from the whole call stack: -func run() (err error) { - defer result.Catch(&err) - port := parsePort(cfg.Port).Expect("load config") - _ = port // happy path continues … - return nil -} -``` - -`Catch` only intercepts error panics produced by this package. Real runtime -panics (nil-pointer dereferences, etc.) are re-panicked so bugs are never -silently swallowed. - -At boundaries where you need to re-join normal Go code: - -```go -port, err := parsePort("443").Unwrap() // back to (T, error) -``` - -See [`pkg/result/example_test.go`](pkg/result/example_test.go) for the -full set of runnable examples. - ---- - -## Git hooks - -The pre-push hook runs **gofmt + go vet + golangci-lint + gosec** against the -full codebase before every `git push`. Running on push (not commit) keeps the -inner commit loop fast while still blocking bad code from reaching the remote. - -`govulncheck` is intentionally excluded (it makes network calls and can be -slow). Run it manually with `make security`. - -To skip the hook in an emergency: - -```bash -git push --no-verify -``` - ---- - -## Tool version management - -Tool versions are pinned in `tools.versions` — a plain `KEY=value` file that -both the `Makefile` and the pre-push hook source directly. - -**Why not `tools.go` + `go.mod`?** - -The conventional `//go:build tools` approach adds development tools (linters, -scanners, debuggers) as entries in `go.mod`. This is fine for applications, but -it's a problem when the repo is intended to publish reusable packages: consumers -who `go get` your package see those tool dependencies in the module graph, even -though they are never compiled into the binary. Keeping `go.mod` clean makes the -module honest — it declares only what the package code actually needs. - -Instead, `go run tool@version` is used in Makefile targets and the pre-push -hook. Go caches downloaded tools in the module cache, so after the first run -there is no extra download cost. - -To update a tool, edit the version in `tools.versions`: - -``` -GOLANGCI_LINT_VERSION=v1.64.8 ← change this -``` - -Both `make lint` and the pre-push hook pick up the new version automatically. -Run `make tools` if you also want the updated binary installed to `GOPATH/bin` -(e.g. for IDE integration or direct CLI use). - ---- - ## Releasing ```bash @@ -296,17 +137,12 @@ make release # list all existing tags make release VERSION=v0.1.0 # run full checks, then tag and push ``` -The release target validates the version format (semver `vX.Y.Z`), ensures the -working tree is clean, and runs `make test-race lint security` before tagging. -The tag is only created and pushed if every check passes. - --- ## Devcontainer -Open this repo in VSCode and choose **"Reopen in Container"** — the Go -toolchain is installed automatically. Run `make init` to configure git hooks, -then `make tools` to install linter/scanner binaries to `GOPATH/bin`. +Open this repo in VSCode and choose **"Reopen in Container"**. Run `make init` to +configure git hooks, then `make tools` for IDE-integrated tool binaries. Works with GitHub Codespaces out of the box. @@ -317,5 +153,5 @@ Works with GitHub Codespaces out of the box. - **HTTP server** — add [chi](https://github.com/go-chi/chi) or [gin](https://github.com/gin-gonic/gin) - **Database** — add [sqlx](https://github.com/jmoiron/sqlx) or [ent](https://entgo.io/) - **CI** — add a Gitea Actions / CI pipeline running `make lint test security` -- **Docker** — add a multi-stage `Dockerfile` to publish the binary as a container image (separate from the devcontainer) +- **Docker** — add a multi-stage `Dockerfile` to publish the binary as a container image - **OpenTelemetry** — add tracing with `go.opentelemetry.io/otel`